From 4eb11484b8ddf216746a8e5503924669dcd0701d Mon Sep 17 00:00:00 2001 From: spereirag Date: Thu, 9 Jul 2020 15:14:20 -0400 Subject: [PATCH 01/49] fix heartbeat manager typo --- manager/subscription/heartbeat_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manager/subscription/heartbeat_manager.py b/manager/subscription/heartbeat_manager.py index f3afee70..ee8de0a2 100644 --- a/manager/subscription/heartbeat_manager.py +++ b/manager/subscription/heartbeat_manager.py @@ -18,7 +18,7 @@ def initialize(self): if not self.heartbeat_task: self.heartbeat_task = asyncio.create_task(self.dispatch_heartbeats()) if not self.commander_heartbeat_task: - self.heartbeat_task = asyncio.create_task(self.query_commander()) + self.commander_heartbeat_task = asyncio.create_task(self.query_commander()) @classmethod def set_heartbeat_timestamp(self, source, timestamp): From f79c68d1c4b425aaffe0640606780cdf6afa1e5e Mon Sep 17 00:00:00 2001 From: spereirag Date: Thu, 20 Aug 2020 17:39:28 -0400 Subject: [PATCH 02/49] Make heartbeat manager a singleton --- manager/subscription/heartbeat_manager.py | 142 ++++++++++--------- manager/subscription/tests/test_heartbeat.py | 22 +-- 2 files changed, 89 insertions(+), 75 deletions(-) diff --git a/manager/subscription/heartbeat_manager.py b/manager/subscription/heartbeat_manager.py index ee8de0a2..5508e8bb 100644 --- a/manager/subscription/heartbeat_manager.py +++ b/manager/subscription/heartbeat_manager.py @@ -8,76 +8,86 @@ class HeartbeatManager: - heartbeat_task = None - commander_heartbeat_task = None - heartbeat_data = {} + class __HeartbeatManager: + + heartbeat_task = None + commander_heartbeat_task = None + heartbeat_data = {} - @classmethod - def initialize(self): - self.heartbeat_data = {} - if not self.heartbeat_task: - self.heartbeat_task = asyncio.create_task(self.dispatch_heartbeats()) - if not self.commander_heartbeat_task: - self.commander_heartbeat_task = asyncio.create_task(self.query_commander()) + @classmethod + def initialize(self): + self.heartbeat_data = {} + if not self.heartbeat_task: + self.heartbeat_task = asyncio.create_task(self.dispatch_heartbeats()) + if not self.commander_heartbeat_task: + self.commander_heartbeat_task = asyncio.create_task(self.query_commander()) - @classmethod - def set_heartbeat_timestamp(self, source, timestamp): - self.heartbeat_data[source] = timestamp + @classmethod + def set_heartbeat_timestamp(self, source, timestamp): + self.heartbeat_data[source] = timestamp - @classmethod - async def query_commander(self): - heartbeat_url = f"http://{os.environ.get('COMMANDER_HOSTNAME')}:{os.environ.get('COMMANDER_PORT')}/heartbeat" - while True: - try: - # query commander - resp = requests.get(heartbeat_url) - timestamp = resp.json()['timestamp'] - #get timestamp - self.set_heartbeat_timestamp('Commander', timestamp) - await asyncio.sleep(3) - except Exception as e: - print(e) - await asyncio.sleep(3) + @classmethod + async def query_commander(self): + heartbeat_url = f"http://{os.environ.get('COMMANDER_HOSTNAME')}:{os.environ.get('COMMANDER_PORT')}/heartbeat" + while True: + try: + # query commander + resp = requests.get(heartbeat_url) + timestamp = resp.json()['timestamp'] + #get timestamp + self.set_heartbeat_timestamp('Commander', timestamp) + await asyncio.sleep(3) + except Exception as e: + print(e) + await asyncio.sleep(3) - @classmethod - async def dispatch_heartbeats(self): - channel_layer = get_channel_layer() - while True: - try: - self.set_heartbeat_timestamp('Manager', datetime.datetime.now().timestamp()) - data = json.dumps({ - 'category': 'heartbeat', - 'data': [ - { - 'csc': heartbeat_source, - 'salindex': 0, - 'data': {'timestamp': self.heartbeat_data[heartbeat_source]} - } for heartbeat_source in self.heartbeat_data - ], - 'subscription': 'heartbeat' - }) - await channel_layer.group_send( - 'heartbeat-manager-0-stream', - {'type': 'send_heartbeat', 'data': data} - ) - await asyncio.sleep(3) - except Exception as e: - print(e) - await asyncio.sleep(3) + @classmethod + async def dispatch_heartbeats(self): + channel_layer = get_channel_layer() + while True: + try: + print('sending data') + self.set_heartbeat_timestamp('Manager', datetime.datetime.now().timestamp()) + data = json.dumps({ + 'category': 'heartbeat', + 'data': [ + { + 'csc': heartbeat_source, + 'salindex': 0, + 'data': {'timestamp': self.heartbeat_data[heartbeat_source]} + } for heartbeat_source in self.heartbeat_data + ], + 'subscription': 'heartbeat' + }) + await channel_layer.group_send( + 'heartbeat-manager-0-stream', + {'type': 'send_heartbeat', 'data': data} + ) + await asyncio.sleep(3) + except Exception as e: + print(e) + await asyncio.sleep(3) - @classmethod - async def reset(self): - if self.heartbeat_task: - self.heartbeat_task = None - if self.commander_heartbeat_task: - self.commander_heartbeat_task = None - self.heartbeat_data = {} - self.commander_heartbeat_task = {} + @classmethod + async def reset(self): + if self.heartbeat_task: + self.heartbeat_task = None + if self.commander_heartbeat_task: + self.commander_heartbeat_task = None + self.heartbeat_data = {} + self.commander_heartbeat_task = {} - @classmethod - async def stop(self): - if self.heartbeat_task: - self.heartbeat_task.cancel() - if self.commander_heartbeat_task: - self.commander_heartbeat_task.cancel() + @classmethod + async def stop(self): + if self.heartbeat_task: + self.heartbeat_task.cancel() + if self.commander_heartbeat_task: + self.commander_heartbeat_task.cancel() + instance = None + def __init__(self): + if not HeartbeatManager.instance: + HeartbeatManager.instance = HeartbeatManager.__HeartbeatManager() + + def __getattr__(self, name): + return getattr(self.instance, name) \ No newline at end of file diff --git a/manager/subscription/tests/test_heartbeat.py b/manager/subscription/tests/test_heartbeat.py index bb22fc85..66bfe0aa 100644 --- a/manager/subscription/tests/test_heartbeat.py +++ b/manager/subscription/tests/test_heartbeat.py @@ -25,7 +25,8 @@ def setup_method(self): @pytest.mark.django_db(transaction=True) async def test_join_and_leave_subscription(self): # Arrange - await HeartbeatManager.reset() + hb_manager = HeartbeatManager() + await hb_manager.reset() communicator = WebsocketCommunicator(application, self.url) connected, subprotocol = await communicator.connect() @@ -94,13 +95,14 @@ async def test_join_and_leave_subscription(self): # Assert 2 assert response['data'] == f'Successfully unsubscribed to heartbeat-manager-0-stream' await communicator.disconnect() - await HeartbeatManager.stop() + await hb_manager.stop() @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_heartbeat_manager_setter(self): + hb_manager = HeartbeatManager() # Arrange - await HeartbeatManager.reset() + await hb_manager.reset() communicator = WebsocketCommunicator(application, self.url) connected, subprotocol = await communicator.connect() @@ -123,20 +125,21 @@ async def test_heartbeat_manager_setter(self): # Act 2 Set producer heartbeat timestamp = datetime.datetime.now().timestamp() - HeartbeatManager.set_heartbeat_timestamp('Producer', timestamp) + hb_manager.set_heartbeat_timestamp('Producer', timestamp) response = await communicator.receive_json_from(timeout=4) # Assert 2 heartbeat_sources = [source['csc'] for source in response['data']] assert 'Producer' in heartbeat_sources await communicator.disconnect() - await HeartbeatManager.stop() + await hb_manager.stop() @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_producer_heartbeat(self): # Arrange - await HeartbeatManager.reset() + hb_manager = HeartbeatManager() + await hb_manager.reset() communicator = WebsocketCommunicator(application, self.url) connected, subprotocol = await communicator.connect() @@ -168,14 +171,15 @@ async def test_producer_heartbeat(self): heartbeat_sources = [source['csc'] for source in response['data']] assert 'Producer' in heartbeat_sources await communicator.disconnect() - await HeartbeatManager.stop() + await hb_manager.stop() @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) @patch('requests.get', return_value = type('obj', (object,), {'json' : lambda: {'timestamp': 123123123}})) async def test_unauthorized_commander(self, mock_requests): # Arrange - await HeartbeatManager.reset() + hb_manager = HeartbeatManager() + await hb_manager.reset() communicator = WebsocketCommunicator(application, self.url) connected, subprotocol = await communicator.connect() @@ -205,4 +209,4 @@ async def test_unauthorized_commander(self, mock_requests): commander_timestamp = commander_heartbeat['timestamp'] assert commander_timestamp == 123123123 await communicator.disconnect() - await HeartbeatManager.stop() \ No newline at end of file + await hb_manager.stop() \ No newline at end of file From 77e9f73226a976f0ff2338d4700f1b8201ec9684 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2020 21:36:18 +0000 Subject: [PATCH 03/49] Bump cryptography from 2.8 to 3.2 in /manager Bumps [cryptography](https://github.com/pyca/cryptography) from 2.8 to 3.2. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/2.8...3.2) Signed-off-by: dependabot[bot] --- manager/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manager/requirements.txt b/manager/requirements.txt index 4b7a7ab4..0b99e5db 100644 --- a/manager/requirements.txt +++ b/manager/requirements.txt @@ -17,7 +17,7 @@ chardet==3.0.4 constantly==15.1.0 coreapi==2.3.3 coreschema==0.0.4 -cryptography==2.8 +cryptography==3.2 daphne==2.4.1 Django==3.0.7 django-auth-ldap==2.1.0 From 24645e235e1f4de76103cd78dda8a54c88221f28 Mon Sep 17 00:00:00 2001 From: Sebastian Aranda Date: Wed, 25 Nov 2020 10:34:25 -0300 Subject: [PATCH 04/49] Add love-csc http subapp --- manager/api/tests/test_lovecsc.py | 82 +++++++++++++++++++++++++++++++ manager/api/urls.py | 1 + manager/api/views.py | 34 +++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 manager/api/tests/test_lovecsc.py diff --git a/manager/api/tests/test_lovecsc.py b/manager/api/tests/test_lovecsc.py new file mode 100644 index 00000000..691a77ca --- /dev/null +++ b/manager/api/tests/test_lovecsc.py @@ -0,0 +1,82 @@ +from django.test import TestCase, override_settings +from django.urls import reverse +from api.models import Token +from rest_framework.test import APIClient +from django.contrib.auth.models import User, Permission +import yaml +from unittest.mock import patch, call + + +@override_settings(DEBUG=True) +class LOVECscTestCase(TestCase): + maxDiff = None + + def setUp(self): + """Define the test suite setup.""" + # Arrange + self.client = APIClient() + self.user = User.objects.create_user( + username="an user", + password="password", + email="test@user.cl", + first_name="First", + last_name="Last", + ) + self.token = Token.objects.create(user=self.user) + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + self.user.user_permissions.add( + Permission.objects.get(codename="view_view"), + Permission.objects.get(codename="add_view"), + Permission.objects.get(codename="delete_view"), + Permission.objects.get(codename="change_view"), + ) + + @patch( + "os.environ.get", + side_effect=lambda arg: "fakehost" + if arg == "COMMANDER_HOSTNAME" + else "fakeport", + ) + @patch("requests.post") + def test_authorized_lovecsc_data(self, mock_requests, mock_environ): + """Test authorized user commander data is sent to love-commander""" + # Arrange: + self.user.user_permissions.add(Permission.objects.get(name="Execute Commands")) + + # Act: + url = reverse("love-csc") + data = { + "user": "an user", + "message": "a message", + } + + with self.assertRaises(ValueError): + response = self.client.post(url, data, format="json") + fakehostname = "fakehost" + fakeport = "fakeport" + expected_url = f"http://fakehost:fakeport/lovecsc" + self.assertEqual(mock_requests.call_args, call(expected_url, json=data)) + + @patch( + "os.environ.get", + side_effect=lambda arg: "fakehost" + if arg == "COMMANDER_HOSTNAME" + else "fakeport", + ) + @patch("requests.post") + def test_unauthorized_lovecsc(self, mock_requests, mock_environ): + """Test an unauthorized user can't send commands""" + # Act: + url = reverse("love-csc") + data = { + "user": "an user", + "message": "a message", + } + + response = self.client.post(url, data, format="json") + result = response.json() + + self.assertEqual(response.status_code, 401) + self.assertEqual( + result, {"ack": "User does not have permissions to send observing logs."} + ) diff --git a/manager/api/urls.py b/manager/api/urls.py index 73b74d91..7b26ffea 100644 --- a/manager/api/urls.py +++ b/manager/api/urls.py @@ -40,6 +40,7 @@ ), path("auth/", include("rest_framework.urls", namespace="rest_framework")), path("cmd/", api.views.commander, name="commander"), + path("lovecsc/", api.views.lovecsc, name="love-csc"), path("salinfo/metadata", api.views.salinfo_metadata, name="salinfo-metadata"), path( "salinfo/topic-names", api.views.salinfo_topic_names, name="salinfo-topic-names" diff --git a/manager/api/views.py b/manager/api/views.py index c3e707d0..ead18493 100644 --- a/manager/api/views.py +++ b/manager/api/views.py @@ -249,6 +249,40 @@ def commander(request): return Response(response.json(), status=response.status_code) +@swagger_auto_schema( + method="post", + responses={ + 200: openapi.Response("Observing log sent"), + 400: openapi.Response("Missing parameters"), + 401: openapi.Response("Unauthenticated"), + 403: openapi.Response("Unauthorized"), + }, +) +@api_view(["POST"]) +@permission_classes((IsAuthenticated,)) +def lovecsc(request): + """Sends an observing log message to the LOVE-commander according to the received parameters + + Params + ------ + request: Request + The Request object + + Returns + ------- + Response + The response and status code of the request to the LOVE-Commander + """ + if not request.user.has_perm("api.command.execute_command"): + return Response( + {"ack": "User does not have permissions to send observing logs."}, 401 + ) + url = f"http://{os.environ.get('COMMANDER_HOSTNAME')}:{os.environ.get('COMMANDER_PORT')}/lovecsc" + response = requests.post(url, json=request.data) + + return Response(response.json(), status=response.status_code) + + @swagger_auto_schema( method="get", responses={ From c64b7678431b615078a29037710ed66f9347dad9 Mon Sep 17 00:00:00 2001 From: Sebastian Aranda Date: Wed, 25 Nov 2020 10:42:36 -0300 Subject: [PATCH 05/49] Spelling fix --- docsrc/source/modules/how_to_use_it.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docsrc/source/modules/how_to_use_it.rst b/docsrc/source/modules/how_to_use_it.rst index 5a07e37c..8811e5cc 100644 --- a/docsrc/source/modules/how_to_use_it.rst +++ b/docsrc/source/modules/how_to_use_it.rst @@ -65,7 +65,7 @@ Returns token, user data and permissions Validate token -------------- Validates a given authorization token, passed through HTTP Headers. -Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuraiton file. +Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuration file. If the :code:`no_config` flag is added to the end of the URL, then the LOVE config files is not read and the corresponding value is returned as :code:`null` - Url: :code:`/manager/api/validate-token/` or :code:`/manager/api/validate-token/no_config/` @@ -119,7 +119,7 @@ If the :code:`no_config` flag is added to the end of the URL, then the LOVE conf Swap token -------------- Validates a given authorization token, passed through HTTP Headers. -Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuraiton file. +Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuration file. If the :code:`no_config` flag is added to the end of the URL, then the LOVE config files is not read and the corresponding value is returned as :code:`null` - Url: :code:`/manager/api/swap-token/` or :code:`/manager/api/swap-token/no_config/` From 5994b8381da71f6c2a88436efccfae1ffa6a9c8c Mon Sep 17 00:00:00 2001 From: Sebastian Aranda Date: Wed, 25 Nov 2020 17:13:23 -0300 Subject: [PATCH 06/49] Code refactor --- manager/api/tests/test_lovecsc.py | 22 +++++++++++++++------- manager/api/urls.py | 2 +- manager/api/views.py | 4 ++-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/manager/api/tests/test_lovecsc.py b/manager/api/tests/test_lovecsc.py index 691a77ca..d16202e9 100644 --- a/manager/api/tests/test_lovecsc.py +++ b/manager/api/tests/test_lovecsc.py @@ -39,12 +39,12 @@ def setUp(self): ) @patch("requests.post") def test_authorized_lovecsc_data(self, mock_requests, mock_environ): - """Test authorized user commander data is sent to love-commander""" + """Test authorized user observing log is sent to love-commander""" # Arrange: self.user.user_permissions.add(Permission.objects.get(name="Execute Commands")) # Act: - url = reverse("love-csc") + url = reverse("lovecsc-observinglog") data = { "user": "an user", "message": "a message", @@ -52,9 +52,14 @@ def test_authorized_lovecsc_data(self, mock_requests, mock_environ): with self.assertRaises(ValueError): response = self.client.post(url, data, format="json") - fakehostname = "fakehost" - fakeport = "fakeport" - expected_url = f"http://fakehost:fakeport/lovecsc" + result = response.json() + + # self.assertEqual(response.status_code, 200) + # self.assertEqual( + # result, {"ack": "Added new observing log to SAL."} + # ) + + expected_url = f"http://fakehost:fakeport/lovecsc/observinglog" self.assertEqual(mock_requests.call_args, call(expected_url, json=data)) @patch( @@ -67,12 +72,12 @@ def test_authorized_lovecsc_data(self, mock_requests, mock_environ): def test_unauthorized_lovecsc(self, mock_requests, mock_environ): """Test an unauthorized user can't send commands""" # Act: - url = reverse("love-csc") + url = reverse("lovecsc-observinglog") data = { "user": "an user", "message": "a message", } - + response = self.client.post(url, data, format="json") result = response.json() @@ -80,3 +85,6 @@ def test_unauthorized_lovecsc(self, mock_requests, mock_environ): self.assertEqual( result, {"ack": "User does not have permissions to send observing logs."} ) + + # expected_url = f"http://fakehost:fakeport/lovecsc/observinglog" + # self.assertEqual(mock_requests.call_args, call(expected_url, json=data)) diff --git a/manager/api/urls.py b/manager/api/urls.py index 7b26ffea..17b9176b 100644 --- a/manager/api/urls.py +++ b/manager/api/urls.py @@ -40,7 +40,7 @@ ), path("auth/", include("rest_framework.urls", namespace="rest_framework")), path("cmd/", api.views.commander, name="commander"), - path("lovecsc/", api.views.lovecsc, name="love-csc"), + path("lovecsc/observinglog", api.views.lovecsc_observinglog, name="lovecsc-observinglog"), path("salinfo/metadata", api.views.salinfo_metadata, name="salinfo-metadata"), path( "salinfo/topic-names", api.views.salinfo_topic_names, name="salinfo-topic-names" diff --git a/manager/api/views.py b/manager/api/views.py index ead18493..ac5755c5 100644 --- a/manager/api/views.py +++ b/manager/api/views.py @@ -260,7 +260,7 @@ def commander(request): ) @api_view(["POST"]) @permission_classes((IsAuthenticated,)) -def lovecsc(request): +def lovecsc_observinglog(request): """Sends an observing log message to the LOVE-commander according to the received parameters Params @@ -277,7 +277,7 @@ def lovecsc(request): return Response( {"ack": "User does not have permissions to send observing logs."}, 401 ) - url = f"http://{os.environ.get('COMMANDER_HOSTNAME')}:{os.environ.get('COMMANDER_PORT')}/lovecsc" + url = f"http://{os.environ.get('COMMANDER_HOSTNAME')}:{os.environ.get('COMMANDER_PORT')}/lovecsc/observinglog" response = requests.post(url, json=request.data) return Response(response.json(), status=response.status_code) From 140fe78d0fbf9b9d88071e3386fe8fcfc19a755c Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 28 Dec 2020 20:34:59 -0300 Subject: [PATCH 07/49] Add config file model --- manager/api/migrations/0006_configfile.py | 31 +++++++++++++ manager/api/models.py | 55 +++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 manager/api/migrations/0006_configfile.py diff --git a/manager/api/migrations/0006_configfile.py b/manager/api/migrations/0006_configfile.py new file mode 100644 index 00000000..b6a67ce7 --- /dev/null +++ b/manager/api/migrations/0006_configfile.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.7 on 2020-12-28 23:34 + +import api.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0005_auto_20190722_1622'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigFile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Creation time')), + ('update_timestamp', models.DateTimeField(auto_now=True, verbose_name='Last Updated')), + ('file_name', models.CharField(blank=True, max_length=30)), + ('config_file', models.FileField(default='configs/default.json', upload_to='configs/', validators=[api.models.ConfigFile.validate_file_extension])), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='config_files', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/manager/api/models.py b/manager/api/models.py index c121d728..5c871ab7 100644 --- a/manager/api/models.py +++ b/manager/api/models.py @@ -4,12 +4,34 @@ For more information see: https://docs.djangoproject.com/en/2.2/topics/db/models/ """ +import os from django.conf import settings from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.core.exceptions import ValidationError import rest_framework.authtoken.models +from ui_framework.models import OverwriteStorage +class BaseModel(models.Model): + """Base Model for the models of this app.""" + + class Meta: + """Define attributes of the Meta class.""" + + abstract = True + """Make this an abstract class in order to be used as an enhanced base model""" + + creation_timestamp = models.DateTimeField( + auto_now_add=True, editable=False, verbose_name="Creation time" + ) + """Creation timestamp, autogenerated upon creation""" + + update_timestamp = models.DateTimeField( + auto_now=True, editable=False, verbose_name="Last Updated" + ) + """Update timestamp, autogenerated upon creation and autoupdated on every update""" + class Token(rest_framework.authtoken.models.Token): """Custome Token model with ForeignKey relation to User model. Based on the DRF Token model.""" @@ -47,3 +69,36 @@ class Meta: ('command.run_script', 'Run and Requeue scripts in ScriptQueues'), ) """((string, string)): Tuple defining permissions in the format ((, ))""" + +class ConfigFile(BaseModel): + """ConfigFile Model, that includes actual configuration files, creation date and user.""" + + def validate_file_extension(value): + ext = os.path.splitext(value.name)[1] # [0] returns path+filename + valid_extensions = ['.json', '.sh'] + if not ext.lower() in valid_extensions: + raise ValidationError('Unsupported file extension.') + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, related_name='config_files', + on_delete=models.CASCADE, verbose_name= "User" + ) + + file_name = models.CharField(max_length=30, blank=True) + """The custom name for the configuration""" + + config_file = models.FileField( + upload_to="configs/", + default="configs/default.json", + validators=[validate_file_extension], + ) + """Order of the View within the Workspace.""" + + # def __str__(self): + # """Redefine how objects of this class are transformed to string.""" + # if self.view_name and self.view_name != "": + # return "{}: {} - {}".format( + # self.view_name, self.workspace.name, self.view.name + # ) + # else: + # return "{} - {}".format(self.workspace.name, self.view.name) From 04f06c86942ccf9fa995e3154d545eb90925d1dd Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 28 Dec 2020 20:35:14 -0300 Subject: [PATCH 08/49] Add config file serializer --- manager/api/admin.py | 2 ++ manager/api/serializers.py | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/manager/api/admin.py b/manager/api/admin.py index c9a2815e..030af392 100644 --- a/manager/api/admin.py +++ b/manager/api/admin.py @@ -8,6 +8,8 @@ """ from django.contrib import admin from api.models import Token +from api.models import ConfigFile admin.site.register(Token) +admin.site.register(ConfigFile) diff --git a/manager/api/serializers.py b/manager/api/serializers.py index 12432085..e37827af 100644 --- a/manager/api/serializers.py +++ b/manager/api/serializers.py @@ -5,6 +5,8 @@ from rest_framework import serializers from django.contrib.auth.models import User from manager import utils +from api.models import ConfigFile + def read_config_file(): @@ -166,3 +168,44 @@ def get_config(self, token) -> dict: return None else: return read_config_file() + + +class ConfigFileSerializer(serializers.ModelSerializer): + """Serializer to map the Model instance into JSON format.""" + filename = serializers.SerializerMethodField() + username = serializers.SerializerMethodField() + + def get_username(self, obj): + return str(obj.user) + + def get_filename(self, obj): + return str(obj.file_name) + + class Meta: + """Meta class to map serializer's fields with the model fields.""" + + model = ConfigFile + """The model class to serialize""" + + fields = ("id", "username", "filename", "creation_timestamp", "update_timestamp") + """The fields of the model class to serialize""" + +class ConfigFileContentSerializer(serializers.ModelSerializer): + """Serializer to map the Model instance into JSON format.""" + content = serializers.SerializerMethodField() + filename = serializers.SerializerMethodField() + + def get_content(self, obj): + return obj.config_file.read().decode("ascii") + + def get_filename(self, obj): + return str(obj.file_name) + + class Meta: + """Meta class to map serializer's fields with the model fields.""" + + model = ConfigFile + """The model class to serialize""" + + fields = ("id", "filename", "content") + """The fields of the model class to serialize""" \ No newline at end of file From 33c549696a74a5d980f81e58f31297d57c82fa65 Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 28 Dec 2020 20:36:10 -0300 Subject: [PATCH 09/49] Add config file views --- manager/api/urls.py | 3 ++ manager/api/views.py | 77 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/manager/api/urls.py b/manager/api/urls.py index 17b9176b..8cd42395 100644 --- a/manager/api/urls.py +++ b/manager/api/urls.py @@ -18,6 +18,7 @@ from django.conf.urls import include from django.urls import path from rest_framework.routers import DefaultRouter +from api.views import ConfigFileViewSet # from api.views import validate_token, logout, CustomObtainAuthToken, validate_config_schema, commander, salinfo_metadata import api.views @@ -47,5 +48,7 @@ ), path("salinfo/topic-data", api.views.salinfo_topic_data, name="salinfo-topic-data"), path("config", api.views.get_config, name="config"), + path("config/", api.views.get_config_detail, name="config-detail"), ] +router.register('configfile', ConfigFileViewSet) urlpatterns.append(path("", include(router.urls))) diff --git a/manager/api/views.py b/manager/api/views.py index ac5755c5..6268664e 100644 --- a/manager/api/views.py +++ b/manager/api/views.py @@ -10,12 +10,17 @@ from rest_framework import status from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.decorators import api_view -from rest_framework.decorators import permission_classes -from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import permission_classes, authentication_classes +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.authentication import SessionAuthentication, BasicAuthentication from rest_framework.response import Response +from rest_framework import viewsets, status from api.models import Token from api.serializers import TokenSerializer, read_config_file, ConfigSerializer +from api.serializers import ConfigFileSerializer, ConfigFileContentSerializer from .schema_validator import DefaultingValidator +from api.models import ConfigFile valid_response = openapi.Response("Valid token", TokenSerializer) invalid_response = openapi.Response("Invalid token") @@ -418,7 +423,7 @@ def salinfo_topic_data(request): }, ) @api_view(["GET"]) -@permission_classes((IsAuthenticated,)) +# @permission_classes((IsAuthenticated,)) def get_config(request): """Returns the config file @@ -436,3 +441,69 @@ def get_config(request): if data is None: return Response(None, status=status.HTTP_404_NOT_FOUND) return Response(data, status=status.HTTP_200_OK) + + + +class ConfigFileViewSet(viewsets.ModelViewSet): + """GET, POST, PUT, PATCH or DELETE instances the ConfigFile model.""" + + queryset = ConfigFile.objects.order_by("-update_timestamp").all() + """Set of objects to be accessed by queries to this viewsets endpoints""" + + serializer_class = ConfigFileSerializer + """Serializer used to serialize View objects""" + + @action(detail=True) + def content(self, request, pk=None): + """Serialize a ConfigFile's content. + + Params + ------ + request: Request + The Requets object + pk: int + The corresponding ConfigFile pk + + Returns + ------- + Response + The response containing the serialized ConfigFile content + """ + try: + cf = ConfigFile.objects.get(pk=pk) + except ConfigFile.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = ConfigFileContentSerializer(cf) + return Response(serializer.data) + + +@swagger_auto_schema( + method="get", + responses={ + 200: openapi.Response("Config file", ConfigSerializer), + 401: openapi.Response("Unauthenticated"), + 404: not_found_response, + }, +) +@permission_classes(()) +@authentication_classes([BasicAuthentication]) +@api_view(["GET"]) +@action(detail=True, methods=['get']) +def get_config_detail(request, pk=None, **kwargs): + """Returns the config file + + Params + ------ + request: Request + The Request object + + Returns + ------- + Response + Containing the contents of the config file + """ + data = read_config_file() + if data is None: + return Response(None, status=status.HTTP_404_NOT_FOUND) + return Response(data, status=status.HTTP_200_OK) From 9848dce2dc2a50a33928707678d1322f233b69b6 Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 28 Dec 2020 20:37:45 -0300 Subject: [PATCH 10/49] Fix test typos --- manager/api/tests/test_commander.py | 4 ++-- manager/api/tests/test_lovecsc.py | 6 +++--- manager/api/tests/test_schema_validation.py | 2 +- manager/api/tests/tests_config.py | 2 +- manager/subscription/tests/test_lovecsc_subscriptions.py | 2 +- manager/ui_framework/tests/test_view_thumbnail.py | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/manager/api/tests/test_commander.py b/manager/api/tests/test_commander.py index c8bd5d25..e9adfa2a 100644 --- a/manager/api/tests/test_commander.py +++ b/manager/api/tests/test_commander.py @@ -16,7 +16,7 @@ def setUp(self): # Arrange self.client = APIClient() self.user = User.objects.create_user( - username="an user", + username="user", password="password", email="test@user.cl", first_name="First", @@ -95,7 +95,7 @@ def setUp(self): # Arrange self.client = APIClient() self.user = User.objects.create_user( - username="an user", + username="user", password="password", email="test@user.cl", first_name="First", diff --git a/manager/api/tests/test_lovecsc.py b/manager/api/tests/test_lovecsc.py index d16202e9..5a23a2b1 100644 --- a/manager/api/tests/test_lovecsc.py +++ b/manager/api/tests/test_lovecsc.py @@ -16,7 +16,7 @@ def setUp(self): # Arrange self.client = APIClient() self.user = User.objects.create_user( - username="an user", + username="user", password="password", email="test@user.cl", first_name="First", @@ -46,7 +46,7 @@ def test_authorized_lovecsc_data(self, mock_requests, mock_environ): # Act: url = reverse("lovecsc-observinglog") data = { - "user": "an user", + "user": "user", "message": "a message", } @@ -74,7 +74,7 @@ def test_unauthorized_lovecsc(self, mock_requests, mock_environ): # Act: url = reverse("lovecsc-observinglog") data = { - "user": "an user", + "user": "user", "message": "a message", } diff --git a/manager/api/tests/test_schema_validation.py b/manager/api/tests/test_schema_validation.py index 72349ab4..2bfab739 100644 --- a/manager/api/tests/test_schema_validation.py +++ b/manager/api/tests/test_schema_validation.py @@ -40,7 +40,7 @@ def setUp(self): # Arrange self.client = APIClient() self.user = User.objects.create_user( - username='an user', + username='user', password='password', email='test@user.cl', first_name='First', diff --git a/manager/api/tests/tests_config.py b/manager/api/tests/tests_config.py index fbba6826..600a52d2 100644 --- a/manager/api/tests/tests_config.py +++ b/manager/api/tests/tests_config.py @@ -19,7 +19,7 @@ def setUp(self): # Arrange: self.client = APIClient() self.user = User.objects.create_user( - username="an user", + username="user", password="password", email="test@user.cl", first_name="First", diff --git a/manager/subscription/tests/test_lovecsc_subscriptions.py b/manager/subscription/tests/test_lovecsc_subscriptions.py index 38462ca5..f7dfa29c 100644 --- a/manager/subscription/tests/test_lovecsc_subscriptions.py +++ b/manager/subscription/tests/test_lovecsc_subscriptions.py @@ -101,7 +101,7 @@ async def test_observinglog_to_lovecsc(self): "csc": "love", "salindex": 0, "data": { - "observingLog": {"user": "an user", "message": "a message"} + "observingLog": {"user": "user", "message": "a message"} }, } ], diff --git a/manager/ui_framework/tests/test_view_thumbnail.py b/manager/ui_framework/tests/test_view_thumbnail.py index 9bcfe8f5..ad458f7a 100644 --- a/manager/ui_framework/tests/test_view_thumbnail.py +++ b/manager/ui_framework/tests/test_view_thumbnail.py @@ -22,7 +22,7 @@ def setUp(self): # Arrange self.client = APIClient() self.user = User.objects.create_user( - username='an user', + username='user', password='password', email='test@user.cl', first_name='First', From dcb512db331732d5732983b8552ef5d3580c0f1e Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 28 Dec 2020 20:38:00 -0300 Subject: [PATCH 11/49] Add config file test cases --- manager/api/tests/tests_configfile.py | 83 +++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 manager/api/tests/tests_configfile.py diff --git a/manager/api/tests/tests_configfile.py b/manager/api/tests/tests_configfile.py new file mode 100644 index 00000000..18660a93 --- /dev/null +++ b/manager/api/tests/tests_configfile.py @@ -0,0 +1,83 @@ +"""Test users' authentication through the API.""" +import datetime +import io +from django.test import TestCase +from django.urls import reverse +from django.contrib.auth.models import User, Permission +from freezegun import freeze_time +from rest_framework.test import APIClient +from rest_framework import status +from api.models import ConfigFile, Token +from django.conf import settings +from manager import utils +from django.core.files.base import ContentFile + +#python manage.py test api.tests.tests_configfile.ConfigFileApiTestCase + +class ConfigFileApiTestCase(TestCase): + """Test suite for config files handling.""" + + @staticmethod + def get_config_file_sample(name, content): + f = ContentFile(content.encode("ascii"), name=name) + return f + + + def setUp(self): + """Define the test suite setup.""" + # Arrange: + + self.client = APIClient() + self.user = User.objects.create_user( + username="user", + password="password", + email="test@user.cl", + first_name="First", + last_name="Last", + ) + self.filename = 'test.json' + self.content = 'this is the content of the file' + self.configfile = ConfigFile.objects.create(user=self.user, + config_file=ConfigFileApiTestCase.get_config_file_sample("random_filename", self.content), + file_name=self.filename) + self.url = reverse("config") + self.token = Token.objects.create(user=self.user) + + def test_get_config_file(self): + """Test that an authenticated user can get a config file.""" + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + response = self.client.get(reverse("configfile-detail", args=[self.configfile.id]), format="json") + import pdb; pdb.set_trace() + self.assertEqual(response.status_code, 200) + expected_data = { + "id": self.configfile.id, + "username": self.user.username, + "filename": self.filename, + } + self.assertEqual(response.data["id"], expected_data["id"]) + self.assertEqual(response.data["username"], expected_data["username"]) + self.assertEqual(response.data["filename"], expected_data["filename"]) + + def test_get_config_file_content(self): + """Test that an authenticated user can get a config file content.""" + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + response = self.client.get(reverse("configfile-content", args=[self.configfile.id]), format="json") + import pdb; pdb.set_trace() + self.assertEqual(response.status_code, 200) + expected_data = { + "id": self.configfile.id, + "content": self.content, + "filename": self.filename, + } + self.assertEqual(response.data["id"], expected_data["id"]) + self.assertEqual(response.data["content"], expected_data["content"]) + self.assertEqual(response.data["filename"], expected_data["filename"]) + + def test_unauthenticated_cannot_get_config_file(self): + """Test that an unauthenticated user cannot get the config file.""" + # Act: + response = self.client.get(self.url, format="json") + + # Assert: + self.assertEqual(response.status_code, 401) + self.assertNotEqual(response.data, self.expected_data) From ca107e3414a05f067e97fcb570b429a196f5ba72 Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 29 Dec 2020 10:58:38 -0300 Subject: [PATCH 12/49] Add config files list test --- manager/api/tests/tests_configfile.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/manager/api/tests/tests_configfile.py b/manager/api/tests/tests_configfile.py index 18660a93..51a2f5b3 100644 --- a/manager/api/tests/tests_configfile.py +++ b/manager/api/tests/tests_configfile.py @@ -43,11 +43,23 @@ def setUp(self): self.url = reverse("config") self.token = Token.objects.create(user=self.user) + def test_get_config_files_list(self): + """Test that an authenticated user can get a config file.""" + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + response = self.client.get(reverse("configfile-list"), format="json") + self.assertEqual(response.status_code, 200) + expected_data = { + "id": self.configfile.id, + "username": self.user.username, + "filename": self.filename, + } + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["filename"], expected_data["filename"]) + def test_get_config_file(self): """Test that an authenticated user can get a config file.""" self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) response = self.client.get(reverse("configfile-detail", args=[self.configfile.id]), format="json") - import pdb; pdb.set_trace() self.assertEqual(response.status_code, 200) expected_data = { "id": self.configfile.id, @@ -62,7 +74,6 @@ def test_get_config_file_content(self): """Test that an authenticated user can get a config file content.""" self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) response = self.client.get(reverse("configfile-content", args=[self.configfile.id]), format="json") - import pdb; pdb.set_trace() self.assertEqual(response.status_code, 200) expected_data = { "id": self.configfile.id, From c4a1cec91a3647141767005c21b70e1069aa363f Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 29 Dec 2020 11:54:02 -0300 Subject: [PATCH 13/49] Add configuration files fixture --- manager/api/fixtures/configs/defaul.json | 10 ++++++++++ manager/api/fixtures/initial_data.json | 13 +++++++++++++ manager/runserver-dev.sh | 3 +++ manager/runserver.sh | 3 +++ 4 files changed, 29 insertions(+) create mode 100644 manager/api/fixtures/configs/defaul.json create mode 100644 manager/api/fixtures/initial_data.json diff --git a/manager/api/fixtures/configs/defaul.json b/manager/api/fixtures/configs/defaul.json new file mode 100644 index 00000000..b2a7258b --- /dev/null +++ b/manager/api/fixtures/configs/defaul.json @@ -0,0 +1,10 @@ +{ + "alarms": { + "minSeveritySound": "mute", + "minSeverityNotification": "mute" + }, + "camFeeds": { + "generic": "/gencam", + "allSky": "/gencam" + } +} diff --git a/manager/api/fixtures/initial_data.json b/manager/api/fixtures/initial_data.json new file mode 100644 index 00000000..c63050fe --- /dev/null +++ b/manager/api/fixtures/initial_data.json @@ -0,0 +1,13 @@ +[ + { + "model": "api.configfile", + "pk": 1, + "fields": { + "creation_timestamp": "2020-12-29T14:21:31.040Z", + "update_timestamp": "2020-12-29T14:21:31.040Z", + "user": 1, + "file_name": "default.json", + "config_file": "configs/default.json" + } + } +] diff --git a/manager/runserver-dev.sh b/manager/runserver-dev.sh index 97e00340..947ff996 100755 --- a/manager/runserver-dev.sh +++ b/manager/runserver-dev.sh @@ -15,7 +15,10 @@ python manage.py createusers --adminpass ${ADMIN_USER_PASS} --userpass ${USER_US echo -e "\nApplying fixtures" mkdir -p media/thumbnails cp -u ui_framework/fixtures/thumbnails/* media/thumbnails +cp -u api/fixtures/configs/* media/configs + python manage.py loaddata ui_framework/fixtures/initial_data.json +python manage.py loaddata api/fixtures/initial_data.json echo -e "\nStarting server" python manage.py runserver 0.0.0.0:8000 diff --git a/manager/runserver.sh b/manager/runserver.sh index 78f97491..7facc96a 100755 --- a/manager/runserver.sh +++ b/manager/runserver.sh @@ -15,7 +15,10 @@ python manage.py createusers --adminpass ${ADMIN_USER_PASS} --userpass ${USER_US echo -e "\nApplying fixtures" mkdir -p media/thumbnails cp -u ui_framework/fixtures/thumbnails/* media/thumbnails +cp -u api/fixtures/configs/* media/configs + python manage.py loaddata ui_framework/fixtures/initial_data.json +python manage.py loaddata api/fixtures/initial_data.json echo -e "\nStarting server" daphne -b 0.0.0.0 -p 8000 manager.asgi:application From 70e1fc1fb8ab920ab11c1eb119871e9e5889b5ac Mon Sep 17 00:00:00 2001 From: spereirag Date: Wed, 30 Dec 2020 11:36:38 -0300 Subject: [PATCH 14/49] Remove unused endpoint --- manager/api/urls.py | 1 - manager/api/views.py | 48 +++++++++++--------------------------------- 2 files changed, 12 insertions(+), 37 deletions(-) diff --git a/manager/api/urls.py b/manager/api/urls.py index 8cd42395..270b269f 100644 --- a/manager/api/urls.py +++ b/manager/api/urls.py @@ -48,7 +48,6 @@ ), path("salinfo/topic-data", api.views.salinfo_topic_data, name="salinfo-topic-data"), path("config", api.views.get_config, name="config"), - path("config/", api.views.get_config_detail, name="config-detail"), ] router.register('configfile', ConfigFileViewSet) urlpatterns.append(path("", include(router.urls))) diff --git a/manager/api/views.py b/manager/api/views.py index 6268664e..4bfa333a 100644 --- a/manager/api/views.py +++ b/manager/api/views.py @@ -437,10 +437,17 @@ def get_config(request): Response Containing the contents of the config file """ - data = read_config_file() - if data is None: - return Response(None, status=status.HTTP_404_NOT_FOUND) - return Response(data, status=status.HTTP_200_OK) + # data = read_config_file() + # if data is None: + # return Response(None, status=status.HTTP_404_NOT_FOUND) + # return Response(data, status=status.HTTP_200_OK) + try: + cf = ConfigFile.objects.first() + except ConfigFile.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = ConfigFileContentSerializer(cf) + return Response(serializer.data) @@ -475,35 +482,4 @@ def content(self, request, pk=None): return Response(status=status.HTTP_404_NOT_FOUND) serializer = ConfigFileContentSerializer(cf) - return Response(serializer.data) - - -@swagger_auto_schema( - method="get", - responses={ - 200: openapi.Response("Config file", ConfigSerializer), - 401: openapi.Response("Unauthenticated"), - 404: not_found_response, - }, -) -@permission_classes(()) -@authentication_classes([BasicAuthentication]) -@api_view(["GET"]) -@action(detail=True, methods=['get']) -def get_config_detail(request, pk=None, **kwargs): - """Returns the config file - - Params - ------ - request: Request - The Request object - - Returns - ------- - Response - Containing the contents of the config file - """ - data = read_config_file() - if data is None: - return Response(None, status=status.HTTP_404_NOT_FOUND) - return Response(data, status=status.HTTP_200_OK) + return Response(serializer.data) \ No newline at end of file From 7664ff4ae61bb8ff2176d5d540637988231263ef Mon Sep 17 00:00:00 2001 From: spereirag Date: Wed, 30 Dec 2020 11:37:12 -0300 Subject: [PATCH 15/49] Update token serializer to read first config file --- manager/api/serializers.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/manager/api/serializers.py b/manager/api/serializers.py index e37827af..0ac7c883 100644 --- a/manager/api/serializers.py +++ b/manager/api/serializers.py @@ -163,11 +163,10 @@ def get_config(self, token) -> dict: warning: 0 } """ - no_config = self.context.get("no_config") - if no_config: - return None - else: - return read_config_file() + cf = ConfigFile.objects.first() + + serializer = ConfigFileContentSerializer(cf) + return serializer.data class ConfigFileSerializer(serializers.ModelSerializer): @@ -196,7 +195,7 @@ class ConfigFileContentSerializer(serializers.ModelSerializer): filename = serializers.SerializerMethodField() def get_content(self, obj): - return obj.config_file.read().decode("ascii") + return json.loads(obj.config_file.read().decode("ascii")) def get_filename(self, obj): return str(obj.file_name) From 369974a55e214e861d7b0d434b61e77879620ba2 Mon Sep 17 00:00:00 2001 From: spereirag Date: Wed, 30 Dec 2020 11:46:15 -0300 Subject: [PATCH 16/49] Remove unused function --- manager/api/serializers.py | 12 ------------ manager/api/views.py | 8 ++------ 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/manager/api/serializers.py b/manager/api/serializers.py index 0ac7c883..d8d9bf2d 100644 --- a/manager/api/serializers.py +++ b/manager/api/serializers.py @@ -8,18 +8,6 @@ from api.models import ConfigFile - -def read_config_file(): - url = settings.CONFIG_URL - with open(url) as f: - content = f.read() - try: - data = json.loads(content) - except ValueError: - return None - return data - - class UserSerializer(serializers.ModelSerializer): """Serializer to map the Model instance into JSON format.""" diff --git a/manager/api/views.py b/manager/api/views.py index 4bfa333a..7330cefe 100644 --- a/manager/api/views.py +++ b/manager/api/views.py @@ -17,7 +17,7 @@ from rest_framework.response import Response from rest_framework import viewsets, status from api.models import Token -from api.serializers import TokenSerializer, read_config_file, ConfigSerializer +from api.serializers import TokenSerializer, ConfigSerializer from api.serializers import ConfigFileSerializer, ConfigFileContentSerializer from .schema_validator import DefaultingValidator from api.models import ConfigFile @@ -423,7 +423,7 @@ def salinfo_topic_data(request): }, ) @api_view(["GET"]) -# @permission_classes((IsAuthenticated,)) +@permission_classes((IsAuthenticated,)) def get_config(request): """Returns the config file @@ -437,10 +437,6 @@ def get_config(request): Response Containing the contents of the config file """ - # data = read_config_file() - # if data is None: - # return Response(None, status=status.HTTP_404_NOT_FOUND) - # return Response(data, status=status.HTTP_200_OK) try: cf = ConfigFile.objects.first() except ConfigFile.DoesNotExist: From 49111661a53894f58ef5163f2c9adebfec65f5cc Mon Sep 17 00:00:00 2001 From: spereirag Date: Wed, 30 Dec 2020 12:11:02 -0300 Subject: [PATCH 17/49] Fix old tests --- manager/api/serializers.py | 11 +++--- manager/api/tests/tests_auth_api.py | 28 +++++++++++---- manager/api/tests/tests_config.py | 51 --------------------------- manager/api/tests/tests_configfile.py | 8 ++--- manager/manager/settings.py | 2 -- 5 files changed, 33 insertions(+), 67 deletions(-) delete mode 100644 manager/api/tests/tests_config.py diff --git a/manager/api/serializers.py b/manager/api/serializers.py index d8d9bf2d..d1a8dd3f 100644 --- a/manager/api/serializers.py +++ b/manager/api/serializers.py @@ -151,10 +151,13 @@ def get_config(self, token) -> dict: warning: 0 } """ - cf = ConfigFile.objects.first() - - serializer = ConfigFileContentSerializer(cf) - return serializer.data + no_config = self.context.get("no_config") + if no_config: + return None + else: + cf = ConfigFile.objects.first() + serializer = ConfigFileContentSerializer(cf) + return serializer.data class ConfigFileSerializer(serializers.ModelSerializer): diff --git a/manager/api/tests/tests_auth_api.py b/manager/api/tests/tests_auth_api.py index 759c8401..e5d4bb6f 100644 --- a/manager/api/tests/tests_auth_api.py +++ b/manager/api/tests/tests_auth_api.py @@ -1,19 +1,27 @@ """Test users' authentication through the API.""" import datetime +import io +import json from django.test import TestCase from django.urls import reverse from django.contrib.auth.models import User, Permission from freezegun import freeze_time from rest_framework.test import APIClient from rest_framework import status -from api.models import Token +from api.models import ConfigFile, Token from django.conf import settings +from django.core.files.base import ContentFile from manager import utils class AuthApiTestCase(TestCase): """Test suite for users' authentication.""" + @staticmethod + def get_config_file_sample(name, content): + f = ContentFile(json.dumps(content).encode("ascii"), name=name) + return f + def setUp(self): """Define the test suite setup.""" # Arrange: @@ -50,6 +58,12 @@ def setUp(self): } self.expected_config = {"setting1": {"setting11": 1, "setting12": 2}} + self.filename = "test.json" + self.content = {"key1": "this is the content of the file"} + self.configfile = ConfigFile.objects.create(user=self.user, + config_file=AuthApiTestCase.get_config_file_sample("random_filename", self.content), + file_name=self.filename) + def test_user_login(self): """Test that a user can request a token using name and password.""" # Arrange: @@ -87,8 +101,8 @@ def test_user_login(self): "Time data is not as expected", ) self.assertEqual( - response.data["config"], - self.expected_config, + response.data["config"]["filename"], + self.filename, "The config was not requested", ) @@ -180,8 +194,8 @@ def test_user_validate_token(self): "Time data is not as expected", ) self.assertEqual( - response.data["config"], - self.expected_config, + response.data["config"]["filename"], + self.filename, "The config was not requested", ) @@ -332,7 +346,9 @@ def test_user_swap(self): # Assert 2: self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( - response.data["config"], self.expected_config, + response.data["config"]["filename"], + self.filename, + "The config was not requested", ) def test_user_swap_no_config(self): diff --git a/manager/api/tests/tests_config.py b/manager/api/tests/tests_config.py deleted file mode 100644 index 600a52d2..00000000 --- a/manager/api/tests/tests_config.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Test users' authentication through the API.""" -import datetime -from django.test import TestCase -from django.urls import reverse -from django.contrib.auth.models import User, Permission -from freezegun import freeze_time -from rest_framework.test import APIClient -from rest_framework import status -from api.models import Token -from django.conf import settings -from manager import utils - - -class ConfigApiTestCase(TestCase): - """Test suite for config files handling.""" - - def setUp(self): - """Define the test suite setup.""" - # Arrange: - self.client = APIClient() - self.user = User.objects.create_user( - username="user", - password="password", - email="test@user.cl", - first_name="First", - last_name="Last", - ) - self.url = reverse("config") - self.expected_data = {"setting1": {"setting11": 1, "setting12": 2}} - self.token = Token.objects.create(user=self.user) - # self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) - - def test_get_config(self): - """Test that an authenticated user can get the config file.""" - # Arrange - self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) - # Act: - response = self.client.get(self.url, format="json") - - # Assert: - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, self.expected_data) - - def test_unauthenticated_cannot_get_config(self): - """Test that an unauthenticated user cannot get the config file.""" - # Act: - response = self.client.get(self.url, format="json") - - # Assert: - self.assertEqual(response.status_code, 401) - self.assertNotEqual(response.data, self.expected_data) diff --git a/manager/api/tests/tests_configfile.py b/manager/api/tests/tests_configfile.py index 51a2f5b3..461f4f12 100644 --- a/manager/api/tests/tests_configfile.py +++ b/manager/api/tests/tests_configfile.py @@ -1,6 +1,7 @@ """Test users' authentication through the API.""" import datetime import io +import json from django.test import TestCase from django.urls import reverse from django.contrib.auth.models import User, Permission @@ -19,7 +20,7 @@ class ConfigFileApiTestCase(TestCase): @staticmethod def get_config_file_sample(name, content): - f = ContentFile(content.encode("ascii"), name=name) + f = ContentFile(json.dumps(content).encode("ascii"), name=name) return f @@ -35,8 +36,8 @@ def setUp(self): first_name="First", last_name="Last", ) - self.filename = 'test.json' - self.content = 'this is the content of the file' + self.filename = "test.json" + self.content = {"key1": "this is the content of the file"} self.configfile = ConfigFile.objects.create(user=self.user, config_file=ConfigFileApiTestCase.get_config_file_sample("random_filename", self.content), file_name=self.filename) @@ -91,4 +92,3 @@ def test_unauthenticated_cannot_get_config_file(self): # Assert: self.assertEqual(response.status_code, 401) - self.assertNotEqual(response.data, self.expected_data) diff --git a/manager/manager/settings.py b/manager/manager/settings.py index 4bdfbbb6..7e8b2a3d 100644 --- a/manager/manager/settings.py +++ b/manager/manager/settings.py @@ -197,11 +197,9 @@ if TESTING: MEDIA_BASE = os.path.join(BASE_DIR, "ui_framework", "tests") MEDIA_ROOT = os.path.join(BASE_DIR, "ui_framework", "tests", "media") - CONFIG_URL = os.path.join(BASE_DIR, "api", "tests", "config", "love.json") else: MEDIA_BASE = BASE_DIR MEDIA_ROOT = os.path.join(BASE_DIR, "media") - CONFIG_URL = os.path.join(BASE_DIR, "config", "love.json") # Channels ASGI_APPLICATION = "manager.routing.application" From 7afb31449f41b78e9f38cfa7daa9f3c0be5040ed Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 4 Jan 2021 10:57:17 -0300 Subject: [PATCH 18/49] Include update timestamp in ConfigFileContentSerializer --- manager/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manager/api/serializers.py b/manager/api/serializers.py index d1a8dd3f..768eee70 100644 --- a/manager/api/serializers.py +++ b/manager/api/serializers.py @@ -197,5 +197,5 @@ class Meta: model = ConfigFile """The model class to serialize""" - fields = ("id", "filename", "content") + fields = ("id", "filename", "content", "update_timestamp") """The fields of the model class to serialize""" \ No newline at end of file From f0d33dafe6b7833ce33366a3e792bc08a12b7cb3 Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 4 Jan 2021 12:05:18 -0300 Subject: [PATCH 19/49] Update default config name --- manager/api/fixtures/configs/{defaul.json => default.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename manager/api/fixtures/configs/{defaul.json => default.json} (100%) diff --git a/manager/api/fixtures/configs/defaul.json b/manager/api/fixtures/configs/default.json similarity index 100% rename from manager/api/fixtures/configs/defaul.json rename to manager/api/fixtures/configs/default.json From 41163fb483eaeba668382bf92a398cf86e4c6889 Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 4 Jan 2021 12:07:41 -0300 Subject: [PATCH 20/49] Remove commented out code --- manager/api/models.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/manager/api/models.py b/manager/api/models.py index 5c871ab7..bb20b920 100644 --- a/manager/api/models.py +++ b/manager/api/models.py @@ -83,6 +83,7 @@ def validate_file_extension(value): settings.AUTH_USER_MODEL, related_name='config_files', on_delete=models.CASCADE, verbose_name= "User" ) + """User who created the config file""" file_name = models.CharField(max_length=30, blank=True) """The custom name for the configuration""" @@ -92,13 +93,4 @@ def validate_file_extension(value): default="configs/default.json", validators=[validate_file_extension], ) - """Order of the View within the Workspace.""" - - # def __str__(self): - # """Redefine how objects of this class are transformed to string.""" - # if self.view_name and self.view_name != "": - # return "{}: {} - {}".format( - # self.view_name, self.workspace.name, self.view.name - # ) - # else: - # return "{} - {}".format(self.workspace.name, self.view.name) + """Reference to the config file""" From 7761da8edd3136715b9cb03d09e01dd145db670e Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 4 Jan 2021 17:06:15 -0300 Subject: [PATCH 21/49] Update documentation --- docsrc/source/modules/how_it_works.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docsrc/source/modules/how_it_works.rst b/docsrc/source/modules/how_it_works.rst index 63c4cf38..cec2fc2d 100644 --- a/docsrc/source/modules/how_it_works.rst +++ b/docsrc/source/modules/how_it_works.rst @@ -62,7 +62,7 @@ Code organization Currently the application is divided in the following modules and files: -* :code:`api`: This module contains the :code:`API` Django app, which defines the models and API endpoints for authentication (:code:`Auth API`) and Commander (:code:`Commander API`) APIs. For more details please refer to the :ref:`ApiDoc` section +* :code:`api`: This module contains the :code:`API` Django app, which defines the models and API endpoints for authentication (:code:`Auth API`), Commander (:code:`Commander API`) and ConfigFile (:code:`ConfigFile API`) APIs. For more details please refer to the :ref:`ApiDoc` section * :code:`ui_framework`: This module contains the :code:`UI Framework` Django app, which defines the models and API endpoints for the UI Framework views (:code:`UI Framework API`) API. For more details please refer to the :ref:`ApiDoc` section * :code:`subscription`: This module contains the Django app that defines the consumers that handle the websocket communication. * :code:`manager`: This module contains basic Django configuration files, such as urls and channels routing, etc. From 35f37bd9f20a2be75f1b8395e84913d1c34c102e Mon Sep 17 00:00:00 2001 From: Sebastian Aranda Date: Thu, 7 Jan 2021 10:54:49 -0300 Subject: [PATCH 22/49] Fix entrypoints --- manager/runserver-dev.sh | 1 + manager/runserver.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/manager/runserver-dev.sh b/manager/runserver-dev.sh index 947ff996..6d4b92d2 100755 --- a/manager/runserver-dev.sh +++ b/manager/runserver-dev.sh @@ -15,6 +15,7 @@ python manage.py createusers --adminpass ${ADMIN_USER_PASS} --userpass ${USER_US echo -e "\nApplying fixtures" mkdir -p media/thumbnails cp -u ui_framework/fixtures/thumbnails/* media/thumbnails +mkdir -p media/configs cp -u api/fixtures/configs/* media/configs python manage.py loaddata ui_framework/fixtures/initial_data.json diff --git a/manager/runserver.sh b/manager/runserver.sh index 7facc96a..ceafc5f2 100755 --- a/manager/runserver.sh +++ b/manager/runserver.sh @@ -15,6 +15,7 @@ python manage.py createusers --adminpass ${ADMIN_USER_PASS} --userpass ${USER_US echo -e "\nApplying fixtures" mkdir -p media/thumbnails cp -u ui_framework/fixtures/thumbnails/* media/thumbnails +mkdir -p media/configs cp -u api/fixtures/configs/* media/configs python manage.py loaddata ui_framework/fixtures/initial_data.json From 37a5a90d28a9f6b0cafe2d3d9a78352de0414513 Mon Sep 17 00:00:00 2001 From: spereirag Date: Thu, 7 Jan 2021 18:00:00 -0300 Subject: [PATCH 23/49] Add emergency contact model --- manager/api/admin.py | 3 +- .../api/migrations/0007_emergencycontact.py | 28 +++++++++++++++++++ manager/api/models.py | 15 ++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 manager/api/migrations/0007_emergencycontact.py diff --git a/manager/api/admin.py b/manager/api/admin.py index 030af392..a96894dc 100644 --- a/manager/api/admin.py +++ b/manager/api/admin.py @@ -8,8 +8,9 @@ """ from django.contrib import admin from api.models import Token -from api.models import ConfigFile +from api.models import ConfigFile, EmergencyContact admin.site.register(Token) admin.site.register(ConfigFile) +admin.site.register(EmergencyContact) diff --git a/manager/api/migrations/0007_emergencycontact.py b/manager/api/migrations/0007_emergencycontact.py new file mode 100644 index 00000000..eafb3a42 --- /dev/null +++ b/manager/api/migrations/0007_emergencycontact.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.7 on 2021-01-05 13:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_configfile'), + ] + + operations = [ + migrations.CreateModel( + name='EmergencyContact', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Creation time')), + ('update_timestamp', models.DateTimeField(auto_now=True, verbose_name='Last Updated')), + ('subsystem', models.CharField(blank=True, max_length=100)), + ('name', models.CharField(blank=True, max_length=100)), + ('contact_info', models.CharField(blank=True, max_length=100)), + ('email', models.EmailField(max_length=254)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/manager/api/models.py b/manager/api/models.py index bb20b920..e2ecf447 100644 --- a/manager/api/models.py +++ b/manager/api/models.py @@ -94,3 +94,18 @@ def validate_file_extension(value): validators=[validate_file_extension], ) """Reference to the config file""" + +class EmergencyContact(BaseModel): + """EmergencyContact Model""" + + subsystem = models.CharField(max_length=100, blank=True) + """EC's subsystem""" + + name = models.CharField(max_length=100, blank=True) + """EC name""" + + contact_info = models.CharField(max_length=100, blank=True) + """EC's preferred contact information (work number, cell, none)""" + + email = models.EmailField(max_length=254) + """EC's email""" \ No newline at end of file From 932bf1d034e9e5310726efea116a79b48778ce74 Mon Sep 17 00:00:00 2001 From: spereirag Date: Thu, 7 Jan 2021 18:50:26 -0300 Subject: [PATCH 24/49] Add emergency contact endpoint --- manager/api/serializers.py | 15 ++++- manager/api/tests/tests_emergencycontact.py | 66 +++++++++++++++++++++ manager/api/urls.py | 4 +- manager/api/views.py | 16 ++++- 4 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 manager/api/tests/tests_emergencycontact.py diff --git a/manager/api/serializers.py b/manager/api/serializers.py index 768eee70..073b9bd3 100644 --- a/manager/api/serializers.py +++ b/manager/api/serializers.py @@ -5,7 +5,7 @@ from rest_framework import serializers from django.contrib.auth.models import User from manager import utils -from api.models import ConfigFile +from api.models import ConfigFile, EmergencyContact class UserSerializer(serializers.ModelSerializer): @@ -198,4 +198,17 @@ class Meta: """The model class to serialize""" fields = ("id", "filename", "content", "update_timestamp") + """The fields of the model class to serialize""" + + +class EmergencyContactSerializer(serializers.ModelSerializer): + """Serializer to map the Model instance into JSON format.""" + + class Meta: + """Meta class to map serializer's fields with the model fields.""" + + model = EmergencyContact + """The model class to serialize""" + + fields = ("__all__") """The fields of the model class to serialize""" \ No newline at end of file diff --git a/manager/api/tests/tests_emergencycontact.py b/manager/api/tests/tests_emergencycontact.py new file mode 100644 index 00000000..0bef916f --- /dev/null +++ b/manager/api/tests/tests_emergencycontact.py @@ -0,0 +1,66 @@ +"""Test users' authentication through the API.""" +import datetime +import io +import json +from django.test import TestCase +from django.urls import reverse +from django.contrib.auth.models import User, Permission +from freezegun import freeze_time +from rest_framework.test import APIClient +from rest_framework import status +from api.models import EmergencyContact, Token +from django.conf import settings +from manager import utils +from django.core.files.base import ContentFile + +#python manage.py test api.tests.tests_emergencycontact.EmergencyContactApiTestCase + +class EmergencyContactApiTestCase(TestCase): + """Test suite for config files handling.""" + + @staticmethod + def get_config_file_sample(name, content): + f = ContentFile(json.dumps(content).encode("ascii"), name=name) + return f + + + def setUp(self): + """Define the test suite setup.""" + # Arrange: + + self.client = APIClient() + self.user = User.objects.create_user( + username="user", + password="password", + email="test@user.cl", + first_name="First", + last_name="Last", + ) + self.token = Token.objects.create(user=self.user) + self.ec1 = EmergencyContact.objects.create( + subsystem="ATDome", + name="Name Lastname", + contact_info="+568984861", + email="name@email.com", + ) + self.ec2 = EmergencyContact.objects.create( + subsystem="ATMCS", + name="Name2 Lastname2", + contact_info="no info", + email="name2@email.com", + ) + + def test_list_emergency_contacts(self): + """Test that an authenticated user can get a config file.""" + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + response = self.client.get(reverse("emergencycontact-list"), format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + self.assertEqual(response.data[0]["subsystem"], self.ec1.subsystem) + self.assertEqual(response.data[0]["name"], self.ec1.name) + self.assertEqual(response.data[0]["contact_info"], self.ec1.contact_info) + self.assertEqual(response.data[0]["email"], self.ec1.email) + self.assertEqual(response.data[1]["subsystem"], self.ec2.subsystem) + self.assertEqual(response.data[1]["name"], self.ec2.name) + self.assertEqual(response.data[1]["contact_info"], self.ec2.contact_info) + self.assertEqual(response.data[1]["email"], self.ec2.email) diff --git a/manager/api/urls.py b/manager/api/urls.py index 270b269f..bfe3d7ce 100644 --- a/manager/api/urls.py +++ b/manager/api/urls.py @@ -18,9 +18,8 @@ from django.conf.urls import include from django.urls import path from rest_framework.routers import DefaultRouter -from api.views import ConfigFileViewSet +from api.views import ConfigFileViewSet, EmergencyContactViewSet -# from api.views import validate_token, logout, CustomObtainAuthToken, validate_config_schema, commander, salinfo_metadata import api.views router = DefaultRouter() @@ -50,4 +49,5 @@ path("config", api.views.get_config, name="config"), ] router.register('configfile', ConfigFileViewSet) +router.register('emergencycontact', EmergencyContactViewSet) urlpatterns.append(path("", include(router.urls))) diff --git a/manager/api/views.py b/manager/api/views.py index 7330cefe..cf4afdb8 100644 --- a/manager/api/views.py +++ b/manager/api/views.py @@ -18,9 +18,9 @@ from rest_framework import viewsets, status from api.models import Token from api.serializers import TokenSerializer, ConfigSerializer -from api.serializers import ConfigFileSerializer, ConfigFileContentSerializer +from api.serializers import ConfigFileSerializer, ConfigFileContentSerializer, EmergencyContactSerializer from .schema_validator import DefaultingValidator -from api.models import ConfigFile +from api.models import ConfigFile, EmergencyContact valid_response = openapi.Response("Valid token", TokenSerializer) invalid_response = openapi.Response("Invalid token") @@ -478,4 +478,14 @@ def content(self, request, pk=None): return Response(status=status.HTTP_404_NOT_FOUND) serializer = ConfigFileContentSerializer(cf) - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) + + +class EmergencyContactViewSet(viewsets.ModelViewSet): + """GET, POST, PUT, PATCH or DELETE instances the EmergencyContact model.""" + + queryset = EmergencyContact.objects.order_by("subsystem").all() + """Set of objects to be accessed by queries to this viewsets endpoints""" + + serializer_class = EmergencyContactSerializer + """Serializer used to serialize View objects""" \ No newline at end of file From 28f2ac11153196ef7ca828e26006a71ee8184de2 Mon Sep 17 00:00:00 2001 From: spereirag Date: Thu, 7 Jan 2021 18:50:35 -0300 Subject: [PATCH 25/49] Add fixture data --- manager/api/fixtures/initial_data.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/manager/api/fixtures/initial_data.json b/manager/api/fixtures/initial_data.json index c63050fe..df5bce65 100644 --- a/manager/api/fixtures/initial_data.json +++ b/manager/api/fixtures/initial_data.json @@ -9,5 +9,29 @@ "file_name": "default.json", "config_file": "configs/default.json" } + }, + { + "model": "api.emergencycontact", + "pk": 1, + "fields": { + "creation_timestamp": "2021-01-05T17:03:55.382Z", + "update_timestamp": "2021-01-05T17:03:55.382Z", + "subsystem": "ATDome", + "name": "Name Lastname", + "contact_info": "+5689846515", + "email": "name@email.com" + } + }, + { + "model": "api.emergencycontact", + "pk": 2, + "fields": { + "creation_timestamp": "2021-01-05T17:04:24.127Z", + "update_timestamp": "2021-01-05T17:04:24.127Z", + "subsystem": "ATMCS", + "name": "Name2 Lastname2", + "contact_info": "none", + "email": "name2@email.com" + } } ] From de5e2b81e345d250c0003f3dd4b0533b16d38f37 Mon Sep 17 00:00:00 2001 From: spereirag Date: Thu, 7 Jan 2021 19:55:48 -0300 Subject: [PATCH 26/49] Update jenkinsfile to publish documentation --- Jenkinsfile | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 3f5b46b0..45fd684a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,9 +1,18 @@ pipeline { - agent any + agent { + docker { + alwaysPull true + image 'lsstts/develop-env:develop' + args "-u root --entrypoint=''" + } + } environment { registryCredential = "dockerhub-inriachile" dockerImageName = "lsstts/love-manager:" dockerImage = "" + user_ci = credentials('lsst-io') + LTD_USERNAME="${user_ci_USR}" + LTD_PASSWORD="${user_ci_PSW}" } stages { @@ -72,6 +81,24 @@ pipeline { } } + stage("Deploy documentation") { + when { + anyOf { + changeset "docs/*" + } + } + steps { + script { + sh "pwd" + sh """ + source /home/saluser/.setup_dev.sh + pip install ltd-conveyor + ltd upload --product love-manager --git-ref ${GIT_BRANCH} --dir ./docs + """ + } + } + } + stage("Trigger develop deployment") { when { branch "develop" From 402d7b2b250b93adaabe3838209cc7ddfdbda771 Mon Sep 17 00:00:00 2001 From: spereirag Date: Fri, 8 Jan 2021 17:57:50 -0300 Subject: [PATCH 27/49] Fix jenkinsfile --- Jenkinsfile | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 45fd684a..5fde6ee1 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,11 +1,5 @@ pipeline { - agent { - docker { - alwaysPull true - image 'lsstts/develop-env:develop' - args "-u root --entrypoint=''" - } - } + agent any environment { registryCredential = "dockerhub-inriachile" dockerImageName = "lsstts/love-manager:" @@ -82,6 +76,13 @@ pipeline { } stage("Deploy documentation") { + agent { + docker { + alwaysPull true + image 'lsstts/develop-env:develop' + args "-u root --entrypoint=''" + } + } when { anyOf { changeset "docs/*" From bd2c280b3ec7186d08df04d3f3f659340732a32c Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 11 Jan 2021 12:29:49 -0300 Subject: [PATCH 28/49] Update docs source --- docsrc/source/modules/how_it_works.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docsrc/source/modules/how_it_works.rst b/docsrc/source/modules/how_it_works.rst index cec2fc2d..c3d0c8d8 100644 --- a/docsrc/source/modules/how_it_works.rst +++ b/docsrc/source/modules/how_it_works.rst @@ -62,7 +62,7 @@ Code organization Currently the application is divided in the following modules and files: -* :code:`api`: This module contains the :code:`API` Django app, which defines the models and API endpoints for authentication (:code:`Auth API`), Commander (:code:`Commander API`) and ConfigFile (:code:`ConfigFile API`) APIs. For more details please refer to the :ref:`ApiDoc` section +* :code:`api`: This module contains the :code:`API` Django app, which defines the models and API endpoints for authentication (:code:`Auth API`), Commander (:code:`Commander API`), ConfigFile (:code:`ConfigFile API`) and EmergencyContact (:code:`EmergencyContact API`) APIs. For more details please refer to the :ref:`ApiDoc` section * :code:`ui_framework`: This module contains the :code:`UI Framework` Django app, which defines the models and API endpoints for the UI Framework views (:code:`UI Framework API`) API. For more details please refer to the :ref:`ApiDoc` section * :code:`subscription`: This module contains the Django app that defines the consumers that handle the websocket communication. * :code:`manager`: This module contains basic Django configuration files, such as urls and channels routing, etc. From 5e2036f000e1ff2565b558f4eeb16d3e790f6e19 Mon Sep 17 00:00:00 2001 From: spereirag Date: Mon, 11 Jan 2021 12:32:17 -0300 Subject: [PATCH 29/49] Update docs --- docs/doctrees/apidoc/api.doctree | Bin 172780 -> 278707 bytes docs/doctrees/apidoc/api.tests.doctree | Bin 65403 -> 87118 bytes docs/doctrees/apidoc/manager.doctree | Bin 50282 -> 50282 bytes docs/doctrees/apidoc/subscription.doctree | Bin 75862 -> 60508 bytes docs/doctrees/apidoc/ui_framework.doctree | Bin 228320 -> 228304 bytes docs/doctrees/environment.pickle | Bin 140624 -> 152749 bytes docs/doctrees/modules/how_it_works.doctree | Bin 32514 -> 33017 bytes docs/doctrees/modules/how_to_use_it.doctree | Bin 128850 -> 128850 bytes docs/html/_sources/apidoc/api.tests.rst.txt | 22 +- .../_sources/modules/how_it_works.rst.txt | 2 +- .../_sources/modules/how_to_use_it.rst.txt | 4 +- docs/html/apidoc/api.html | 445 +++++++++++++++++- docs/html/apidoc/api.tests.html | 110 ++++- docs/html/apidoc/manager.html | 4 +- docs/html/apidoc/modules.html | 10 +- docs/html/apidoc/subscription.html | 67 +-- docs/html/apidoc/ui_framework.html | 6 +- docs/html/genindex.html | 278 ++++++++--- docs/html/modules/how_it_works.html | 2 +- docs/html/modules/how_to_use_it.html | 4 +- docs/html/objects.inv | Bin 4898 -> 5334 bytes docs/html/py-modindex.html | 12 +- docs/html/searchindex.js | 2 +- docsrc/source/apidoc/api.tests.rst | 22 +- 24 files changed, 825 insertions(+), 165 deletions(-) diff --git a/docs/doctrees/apidoc/api.doctree b/docs/doctrees/apidoc/api.doctree index 57208d795f9a73aa8ee4b1258b52a99527718508..db159d6c8f4ff128bd5e68cc2b5f74dbffe12d42 100644 GIT binary patch literal 278707 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%0D6M;m?3M^sXm=#C(D>c& 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�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 delta 24966 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 diff --git a/docs/doctrees/apidoc/api.tests.doctree b/docs/doctrees/apidoc/api.tests.doctree index 6285486205630f271a99a864578b11f78b11903f..d7d9e3beaa913e12bdb8e2a9a8a1357e635f23f7 100644 GIT binary patch literal 87118 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 delta 7334 zcma)Bdw5jUwPznfX3k{J%mhOqCl3-JL0*{z11P8v5)hc@zuYe$umG-0C8MBy;RYca2wN7F!??-$@cG$H>@%kJXX!%e4!F7a9w>bDQCM63 zU{mX(B@9+R)2HdashR=>i=T$Q)0RQ;liT5q=>s9HZW{ESkq^yv2cgU3OT@rT=epQV zodRv4bjVni0s9}n3ArnF(O}ogpRotv+U$OCHAQ2Nrd=y%IM`GYjPkhMg$|*|0-XcN zd{mpeA?FFk5fx6Q9HcE7UKse9U zLS%RX6M|n0Qm6WNP`El|r1A-|QTJh#lX=JiYxGgDIn*q8!AI-=8%|$O7MV?g3oj&q zI{sBwCn zM8gfa)%{@gJg3%9W{HaGBr0}AL51~&GZoz;Oxh)rL-#ts^U^M4leTdKY9?}V#EGH^=4 z+KLJ6E73OyMr_#uPgVAWAGR!G--K<)7*|KL7*S+_s`2pM))M8i6UM)GL3OJH!KFfR zn;+(ED-#_?eP@;rvD+(fP)o@G*uH&+k}XCg=EVM7;O(y(p)8gIId2rg$R9i~uD2IT z_r*c$>$%E(G%WsQ8YWE3tlz6b;f@EPwr;4_A00-eWqp<^GgY{DyT39G|75|Tv>3l_ zj2PabUNVIVGsu}LwC^008i)pj$}$zsl&8_u`7gAdLYZ2s zLgO3N>MoS%{4E*D>2EGnmZ~z6aRYmZ_~>w@-31km_X{~RHeOO%F;scVDg3oku3x82 z%;(90y}FJHnMwR863~KJMcdRYA8({SE~>{~O}T(T=)ulKznIBVWR!nS8h06UJENNaY{(4|SlLra--d;~XZ#I3dWV(n*hAS$a_ResB zhD(@{iM|UzJ0vk#$V@KDt_YX=_CKJdSygGG&wsZswC$gwj>jIIKOj7|<+xMHE|D4o zx%C4&C?co1HcqLoFsJtghbG_KHB70K;Floy*P-;CJpU5YdypL1lTamOCh#f?@WHV4 zZFBQmY&8=mu|jfW*dP;E@*l0G=i@`s4?YZ3I3!D>JJ8C z)}cKANz?0>WZVw^h>)4!k3_*=JCv;*v#Ju{qLvrc8z|NJbqV-GvPpoi5Ma9!0@mWJ zRrH~Z_^tOhW7XU|{32|epA4f8uTuKNQPrf!3F!y^^f=*0R?A@g>`*PbkeRskxJb3c z?@ELV=UuSpL@bOv>eW)MW{AUzqdU|GQL6KC!lRf9DP*5G6vok1uxlZSIW`h0aa1-{ zIkE7I<6WWT*w2y7dY%mrUoH~9yAr9@$CoQlN>Vj(mfo@c8WYvCRBWkq+uMOH&ZulZz?x)r55-z@1ZBo9RI6-m#oh4c>C=q;4=p8044(klD- zIimCK=>^JZN#{hIkiO?XVS4|9oY-j`7BZ8@;b@LlwEDFVt*QjPt+iJD8%lM4O#(kk zHVOE*61ZIpfxE@w#ayC}O5h7V2_f)%fBP%}-}31^)*H_JdWh1~Erjo6_jC(4vbJ*Z zvqNnq37Lso9Lje3BhgQT_$D1%FUE9hh-@n<7GqGqzc=jsvj)HU-DKwj*rD^W!ZV$& z+{xsN_!L0P-T|6b)lDmInT<}eo60Qv>^4ECsr76wV@rugwHsQ`*2Dkq%ge4d-9JP2 z?K!9rGLuUMa={H^=DA_+g%(W$UVCY+a|;S}{%RC-6&WSa8ws>kO@iKU16>sX?Sm`l zRapMTvzUw4vungpA02d;f0j;N=e~TLeMEeYBOe@4n0?$t)kd!Ed_E8|lg|expG=|c zN{A%osKp9#D{9eT!iB*w?83v&>)5IDt5J?e$Psb;D{-`{OOA1xwa$)6j<2`x5eaFm z^V76%z4$w&mqu$?ng+Y;Q0yJK6YDcVghyk8b+*hZl*RV(DS0szW@H*`)9xPJR3j_jV4^zeMpAU|(W4%OF`_%?yEb(!N_Ac+P>2Ir5_uu2 z`LO5I0L`w1OiOJ{lRT08i)KBq<9Rgqr7fDWfJWn;WKTXzg!dj!gw~riaDGcqm{;tD zlebh@Rh$Nu-#cCD3o3t7^F(>W+6E{s?v7nrL?i&#A;5uj2~ui3uygB!F!d2HDni90 z8T5C|3to3^lw_#HXm_!JMtq#O0;m1oDs_`&x)BZe6>GPvA$$|4_0WOeaaTcQ#ZTwY zgShQkuXHewdij-iF{L>^ylOR@ zc*;#YQ9HRmq`(i8iH0k?A>a@%8{I zHa_;Vhnyo&tn+)|;kFzl^q*Oc^_q7=K{%Sp;;*}jyJ0+*OH`J-=MH$SQB#D^88fnt z3ye9b94!NSz-zOT9ZBJ`nz(zMGRsR_()1tKndqO6z~$!j1W~iEfRlyH%#J9paCW+h z>~PGCIG1)ei{#sN-L{3p2ahxRA*1OsWo)r^~pd|8)Kn<q5VY$9@Fyj_uq>u2YNP^z)MkJu@PRLV^){Z^q79L?@h=Sn&zFAT9f&OB7 z>j$|7BaD#+Y>3^22efM2ojN+2EasQ-ik&P7R*FIW>4*<<#K8k5hvOBTmf> zWcV01xMj%(mq$(w&RI?kmJp}rX)(A5UnAO@kXli;puGCYs`ACS5%ZAGXq?C%TZNG- z;&a452ZH1tdtp2vQ(|6ojA%09wNU z1c`T$I0uPukT1AT@C*{sAdw7mWLZSyfVsTQq=rlflgr(!KX%Dfy`|;mKvL5h1G_oG>-y5y6ttRQ?(l@)}qk+OpD zc~4dlzLm)e!q+ERLHJZd0p$4FAqxoK4`cy}+xM7o1>wb2RuEqGWCh{XNLCQuF=Ped z8C+Hno-O%Qfj&xUs*fm52~#o;pWF~*!<4A-@H{1J4)<noA;Nt?vP=k&UTn-V9)Lc3AmLdK=lNDkf^;cw=^iIKio-U%v~= ztP}l8#mV>Lfuh6UvKa&nEEhuUPUZ2V1L=%8a3qD>79WF7One`2xaZ1S-ee|kX02B1 zpi}wcMBCnm%E42Ev2y;JzWiqElh&8>BjVSSyRRZ*)s-E{=AMKRyz_T;YD>10;*~(0bW$E(2YFwo^a@2RJ$C>nTv2|2LW28MSX7>N0KHUd%(qbJ z6(~IX&>mi-Feu)qBM->*t-R=m5dOYS=T|jgWQ5||M%?>>&~2z9r-cktzT}4($;p(C znxU2A>=fl@0LI8!ig$K*Y^#Wf4%a8M{V6LXwT%dM(gIl4Doy$RT8K3a3$NT*PEJwNug$j_i%YlKxvw64a8;_S*m~(K zOZmg~@Qd=JLv_m$b)NErL3oAC)9HmE>~fJdZ91gGIoBGuCT<%v{&^T?lG_D{wM)|@ zcI6nkeP)~QWS}ICoYFD>$k$zAvN>hIJf6x7J)cU;OJi=0w>H6xTP0`GrW&_t#+l?v z+VF8E@yVg3=`9)1UG_e>!SVKP01Vhohh?h? z!N~4GUE-5pFrZ1ocSNB%WKZ&&OtdGFsDjz|Em!g^J3O`j@yHbCpxIw^`i+h7Es$AF{NWhG2ox9Go9b3R^Z5>C-6m4=Z6nN5uMr)EPzOXS6f^&a~Fr={fhld*8nO z3HZ|~!@SMA@7{aP{mysJJ@?#qPn5j%+W4{^rS~lzXB#eSJVZ{7){;YwGe;taVzyGf z-8pU?`C{}XTRVCB@HQ#sBu9K#NW@7h{EgCmPI9S#iFB`%>}#Dwe&zQ|dz|Fh<#Q(N zf>CNAI>Dzy_cGw3E!NY$fN8Zny0o zQD5F~vmap}4m-*4{jD{lY+MPB!#{0W39)$=`qy_w`}&kfw;J1|sY)!-sVa$02_?Qo z4v?A0TB>9vk%)ym2NG&9lU@Z)t+R`b$Y^D4iHtI!1-0w5#AyEXU1VR$6yiMAURP`^ zIaSq^46foDtW_dPj~WXV>z>idH)aCbQ^B2??eJTgE;=#osd>lfusdT+LLPecD%(Y< z8a{TX^AupHXp=W)v?&`i`YCej;W@LYv9+(#7rzX0<7*+(GBP3Au5MOiYNSh*6Hz&# z!4Fy>p(zPDrfwZj%8*2Of^}JS>_Uu=-z6|t{QUi z^%T0re^>p&b*RCbIj@Q~Lt^E3FC)vJyN!|MHaJ*khbZI87;(;I| zl}qmGRpN2EPfcjiZs652Za4Z!~X`ys?0aY?uecM217N-lXjB8Txn^l$d zV6z}-@FUVXFuu~%Lcgw>)L(gdi=_p$M(L{7fd147oL&fOn+#uAO_GOaFXI%SdNJ5$ zFcPEzl*Ix8l-+S)s(-mb4q55#rNlOwO!oHp7s}xXZC<7+k%-zGm#BYtig!G$HC!apZiJe@84J@$64Sm5HVL^*&>$PxPhW|IASE7%02&HMj?2x;oYTTfc zH|@&npApgveB6)$ZFUKu+Ne47lO^NE2@R@q1#&p5tcsX&C?)TzhV7+^>`9eR!}(!R z-gGI`)l|r6pgNGP>1OKd1|+y=#H-1qt<%Wflj9xP)+CRgoJH0xuaZW_$kKAc<_KciK^3fy* zpZ3^}X1kGNR9Gl2lv$E1hx)M@P^4S7^SMjqMKZhz+^K=yF(f!z%-Qu5C@9AzUw}5# zC10Q}xtP1;1((mg@h@TtAcWjAcnR6@&IISi<>9VGI2uu6Lo)2T3KSmG3+02RrT;(|)kycOerw z{YzYiPyd`daOH~>F`5~DLU$%d_DU&J)3aPsr7N4MILOM>V!2N)Q?cK-V=9h7rfOrE zicMRbsp30$aTbATvz1m;UF6=$a~!e@aH2bs$=IBMBz$bHG}}c!TQaA`?5@wkkvSCf zL7PcIp9^<|i=rS^kW;Wh=mbJI1(S2fNL~1Tsj@ssc$}H|e8EPHVER_~|tTJ_`kL7$uxi=c^Zt@XJ|dk+{{1=bBsq_JjbZCS_oYYlZ+D7%izhUN*!5zY>J%IEzjSGW9JM!qB?Ur(z8TUE+IlWy?*F(#3u%G zG(Tp70=jH@n<>0^G*)AYvA}4vm(9k+(qhvaA zI~i}*NpE*!`c`R9s^8|O+oPog`D#74>!5|{jJg{$w7J|FYYPJ+T(D0p0i_vdgJirN zp{H)xkvl<0v)l!T^qz2}hjw5aT8!7T;xcYo8b6uAiNZW)bShm?d45YC-#0zEFqciCBEtjh+}SKAxElF&J8L#;IIp!)n1H6ZAK94So)z3l zr+E-b7EvT`tG%RXM;1Mbez)rKmC%9lm+YB5Eo>^L|C5>$>w}}B9^}dc#RzM!h%RNU z3lB8>)hc^J4Vk%M`r`5$>H+3py8xSy&uXzwF_BIomDj*M#U%26lcS}O?pf8vQ?MR0 zN}ZxnY9)_Sfs3Vuh+C%xe1H^Y^vU_xLBvwzo-pLpCTk$;&dnyEl)<}M!TKIE?9RB1 zI|Y3KpbGJ69spF?Y7_jLUW_*P*#hI}Kf`h7){x_uYMjlFYkc8Z|0VjN zGP67gl*X!oezc723;C1l*ka^B9IkI7JwbWK47p9|gVN1riU8y-tbBtJIIaxBl>?MC zMWD_v>M(BUhq5b@oKJ?c@mJtRCA*iQex{-BlSs!Fm(*QDE=^6upFL#0v<*Md$|SaY zx}^?(Tr=vE-HeLf^wMvya!$!8Ys*-4v6K1X4y+IWJ{|Of;%J!$7FDS7)knYt@b(L; zF$cdzc#a&{+#6LA6n?YOuz^6k5Vl#qX26@(V)ed1QQ?ZH-IM*TP>uz-AEaN%ez>ASm{otMB(uRolgx>pd`;#tjdOBLsvkEm zdl43))@}CC4AY!ZztlrBOiK%h%gHcnKm{{)*LX0)oWo~FTSzdXN`yEK33739$`ukejf3{;FFWd z`?JU6RR@`We2sJy?J)A&?%VD(&=;FJI5V&&ZvBU2L=efqmTPA;;fH z;l~YcK7l{3x4xD}5$e{mhd{pvH#W86;c>erw1kjlS(XrJn((=>T0JdIw`{O&GCBQ`SES%t1M^4`1d z5-IK-`Qp9bmz1xCulm(q^6S$_lDEQS`Yrfa1DDL~f%oh>ec*j0y_D4P8iYNt8m-d@ z76oA9xbd=>xM3oj#DeI(^imZGVDDXK<_s*s}Mp(n^sQL!l<<+f3oDJrgQKx;$+ z0KyGtHb_r;$rER0Nl$r6|5+cA&NgAow6m*N%jNj?*jeJDFik>mwxRZ{yR;3sdS1pp z_niCj77E)r>_f4``(6ecUNbS+u-eaH!=fvL4U0VtHauxE*f5)8uwiUqu%YesIt0v+ zUI&~wFVA}tnt)7{8jQkwp(hdwN*EMtBr!!Zd~Z0S XX4bgUJaf?>zzMXVUR6uHe_#1u<(^_i diff --git a/docs/doctrees/apidoc/ui_framework.doctree b/docs/doctrees/apidoc/ui_framework.doctree index 2d81235c24c9389db2fa9a2370253f98b5561459..4692f1aa9dac4f8fc587114d39a91812ec31adf1 100644 GIT binary patch delta 760 zcmaFxo%h0b-i8*&DNGWK{97fN4xEx@U}9jf2`o)5stisoQHZt)%SY8Da}bOLP&;W=BB!278m3sRw4w$6H9<9 zi-0=(lZt)v(~FU$QWHynQo$vOC7Jno2yu{0bba%sfDTar`3E7B1Jn<~(~q8EnjfI9 zr>Cb;l$x8EnU|TDu8^9Onwy$eQmjx?RGOEZSdyBe2~?mSYd0l>J$6clXaqAb&=hPH zMtZoZ5f^UT1HDqsx=NDxc7bGU9S}FKeD!_S&!0ORN3n;i6G#%UD ziZP!s(#phYIwaj7+n}I3z3>c^L@eAmY)TYhsRAB%Jvq3XgH1o!3N_-a*nZ2C`I{jC D29ypR diff --git a/docs/doctrees/environment.pickle b/docs/doctrees/environment.pickle index a23b396e09b971e3c8f285a0eb7a343bdc9ff3e4..bce87e0f6126b722c1a999421c0c87c0b50c036e 100644 GIT binary patch literal 152749 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 Z(5PZQux|~Nkw%sQ1w%?avV3Wh9suy;^^E`k literal 140624 zcmd6Q34kO=b>-am)pH-3nHr5onvtk#-IsN>W_qMi&mqqt2{clC7%LnZBYwG7Ok?gfi|uhSKfybB$# z;a8gtzxNm*-5^_bp;Ks-TFtuGZ1i+{fm-Tznt&4;U}Qz2?@e@oPrWqm)qBBaBemM- zcx`&_1q-LIZ}+@lX{+6=L5W_l>gb7wM)T99Mrq0exh$V(wr1MZsR|?wU4QL$x4kHL zq|vUHatABbIyM>Vsx%?vN=9DV_Uc~AhYX9aEx`X!Btl3y?6a z-i3*Jx9njuv{MK&SW)Rr*NdHsHw}o^QQM2{PFayey5A{vsuPOu8n4wIFHZP=aT1z} z3JKPTe2lbOkZMMz)s5~nSV3{J*`6+SgvBilwE&pzb{c;R(u#>@*~^dDn-fn!`J+|8Q*BIPN;gIL(9l%A{X5q|`W!H!BZy~-FpE*Fo*$!X94QI^$9 zjj1kJg(R#5M5Ve{gpMJEvy`6wUWp29rPdW?IU&YI301K92iwhN=jp-^%Cx6{5G?;` z!W68SF8QFkqOj}5)kfL75Td=jawYLz8(Nds0O5c*z)-uL$^5N(AG#l9SrcYymD)bE z8uTFOm3*Z@8CI3O$x^r8DYl_s$wqB0mCMD+ZbNh)FoiZalZxkqjGv=Y>oCJ~v)qN2 zV`Egp&Q7~J4N7lz8qJPZY~H`jzJSB6S*H64$h5rqFBoMZYs6 zu-uQ@T3>;FSI54mWC$t@QHQM@(s`OK?C`pwS68m72tI3N|0s@w%Ml6`^wm#zUaNS% z)NX(T6__Ev+iJBvFng8qSXkL+Xtz*DaiTdr-E0&gA9iL)7YtWGhb?-oYP~rn&bIn7`VAo>JLC`B#ek z;aCK|Ph=!OvMpdtUZY1TDr%1+9^R+A}eqs;}Gb8_QI4QQRaNRiUTD2Xc%c$KyXrh3Nn}KRAFVfa&71q#mFt}PLBDM zu--^8jE`I#r3vE;a%QZQ6M;?+{8|@%IfN20&Jx3Qq7aCn-6?j`6{X094GYT!H*ly; z==}oJDnXenovVUaLSp5n%FQCRa!ch_`s2FF^^||D$PazDUE(d@M66->Ep}UFh_YZT z6cGT87b4XMOC5(X6$ey7ra~|*g4~;pQXN>LameHiq7*rF)Nyq}xF}JS(r`p=c@Qjt zSv&y-vf%yi30YITWg3Sl7Rs|#uXaMKxkykb0Ylbe7?VFo2^$GIj=hwCRe`w+cAF_t z#HUDCG3ug12mBjlBB%%jx~FGcs2KueG7S@mo#+)h!Vd8$a{;h4 z+=U6GXEc5EyHdc^wr01}>cZ%&0;0ttAPUiwNyDfOu?R#H5JTcHA+VGBXn4cXO9to* z3c0XwC_K?*9f|OUP-3*VP_-niA~=&a`r0uuE`&nI2(nE61Yl{MP*ZU*!4X`_nfQs3 z6JjfzF|_3fC<@$I#NH3Y&>kQT=u~Rpn8aKmc(l^#wEWwLhbPL7nqQcxH@jtsKiXaa zBr{xsjKlTnxIY}08os`8OW~$rG27@AYX03Ic^IwlJncP_A31g^f9%-N;{B(O9c{MZ zuTWMZ5WpIvqF5uLgx(KF(FJGsgSDSM(DO(Ys&KjL!z5=$47Cf+K8l$hlbIAFLL+^^ z+9?m_I~O__nxT?=&jm|Ek{D&z$#jg;g>QPU_5(117U7ai>*8Q_QPI~+I!-8yA^?y> z#CDaW`+cX>1}X*!bza_G>> z(Nm}H>Z!^%>C!asfN5T7x)*FbI&%EJGb8sw(W6K2saFfl_LNa<6{_n3^n)oJV}fNZ z)G$yexLZr9(n*V8rgAn|hm(Fe=`CRH=scKDZ)8ZdsK_BT*5k%X=rVpSO0Mba8dg1Nkyo`A~{88J(eQH6 zCNF0MPr2q~POb2)K34T7ygJ0iAUR*z@OoL3ZL{lSl4S@o!D({}yKZ!_Oiqx=CN@b1 z4QQd)DOKxWXx-5IuzW|F`9uX{j$rB4cjkZi@@sw|bTO+%mWFp8TMWsIx(%3*lKa^p z(lEGCgPWQgVZDNNIAvpl9);=`V8saLx1Elfi*1=`wJ6@O=f9%NkjPLj!pf6~O#zYg zg&Q$REGM{9H-XH-2)ud=ylkT!E-k?9CRnaggcskS9%V*w*vD&s6*}^bI=SqfgEfdL z3_^p&ULy~hR@t`dY+-eBeiqEXPG?)Avr%BF7#Q_DF%D9K05MJT7Qsz_@ZL5O=p>|Hejr1PZ#Sj3V=V+@-f8cgC7MSQTBV4 z4=E-FMq11=6W8h*!XSQ#b^7~3eOh=sbZ%U~gLSD!ktUE}P#Te;3YCqPL>|+;Hfvs@ z`4FX1K(kh?T8~#7!c&`f3@VhVkdxCt(JM z{YIk*WAZRTEYgW3xUj^kFo9Mag2fG(w49Tnu9;@Va6=dei7^cv00iN<&#;)rIC41~ zF$1)!IPHm*Ah0h8MuQcldaD8p@i5MZzG;)pDAypR&cnzlnruXq!qeW5AElC`Y?ZZd(+mnoSt!bQZ~EtI7es7$$Cths>}`WvDi2X}=09cd*I; zD{hcku0rgwvcY2FISBm{0s(TU8=llgEz6{htke_U4EjNtsMRsi z1s^#r$6vMjm_wz9v8UKw3Ri(q7a~HD;I>&AVTp(qpTbqk4aQbfaTZyM zCc+A61p?;kbhQH!9r+AaTE&5swi)6H7)T)i#7Son_KYeezp5m<6?VOFGzO)@Mioql z&LMMLdFk|fLtb5HD1p!y)(4FZo! zVBlBp`R;h-0bI5YH}(n?jKcgLWxh3(ry^Eh8w)mcz>ekzTcU~8aNQ2l!ky5Q0*iLO=iD><}8R{VWoidOsTOnx) z@#>A;+6^{_5gm>UV0E-M*Si?3XLML8fbrrY4BJiIZ^5Zrunc5_3#j-2GR1)mB52%~ zf~9g0+r?lLES!{{fKCPBIyl8LZWe)adoEbjg8d{Mh(-7oKB96a?1fE4{Oks4+X14fuh`>=?2SSk4TJkDCFAAf_d}q8~Myf-*u$^4?k6VQa!!( z{l8disHeBR?tj&$)zcq+V`t4*Paj+S*;-RQeQEz^Y90CXz?FCYms(pr9eeo3+6DD= z{Ih>u>#C=d&;4xeyn6B;Y}d}Ir>T>zT1!3E-u!Rf05(u@bpw_hac*AO8>|eY8CdYZ zU3}Uz0$YMUz7Tud>wC}iU}s;n7hTo^g~G7ztN_Vp@@@=#8 z;iiR1%h_pQ8W1*ApMr5lWZ7+JnXZ;$J_qrWCvuEY4*EH`$>kb&tL899n=qpsh7q+g z46e}&AzfzICqlR|*&z~iuY@%vl8Eqey}s&;lPaQJJ8UEd&V!j+4FQ3VO@}pCQO7ld zxZsWj1_C1Yi9xyh8W&2s(|p2f3~Pxwi~}Kx+V)A4n|U+htpvU`vS5V z#vc=n}YLpYQ}-S72}bM5{9&*!b9mQjPza< zpJ3C!+x7+Ii&HE`@UzJ2c`;C-m2BayAb(6*aP-0~dt6!C+>9dLR=RcU0}z}D@R2?s zvy}WP0VznS6xgI_!_MI_dJu3sp~K$g#;Ub;cREd!+mpo^fN1!Yl*3?XzQQjJ!yynD zgC>SC>gp$y^HL}y>EOb$Yoh_f&9Eu>l+J|!leyH(rIzW_V@yHDp#7R82j=HRhkpo!7Z8Nce&-P(z9|k#B_K`9h z3BuS;_DjaH0vpDiaj(>YS+kh03ZyAx5@npS7o7LTeOT=B$hoEDHeguj>nfiY@iyAv z><#e|wV6|Ch;GBU<7hh52-{d552MK8($o~}5|}U~N0We^KMZH2>Scep z<~JK;g0s^PBI6O0NHi2rsRyO}wg>28uB$z2#9{DSanUs#zOUf@g5@(};eKkBE-e~r zF$3eATNk?YE~iZ)Y%3r98N>I!lGgurgNgdMXz@mo0*3a0eG93fFXkaZ&hO-v$<^7CQ^1?_X6T(yK2!@&nQ3p0F-Mz`W2 zvAOLR{Bu(fE90^X7PvCLz=?1zG%SHjFIZwN3G?LW$f0AS_)5g{u$UC?i3kQ9~0JKY4=kuM+eK>rSsuwJ6OwJHUTe&z>;7Cw!Uja3z7Y4Sm?&(pp~H7T)3J8 zD?#FAoZ4p0!cu}IVxPRzt8Ij|?sy4Sd-aAoE=n$}!nQ5EjI1lqCr zh`2Tk6bTlW2M2(~b0^?%b7X5=@R2kEc{lLS401sPAkO&)0EA<2A_R2H(n=+e$1ers z!U#xQNF4xj$w;eu2zDEp-AW=sdMqoXvBf2>Rb0v)fM=r_PP--N&O%u0S+SxaE{cc` z*O~{xHOF>0+)dr;S-rfg@(|u1ycE2PBY1H&d;s3c}dtEogUZk z2jIW@VXqD=MqaMdWT(_W_tH1|{EYe@ZqoZ>3SMOuqsF~4b>q0HE)bwHj(h<-LTq8jJXj2qp|u24GrwQfxHm4#O;q!(u6f>{FSME#k z7ZAg%k?LI21OEp9U|Le5SJsXPi>B($aX4_WqypQ(Feie;0U-}XSVl*X;b6hoykG@6 zD0pulPW=ZfU}mLJh4Q))>V%^n;gthdi_@I!-U4+Y0q%k0i6ES;;OUWI5uTFnjV&e1 zxh|{5z5se47NjvR#dIcY#6Kt&Yf0&Gov4Bpp7cvv55#`i!Na&e@mDqzM z3f6(tEJ=5uQfe4h2YNg!i#b;Zo+m4a6+P!P)J`VVTuj}dq^cR(OM1yGU_GWFHAB=2 zdNwr}OF{oBD~mZ-&{`n}*i~@q$4Vvj{Br8%BvsEkrXGOBwWB7WT06g(8jGc!pUcW( z&b5PQFY&4u<2;E|Pkr){#y*+4iAgoKooS3g<+@Xoajm|awiEve2nn>y1FxxSXzvEJ8GBeL}MPgz;axxU2OEesBD=)tKgM>^?g z=i<4F#Md)D320nLOKlx(PmRXX(Uz<%=3Gbca#gj!PhvIOPHkPLbDq^_J!Mu>+`XyW znY4plW-2ac%D2+ZLub z4U(&DxvjEtYDkvKN?BRVxysfHmC=a@r@CAjNL4}VMkQ6%2Bs0l()CYfWijWvj&xuB z4w%%HIc5cWxQ_j^;vFFP3Fkm*`=v*^{e#pEPO96O{;Pr8I(2L={9bB&mQH^sD~mbT z>CT9j!dEnV)DQ@1s#I(NCdDqN8)VA#rZ>^A&uYIK$|KbMuo zoGa6spD^`Vsid#fOXnz}4A|sS4Ewf<92*QPQUkJ7v@|P=sERU7Li9X_Lf#B4G>R8x zwY6{_BTk5?p2uVjxg!V4P3x;2R#TY!QY**pAh2!DDaD_Xfx~#x!!cA}f4F*~~#B=X+8&E2)Au z%1Fno+#Ea?x+a>xmKu+xsb9&;V$L zKTQqEQrVwmWijU}+ajeQFLZF~EH)P@Z616xB;87GQl)K;b}|W*Yt1nu{BBl}>x1Rr z%F1HSwWb_i>@K1+2We~HoN3E)iP7D}g=3EC-sP$BSp92vRu*%vsg1IKi8pkeoy(nt zboD^$h9&J;o1#5S0OZ}uG4Z=EH6Tk_hqAJmb7iScgO~9*b>+-Ks%oZgR#H`2-3sAx zO*zh&)l=iKG*!*YV$L;Xt&^~L&`Ko@^-?z{sfN}@IulopS1YS&_Qz7I#cgERD(5u&3D-3HBdKe( z?CB2$i`Z#)?It)7?0Ua0#k)oNg)Y%LT1xei~3>OBi@ctvhUMv}V4n5KIo zo4m*vUgXi2CSzuqV7?~~B|PgTF<5zlrq{lPKfZxKzKK7+4L>~N&9SjXFTiVKwG$xZ zppf8|G1{H+#cN}=?}t)ji^<7-9&o3N#Y0QbeVQ!clKS)qF9ijh?GX1Xyl{QmeGvoe z;lAqOS1eB}VRsM5HadIwQ?4GqCv~k>58owN#P;xi*W(=rQ#UW6P-7R;7P%JE?nw>9 zlJcEdS?Ce*c2d-AFQjcXePjV|sB^}+@!813R8luEsj9ZgFvuO$VP?qAx zv$A-h6$kHbB~hG_jTHA|sT+8SiUR~(agNosSEdGKDee_nSb56U?3GTZYPEqY*s)CasniH91%Dzdi#b>D4yLSr+ZTOvk}kiKx|vCJsa{ue!Q~os ztlfPxH7-koU(d>7&NXPQ`IymrV5O3#_NQV& z%>VQhlBOCNSZEYGvYJ}BEF|u{djY4A<~oDA8_zVt+RN1x^3l{va(f7DqjL&*(KUs9 zBz3Kpb)69`GE5;eA8%QC%DCgm6lcIvs6RiIx`hel8h_W4xT7@sF2?!PI4ntb`b&Ct zm*a#T8Ow3U`+ZEzdC}6_0PerXMN70|c0+;{GqZmh%VyzSORvwWG41n6W0&Ofo+PEu z_Qc=3Qi*jTGl6>0`%}vyX%C9KYiY4-I{&`ZFsu&r+gVxYA!v@&v5lEx{H059#i?=M zD!-kHwDpD5ZA_{ybz6*$)r?{mk2$9FpHGd-Qr_QWWijW<+sXwJw}=KhX1X)-k?K~h zoSo3q3IPJOie@3@nsZFzm!}40X>LhY7IUsSeq(T-3uGcU2J@Vxz3Wo9GHJ)#A*bt1 zdxU}O&oM_Yq=seb@0zSE=3IZfnM4NNOp@ar(2<=q`B3WiCe@^RvndWYSEpmbekwIK zOPvp9WijXKgkGjE6EB2cMU;zTEK0d7qDp?j390c1Q@1mz8r7PM3olopV@7`=H7-kq z-K;F;T!nlrj6I7O84JyPq`bGLZedd8xyQojvBbq=jw3p6P7TV^+?%qpm~+kX5H$X_ zlSl~4GLr5-n7WNgb>|L2tqV{Vk2$V+dVgwEmh#@0mBpMZ&sxl6-qK1XJ^g*^<|NgV zTIw_%BzKIC8=k(98jq!+&u3*3)lh~xyPo#bl2`^78pX-1vz>6MQ+zJtg4T}8=kC&$ zP-D-`z+$KSN|bw2L$M@%XI2)vAKgyMn(gJYyy@7?J4SFU(jTRB#;(`$*i~Pw0{3V@ z>9clm(rqQRG?MDJ;GAG;V3AMlaYr6=eTtVFt)>3)tSshS{d;574_8#^Ycg0@-4~~H zykJ9@Ax-~S>V_xP^c4d%EgE3!*Ri7a%G4Mw{k|eAi#gZt)v@~3A3SvSZSV``8lUb$ zpk^xSQ1`o2%OR<{uNkOr-5y)(j@7_-rG{#0{he7^%(>RDjMX|{?_(W%e;lW{Nw-V<8~16vor|K1Egz${(^+a#%#CQQki zMflt!e0onLZ8Vdrog#guQoJH2Q}83q(_Rt2zV(DCqFywks$yOndnrhw_Hz93qxj=l z{P7(8V6TlW(65aJ%MXTE7T*q^`bzWRv14CY*!Rn0;zMNeqh_^N1BOs-_$@LzBD1gG z5t(YE>|HPp#VjY6`6Mz*^)ckITI~|ZS}w^YoD?WxFZ9j2(6atW13s(m`MFY~oa>av z@w1a`9C2kJx%@(Exg_kd!G^t{3PhC$RQV8<)r)^Nb^TT^{+X;Sw5uk)WKeDBs>O$8 z<6L#Dx-)xf{v}#d)3sk#zW6h##p2d1wk_g|=_8oG?pmk(RO(tSeSSi)i1o^IF!HTG zdg7tce0aSD9C&SaqW#B_f%?zaQ@1yvl%fCBVOWr;@`Or#H_mx?)<35PV+sEsvkE_} z?^-s3FL#YRc!YZ_O(r>SU6a;-CzbQ^z4);+{5GGCa6!7RS1LE924Tr}eO4CQS*;Z; zV!cXMMXWn>BzL%7n)WcLuoYq_lVa{n-Jqn38G_Zy{G>!=E68#8_3f#_SPHr&D~mZ- z(CUNg{&K6kMAAq#Z-VdO4ew#hmK}Lt%OulDja2PWpI7 z>Lw)B#|qGg#9$5)V~(ZJA5D$I(#6ZNvY2yStUg#NH5y($ceFI)wYfebX{3{Prfy17 zoj@NUEcv>?!t~-;x%~OmNG!d)BP)wJ*UPd)C71z}e2=R{lSvz&NZo*>+E~7~EDv7t5miLSDG5oFVz~?~G>R`%I5^we&`U=zl%`wth&3oG!C$a;u0pYl7Dq8iH3(e&aHO?or1&?r8Zb@mr7Vw{JGe(bC}cDj*y z5o7iJ&GWfVGY89?IauB_ZLjt_Y89iDT08C$0UPIB#rS}0731;LwOVHNs9+H*&pBAd zSc5ZZ_=;S0!qJcs&vF4#t zy$aEu=Tb~Gku>w6)Qw51nYDY*g=k!=al{-SNDakO&F^PrG3TmTaq4`j%vmcCYde|HHn-foX9X|)lSq*Q( zo)E<3Av?J@wS<#)*Ufv+D{OgK=91V9vvIi5yHn$_jOGr(B0~h1HK4f62;jWj`6}#- zKyci4`OE%UNhRgfB1o!|on~<7Hfk=j1h8SIr(1G9AVcvcp=D?Li;o$csjyRF1l z$@gJXrOf6yLBEWoz>BFHnN)#0912t@&5q@|(Bj$Duq^fcr>rcZ>dW9vwLhi~o`Ho% zF<1V0gW->ldA?toiXQ23<{-QI@2RDm)NVGKzIa;VG7QEMLH$N*G?uyiH^Jfs^u&AE zem`8>r0(PPbK*;_k(J8?`AIc@o>~}5RdYE%E@0l(=I2AqrlMTa!aqxm&QjHXW4HILDlM!D)@`NV(2TE83l+iKMRiLvt1V!e=l{KK*WcHk#Y%m`NWAc#v_s*I8H}5rjNM2#%YH)RuTxv{KC)t^mh3+KV1&i2; zKvs?IIPKNFX|L0sft`We=s8%xi_u%(oTR<`Q#UiIv%73>N8*Itg1m<~V|D20Orxoh zSvtHoD~mbT;rjbMIKejVmEu(A%0L>MPTi=a8r!f}UDF$@C`a&6OAW|U)Kpd$bFQeh zBVv{v)4{A%Qqk*DHz%oz*6l3`JhA$5tj)Y8H5yAlK~@%XuAj{(#$i%>uG*N&!9rP# zn&L8%wtgdZ?8vVjbg5*k-PPgL3}mf1ndvCbJWXy`-tbj++@@Ll3MpkZ8~QjD}Q%do#9SiFE!&84A=cvP&YB=XJc=V~6ZOp06|eg@IH;@%CZF<8P~ zmz9O?DXRsGS&w@+t35+l{hq8&|?3XSbBO%Ru*%vr_F|*PB&ZCiQI8`$sqEAOn8F-k@5@sIvK00sSy{}v!Zuk7!^6A0YsKUtb^Su>rX^L^#;CePr)S!7 z#JfM68jq!|pUKK%&b74;m#T5k7Duha)##a8JDGI!zfw0Ssg8zdAvzqXCJ_-+kmJPl zAEgFkDd>M@Wf4_ShB#hN1t@yVz(S*#tEs>$^Yt#?nRyb~$Gi=56>+X+uXUNW;pn>G z&8k+t>isRj;suQ8x1DL=8-?N7N3Wb~dwvUE&xwwCee#hGE}t_UY~S1E81ovV(OI1z z1lgS$mDO1<%gRD`lWl^$w?yDxR)l6uxHq1FgpoXQ;YfJ3Pv zSt=XJ$|AAKvhV9X1h)_Rx$rb@B(Pi)NhBTBQ@19mj^Ml}q5INp2^{TxS0!JQ29uc%_1>;qzkyxUBE-MS& z4W1!w#cgkA-ML$H5_avXuoEV<672<%MAFQ!rEW}8&8&s<=!8b-CfW;Jr;2_hH5f}d zzm%25oGWMLnU*+^?&vUP3aRB!Q#T{2T2}4t3LMT3Eyeqn~_{6Wr4$~6UWlSw^Adqbn=a?EaqG%L-%@JM};D^mZ7)Nm}x_p-9ko#eHoyxES_H>hA-DJc@JJ9Cht-j}*rNfotG$LvZ~ zk-&YCD{%kq)QBuy{Z>{MbFQm(VR)`ICA!lamd%?#5C1*g+h5xZFOUazK2q1`Q@1Rs zy5M#!ishBEqW#Jd(Em+pIF_#dGAoO@*Hsv{b6sf_S;?fL<(uXzbR60nhW4>4atu04 zQp2%Sv@k1+IakqojPDP3>-AiWl3W=`O@-8rO4^e)hzNfYP~~G4<=C3ICN&^SQTwy9 zm~%z#LPaSZ(YuJac;V-0gWSBWBN&vK6nH9iW0NXyw@{$c9lei;Yl6PLoxd0kH#z18 z52glZDf3uX7Extp2<-LTfCA18EHsL_nj36U_C%KCo*%^KA}jjQ)KX4rMO*b8fo#XU zgzxYKFH4QdGM*n6EMCBQ!p7)40d>OX3hpeVhIgcHa#A&HvgQk@4IbM#_a)z+8j_`j zw`OIbJI|X*k+VH#*kaEaL?_}Ji_Jw!`$+2MB~{v1$J{}5qNv^+o0ET-8kMED4`yXC z=X%>1oj*9bnKKJ%>>pD%EUCseSe{q< zuBH;3tf_=cU5@F*`qYptb*;_HBC4(oQHc(0XoSqbLZhI-CIbtNf({d9V4+da0j~@! zGzz*~E&~gV;*qSE5+RE2cSwOXC4l$C{6<>OgdgsN<1Qf1~Xf|V!57i#RYsE)cLy9O>(Ofo90yK=UnQ1 zP3l@Lbq0b(hB!X6ICq_a`!h?v_-aM2IhlilhdFU1*q`w`-ZT#o2W6&V>{nCEA))kx z-FxAhpnS;}U$e+JC-Z<7-d=>yT!d)V&gvr9w!kl^24*Sf7yBz{)(;fI8!FXK6|Mk$ z%FB&5&Q;sZ#x%ZEf3DQ7IzH4hI6JB6lc^<vpo`V-qVQl8nLM)mzqAF8l0u3 zk7i||-QtI{ve4r!8OtS&vp*+L#F|y+ZomC#bD~tw)tjYqZW69)D8tFGdb5SL#k+jJ zjAToTw#-h{YB}3r2fPXZD4~a!%6UMUH&Gf^_lT4VO4^-6SbMI=V8oIFk1Fy`Uq|4t}Ex54s>~;gtz>o=}^* za`pJVso`1L%4KDtd;CtpBDSw(4L+Bj>^5@r2~0Ek3=BFcX)JXUk}7G%-gdWv` zc4J-pvG=D&Vd-KtD~mbT#fE#k)p{AbR2N;Hz#*yv6d z!8qYGw%^0;=NXuVjIF6XiNBWC8u)8|Z5n?qule-U#9zy59sISj)}|-?V(8d{8h(Ip zY())k&>bs?i8mC-!FFtEH&}xe`rZW8IqtPZeU8tHKHaN~jm>|i=Laz#7X_G*W5F}M z+G}B9036HwXM43@3;!IO|B7DiPvye`c=)b-SO^c7!?j{qVr&sSoT7&XP`n%dDT=>I zK49^W(8EH=^yTnRk!d|#z5tvSj$x)7;bA{Ljm_`$YA-gP7r^rm8_x^j`R&H@B6$8o z<9RVWFNXKPfZZaf^s4YrQRz|nfR#QeAFyFRE+4RA@0AaW;o-04!xDHHg5$Kncrh@( zA^cM?endVXI67m3O~ZaiaUUo)PuvhA=sOO-7e!^-Y9p0TpB@r;$d0iH!=D45?+iLgfl ziGOZ9V`cN9gAsFRJ3L=$JVRyO!2N|=GS7uzo=f3-DkwD&_JQzEA?)Mw0fp_$2Nd>C z$_Et2@5l!P`#Jf5THJsp4VDLtZ>2xMJiEaL*LQUZ&LV*Ge!!2{Wk9LVhkpt2Sz z4^VNp_HCmsbW5+IR4^IH`bqhKb^WV+z`Ay>6dVB`JlrcEu&ycjfOQ4(0UPoj~J9W6mT3rm<`6tsl5FbZ12A2149!WS6@Enzi$ z3rBNCH+4OuECt&L2Tyo!UJstZIv7toZ4bV<-I%C%%f<-)7{~<7xg>_{$6#2;ZYk_b z7?n`4kh~{`ambEsF4b@p$Lz76*zN}F@GW^CzxCbrL?9``2CVHhVfgU8s12VmV0d0s ztDn%Q^%Gi}enMN&PY4O0Fwl5jK*A@4_`LZ1w|>I!fa@pxAh>?QFN3S6Zm=4^{|)Gc z;QZcedchD$)GM3Ru%2K=xj9{eZ$smy5?~U+{0Z@EX>+^=*Un=IQ+Z%)UN=}i0pHJq zdyl;irH79yec41coB*IQXeR*BxR&Mh0iO? z-b5V|#r65egQein@x&CQtgZSdJu~%Uu)H`?foVe=!;4PLK&Bj4Zfo+znV4XUY zN9C++ruv9HqQv0xvEKgu!4e-Xs)NfGg7q+v6yPK9)kbbV4JHSAGA6`Jv?vWmm*X8M z|MXU9`;Z{FTE0vH@JeQ(kuh3tqxYAAcM6-Xwi!|J&;UMY^E%(uNLS#1N$KseGK61K~(Rx%SU$avg>}l48TV&?%13;1CO;pc*Jl zW_kcMrAGBQSg@AgfEikC?_9On^%K`)wa=$Vri%%=UoIq}D=gUM8UL$= zwI*aPysQa5QkZeR3Hu=3gr}5h;=*{-s8_|bS6eOMISZOEtM7Kv=k-9Es?y;km9{i< zR`ab`a|iTP#Cd(HdZ`!2i$ye6v1Ya2C~8I7q^eO2{(W>vH6mMH8yvN28)1r}xrj^9 zjR9k~YKvvVYCAGf!nuey0drxnA-H*>QC3qq6MQT??Hlfjsy4Pv!Bs26bpXpIZ2B%& zy}B&pTi|>P$KdwdzD{Isf98S#tKsI!s#h6!jW||!bU-Z-b5`L^V!TUVpVMNr z*hI@56N3USXoT*jh)astDPUcO+;`OHy4-M-0c*`hAvRbog(U@JEM0Go4wTA@4bwC+ zoLs}yz{9mM2Q(@Ru*9Upr^kY`G^;=?UUl0Hl(~3?NvwcaoWk?-W;50q%@Ap<$khkC zey2HoV!Q(@m$;-u0S@o>q8Q*8LxgxJIEyjPBk=KtYNI$=0)3TXrIN0SB%A5OgW2az zsF^;Q3(h(ijN2(ow)(}Y)!_Oib5`+itLb;7_wK_(J2Mec_sLm6j;xOTqz3%>@F3lZ z$kAlZ3NfyCJEdxCj&WdmyCJGKmNkLaZXKjG#*o!5)*oUU8rJR9SUyf;yfPKch;S^v zCQDm;!oiMKmbm{CY@myYSqbq}xtVe<*AbHKY9tro(TI!A&e-e%0mB!P<5`GFEU}=h ztoVMo3m&h6FM=?{1@-Zawrg967Gf-~mZ{>tausd4#UvI?2%TMwvAu?;i?_Vv!5Y29 zQI0WIvkf^g(G(?~w-~1gvz(~#qKy5ODo;m-fo@TV`AvkaD8ksS0xcM0+X_7>P&QAT z;=%nE+BgwIl{lAIz@IWsgX5lhvbkF`5t;l*RtDgv@N*zu`!w)7Sc1d-e2{Tf1|33# zD6TY^T8Yq6Gi3GMHC?)Js5&`GZYvoB=0~nayG>TE5Z1*2d>`!ZooS&tBxj_@f@_l( zDu{|$-KPNp}MPQIBz&b zPVaOo1CCdJgdMBvj=|;|Db9>H-h3jZoThu*VAGAT(KaGNCTF*a6mgjC5n%|vV`z!h zW`wE6A!de&a5yVv${1r=`1q6_**!^%P#==w(A6?*55-_B;>XL2E!@uU+lOKWICiNp z4FnAFQ3ql*EGMm0D|#ep5tlecy$s#Map;WZWE1GoO9pB{V3@_U8gYjsFkdX?d;M3r z2W_hetKiTn@ztn@pl8A=7%)6N&;?oyVJsIr+Wl}A;_6{&j>Y*#tIk+{%vnY)C^;La z_dG@4)GiVmYxqyjoq-hR6*9>V*3R42790wqFMpZ4J`cn>`V=75bV!^E$cPX_C8py? zkP#fC5K(X#COf)a7M#Z-{V+KX@*o@(LmfKf8F+!?LeXoto9YZfCb?)Q7j?#IG5ZE2 z2jV%9@}(j+Au+^}nTapL+#=|7;LJp4Wejh?rIV3X_4FX0rptpbi-dJLtq@_y>WYi3 z;)`n=h(uQLMI4dUW1>5CVS!sL$cZDFLc*}mTNF!|;z*;h-=GaPz9Nn7B=1x4MW_oU z0m9_g7k_xJi56mQClL>3w(&C{4F5qbf3dQa4dtyFq%5jqSRROA372TIL5ev2cv%~Y z7m&hQ88V9hg>g`x&iU$uSnN;QaIMiu{GS4tsFdM4#3U9jo27>o34fA8g8CSmdo>y# z7Jjqo?UtnQ4ZUG)3<*W@LSL9xgg2qb;q;Z>v_zy7lg2kzt1J?S#TSWS()IR5*J#GZ z5%E>c0y-vVfFGkdDe3P6`i&&LdfsjbsBG3AVC;RR|bjvEyf2Af5RzLam+7 z`J^9qHOA})It(+CDAb~gT!9v)gePkZW~{FGKr61ewoWV1iYwv>v}mB$n=USW#<9}~ ztbsnR2=!uJ9h1RJXrSO9#4Ex%hz7hOqRV81cp}>C`$MGVRXn#pfkz8%6b!l8$QEX2 zSem(!Ez+onCtf^;oeSEySPREV;64hY-0ILmDS--=#B0iWyJHh2whJYH%A81qZ%O9O zWQKIBOliX~4TtHBdgbY|NDfwfn@og>I2JcI?zb@9Z8SEKg)z8#%7hejSOo*F&*(CEqHnUhBb@ZV?icQ9eqTyyaL zk>kflj~0)Pym<8Fpa$%=D&WN#>njh9oF2J%*KF3Xr+ z6*3(xW(Q8<2}K#pD^8D|I(_8$eFHEmCn39VAuPw3U3vP*vC-m7P8=U(TDBFal8oU^ zr%#NH9xskQ^5Bt^;2}m1pN3w1XynC%%;10}(l|z}y8P*rBL_!|hy^5l?7>0R?6Iqf zFktMJi;kR#in?g)RMci!4>)LcmMVNDUpaS?&gy&-ya4F6Bj-Q$Hauf&`d7gT#u8qb zvA!AyU^sLIs}{v>X95S|c*e@ei7_wA*zOZ)a9mZKsEp^hg<)4GQTcf?CGzwAM@39# zDAtACjnQxoAI&Jc7rt{`!)rqHXuRF;j?F9JBjWnJ zfmIficZEeP*gRMrH{tG4Zhw+;Y$^Xhk`ng&pb@FSqNO2Tw^SexLwv0`-G>X5&bOpE zF31I9*=G^yZ3)hV)pDz&mLmqg9w4}ClgwDHSEA+P+{)}o z7CWfJmZLC#EXo+}W`!ZVg|FnnO*Dqk<=n@ekukgZl+iG-N5Lyj7^*HndySiU)HVe_ zR^wg3r2+4qqi0FMLr+3(NKvW~R>lyFl&k)wg19*i2wfk;aWp(?TbjVFOE|LT`!GYu zESHfqkepRBbPt4C@b!XA#&&lGY-Ys_*@+NY1-?eu8HZQGE*ajT4DhVl8OD<#Mw9U+ z!@SSrhNewD-`VHnIc~Fv1i+Bq2}|1H(y^I$0DIjXuTN15Pq{{yjSsfH39ow2E7p8E zrJjPHMsaJ3QgCcv*T`fs>U)iHQE4`FSG+w98eS*EbJXNhEY<7kn4FoASXHS!gjs{C znxVU&Ws$ETnZ(4%?o3gtVCEG9VW_S$lM70hMLYD{=Ze^@0*v|X_e-C1Of2V^?e#imPl(g~CnBS$h2LV!Vm_urTUu6O&}ewNH3EZ!g%Ph+>P%F0 zCK30i6hMw*GIf&UV9N3Z2MmVu0WB>w-%AtaU1`zr%Hx1tx{faa7*@`Jjp@s(E$^t? zBPVh9M|(>|q1;anE?>~KGgJ>g+4b7+ez>&FS%AhPmITlu5E#D0@Lq>!N4$f%Mti_b zX>CkY%&)oepwA6=z)YiX43t*}(%?U+y4l*7nQDx0J^HP8;Uh z5m}QMA|;t>uNhoZK{97exSp`t$1pAGQG_n3L3 zh8|~R9LG&@(h>PA@aKoLNoA*i%)aU9U6tRQZ_SvppG; zWv19u)ua(QV^5WjA?oXCE+O_*wP{q&*i+>bs8aak%(17cQ6qE4o+_V(tgp;&!?D6| zw(+EDfw_!5M1`8uVWirHB9*6SgA&EUH?w&sDpuc zQQXj^8o^wp)8VVEg~%W!mL}XzSe0Syoep1RwO2Z>>pNVjf~*c_jH0uj!v+7~H~IIIqw8SLp>6S833w=?dM(WZab zL0-IoITY;UjAKU~XZ)6|tXZA#sxlI`M}&c$R>)RuIQ93cqTyy#Z`a3UEWE137WH!} zk@feg7P4q<4A+Th1a7w@CAvccjnDG?cAX64u_(r9i&7%IH$B2=jSSzl1D&(5Ec14F z=jsHv_=uCfDVYzoYU0bqVKmEYa@`GlD~^Zw=291q#|@Mh^K&m6n7yExGeKT|PI$M0 zW0w<~f6$tD>qAA2S_f@(EMFGQtJ(IvJ+JZ~`2@P77O; zr8;?mjF9+JQC~<_l@_A52lE&>GDTk^dO;BJDjA~d9|9+GN?BVn8@VdZ01KQOtLYY= z)_0H05HXk}_r*q9q&j=c>~48~Z$iKX${gcvNK?{vqwif}w2Jtz0c~L3^ZHZ>L~Ts& zcdGTP#2{GxeA3qkxvGa%f16Ny7tUO@XHKQGGwx5Vs-sL~h5O<{EdG#{1Sv;xfh2M5Z_x51;8I#A6%g_dP5}_ru+KJ*{o<%^DL#3|nR$_rzpM zVIO>zF#!PELhc`EA$?(*eky*{?8m}oCHD=ol70-C-Np}_{a84zDZfSKPirOdLx+PU zvyeL+8B$nByw{B5%W6NA+%mLC2%c#bx5kbgaVT6CaWJt(^uxk8m7V=1Qruw)+Gp@+ zB3ay$z-Mr_OeflB@JuPJB7x6v@Mb7d_zVXt);|;Y3$wY*-(cQ0;%SFY-1Pb|vQ-ErY8S|6)* z@vtZD`a>8NJ7xj=IBVqfZ~5XYtI~Q0oF5`M2i?qkkJEXqR%Bv04q*4h|4w;uxY2zk zN|b`8?qYZZRZIiJ;*9SNaN^XO+ zB$=|RuJz%4j){u3=whMN%Ta@5MjTl!a6!FXy(^M2^TlVoHZx`$?r-4m$^mE2s=m(e zPEJ-Yq>|*o77CWpYQ%&-jUf3Fw!Z^(nyaI5UiPLV~6 zvfjk!7Sy0<0k^aGKBH5ouh(TDR=q|Iti9l;V%s%csn!A9US>s%9KDhP5nwF?C%Wd5%JxqEHCbAsKp0)6vXzx#7`Hc?$Vu?0d4FdsJ z2gEv=C?_W9L^edsS%r6=$1C?ylkNk}F*;Ve;Cu;a_~K72Y%#a8DrA_BLFi83V@g)Jo24H6`N@`)$Q(C^(iactE_R_5 zD;h~zhlYCWQd&$`j2gI@RL>wNc>V4CEJJ?a(jiMw3@@KWXoB*?QFG=e;ObR)o-Onb&W7|& zL=1Z|ioNZ1+EowkE}AX;r)L9y6cuZOWk2Du#@TAYtk^%t#PA!$?Zx}Vu;(Mx7mOC~_p9(Xkye0fx|48V~Bgs@ACZ#&=RI4&Mo10Z=wf z6=rjs{lGqyz;^BOrpt69W0G~WgL|Tc25aNco^saO!I80cP|QU>C2Y0ne`cW-{bQ~; zGj?dh#Kqb$VcS&H-^@Zg9+@qxieh7Jao832GPBTr$+m(d^T2%@KSpKg8;W8#9Gwc*X`hTwQMrL+t?g`*EpIIb1JIP4Go8+?pz$ zm*-piT2uL7l1!&3LB%Zt`^C7jWaHvJOgcoxBt`hbf%@MOpMikkJ0aw!hOSva z$WP-XQmc)DVI&!bgVHP@%&KDp0mr8{>qZ*ohrkB?lU`kXSKB|GrE?C%H@H5A?^NR6 zI+(HUzW2xa(y*rtX6ES6PYPQomCMBvTq%x&0xDd5;P!l?fh}?6x+Rmd(2@kOnNFrf zfykgY*~S2B(AJA`MS}&b+c-X3ZH$M^+Bc%W9m_mO-JBRRtApXVFTL$EW?F)ATbH&v z7z2lmKP+?~ju_djnpL4O9?d2(HiRiaOpb&(97R-?B!g{?A*dyYOTrul#4x{D$XShT zvx@rorAXR_#Bh_W!_j6|QD2g*qktIZQKe=a0J(X_oUKOf$gc@ci%WI;PS_|5Kby(w zVR#Zu*akAx{VIOKHjs&<|Nnrq|EG8Rwxm_3I+NHuEhcVfAkZ-hQ!An!@IO{7oak69 z#$$ZMthVFfOVx($FyizP?WD8Xj!U*GCpxANejx?9i0Fs@oEK6USsBA??}fK9PA3Y( zMLpnyT!%{NOMNCUR?E%(!VJ5t_ALC$5Tg;|;tPq};Y5Zp5Gz;dgJ3bQ>KN>xS+SxO zH(DSo)+GLpjbLS*CUd@HBQg;auXaJ4SF3gD*LnJNj()YouWmP3Q1g3F!?%7P-8ygH z)4gCRxKlWzhP289WAnPfqUjQR^Ulbz8n1ivp6t@}N~FvHvx~JS#m|QLIW2zr;wOHJ z@M5hae&P|jivrk1f$L%oVO^{ts*5!Qb+Iezt-MlKxj$Hr-$pK#r>l)#4J!+l5X|1Q zy(sZ?4u6yT8 zZI3tPovM&x6s!;hL`=XlzuV4S`*lTI?p>JhTKH8slsL;rn6R{(1t zP&8iWhhpd-R&+yzA9WEwu82&AwNJ*-KkcRm8&QP*M8%ti31z8Xg)5$$Z7$3|S4DNh z{z}m_&|t0LrQQIF*!th9BAU$?9kl=JpnXNrbal0_x`_YcB7Va~{FWkWJl|C`qn>%& z>`uDSL0h6|TqVn6=&KZc*<`mNzEX%?dZmzJ_y(qM4$@n!cNY-8MZEiH<|cO}RzXwFE{HZ^w)Dr`fRP;&Yha**>C3!~CxFjD@bVFi~x`>av zh$ThTRh1QuS2d;RMpZQzaoR;}xrn}tc+No#auL~arzHkH6q%Ia4=EnHVLzg1ykReo zp+BSOMuVSo5ntsZzS=>IngASJh0zp?Zk&p)|jkLaq)gYmDJ+>kfIsl{jiJp zQ3sLyErqsbXND+He9N#`!=N*?&R~`NhFb|gqFuvCR3@#JN#Q53c&hxyHir70YGVop zS)*|6Zx9(0f<@wE4G`aa-X?v)CjGrl`m#;>N1OD|HtFj&>6y64k2;9KE+!xAGksi6CB;F@sjO&B@UBw&*{rVp^SVanOER z(Rj5#6GQ)5Mb~g@zu+SNl8g8&F5<5#BFFF^Mb~ZkEk!fx|7}Io8Q-U9e8T(w82Se_ zolUa;P|=MFKjI>O%tic!i})!;``2zi(=?YW9Tbl=&NJs>l9r}ZiAw6$!(6I zZ&P$ba+kS?yIsV~UBrEg$mOv=hJH;9y%0mcE{1+%4E>fE`t5Fdu$4JD{Z2FlnWiJX zM-|uNKd5MiI1jssM_k0Cil|whaL`UF8kg{y82TeI^hXt4*Y~)CR#G%xUpa<8rRbV( z%|V-1G|smbL-!S3^F8OF%_th@`$IAGA5nD8_vH@SGm6IfJ{LoOm7;6DuXfO0t7x2W z&rJ_DatoEKWTth#Nfk9C_<%{uZdCYjMdZvs8AJbc z4E@hy=zp&0Mx}qHh`N=ZQ#20bZ)50RjG_PE82VRY=wFSY|BIU*tTMX*bIbw1#mZP` zL~}MSZ4j44$e8teSPyP_8Da5!*q$PqA+$v<;!+1OxXgrTtapmHw0o@f)vCPK#5zSY zYTw`@Zgvs3xrmp!h`U|H%N0>0+NWp+qWv!7H7;VoMZC^MywOFxMG-Zk+ZD|~bf=4W zkBfNFMLg^x9&r#OpZ(T`28(U#^$xD*6N-&)!%0Om+Hl52e8fQvcE?(ooSLCkOEZ65 zvCsfYie>;PyNFXRV$DUIb`e{Os4Mps&8YmGi#X#V{*WSSmOr9s2FsVbh|jo)&$)=N zauHvxh`RFEDwC(W$a?7S9`xj`LIR#bBprV7Ueq@Wx>v9T{(+#g+;m2 zqTFv$9uO4VLXoYQv?%8+%8y%=w_2392?{QMOU^$dD7YpoDetf-KW9AzKe8wvw(NkU}yRt%galE7i z%euVyTC+GV`q~+x@HWAn9hBm1ThR+!oons%%7Jw4U`rJ99gk)0-A$8 zG5E?@2l^2D*+5#B9j-!=W8@=L5nv+>K*SrWbJAfDOvsA_-G?2Sfkou3#94jNM72yD zFOf%NQQH(q;ZL2r7wnEpl8m6X13b^!V#M_D)^Vv(QSDWRMvZFi6CJ@-oOQ@qTB^Yq zVC>;}{j=g|1^fgz$f|d@+Bk_3ZM9C4jG($*R&{jHKxQGG?Q}L(9ZCpxM!1Bh6(kd= zC)}-;6mg6Ih{A62w3}>Yf@1O*fwm!WEI^XA1xbp^_=?^e_U)S|{skN4Rym$iP!I$s zc?qSNqvtLFa5l?q;Z7juB^^d2Q+zStxJ71`Cm}e?ElfgfeO@BIATU+!Wj@5&Uh*T( z0>EgaL?>n@9NNYRG@87`6fr^JaRSaNsswT1K)1py0F0J^v6Y(>y&9f*R!_l_kyiB( zkP$YF==xIuGE$(U2P{q74-8xL7$baPOi#iW&q4Gl z3Ve4mAnITPXD<7*JMTayP|cOBs5&2TwisFruWw&yEAB$F47`lcKZ9fn_3vOgquo(x zM#cuNoC6p@1#oNa+2Ujy?qk50(v(He);lcV=T(|vMCRbAiU2eC3W&ih5^qdD-66NIu>D!GF%G$pnAjBMGkH8kZ9gK|>Aq%*@B^FSm*9&nZy}~7SCr7?TZeWOb1H#Os4pFz$ z8yLBx-oQvQYTJL_t4}niydza`2HGCfv%1z!FsbX zVRqoM49|VzJS{9Mo^cHXlJHim+{lARFd|#mt>)B#Y2*jG;ao(yb!U#`4y$oDEWaAG z1(8MBYa4|%k!oChP-O~>Em!eRh|eGd%MUgi@I@T2?IXg~6Yy!UhF8zkOEVt4J0wIQ ziFqOhs}F{mj*3hu06d_KUzCsnP|v}#Lox~SE%h#xrdzPgM~u~Duyiy`fJ{RZFvjMZ z?Wt0u`jmL-FU&3y@+=`3I%s9aW}qp6Rd;<4ETSjg`4dDmT_A!oe7GqiN1Y@ z?+RMfU=8Zjcu5Z{Y{LtRFx>`zPk8X!o+v1ZP$?3F)hAgdtT22{3%j5$r>L^vnuq9Z zE}0EVuJF&7TC9|y$Pz0~>14>gqz((ST`36p6f8MPWXQ4Uyf^MQC!X*+KCXE*JbH(T z8e$~ojl^Kn!@3Zdkt_u4E;A(Qg7-YECrDk<1t2;-P60F3r97h{Iueom~C zKyMIiS;joem=7}sdzb?{WlZoCGpKTnM3w8zv=nSb%@Z|PBVmTwpyVdAT6krdwVNdx zE#G9~(1n2Aj*eIN;0@RI4D{z5T>stZ=yYne&H_CR2VX+3|5;@ zsAA9ybur+$+i>$RAU46-fWsFx!w`w1jIbA-O^l8rTRzN2L&0u66yV5uf-wpQhoxa< zAyf4jEDa0eu&`PADd_(20uC8&DJ-K>(K$dWS!)T3DZd5Y0gIgyFQ+2M4SuPft2QQ^ zxjeMJQxb2uP`id8DUV4YtuP_jaBAczY`%!>$0QYj8EiVuR&^qWcL&Ojx>0cwX?aQu zHXF=Ni$cdmA>g;k;%5Lu%mj?siKJkY#SKS1G2CO%M{kNvpRD4rClt3%c!W?^f*!2H zew_=YbQrS&7&slI!-cRc&~&H(SrzfrS`RA?jN55Z(ut3r9~8r>vx_lyb-* zluSs<3sSK1U>kb5m&2kcmo@S}4N*i@C8?qmR&_G`PJt*$RnZL=tSTbOs?M~)5_DB7 z=?W57fJm~yA#iF&fg!lpfneq2w+X|Z011Q)h69NF+A|Ox-3OUTAg4kaG;byCII=p4 zBKuph=>>#1tIEIlBQaXBtr=s9+r2paZRiXHKF)PBPTjV-dGQWzoAYPCsfP^ zsRD}I1)`9JO43X~MH`fg6d$3db)lXtAfd7}Jy;iNOff(p>q52Zd|_`5b*JgUx=?|v z{Cd5yZc`bvX0)hw> z7G6yf=;vVB37G^!SqW~f3{!R*PVoK`Ny^hmvhpNlkb}rBoCB47FL^i=>=XRU*5=<*ZBe%>J_U|Co8CLyErlejVYCLlMr5(A$Y7eTUck< zD#`&3DV6h(BX8yiwjae4C+_0F{c^z+G9Gr0UH!tBouYF*y$HgaOMCTs!{XrCAoPpU-7x&iKYte5#pO<0JMqKh&c z;F3|Rz$$B@zAx3Akoe`y3Je773!r-x1+UitzVD__88=g$Q3&gnuH!*A(IFMEIs6 ze2WO*RfO*mVSY|ly8!+Iq>B|{2@#en!U`g+MuLF8h6qDQ5cd+SC&DI0*i3|Nim;ss zI~8FU5%ws;(QKiEu^{9wNev72zdBcuWxi+5vqz%Bf_*IG>Gt|BD9InRfKaym{EkMi14%`{4fz-h6JJPmlNR`MR=A7 z7Zu@^MEEg9cr_7Ts|c?n!W$If$BFPpB;YMswKoyr&5H0ABK$NGaMi8$HX{62MR*4h zeqIrNfe62(2)|5(UsZ&66XDkt;XOq7Ek*e6MED&=_+29Wz9PJz2p?30|3QTRi3FU_ z)ILmvk1E2)i0}zT_+ujci6VTO2!EyspC!UyD8gS7;cpb-b42)CMff`+d`S`ho(Nx7 zgs%|ctBUYXMEIH_e4Pm2RD^F4;k%0PJtEBCBU`%w{zCtu2up~tToG0fVYMQxA;OR% ztS7=IMc7P)ZHlm+2s;&F7ZLU-!sSHRrwCUP;eaAsO@zE66o_!0B3w^|n-t+@BHX43 zw-ez`MYxLyBZ_b@5k?i^FcFR@!UIG&rU=K0@coK#k_cxM;UOZtSP@=AgvS)&aUzry zVVnq_B1{sYst7eAOe;cz2u~_Pn+RP+I7fsTMRZz95*72z#{K<9M3!4eU6KrFm6JYI{N zNHE92s1RoWXByQf58?HHukRgr862;jw{YIP*T8S|JkJLkj?u*y#Y5s7?tZ~PH-*=$ zEeLaAJD(3W%3Lx&4YPHT(XPv(<$K`A^W(wNavft+2;8>;Ay|E=dA@PE2?s(z<17ppr;Q#;t diff --git a/docs/doctrees/modules/how_it_works.doctree b/docs/doctrees/modules/how_it_works.doctree index 1c56d4b48ba91bf11607ff2e61d0797ac1628831..f0247448c3fe08dbe85415e0c971febdccef7543 100644 GIT binary patch delta 1067 zcmZ`&T}V@57~Xr2LSV5p=mL=uS%KmV*Gj}$RJ6r94!$c=)2weITmD@7!o$VS&g7{8Q@lLu0&|BXn!B{7MU#N255+Bp-9)+3l{$s2ClUR$bCD8e=( z&D(5G2_~Cz=vXa<%V)LW?pG$v*$7Q&u`wRrqFC zHV$q}#U*Z*4x1J_3gTKgpqg*{%4DnMWp0nA2#d1$6$v@cq zB;MfmFq$Ns;EJIL%iY08%f7?Ycf#wxG802n3Iy>N%n9>SDN`RcBQ8JRMT zX3uSymd$F3SzI=Ad%f`aFVlMg7G>r<`hE9co-(o8zXd+Y%mM!}laY*-)&e{fsA6)G z>7g=N$=Fz@#>qe`Of)lj(%u=d4NgAe6fL<9rtNd6b#44dLcD;Q}sWK-k z6fP6$I*pq1G;Jaw98K}?ZXwj+6N2i4>htbZezdcIC(kL+VnNX$!_JS|dbvtZO>CS^fQ5MRMLKQ|Xh zrxqz_Olh6cIHh)qMh0sSL~`=Qn0b?h<7Y9}PhJwb)ifE^Ci7*yW>I5jV3@o?QG?AnLn=!NBqKaIpxAVBab^Rf_v9~`UQ7WH z<-U`%vTm~Y!j!uJm3tzT@6G+1LPZr91$^sGs zTjdJWTY%78n4iQs6DXI#9y>*2a$}(IvV+Hl)`+* z1Cy^5W-uKEE9W=_wRZA>B9F=2i$YjH0$@wsfjalWbRK7X4h#zE$;KsiOmDzCnO?F_ zo>!s-q7IcLGBI#~!U)6*DBZ#G5@v`8(2%DvL)0g;a!YOQD|^kv7`3^oay~Pg2?qm1 zmf>XiT0dzW5K|LKl=g7uCFZ7fe&BVLfaUulLsNW^gS@mf|!riCCo rg(puC5@eRjnm74tT^!?_$szULjGB`d)c<14m^`~dj&a`PT@CgCzo7cN diff --git a/docs/doctrees/modules/how_to_use_it.doctree b/docs/doctrees/modules/how_to_use_it.doctree index 873a01ab3210559eff377398a457ac9125a01db9..65903122d6c5a736b7964c6dfbf5dd6ffbeb89d0 100644 GIT binary patch delta 63 zcmccgj{VX*_6-k3m`XA?Ptc5G1k#f~Xq^Ue1;p!wFoh=zh>J8|)ZTtko3ZgU08}Cw As{jB1 delta 63 zcmccgj{VX*_6-k3m@-Q?Ptc5G1k#f~Xq^Ue1;p!wFoh=zh>J8|)ZTtko3ZgU08`)@ As{jB1 diff --git a/docs/html/_sources/apidoc/api.tests.rst.txt b/docs/html/_sources/apidoc/api.tests.rst.txt index 232076ac..b18a5ad1 100644 --- a/docs/html/_sources/apidoc/api.tests.rst.txt +++ b/docs/html/_sources/apidoc/api.tests.rst.txt @@ -12,6 +12,14 @@ api.tests.test\_commander module :undoc-members: :show-inheritance: +api.tests.test\_lovecsc module +------------------------------ + +.. automodule:: api.tests.test_lovecsc + :members: + :undoc-members: + :show-inheritance: + api.tests.test\_schema\_validation module ----------------------------------------- @@ -28,10 +36,18 @@ api.tests.tests\_auth\_api module :undoc-members: :show-inheritance: -api.tests.tests\_config module ------------------------------- +api.tests.tests\_configfile module +---------------------------------- + +.. automodule:: api.tests.tests_configfile + :members: + :undoc-members: + :show-inheritance: + +api.tests.tests\_emergencycontact module +---------------------------------------- -.. automodule:: api.tests.tests_config +.. automodule:: api.tests.tests_emergencycontact :members: :undoc-members: :show-inheritance: diff --git a/docs/html/_sources/modules/how_it_works.rst.txt b/docs/html/_sources/modules/how_it_works.rst.txt index 63c4cf38..c3d0c8d8 100644 --- a/docs/html/_sources/modules/how_it_works.rst.txt +++ b/docs/html/_sources/modules/how_it_works.rst.txt @@ -62,7 +62,7 @@ Code organization Currently the application is divided in the following modules and files: -* :code:`api`: This module contains the :code:`API` Django app, which defines the models and API endpoints for authentication (:code:`Auth API`) and Commander (:code:`Commander API`) APIs. For more details please refer to the :ref:`ApiDoc` section +* :code:`api`: This module contains the :code:`API` Django app, which defines the models and API endpoints for authentication (:code:`Auth API`), Commander (:code:`Commander API`), ConfigFile (:code:`ConfigFile API`) and EmergencyContact (:code:`EmergencyContact API`) APIs. For more details please refer to the :ref:`ApiDoc` section * :code:`ui_framework`: This module contains the :code:`UI Framework` Django app, which defines the models and API endpoints for the UI Framework views (:code:`UI Framework API`) API. For more details please refer to the :ref:`ApiDoc` section * :code:`subscription`: This module contains the Django app that defines the consumers that handle the websocket communication. * :code:`manager`: This module contains basic Django configuration files, such as urls and channels routing, etc. diff --git a/docs/html/_sources/modules/how_to_use_it.rst.txt b/docs/html/_sources/modules/how_to_use_it.rst.txt index 5a07e37c..8811e5cc 100644 --- a/docs/html/_sources/modules/how_to_use_it.rst.txt +++ b/docs/html/_sources/modules/how_to_use_it.rst.txt @@ -65,7 +65,7 @@ Returns token, user data and permissions Validate token -------------- Validates a given authorization token, passed through HTTP Headers. -Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuraiton file. +Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuration file. If the :code:`no_config` flag is added to the end of the URL, then the LOVE config files is not read and the corresponding value is returned as :code:`null` - Url: :code:`/manager/api/validate-token/` or :code:`/manager/api/validate-token/no_config/` @@ -119,7 +119,7 @@ If the :code:`no_config` flag is added to the end of the URL, then the LOVE conf Swap token -------------- Validates a given authorization token, passed through HTTP Headers. -Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuraiton file. +Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuration file. If the :code:`no_config` flag is added to the end of the URL, then the LOVE config files is not read and the corresponding value is returned as :code:`null` - Url: :code:`/manager/api/swap-token/` or :code:`/manager/api/swap-token/no_config/` diff --git a/docs/html/apidoc/api.html b/docs/html/apidoc/api.html index 3a1df829..3227f2c4 100644 --- a/docs/html/apidoc/api.html +++ b/docs/html/apidoc/api.html @@ -187,10 +187,12 @@

5.1.2. SubmodulesDefines the Django models for this app (‘api’).

For more information see: https://docs.djangoproject.com/en/2.2/topics/db/models/

+
+
+class api.models.BaseModel(*args, **kwargs)
+

Bases: django.db.models.base.Model

+

Base Model for the models of this app.

+
+
+class Meta
+

Bases: object

+

Define attributes of the Meta class.

+
+
+abstract = False
+

Make this an abstract class in order to be used as an enhanced base model

+
+ +
+ +
+
+creation_timestamp
+

Creation timestamp, autogenerated upon creation

+
+ +
+
+get_next_by_creation_timestamp(*, field=<django.db.models.fields.DateTimeField: creation_timestamp>, is_next=True, **kwargs)
+
+ +
+
+get_next_by_update_timestamp(*, field=<django.db.models.fields.DateTimeField: update_timestamp>, is_next=True, **kwargs)
+
+ +
+
+get_previous_by_creation_timestamp(*, field=<django.db.models.fields.DateTimeField: creation_timestamp>, is_next=False, **kwargs)
+
+ +
+
+get_previous_by_update_timestamp(*, field=<django.db.models.fields.DateTimeField: update_timestamp>, is_next=False, **kwargs)
+
+ +
+
+update_timestamp
+

Update timestamp, autogenerated upon creation and autoupdated on every update

+
+ +
+ +
+
+class api.models.ConfigFile(*args, **kwargs)
+

Bases: api.models.BaseModel

+

ConfigFile Model, that includes actual configuration files, creation date and user.

+
+
+exception DoesNotExist
+

Bases: django.core.exceptions.ObjectDoesNotExist

+
+ +
+
+exception MultipleObjectsReturned
+

Bases: django.core.exceptions.MultipleObjectsReturned

+
+ +
+
+config_file
+

Reference to the config file

+
+ +
+
+file_name
+

The custom name for the configuration

+
+ +
+
+get_next_by_creation_timestamp(*, field=<django.db.models.fields.DateTimeField: creation_timestamp>, is_next=True, **kwargs)
+
+ +
+
+get_next_by_update_timestamp(*, field=<django.db.models.fields.DateTimeField: update_timestamp>, is_next=True, **kwargs)
+
+ +
+
+get_previous_by_creation_timestamp(*, field=<django.db.models.fields.DateTimeField: creation_timestamp>, is_next=False, **kwargs)
+
+ +
+
+get_previous_by_update_timestamp(*, field=<django.db.models.fields.DateTimeField: update_timestamp>, is_next=False, **kwargs)
+
+ +
+
+id
+

A wrapper for a deferred-loading field. When the value is read from this +object the first time, the query is executed.

+
+ +
+
+objects = <django.db.models.manager.Manager object>
+
+ +
+
+user
+

User who created the config file

+
+ +
+
+user_id
+
+ +
+
+validate_file_extension()
+
+ +
+ +
+
+class api.models.EmergencyContact(*args, **kwargs)
+

Bases: api.models.BaseModel

+

EmergencyContact Model

+
+
+exception DoesNotExist
+

Bases: django.core.exceptions.ObjectDoesNotExist

+
+ +
+
+exception MultipleObjectsReturned
+

Bases: django.core.exceptions.MultipleObjectsReturned

+
+ +
+
+contact_info
+

EC’s preferred contact information (work number, cell, none)

+
+ +
+
+email
+

EC’s email

+
+ +
+
+get_next_by_creation_timestamp(*, field=<django.db.models.fields.DateTimeField: creation_timestamp>, is_next=True, **kwargs)
+
+ +
+
+get_next_by_update_timestamp(*, field=<django.db.models.fields.DateTimeField: update_timestamp>, is_next=True, **kwargs)
+
+ +
+
+get_previous_by_creation_timestamp(*, field=<django.db.models.fields.DateTimeField: creation_timestamp>, is_next=False, **kwargs)
+
+ +
+
+get_previous_by_update_timestamp(*, field=<django.db.models.fields.DateTimeField: update_timestamp>, is_next=False, **kwargs)
+
+ +
+
+id
+

A wrapper for a deferred-loading field. When the value is read from this +object the first time, the query is executed.

+
+ +
+
+name
+

EC name

+
+ +
+
+objects = <django.db.models.manager.Manager object>
+
+ +
+
+subsystem
+

EC’s subsystem

+
+ +
+
class api.models.GlobalPermissions(*args, **kwargs)
@@ -485,6 +693,78 @@

5.1.2. Submodules

5.1.9. api.serializers module

Defines the serializer used by the REST API exposed by this app (‘api’).

+
+
+class api.serializers.ConfigFileContentSerializer(instance=None, data=<class 'rest_framework.fields.empty'>, **kwargs)
+

Bases: rest_framework.serializers.ModelSerializer

+

Serializer to map the Model instance into JSON format.

+
+
+class Meta
+

Bases: object

+

Meta class to map serializer’s fields with the model fields.

+
+
+fields = ('id', 'filename', 'content', 'update_timestamp')
+

The fields of the model class to serialize

+
+ +
+
+model
+

alias of api.models.ConfigFile

+
+ +
+ +
+
+get_content(obj)
+
+ +
+
+get_filename(obj)
+
+ +
+ +
+
+class api.serializers.ConfigFileSerializer(instance=None, data=<class 'rest_framework.fields.empty'>, **kwargs)
+

Bases: rest_framework.serializers.ModelSerializer

+

Serializer to map the Model instance into JSON format.

+
+
+class Meta
+

Bases: object

+

Meta class to map serializer’s fields with the model fields.

+
+
+fields = ('id', 'username', 'filename', 'creation_timestamp', 'update_timestamp')
+

The fields of the model class to serialize

+
+ +
+
+model
+

alias of api.models.ConfigFile

+
+ +
+ +
+
+get_filename(obj)
+
+ +
+
+get_username(obj)
+
+ +
+
class api.serializers.ConfigSerializer(instance=None, data=<class 'rest_framework.fields.empty'>, **kwargs)
@@ -492,6 +772,32 @@

5.1.2. SubmodulesCustom Serializer to describe the confi file field for the Apidocs.

+
+
+class api.serializers.EmergencyContactSerializer(instance=None, data=<class 'rest_framework.fields.empty'>, **kwargs)
+

Bases: rest_framework.serializers.ModelSerializer

+

Serializer to map the Model instance into JSON format.

+
+
+class Meta
+

Bases: object

+

Meta class to map serializer’s fields with the model fields.

+
+
+fields = '__all__'
+

The fields of the model class to serialize

+
+ +
+
+model
+

alias of api.models.EmergencyContact

+
+ +
+ +
+
class api.serializers.TimeDataSerializer(instance=None, data=<class 'rest_framework.fields.empty'>, **kwargs)
@@ -645,11 +951,6 @@

5.1.2. Submodules

-
-
-api.serializers.read_config_file()
-
-

5.1.10. api.signals module

@@ -699,6 +1000,70 @@

5.1.2. Submodules

5.1.12. api.views module

Defines the views exposed by the REST API exposed by this app.

+
+
+class api.views.ConfigFileViewSet(**kwargs)
+

Bases: rest_framework.viewsets.ModelViewSet

+

GET, POST, PUT, PATCH or DELETE instances the ConfigFile model.

+
+
+basename = None
+
+ +
+
+content(request, pk=None)
+

Serialize a ConfigFile’s content.

+
+
request: Request

The Requets object

+
+
pk: int

The corresponding ConfigFile pk

+
+
+
+
Returns
+

The response containing the serialized ConfigFile content

+
+
Return type
+

Response

+
+
+
+ +
+
+description = None
+
+ +
+
+detail = None
+
+ +
+
+name = None
+
+ +
+
+queryset = <QuerySet [<ConfigFile: ConfigFile object (5)>, <ConfigFile: ConfigFile object (4)>, <ConfigFile: ConfigFile object (1)>]>
+

Set of objects to be accessed by queries to this viewsets endpoints

+
+ +
+
+serializer_class
+

alias of api.serializers.ConfigFileSerializer

+
+ +
+
+suffix = None
+
+ +
+
class api.views.CustomObtainAuthToken(**kwargs)
@@ -779,6 +1144,50 @@

5.1.2. Submodules

+
+
+class api.views.EmergencyContactViewSet(**kwargs)
+

Bases: rest_framework.viewsets.ModelViewSet

+

GET, POST, PUT, PATCH or DELETE instances the EmergencyContact model.

+
+
+basename = None
+
+ +
+
+description = None
+
+ +
+
+detail = None
+
+ +
+
+name = None
+
+ +
+
+queryset = <QuerySet [<EmergencyContact: EmergencyContact object (3)>, <EmergencyContact: EmergencyContact object (1)>, <EmergencyContact: EmergencyContact object (2)>]>
+

Set of objects to be accessed by queries to this viewsets endpoints

+
+ +
+
+serializer_class
+

alias of api.serializers.EmergencyContactSerializer

+
+ +
+
+suffix = None
+
+ +
+
api.views.commander(self, request, *args, **kwargs)
@@ -834,6 +1243,24 @@

5.1.2. Submodules

+
+
+api.views.lovecsc_observinglog(self, request, *args, **kwargs)
+

Sends an observing log message to the LOVE-commander according to the received parameters

+
+
request: Request

The Request object

+
+
+
+
Returns
+

The response and status code of the request to the LOVE-Commander

+
+
Return type
+

Response

+
+
+
+
api.views.salinfo_metadata(self, request, *args, **kwargs)
diff --git a/docs/html/apidoc/api.tests.html b/docs/html/apidoc/api.tests.html index 7cdad432..a01d9fdb 100644 --- a/docs/html/apidoc/api.tests.html +++ b/docs/html/apidoc/api.tests.html @@ -263,9 +263,41 @@

5.1.1.1.1. Submodules +

5.1.1.1.3. api.tests.test_lovecsc module

+
+
+class api.tests.test_lovecsc.LOVECscTestCase(methodName='runTest')
+

Bases: django.test.testcases.TestCase

+
+
+maxDiff = None
+
+ +
+
+setUp()
+

Define the test suite setup.

+
+ +
+
+test_authorized_lovecsc_data(mock_requests, mock_environ)
+

Test authorized user observing log is sent to love-commander

+
+ +
+
+test_unauthorized_lovecsc(mock_requests, mock_environ)
+

Test an unauthorized user can’t send commands

+
+ +
+

-

5.1.1.1.3. api.tests.test_schema_validation module

+

5.1.1.1.4. api.tests.test_schema_validation module

class api.tests.test_schema_validation.SchemaValidationTestCase(methodName='runTest')
@@ -308,13 +340,18 @@

5.1.1.1.1. Submodules -

5.1.1.1.4. api.tests.tests_auth_api module

+

5.1.1.1.5. api.tests.tests_auth_api module

Test users’ authentication through the API.

class api.tests.tests_auth_api.AuthApiTestCase(methodName='runTest')

Bases: django.test.testcases.TestCase

Test suite for users’ authentication.

+
+
+static get_config_file_sample(name, content)
+
+
setUp()
@@ -402,37 +439,82 @@

5.1.1.1.1. Submodules -

5.1.1.1.5. api.tests.tests_config module

+
+

5.1.1.1.6. api.tests.tests_configfile module

Test users’ authentication through the API.

-
-class api.tests.tests_config.ConfigApiTestCase(methodName='runTest')
+
+class api.tests.tests_configfile.ConfigFileApiTestCase(methodName='runTest')

Bases: django.test.testcases.TestCase

Test suite for config files handling.

-
-setUp()
+
+static get_config_file_sample(name, content)
+
+ +
+
+setUp()

Define the test suite setup.

-
-test_get_config()
-

Test that an authenticated user can get the config file.

+
+test_get_config_file()
+

Test that an authenticated user can get a config file.

+
+ +
+
+test_get_config_file_content()
+

Test that an authenticated user can get a config file content.

+
+ +
+
+test_get_config_files_list()
+

Test that an authenticated user can get a config file.

-
-test_unauthenticated_cannot_get_config()
+
+test_unauthenticated_cannot_get_config_file()

Test that an unauthenticated user cannot get the config file.

+
+
+

5.1.1.1.7. api.tests.tests_emergencycontact module

+

Test users’ authentication through the API.

+
+
+class api.tests.tests_emergencycontact.EmergencyContactApiTestCase(methodName='runTest')
+

Bases: django.test.testcases.TestCase

+

Test suite for config files handling.

+
+
+static get_config_file_sample(name, content)
+
+ +
+
+setUp()
+

Define the test suite setup.

+
+ +
+
+test_list_emergency_contacts()
+

Test that an authenticated user can get a config file.

+
+ +
+
-

5.1.1.1.6. Module contents

+

5.1.1.1.8. Module contents

diff --git a/docs/html/apidoc/manager.html b/docs/html/apidoc/manager.html index 621821ea..a87f4ac6 100644 --- a/docs/html/apidoc/manager.html +++ b/docs/html/apidoc/manager.html @@ -206,7 +206,7 @@

5.3.1. Submodules
-manager.settings.CHANNEL_LAYERS = {'default': {'BACKEND': 'channels_redis.core.RedisChannelLayer', 'CONFIG': {'hosts': ['redis://:admin123@redis:6379/0'], 'symmetric_encryption_keys': ['tbder3gzppu)kl%(u3awhhg^^zu#j&!ceh@$n&v0d38sjx43s8']}}}
+manager.settings.CHANNEL_LAYERS = {'default': {'BACKEND': 'channels_redis.core.RedisChannelLayer', 'CONFIG': {'hosts': ['redis://:admin123@redis:6379/0'], 'symmetric_encryption_keys': ['tbder3gzppu)kl%(u3awhhg^^zuj&!ceh@$n&v0d38sjx43s8.']}}}

Django Channels Channel Layer configuration (dict)

@@ -236,7 +236,7 @@

5.3.1. Submodules
-manager.settings.SECRET_KEY = 'tbder3gzppu)kl%(u3awhhg^^zu#j&!ceh@$n&v0d38sjx43s8'
+manager.settings.SECRET_KEY = 'tbder3gzppu)kl%(u3awhhg^^zuj&!ceh@$n&v0d38sjx43s8.'

Secret Key for Django, read from the SECRET_KEY environment variable (string)

diff --git a/docs/html/apidoc/modules.html b/docs/html/apidoc/modules.html index c6147bc0..8d1bdf92 100644 --- a/docs/html/apidoc/modules.html +++ b/docs/html/apidoc/modules.html @@ -168,10 +168,12 @@

5. ApiDoc5.1.1.1. api.tests package diff --git a/docs/html/apidoc/subscription.html b/docs/html/apidoc/subscription.html index 85426042..20205d4b 100644 --- a/docs/html/apidoc/subscription.html +++ b/docs/html/apidoc/subscription.html @@ -441,70 +441,9 @@

5.4.1. SubmodulesUses an internal data structure (dictionary) to store the heartbeats of LOVE components. Runs 2 tasks in order to dispatch the heartbeats and request the LOVE-Commander’s heartbeat periodically.

-
-commander_heartbeat_task = None
-

Reference to the task that requests the LOVE_COmmander heartbeats.

-
- -
-
-async classmethod dispatch_heartbeats()
-

Dispatch all the heartbeats to the corresponding group in the Channels Layer.

-

This is what the heartbeat_task does

-
- -
-
-heartbeat_data = {}
-

Dictionary comntaining the heartbeats data, indexed by source or component, e.g. “Commander”.

-
- -
-
-heartbeat_task = None
-

Reference to the task that dispatches the heartbeats.

-
- -
-
-classmethod initialize()
-

Initialize the HeartbeatManager

-

Run 2 async tasks in the event loop, one to dispatch the heartbeats periodically, -and the other to request the heartbeats from the LOVE-Commander periodically.

-
- -
-
-async classmethod query_commander()
-

Query the heartbeat from the LOVE-Commander periodically.

-

This is what the commander_heartbeat_task does

-
- -
-
-async classmethod reset()
-

Reset the HeartbeatManager, changing the tasks references and heartbeats dictionary back to their default values.

-
- -
-
-classmethod set_heartbeat_timestamp(source, timestamp)
-

Set a given timestamp as the heartbeat for a given source

-
-
Parameters
-
    -
  • source (string) – Name of the component to save the heartbeat, e.g. “Commander”

  • -
  • timestamp (float) – timestamp of the heartbeat

  • -
-
-
-
- -
-
-async classmethod stop()
-

Stop (cancel) the tasks.

-
+
+instance = None
+
diff --git a/docs/html/apidoc/ui_framework.html b/docs/html/apidoc/ui_framework.html index a7953b7f..587882ca 100644 --- a/docs/html/apidoc/ui_framework.html +++ b/docs/html/apidoc/ui_framework.html @@ -932,7 +932,7 @@

5.5.2. Submodules
-queryset = <QuerySet [<View: Seba>, <View: Scheduler>, <View: HealthStatusSummary>, <View: LATISS>, <View: ATCamera>, <View: AT Lightpath>, <View: ATMount overview>, <View: HealthStatusSummary>, <View: ObservingLog>, <View: Time Displays>, <View: AT generic camera>, <View: CSCSummary>, <View: ScripQueue>, <View: Watcher>, <View: CSCGroup>, <View: TimeSeriesPlot>, <View: Int. TimeSeries>, <View: ATMount state>]>
+queryset = <QuerySet [<View: sqqtest>, <View: CSCSummary>, <View: Random view>, <View: CSCSummary + CSCG>, <View: Dome & Mount>, <View: HealthStatusSummary>, <View: LATISS + Camera>, <View: Network + Scheduler>, <View: TimeDisplay>, <View: Watcher + ObsLogs>, <View: WeatherStation>, <View: Dome-Mount test>, <View: logslogs>, <View: LATISS>, <View: ATCamera>, <View: AT Lightpath>, <View: ATMount overview>, <View: HealthStatusSummary>, <View: ObservingLog>, <View: Time Displays>, '...(remaining elements truncated)...']>

Set of objects to be accessed by queries to this viewsets endpoints

@@ -1028,7 +1028,7 @@

5.5.2. Submodules
-queryset = <QuerySet [<Workspace: My Workspace>]>
+queryset = <QuerySet []>

Set of objects to be accessed by queries to this viewsets endpoints

@@ -1091,7 +1091,7 @@

5.5.2. Submodules
-queryset = <QuerySet [<WorkspaceView: My Workspace - HealthStatusSummary>, <WorkspaceView: My Workspace - Watcher>]>
+queryset = <QuerySet []>

Set of objects to be accessed by queries to this viewsets endpoints

diff --git a/docs/html/genindex.html b/docs/html/genindex.html index 4f32ec7f..127b15c5 100644 --- a/docs/html/genindex.html +++ b/docs/html/genindex.html @@ -178,8 +178,12 @@

Index

A

@@ -326,10 +376,26 @@

D

E

@@ -338,9 +404,15 @@

E

F

@@ -372,15 +446,37 @@

G

  • (in module api.views)
  • +
  • get_config_file_sample() (api.tests.tests_auth_api.AuthApiTestCase static method) + +
  • +
  • get_content() (api.serializers.ConfigFileContentSerializer method) +
  • get_dict() (in module ui_framework.tests.utils)
  • get_file_extension() (ui_framework.serializers.Base64ImageField method)
  • +
  • get_filename() (api.serializers.ConfigFileContentSerializer method) + +
  • get_next_by_created() (api.models.Token method)
  • -
  • get_next_by_creation_timestamp() (ui_framework.models.BaseModel method) +
  • get_next_by_creation_timestamp() (api.models.BaseModel method)
  • -
  • get_next_by_update_timestamp() (ui_framework.models.BaseModel method) +
  • get_next_by_update_timestamp() (api.models.BaseModel method)
  • + + -
      +
    • handle_token_deletion() (in module api.signals) +
    • hanlde_view_deletion() (in module ui_framework.signals)
    • has_read_permission() (ui_framework.models.Workspace static method) -
    • -
    • heartbeat_data (subscription.heartbeat_manager.HeartbeatManager attribute) -
    • -
    • heartbeat_task (subscription.heartbeat_manager.HeartbeatManager attribute)
    • HeartbeatManager (class in subscription.heartbeat_manager)
    • @@ -478,9 +590,13 @@

      H

      I

      - +
      -
      @@ -550,6 +670,8 @@

      M

      • (api.tests.test_commander.SalinfoTestCase attribute) +
      • +
      • (api.tests.test_lovecsc.LOVECscTestCase attribute)
      • (api.tests.test_schema_validation.SchemaValidationTestCase attribute)
      • @@ -562,6 +684,12 @@

        M

        • (api.authentication.TokenAuthentication attribute) +
        • +
        • (api.serializers.ConfigFileContentSerializer.Meta attribute) +
        • +
        • (api.serializers.ConfigFileSerializer.Meta attribute) +
        • +
        • (api.serializers.EmergencyContactSerializer.Meta attribute)
        • (api.serializers.UserSerializer.Meta attribute)
        • @@ -587,6 +715,12 @@

          N

        • name (api.apps.ApiConfig attribute)
            +
          • (api.models.EmergencyContact attribute) +
          • +
          • (api.views.ConfigFileViewSet attribute) +
          • +
          • (api.views.EmergencyContactViewSet attribute) +
          • (subscription.apps.SubscriptionConfig attribute)
          • (ui_framework.apps.UiFrameworkConfig attribute) @@ -608,9 +742,13 @@

            N

            O

              -
            • objects (api.models.GlobalPermissions attribute) +
            • objects (api.models.ConfigFile attribute)
                +
              • (api.models.EmergencyContact attribute) +
              • +
              • (api.models.GlobalPermissions attribute) +
              • (api.models.Token attribute)
              • (ui_framework.models.View attribute) @@ -646,11 +784,13 @@

                P

                Q

                - + + + + + + + diff --git a/docs/html/searchindex.js b/docs/html/searchindex.js index 528fbde1..187d49dc 100644 --- a/docs/html/searchindex.js +++ b/docs/html/searchindex.js @@ -1 +1 @@ -Search.setIndex({docnames:["apidoc/api","apidoc/api.tests","apidoc/manage","apidoc/manager","apidoc/modules","apidoc/subscription","apidoc/ui_framework","apidoc/ui_framework.tests","index","modules/how_it_works","modules/how_to_use_it","modules/overview","modules/readme_link"],envversion:{"sphinx.domains.c":1,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":1,"sphinx.domains.index":1,"sphinx.domains.javascript":1,"sphinx.domains.math":2,"sphinx.domains.python":1,"sphinx.domains.rst":1,"sphinx.domains.std":1,"sphinx.ext.intersphinx":1,sphinx:56},filenames:["apidoc/api.rst","apidoc/api.tests.rst","apidoc/manage.rst","apidoc/manager.rst","apidoc/modules.rst","apidoc/subscription.rst","apidoc/ui_framework.rst","apidoc/ui_framework.tests.rst","index.rst","modules/how_it_works.rst","modules/how_to_use_it.rst","modules/overview.rst","modules/readme_link.rst"],objects:{"":{api:[0,0,0,"-"],manage:[2,0,0,"-"],manager:[3,0,0,"-"],subscription:[5,0,0,"-"],ui_framework:[6,0,0,"-"]},"api.apps":{ApiConfig:[0,1,1,""]},"api.apps.ApiConfig":{name:[0,2,1,""],ready:[0,3,1,""]},"api.authentication":{ExpiringTokenAuthentication:[0,1,1,""],TokenAuthentication:[0,1,1,""]},"api.authentication.ExpiringTokenAuthentication":{authenticate_credentials:[0,3,1,""],expires_in:[0,3,1,""],is_token_expired:[0,3,1,""],model:[0,2,1,""],token_expire_handler:[0,3,1,""]},"api.authentication.TokenAuthentication":{model:[0,2,1,""]},"api.middleware":{GetTokenMiddleware:[0,1,1,""]},"api.models":{GlobalPermissions:[0,1,1,""],Token:[0,1,1,""]},"api.models.GlobalPermissions":{DoesNotExist:[0,4,1,""],MultipleObjectsReturned:[0,4,1,""],id:[0,2,1,""],objects:[0,2,1,""]},"api.models.Token":{DoesNotExist:[0,4,1,""],MultipleObjectsReturned:[0,4,1,""],get_next_by_created:[0,3,1,""],get_previous_by_created:[0,3,1,""],id:[0,2,1,""],objects:[0,2,1,""],user:[0,2,1,""]},"api.schema_validator":{DefaultingValidator:[0,1,1,""]},"api.schema_validator.DefaultingValidator":{validate:[0,3,1,""]},"api.serializers":{ConfigSerializer:[0,1,1,""],TimeDataSerializer:[0,1,1,""],TokenSerializer:[0,1,1,""],UserPermissionsSerializer:[0,1,1,""],UserSerializer:[0,1,1,""],read_config_file:[0,5,1,""]},"api.serializers.TokenSerializer":{get_config:[0,3,1,""],get_permissions:[0,3,1,""],get_time_data:[0,3,1,""],get_token:[0,3,1,""]},"api.serializers.UserPermissionsSerializer":{can_execute_commands:[0,3,1,""]},"api.serializers.UserSerializer":{Meta:[0,1,1,""]},"api.serializers.UserSerializer.Meta":{fields:[0,2,1,""],model:[0,2,1,""]},"api.signals":{handle_token_deletion:[0,5,1,""]},"api.tests":{test_commander:[1,0,0,"-"],test_schema_validation:[1,0,0,"-"],tests_auth_api:[1,0,0,"-"],tests_config:[1,0,0,"-"]},"api.tests.test_commander":{CommanderTestCase:[1,1,1,""],SalinfoTestCase:[1,1,1,""]},"api.tests.test_commander.CommanderTestCase":{maxDiff:[1,2,1,""],setUp:[1,3,1,""],test_authorized_commander_data:[1,3,1,""],test_unauthorized_commander:[1,3,1,""]},"api.tests.test_commander.SalinfoTestCase":{maxDiff:[1,2,1,""],setUp:[1,3,1,""],test_salinfo_metadata:[1,3,1,""],test_salinfo_topic_data:[1,3,1,""],test_salinfo_topic_data_with_param:[1,3,1,""],test_salinfo_topic_names:[1,3,1,""],test_salinfo_topic_names_with_param:[1,3,1,""]},"api.tests.test_schema_validation":{SchemaValidationTestCase:[1,1,1,""]},"api.tests.test_schema_validation.SchemaValidationTestCase":{maxDiff:[1,2,1,""],script_schema:[1,2,1,""],setUp:[1,3,1,""],test_invalid_config:[1,3,1,""],test_syntax_error:[1,3,1,""],test_valid_config:[1,3,1,""]},"api.tests.tests_auth_api":{AuthApiTestCase:[1,1,1,""]},"api.tests.tests_auth_api.AuthApiTestCase":{setUp:[1,3,1,""],test_user_fails_to_validate_deleted_token:[1,3,1,""],test_user_fails_to_validate_expired_token:[1,3,1,""],test_user_login:[1,3,1,""],test_user_login_failed:[1,3,1,""],test_user_login_twice:[1,3,1,""],test_user_logout:[1,3,1,""],test_user_swap:[1,3,1,""],test_user_swap_forbidden:[1,3,1,""],test_user_swap_no_config:[1,3,1,""],test_user_swap_wrong_credentials:[1,3,1,""],test_user_validate_token:[1,3,1,""],test_user_validate_token_fail:[1,3,1,""],test_user_validate_token_no_config:[1,3,1,""]},"api.tests.tests_config":{ConfigApiTestCase:[1,1,1,""]},"api.tests.tests_config.ConfigApiTestCase":{setUp:[1,3,1,""],test_get_config:[1,3,1,""],test_unauthenticated_cannot_get_config:[1,3,1,""]},"api.views":{CustomObtainAuthToken:[0,1,1,""],CustomSwapAuthToken:[0,1,1,""],commander:[0,5,1,""],get_config:[0,5,1,""],logout:[0,5,1,""],salinfo_metadata:[0,5,1,""],salinfo_topic_data:[0,5,1,""],salinfo_topic_names:[0,5,1,""],validate_config_schema:[0,5,1,""],validate_token:[0,5,1,""]},"api.views.CustomObtainAuthToken":{login_failed_response:[0,2,1,""],login_response:[0,2,1,""],post:[0,3,1,""]},"api.views.CustomSwapAuthToken":{login_failed_response:[0,2,1,""],login_response:[0,2,1,""],post:[0,3,1,""]},"manager.settings":{ALLOWED_HOSTS:[3,6,1,""],AUTH_LDAP_SERVER_URI:[3,6,1,""],CHANNEL_LAYERS:[3,6,1,""],DATABASES:[3,6,1,""],LANGUAGE_CODE:[3,6,1,""],MEDIA_URL:[3,6,1,""],PROCESS_CONNECTION_PASS:[3,6,1,""],SECRET_KEY:[3,6,1,""],STATIC_ROOT:[3,6,1,""],STATIC_URL:[3,6,1,""],TESTING:[3,6,1,""],TIME_ZONE:[3,6,1,""],TOKEN_EXPIRED_AFTER_DAYS:[3,6,1,""],TRACE_TIMESTAMPS:[3,6,1,""]},"manager.utils":{assert_time_data:[3,5,1,""],get_tai_to_utc:[3,5,1,""],get_times:[3,5,1,""]},"subscription.apps":{SubscriptionConfig:[5,1,1,""]},"subscription.apps.SubscriptionConfig":{name:[5,2,1,""]},"subscription.auth":{TokenAuthMiddleware:[5,1,1,""],TokenAuthMiddlewareInstance:[5,1,1,""],get_user:[5,2,1,""]},"subscription.consumers":{SubscriptionConsumer:[5,1,1,""]},"subscription.consumers.SubscriptionConsumer":{connect:[5,3,1,""],disconnect:[5,3,1,""],handle_action_message:[5,3,1,""],handle_data_message:[5,3,1,""],handle_heartbeat_message:[5,3,1,""],handle_subscription_message:[5,3,1,""],logout:[5,3,1,""],receive_json:[5,3,1,""],send_heartbeat:[5,3,1,""],subscription_all_data:[5,3,1,""],subscription_data:[5,3,1,""]},"subscription.heartbeat_manager":{HeartbeatManager:[5,1,1,""]},"subscription.heartbeat_manager.HeartbeatManager":{commander_heartbeat_task:[5,2,1,""],dispatch_heartbeats:[5,3,1,""],heartbeat_data:[5,2,1,""],heartbeat_task:[5,2,1,""],initialize:[5,3,1,""],query_commander:[5,3,1,""],reset:[5,3,1,""],set_heartbeat_timestamp:[5,3,1,""],stop:[5,3,1,""]},"subscription.routing":{websocket_urlpatterns:[5,6,1,""]},"ui_framework.apps":{UiFrameworkConfig:[6,1,1,""]},"ui_framework.apps.UiFrameworkConfig":{name:[6,2,1,""],ready:[6,3,1,""]},"ui_framework.models":{BaseModel:[6,1,1,""],OverwriteStorage:[6,1,1,""],View:[6,1,1,""],Workspace:[6,1,1,""],WorkspaceView:[6,1,1,""]},"ui_framework.models.BaseModel":{Meta:[6,1,1,""],creation_timestamp:[6,2,1,""],get_next_by_creation_timestamp:[6,3,1,""],get_next_by_update_timestamp:[6,3,1,""],get_previous_by_creation_timestamp:[6,3,1,""],get_previous_by_update_timestamp:[6,3,1,""],update_timestamp:[6,2,1,""]},"ui_framework.models.BaseModel.Meta":{"abstract":[6,2,1,""]},"ui_framework.models.OverwriteStorage":{get_available_name:[6,3,1,""]},"ui_framework.models.View":{DoesNotExist:[6,4,1,""],MultipleObjectsReturned:[6,4,1,""],data:[6,2,1,""],get_next_by_creation_timestamp:[6,3,1,""],get_next_by_update_timestamp:[6,3,1,""],get_previous_by_creation_timestamp:[6,3,1,""],get_previous_by_update_timestamp:[6,3,1,""],id:[6,2,1,""],name:[6,2,1,""],objects:[6,2,1,""],thumbnail:[6,2,1,""],workspace_views:[6,2,1,""],workspaces:[6,2,1,""]},"ui_framework.models.Workspace":{DoesNotExist:[6,4,1,""],MultipleObjectsReturned:[6,4,1,""],get_next_by_creation_timestamp:[6,3,1,""],get_next_by_update_timestamp:[6,3,1,""],get_previous_by_creation_timestamp:[6,3,1,""],get_previous_by_update_timestamp:[6,3,1,""],get_sorted_views:[6,3,1,""],has_read_permission:[6,3,1,""],id:[6,2,1,""],name:[6,2,1,""],objects:[6,2,1,""],views:[6,2,1,""],workspace_views:[6,2,1,""]},"ui_framework.models.WorkspaceView":{DoesNotExist:[6,4,1,""],MultipleObjectsReturned:[6,4,1,""],get_next_by_creation_timestamp:[6,3,1,""],get_next_by_update_timestamp:[6,3,1,""],get_previous_by_creation_timestamp:[6,3,1,""],get_previous_by_update_timestamp:[6,3,1,""],id:[6,2,1,""],objects:[6,2,1,""],sort_value:[6,2,1,""],view:[6,2,1,""],view_id:[6,2,1,""],view_name:[6,2,1,""],workspace:[6,2,1,""],workspace_id:[6,2,1,""]},"ui_framework.serializers":{Base64ImageField:[6,1,1,""],ViewSerializer:[6,1,1,""],ViewSummarySerializer:[6,1,1,""],WorkspaceFullSerializer:[6,1,1,""],WorkspaceSerializer:[6,1,1,""],WorkspaceViewSerializer:[6,1,1,""],WorkspaceWithViewNameSerializer:[6,1,1,""]},"ui_framework.serializers.Base64ImageField":{get_file_extension:[6,3,1,""],to_internal_value:[6,3,1,""],to_representation:[6,3,1,""]},"ui_framework.serializers.ViewSerializer":{Meta:[6,1,1,""],thumbnail:[6,2,1,""]},"ui_framework.serializers.ViewSerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.serializers.ViewSummarySerializer":{Meta:[6,1,1,""]},"ui_framework.serializers.ViewSummarySerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.serializers.WorkspaceFullSerializer":{Meta:[6,1,1,""]},"ui_framework.serializers.WorkspaceFullSerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.serializers.WorkspaceSerializer":{Meta:[6,1,1,""]},"ui_framework.serializers.WorkspaceSerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.serializers.WorkspaceViewSerializer":{Meta:[6,1,1,""]},"ui_framework.serializers.WorkspaceViewSerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.serializers.WorkspaceWithViewNameSerializer":{Meta:[6,1,1,""]},"ui_framework.serializers.WorkspaceWithViewNameSerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.signals":{hanlde_view_deletion:[6,5,1,""]},"ui_framework.tests":{test_view_thumbnail:[7,0,0,"-"],tests_api:[7,0,0,"-"],tests_custom_api:[7,0,0,"-"],tests_models:[7,0,0,"-"],utils:[7,0,0,"-"]},"ui_framework.tests.test_view_thumbnail":{ViewThumbnailTestCase:[7,1,1,""]},"ui_framework.tests.test_view_thumbnail.ViewThumbnailTestCase":{setUp:[7,3,1,""],test_delete_view:[7,3,1,""],test_new_view:[7,3,1,""]},"ui_framework.tests.tests_api":{AuthorizedCrudTestCase:[7,1,1,""],UnauthenticatedCrudTestCase:[7,1,1,""],UnauthorizedCrudTestCase:[7,1,1,""]},"ui_framework.tests.tests_api.AuthorizedCrudTestCase":{setUp:[7,3,1,""],test_authorized_create_objects:[7,3,1,""],test_authorized_delete_objects:[7,3,1,""],test_authorized_list_objects:[7,3,1,""],test_authorized_retrieve_objects:[7,3,1,""],test_authorized_update_objects:[7,3,1,""]},"ui_framework.tests.tests_api.UnauthenticatedCrudTestCase":{setUp:[7,3,1,""],test_unauthenticated_create_objects:[7,3,1,""],test_unauthenticated_delete_objects:[7,3,1,""],test_unauthenticated_list_objects:[7,3,1,""],test_unauthenticated_retrieve_objects:[7,3,1,""],test_unauthenticated_update_objects:[7,3,1,""]},"ui_framework.tests.tests_api.UnauthorizedCrudTestCase":{setUp:[7,3,1,""],test_unauthorized_create_objects:[7,3,1,""],test_unauthorized_delete_objects:[7,3,1,""],test_unauthorized_list_objects:[7,3,1,""],test_unauthorized_retrieve_objects:[7,3,1,""],test_unauthorized_update_objects:[7,3,1,""]},"ui_framework.tests.tests_custom_api":{AuthorizedCrudTestCase:[7,1,1,""]},"ui_framework.tests.tests_custom_api.AuthorizedCrudTestCase":{setUp:[7,3,1,""],test_get_full_workspace:[7,3,1,""],test_get_workspaces_with_view_name:[7,3,1,""]},"ui_framework.tests.tests_models":{ViewModelTestCase:[7,1,1,""],WorkspaceAndViewsRelationsTestCase:[7,1,1,""],WorkspaceModelTestCase:[7,1,1,""],WorkspaceViewModelTestCase:[7,1,1,""]},"ui_framework.tests.tests_models.ViewModelTestCase":{setUp:[7,3,1,""],test_create_view:[7,3,1,""],test_delete_view:[7,3,1,""],test_retrieve_view:[7,3,1,""],test_update_view:[7,3,1,""]},"ui_framework.tests.tests_models.WorkspaceAndViewsRelationsTestCase":{setUp:[7,3,1,""],test_add_and_get_views_to_workspace:[7,3,1,""],test_get_workspaces_from_a_view:[7,3,1,""]},"ui_framework.tests.tests_models.WorkspaceModelTestCase":{setUp:[7,3,1,""],test_create_workspace:[7,3,1,""],test_delete_workspace:[7,3,1,""],test_retrieve_workspace:[7,3,1,""],test_update_workspace:[7,3,1,""]},"ui_framework.tests.tests_models.WorkspaceViewModelTestCase":{setUp:[7,3,1,""],test_create_workspace_view:[7,3,1,""],test_delete_workspace_view:[7,3,1,""],test_retrieve_workspace_view:[7,3,1,""],test_update_workspace_view:[7,3,1,""]},"ui_framework.tests.utils":{BaseTestCase:[7,1,1,""],get_dict:[7,5,1,""]},"ui_framework.tests.utils.BaseTestCase":{setUp:[7,3,1,""]},"ui_framework.views":{ViewViewSet:[6,1,1,""],WorkspaceViewSet:[6,1,1,""],WorkspaceViewViewSet:[6,1,1,""]},"ui_framework.views.ViewViewSet":{basename:[6,2,1,""],description:[6,2,1,""],detail:[6,2,1,""],name:[6,2,1,""],queryset:[6,2,1,""],search:[6,3,1,""],serializer_class:[6,2,1,""],suffix:[6,2,1,""],summary:[6,3,1,""]},"ui_framework.views.WorkspaceViewSet":{basename:[6,2,1,""],description:[6,2,1,""],detail:[6,2,1,""],full:[6,3,1,""],name:[6,2,1,""],queryset:[6,2,1,""],serializer_class:[6,2,1,""],suffix:[6,2,1,""],with_view_name:[6,3,1,""]},"ui_framework.views.WorkspaceViewViewSet":{basename:[6,2,1,""],description:[6,2,1,""],detail:[6,2,1,""],name:[6,2,1,""],queryset:[6,2,1,""],serializer_class:[6,2,1,""],suffix:[6,2,1,""]},api:{admin:[0,0,0,"-"],apps:[0,0,0,"-"],authentication:[0,0,0,"-"],middleware:[0,0,0,"-"],models:[0,0,0,"-"],schema_validator:[0,0,0,"-"],serializers:[0,0,0,"-"],signals:[0,0,0,"-"],tests:[1,0,0,"-"],urls:[0,0,0,"-"],views:[0,0,0,"-"]},manager:{asgi:[3,0,0,"-"],routing:[3,0,0,"-"],settings:[3,0,0,"-"],urls:[3,0,0,"-"],utils:[3,0,0,"-"],wsgi:[3,0,0,"-"]},subscription:{apps:[5,0,0,"-"],auth:[5,0,0,"-"],consumers:[5,0,0,"-"],heartbeat_manager:[5,0,0,"-"],routing:[5,0,0,"-"]},ui_framework:{admin:[6,0,0,"-"],apps:[6,0,0,"-"],models:[6,0,0,"-"],serializers:[6,0,0,"-"],signals:[6,0,0,"-"],tests:[7,0,0,"-"],urls:[6,0,0,"-"],views:[6,0,0,"-"]}},objnames:{"0":["py","module","Python module"],"1":["py","class","Python class"],"2":["py","attribute","Python attribute"],"3":["py","method","Python method"],"4":["py","exception","Python exception"],"5":["py","function","Python function"],"6":["py","data","Python data"]},objtypes:{"0":"py:module","1":"py:class","2":"py:attribute","3":"py:method","4":"py:exception","5":"py:function","6":"py:data"},terms:{"abstract":6,"boolean":[0,1],"byte":6,"case":[0,5,6,9],"class":[0,1,3,5,6,7],"default":[0,1,3,5,9,10,12],"final":0,"float":[3,5],"function":[0,3,5,6],"import":[0,3,6],"int":[0,3,6],"new":[6,7,10],"null":10,"public":12,"return":[0,3,5,6,7,10],"static":[3,6],"super":6,"true":[0,1,3,6,10],"try":[6,10],"while":9,And:[0,10],For:[0,3,6,9,10,11],The:[0,3,5,6,9,10,11,12],Then:9,These:[4,9],Use:[0,8,10],Uses:5,__all__:6,abort:10,abov:9,accept:10,access:[3,6,9,11],accessor:6,accord:[0,5],ack:10,acknowledg:[9,10,11],act:[9,12],action:[5,8,9],add:[0,3,6,7],added:[7,10],adding:7,addit:9,addition:9,additionalproperti:1,additt:0,admin123:3,admin:[3,4,10,12],admin_user_pass:12,afer:1,against:[0,10],alarm:10,alarms_sound:0,alia:[0,6],all:[5,7,9,10,11,12],allow:[3,10],allowed_host:3,allski:10,also:[9,11],alter:0,among:9,ani:[0,10,11],anonymousus:5,anoth:[0,3,6,11],api:[4,5,6,7,8,11],apiconfig:0,apidoc:[0,8,9,10],app:[3,4,9],app_modul:[0,5,6],app_nam:[0,5,6],appconfig:[0,5,6],append:10,appli:[0,9],applic:[0,2,3,5,9,10,12],appliedsettingsmatchstart:10,arg:[0,5,6],argument:[0,6],as_view:[0,3,6],asgi:4,asgi_appl:3,assert:3,assert_time_data:3,associ:[5,6],async:5,asynchron:10,asyncjsonwebsocketconsum:5,atcamera:6,atmount:6,atomic_request:3,atpneumat:10,atptg:10,attach:10,attribut:6,auth:[0,4],auth_ldap_server_uri:[3,12],authapitestcas:1,authent:[1,4,5,8,9,11,12],authenticate_credenti:0,authenticationfail:0,authlist:10,author:[0,1,7,10,11],authorizedcrudtestcas:7,authtoken:0,autocommit:3,autocomplet:0,autogener:6,autoupd:6,avail:[0,6,7,10],back:[5,11],backend:[3,10],base64:6,base64imagefield:6,base:[0,1,3,5,6,7,10,11],base_url:6,basemodel:6,basenam:6,basetestcas:7,bash:12,basic:9,becaus:9,been:0,befor:[0,1,12],behavior:7,behind:10,being:[0,3],below:[6,9],between:[0,3,5,10,11],blog:[0,3,6],bool:[0,3,6],both:11,browsabl:10,build:5,built:6,call:5,callabl:3,camera:[6,10],camfe:10,can:[0,1,7,9,10,12],can_execute_command:0,cancel:5,cannot:[1,7,12],categori:[5,10],ceh:3,central:6,certain:10,chang:[5,10],channel:[3,5,10,11,12],channel_lay:3,channels_redi:3,character:10,charg:9,charset:3,check:[0,6,11],checkpoint:1,child:6,children:6,classmethod:[0,5],cleanup:1,client:[7,9,10,11],close:5,close_cod:5,cmd:[10,12],cmd_acknowledg:10,cmd_user_pass:12,code:[0,6,8,10,12],collat:3,com:[0,1,3,6],combin:10,command:[0,1,5,8,11,12],command_data:[0,10],command_nam:[0,10],command_name_1:10,command_name_2:10,commander_heartbeat_task:5,commandertestcas:1,common:7,commun:[9,11],comntain:5,compar:9,compon:[5,9,10],compos:[9,10,12],concaten:6,conext:10,confi:0,config:[0,1,3,5,6],config_path:10,configapitestcas:1,configseri:0,configur:[0,1,3,5,6,9,10,12],configuraiton:10,confirm:10,conn_max_ag:3,connect:[3,5,8,9,11,12],constitut:6,consum:[0,4,10,11],contain:[0,3,5,6,9,10,12],content:[4,8,9,10],contian:10,contrib:[0,6],copi:[0,12],core:[0,3,6],correct:3,correspond:[0,5,6,9,10,12],could:[0,10],creat:[0,3,7,9],create_doc:12,create_forward_many_to_many_manag:6,creation:[6,10],creation_timestamp:[6,10],credenti:[1,9,10,11],critic:[0,10],crud:[7,9,10],csc:[0,5,10,11],csc_1:10,csc_2:10,cscgroup:6,cscsummari:6,currenlti:0,current:[0,3,5,6,9,10],custom:[0,5,6,7],customobtainauthtoken:0,customswapauthtoken:0,dai:3,dalet:7,data1:10,data2:10,data:[0,1,3,5,6,9,11],data_dict:0,databas:[0,3,7,12],date:[0,3,5,10],datetimefield:[0,6],db_engin:12,db_host:12,db_name:12,db_pass:12,db_port:12,db_user:12,debug:12,decod:6,decoded_fil:6,def:6,defaultingvalid:0,defaults_valid:0,defer:[0,6],defin:[0,1,3,5,6,7,9,10,12],deleg:6,delet:[0,1,6,7,9],deploy:[3,12],deprec:12,describ:[0,10,12],descript:[0,1,6],detail:[6,9,10,11],dev:9,develop:[8,9],dict:[0,3,5,6],dictionari:[0,3,5,6,7,10],diffent:9,differ:[0,1,3,5,9,10,11],directory_permissions_mod:6,disabl:10,disconnect:5,disk:6,dispatch:5,dispatch_heartbeat:5,displai:6,divid:9,django:[0,1,2,3,5,6,7,9,11,12],djangoproject:[0,3,6],djangosnippet:6,djangpo:[0,6],doc:[0,3,6],docker:9,docsrc:12,doe:[0,5,12],doesnotexist:[0,6],done:[9,10,11],draft7valid:0,draft:1,drf:[0,11],durat:3,dynam:6,each:[0,1,5,6,9,10],edit:[10,12],either:10,email:[0,10],empti:[0,6,10,12],enabl:10,encod:6,end:10,endpoint:[0,6,9,10],engin:[3,12],enhanc:6,entercontrol:10,entrypoint:3,env:12,environ:[3,11],error:[0,9,10],errorcod:10,establish:[9,10,11,12],etc:9,event:[5,11],event_data:[0,10],event_nam:[0,10],event_name_1:10,event_name_2:10,everi:[6,9,11,12],exampl:[0,3,6,8,10],except:[0,1,6],exec:12,execut:[0,2,6,9,11,12],execute_command:10,exitcontrol:10,expect:[3,5,6,10],expet:0,expir:[0,1],expires_in:0,expiringtokenauthent:0,explain:9,expos:[0,3,6],extens:6,fail:[0,1],fail_cleanup:1,fail_run:1,failur:10,fals:[0,1,3,6,10],fer:11,field:[0,6,7,10],field_11:10,field_12:10,field_21:10,field_22:10,figur:[9,11],file:[0,1,3,6,7,8,9],file_nam:6,file_permissions_mod:6,filenam:6,filesystemstorag:6,final_valid:0,first:[0,6],fixtur:9,flag:[0,10],folder:12,follow:[0,3,5,9,10,11,12],foreignkei:[0,6],format:[0,3,5,9,10],forward:[6,9,10,11],found:[5,6],framework:[6,7,8,11],free:6,frequenc:10,frmework:9,from:[0,3,5,6,7,9,10,11,12],frontend:[9,11,12],full:[3,6],fulli:[6,7,10],further:11,gencam:10,gener:[0,3,5,6,10,11],genera:9,get:[0,1,3,5,6],get_available_nam:6,get_config:0,get_dict:7,get_file_extens:6,get_next_by_cr:0,get_next_by_creation_timestamp:6,get_next_by_update_timestamp:6,get_permiss:0,get_previous_by_cr:0,get_previous_by_creation_timestamp:6,get_previous_by_update_timestamp:6,get_respons:0,get_sorted_view:6,get_tai_to_utc:3,get_tim:3,get_time_data:[0,5,10],get_token:0,get_us:5,gettokenmiddlewar:0,github:[1,6,12],given:[0,5,6,7,9,10],globalpermiss:0,greenwich:[0,3,5,10],group:[0,5,9,10,11],handl:[0,1,5,6,9,11],handle_action_messag:5,handle_data_messag:5,handle_heartbeat_messag:5,handle_subscription_messag:5,handle_token_delet:0,handler:9,hanlde_view_delet:6,has:[0,3,6,9,11,12],has_read_permiss:6,have:[0,5,10,11],header:10,healthstatussummari:6,heartbeat:5,heartbeat_data:5,heartbeat_manag:4,heartbeat_task:5,heartbeatmanag:5,heavili:6,here:12,hola:10,home:[0,3,6],host:[3,12],hourangl:[0,3,5,10],how:[8,11],howto:3,html:12,http:[0,1,3,6,9,10,11,12],identifi:10,ids:[7,10],imag:[6,9],imagefield:6,implement:[6,12],includ:[0,3,6,10,11],incom:5,index:[0,3,5,8,10,12],info:[9,11],inform:[0,3,6,9,10],inherit:7,ini:9,initi:[5,9],inner:5,input:[5,10],insid:[9,12],instanc:[0,3,5,6,9,10,11],instead:[10,12],instruct:[0,12],integr:12,intend:[5,10],interfac:[0,6],intermediari:[10,11],intern:[5,6,10],invalid:[0,1,9,10],is_next:[0,6],is_token_expir:0,iso:10,its:[6,7,9,10,12],ivalid:0,join:5,jpg:6,json:[0,1,5,6,9,10],jsonschema:0,julian:[0,3,5,10],kei:[0,3,6],key11:5,key12:5,key1:10,key21:5,key22:5,key2:10,keyword:0,kwarg:[0,5,6],languag:3,language_cod:3,last:[5,10],lastli:11,latiss:6,latter:9,layer:[3,5,10,12],layout:9,ldap:[3,12],leav:5,length:6,less:0,level:[0,3],librari:9,lightpath:6,like:[6,9,10],list:[0,3,5,6,7,10],load:[0,6,8],local:[0,8],localhost:3,locat:[0,3,5,6,10,12],log:[1,11],login:[0,9],login_failed_respons:0,login_respons:0,loglevel:10,logmessag:10,logout:[0,1,5],loop:5,love:[0,1,3,5,11],love_command:5,love_csc:10,lsst:[1,11,12],mai:10,main:[2,9],make:[5,6,9],manag:[0,4,5,6,7,11,12],manager_rcv:5,mani:[6,10],manytomanydescriptor:6,manytomanyfield:6,map:[0,6],match:[0,5,6],max_length:6,maxdiff:1,maximum:6,measur:[0,3],mechan:10,media:[3,10],media_url:[3,6],messag:[0,3,5,8,9,11],meta:[0,6],metadata:[0,1],method:[1,6],methodnam:[1,7],middlewar:[4,5,12],migrat:9,minimum:1,minseveritynotif:10,minseveritysound:10,mirror:3,miss:0,mjd:[0,3,5,10],mock_environ:1,mock_request:1,mode:12,model:[4,7,9,10],modelseri:[0,6],modelviewset:6,modifi:[0,3,5,10],modul:[4,8,9],moment:10,more:[0,3,6,9,11],most:6,mostli:9,mount:[3,12],move:12,mtm1m3:10,multipleobjectsreturn:[0,6],must:[9,10,12],mute:10,my_app:[0,3,6],my_dev_password:3,myimagefieldnam:6,mymodelnam:6,name:[0,1,3,5,6,7,12],necessari:10,need:[6,11,12],never:9,nginx:3,no_config:[0,1,10],no_debug:12,none:[0,1,3,5,6,10],note:0,number:[0,1,3,5,10],numer:10,obj:7,object:[0,1,5,6,7],objectdoesnotexist:[0,6],observ:11,observinglog:[6,10],obtain:0,obtainauthtoken:0,off:12,onc:[9,11,12],one:[0,5,6,10],onli:[5,6,9,10,12],oper:[9,10,11],option:[0,3,5,10],order:[0,5,6,9,10,12],org:[1,6],organ:8,origin:[0,11],other:[0,5,8,9,11],other_app:[0,3,6],otherwis:[5,12],our:0,out:[10,12],output:[0,1,5,10],outsid:12,over:[6,9,10],overrid:[6,12],overview:[6,8],overwrit:6,overwritestorag:6,packag:[4,8],page:[0,6,8],param:[1,10],paramet:[0,3,5,6,10],parameter_1:10,parameter_2:10,parent:6,pars:5,part:[5,8,11],particular:[5,9,10,11],pass:[1,5,6,10],password:[1,3,9,12],patch:6,path:[0,3,6,10],pattern:5,payload:10,perform:[9,10],period:5,permiss:[0,6,9,10,12],pipe:11,pizza:6,pleas:[0,3,6,9,11,12],plu:10,png:10,port:[3,12],post:[0,6,10],postgr:[3,12],postgresql:[3,9,12],present:0,problem:6,process_connection_pass:[3,12],produc:[3,9,10,11,12],project:[3,4,11,12],properli:7,properti:1,propos:6,provid:[0,9,10,11,12],pull:6,purpos:[7,9,12],put:[6,10],pytest:[9,12],python:[9,11],queri:[0,1,5,6,10],query_command:5,queryset:6,rais:[0,1],raw:6,react:5,read:[0,3,6,10],read_config_fil:0,readi:[0,6],readm:8,readonli:12,reason:10,rebuild:12,receiv:[0,1,5,6,9,10,11],receive_json:5,recept:5,recommend:12,redi:[3,12],redirect:11,redis_host:12,redis_pass:12,redischannellay:3,redoc:10,ref:[0,3,6],refer:[5,6,9],regist:[0,6],regular:10,reject:5,rel:10,relat:[0,6,10],related_nam:6,relationship:[7,10],relev:[0,3],remain:0,remian:0,remov:0,repli:[10,11],repo:12,repons:11,repositori:12,repres:10,represent:6,request:[0,1,5,6,7,9,11],request_tim:[5,10],requet:6,requier:9,requir:[1,9,10],reset:5,respect:[0,3,5,9,10],respond:9,respons:[0,6,9,11],rest:[0,6,7,9,10,11],rest_framework:[0,6],restart:12,result:[0,10],retriev:[7,9],revers:6,reversemanytoonedescriptor:6,rout:[0,4,6,9],rule:[3,5],run:[1,3,5,9],runserv:9,runtest:[1,7],sal:[9,11],sal_vers:[0,10],salindex:[5,10],salinfo:[0,1,10],salinfo_metadata:0,salinfo_topic_data:0,salinfo_topic_nam:0,salinfotestcas:1,salobj:9,save:[5,6,9],scale:[0,3,5,10],schedul:6,schema:[0,1],schema_path:10,schema_valid:4,schemavalidationtestcas:1,scope:5,scripqueu:6,script:9,script_schema:1,scriptqueu:[5,10],search:[6,8,9],search_text:10,seba:6,second:[0,1,3,5,10],secret:3,secret_kei:[3,12],section:[9,11],see:[0,3,6,9,10,12],select:10,self:[0,6],send:[0,1,5,9,10,11],send_heartbeat:5,sender:[0,6],sent:[0,1,5,6,10,11],separ:10,serial:4,serializer_class:6,seriou:[0,10],server:[3,9,10,12],server_tim:10,set:[0,4,5,6,7],set_heartbeat_timestamp:5,setauthlist:10,setloglevel:10,settingsappli:10,settingvers:10,setup:[1,7],setvalu:10,sever:0,should:[10,12],showalarm:10,shown:11,side:6,sider:[9,10],sidereal_greenwich:10,sidereal_summit:[0,3,5,10],sidereal_tim:[0,3,5,10],signal:4,similarli:[9,11],simpl:9,simulationmod:10,sky:10,skycam:10,snippet:6,softwar:[5,9],softwarevers:10,solut:6,solv:6,some:[9,10,12],sort:[6,10],sort_valu:[6,10],sound:0,sourc:[5,10],specifi:10,sqlite3:12,src:[3,12],stablish:10,standard:[0,10],standbi:10,start:[1,7,10],startproject:3,state:[0,6],static_root:3,static_url:3,statu:[0,10],still:7,stop:5,storag:6,store:[5,6,9,10],stream1:[5,10],stream2:5,stream:[5,10],string:[0,1,3,5,6,9,10],structur:[3,5,10],submodul:4,subpackag:4,subscirpt:5,subscrib:[0,5,9,10,11],subscript:[4,8,9],subscription_all_data:5,subscription_data:5,subscriptionconfig:5,subscriptionconsum:5,subseri:[6,7,10],succes:0,succesfulli:10,success:10,suffix:6,suit:1,summar:[6,10],summari:6,summaryst:10,summit:[0,3,5,10],support:5,sure:9,swagger:10,swap:[0,1],symmetric_encryption_kei:3,system:[6,8,10],tabl:10,tai:[0,3,5,9,10],tai_to_utc:[0,3,5,10],target:6,task:5,tbder3gzppu:3,teelemetri:10,tel1:9,tel2:9,telemetri:[5,9,11],telemetry_data:[0,10],telemetry_nam:[0,10],telemetry_name_1:10,telemetry_name_2:10,test:[0,3,4,6,9],test_add_and_get_views_to_workspac:7,test_authorized_commander_data:1,test_authorized_create_object:7,test_authorized_delete_object:7,test_authorized_list_object:7,test_authorized_retrieve_object:7,test_authorized_update_object:7,test_command:[0,4],test_create_view:7,test_create_workspac:7,test_create_workspace_view:7,test_delete_view:7,test_delete_workspac:7,test_delete_workspace_view:7,test_get_config:1,test_get_full_workspac:7,test_get_workspaces_from_a_view:7,test_get_workspaces_with_view_nam:7,test_invalid_config:1,test_new_view:7,test_retrieve_view:7,test_retrieve_workspac:7,test_retrieve_workspace_view:7,test_salinfo_metadata:1,test_salinfo_topic_data:1,test_salinfo_topic_data_with_param:1,test_salinfo_topic_nam:1,test_salinfo_topic_names_with_param:1,test_schema_valid:[0,4],test_syntax_error:1,test_unauthenticated_cannot_get_config:1,test_unauthenticated_create_object:7,test_unauthenticated_delete_object:7,test_unauthenticated_list_object:7,test_unauthenticated_retrieve_object:7,test_unauthenticated_update_object:7,test_unauthorized_command:1,test_unauthorized_create_object:7,test_unauthorized_delete_object:7,test_unauthorized_list_object:7,test_unauthorized_retrieve_object:7,test_unauthorized_update_object:7,test_update_view:7,test_update_workspac:7,test_update_workspace_view:7,test_user_fails_to_validate_deleted_token:1,test_user_fails_to_validate_expired_token:1,test_user_login:1,test_user_login_fail:1,test_user_login_twic:1,test_user_logout:1,test_user_swap:1,test_user_swap_forbidden:1,test_user_swap_no_config:1,test_user_swap_wrong_credenti:1,test_user_validate_token:1,test_user_validate_token_fail:1,test_user_validate_token_no_config:1,test_valid_config:1,test_view_thumbnail:[4,6],testcas:[1,7],tests_api:[4,6],tests_auth_api:[0,4],tests_config:[0,4],tests_custom_api:[4,6],tests_model:[4,6],testscript:1,text:10,than:0,thei:[0,10],them:[0,10],therefor:11,thi:[0,3,5,6,9,10,11,12],those:0,though:5,throgh:[0,6],through:[1,6,7,9,10,11],thumbnail:[6,7,10],ticket:6,time:[0,1,3,5,6,9,10],time_data:[3,5,10],time_zon:3,timedataseri:0,timeseri:6,timeseriesplot:6,timestamp:[0,3,5,6,10],timezon:3,titl:[0,1,10],to_internal_valu:6,to_represent:6,token:[0,1,3,5,9,11],token_expire_handl:0,token_expired_after_dai:3,tokenauthent:0,tokenauthmiddlewar:5,tokenauthmiddlewareinst:5,tokenseri:0,tokn:0,tomchristi:6,tool:12,top:6,topic:[0,3,6],topic_data:[0,1,10],topic_nam:[0,1,10],trace:3,trace_timestamp:3,transfer:10,transform:6,treat:10,tri:9,trigger:10,ts_salobj:1,turn:[9,11],twie:1,two:0,txt:9,type:[0,1,3,5,6],u3awhhg:3,ui_framework:[4,8,9,10],uiframeworkconfig:6,unacknowledg:10,unauthent:[1,7],unauthenticatedcrudtestcas:7,unauthor:[1,7],unauthorizedcrudtestcas:7,uniqu:9,unix:[0,3,5,10],unmut:10,unpars:1,unsubscrib:[5,10],unsubscript:5,unus:[0,6],updat:[6,7,9],update_timestamp:[6,10],upload:6,upon:6,url:[4,5,9,10],urlconf:[0,3,6],urlpattern:[0,3,5,6],use:[0,3,5,7,8,12],used:[0,5,6,7,9,10,12],user:[0,1,3,5,6,7,9,12],user_user_pass:12,usernam:[0,9,10],userpermissionsseri:0,userseri:0,uses:[6,11],using:[0,1,3,9,11,12],usr:[3,12],utc:[0,3,5,9,10],util:[4,6],v0d38sjx43s8:3,valid:[0,1,9],validate_config_schema:0,validate_token:0,validationerror:0,validatorclass:0,valu:[0,3,5,6,9,10,12],value11:5,value12:5,value1:10,value21:5,value22:5,value2:10,value_11:10,value_12:10,value_21:10,value_22:10,variabl:[3,10],variou:10,vaue:10,version:10,vetween:7,via:10,view:[3,4,7,9],view_1:10,view_2:10,view_3:10,view_i:7,view_id1:10,view_id2:10,view_id3:10,view_id:[6,10],view_nam:[6,10],viewmodeltestcas:7,viewseri:6,viewset:6,viewsummaryseri:6,viewthumbnailtestcas:7,viewviewset:6,visual:11,wai:[10,12],wait:[1,9],wait_tim:1,warn:[0,10],watcher:[6,10],websocket:[3,5,8,9,11,12],websocket_urlpattern:5,well:10,wether:[0,3,12],what:5,when:[0,6,7,9,11],where:[10,11,12],which:[0,9,10,11,12],whise:10,who:10,whole:3,with_view_nam:[6,10],within:[6,10],without:[6,10],work:[1,6,8,11],workflow:11,workspac:[6,7],workspace_i:7,workspace_id1:10,workspace_id:[6,10],workspace_view:[6,7],workspaceandviewsrelationstestcas:7,workspacefullseri:6,workspacemodeltestcas:7,workspaceseri:6,workspaceview:[6,7],workspaceviewmodeltestcas:7,workspaceviewseri:6,workspaceviewset:6,workspaceviewviewset:6,workspacewithviewnameseri:6,workview:6,wrapper:[0,6],write:11,written:[6,11],wsgi:4,xml:10,xml_version:[0,10],yaml:[0,1,10],you:[10,12]},titles:["5.1. api package","5.1.1.1. api.tests package","5.2. manage module","5.3. manager package","5. ApiDoc","5.4. subscription package","5.5. ui_framework package","5.5.1.1. ui_framework.tests package","Welcome to LOVE-manager\u2019s documentation!","3. How it works","2. How to use it","1. Overview","4. Readme File"],titleterms:{Use:12,action:10,admin:[0,6],api:[0,1,9,10],apidoc:4,app:[0,5,6],asgi:3,auth:[5,9],authent:[0,10],build:12,channel:9,code:9,command:[9,10],config:10,connect:10,consum:[5,9],content:[0,1,3,5,6,7],creat:10,data:10,databas:9,delet:10,develop:12,docker:12,document:[8,12],environ:12,event:10,exampl:9,file:[10,12],framework:[9,10],full:10,get:[10,12],heartbeat:10,heartbeat_manag:5,how:[9,10],imag:12,indic:8,info:10,initi:12,layer:9,load:12,local:12,log:10,logout:10,love:[8,9,10,12],manag:[2,3,8,9,10],messag:10,metadata:10,middlewar:0,model:[0,6],modul:[0,1,2,3,5,6,7],name:10,observ:10,organ:9,other:10,overview:11,packag:[0,1,3,5,6,7],part:[9,12],password:10,readm:12,request:10,respons:10,retriev:10,rout:[3,5],run:12,sal:10,schema:10,schema_valid:0,scheme:10,search:10,serial:[0,6],set:3,signal:[0,6],submodul:[0,1,3,5,6,7],subpackag:[0,6],subscript:[5,10],summari:10,swap:10,system:12,tabl:8,telemetri:10,test:[1,7,12],test_command:1,test_schema_valid:1,test_view_thumbnail:7,tests_api:7,tests_auth_api:1,tests_config:1,tests_custom_api:7,tests_model:7,token:10,topic:10,type:10,ui_framework:[6,7],unauthent:10,unauthor:10,updat:10,url:[0,3,6],use:10,user:10,util:[3,7],valid:10,variabl:12,view:[0,6,10],websocket:10,welcom:8,work:9,workspac:10,workspaceview:10,wsgi:3}}) \ No newline at end of file +Search.setIndex({docnames:["apidoc/api","apidoc/api.tests","apidoc/manage","apidoc/manager","apidoc/modules","apidoc/subscription","apidoc/ui_framework","apidoc/ui_framework.tests","index","modules/how_it_works","modules/how_to_use_it","modules/overview","modules/readme_link"],envversion:{"sphinx.domains.c":1,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":1,"sphinx.domains.index":1,"sphinx.domains.javascript":1,"sphinx.domains.math":2,"sphinx.domains.python":1,"sphinx.domains.rst":1,"sphinx.domains.std":1,"sphinx.ext.intersphinx":1,sphinx:56},filenames:["apidoc/api.rst","apidoc/api.tests.rst","apidoc/manage.rst","apidoc/manager.rst","apidoc/modules.rst","apidoc/subscription.rst","apidoc/ui_framework.rst","apidoc/ui_framework.tests.rst","index.rst","modules/how_it_works.rst","modules/how_to_use_it.rst","modules/overview.rst","modules/readme_link.rst"],objects:{"":{api:[0,0,0,"-"],manage:[2,0,0,"-"],manager:[3,0,0,"-"],subscription:[5,0,0,"-"],ui_framework:[6,0,0,"-"]},"api.apps":{ApiConfig:[0,1,1,""]},"api.apps.ApiConfig":{name:[0,2,1,""],ready:[0,3,1,""]},"api.authentication":{ExpiringTokenAuthentication:[0,1,1,""],TokenAuthentication:[0,1,1,""]},"api.authentication.ExpiringTokenAuthentication":{authenticate_credentials:[0,3,1,""],expires_in:[0,3,1,""],is_token_expired:[0,3,1,""],model:[0,2,1,""],token_expire_handler:[0,3,1,""]},"api.authentication.TokenAuthentication":{model:[0,2,1,""]},"api.middleware":{GetTokenMiddleware:[0,1,1,""]},"api.models":{BaseModel:[0,1,1,""],ConfigFile:[0,1,1,""],EmergencyContact:[0,1,1,""],GlobalPermissions:[0,1,1,""],Token:[0,1,1,""]},"api.models.BaseModel":{Meta:[0,1,1,""],creation_timestamp:[0,2,1,""],get_next_by_creation_timestamp:[0,3,1,""],get_next_by_update_timestamp:[0,3,1,""],get_previous_by_creation_timestamp:[0,3,1,""],get_previous_by_update_timestamp:[0,3,1,""],update_timestamp:[0,2,1,""]},"api.models.BaseModel.Meta":{"abstract":[0,2,1,""]},"api.models.ConfigFile":{DoesNotExist:[0,4,1,""],MultipleObjectsReturned:[0,4,1,""],config_file:[0,2,1,""],file_name:[0,2,1,""],get_next_by_creation_timestamp:[0,3,1,""],get_next_by_update_timestamp:[0,3,1,""],get_previous_by_creation_timestamp:[0,3,1,""],get_previous_by_update_timestamp:[0,3,1,""],id:[0,2,1,""],objects:[0,2,1,""],user:[0,2,1,""],user_id:[0,2,1,""],validate_file_extension:[0,3,1,""]},"api.models.EmergencyContact":{DoesNotExist:[0,4,1,""],MultipleObjectsReturned:[0,4,1,""],contact_info:[0,2,1,""],email:[0,2,1,""],get_next_by_creation_timestamp:[0,3,1,""],get_next_by_update_timestamp:[0,3,1,""],get_previous_by_creation_timestamp:[0,3,1,""],get_previous_by_update_timestamp:[0,3,1,""],id:[0,2,1,""],name:[0,2,1,""],objects:[0,2,1,""],subsystem:[0,2,1,""]},"api.models.GlobalPermissions":{DoesNotExist:[0,4,1,""],MultipleObjectsReturned:[0,4,1,""],id:[0,2,1,""],objects:[0,2,1,""]},"api.models.Token":{DoesNotExist:[0,4,1,""],MultipleObjectsReturned:[0,4,1,""],get_next_by_created:[0,3,1,""],get_previous_by_created:[0,3,1,""],id:[0,2,1,""],objects:[0,2,1,""],user:[0,2,1,""]},"api.schema_validator":{DefaultingValidator:[0,1,1,""]},"api.schema_validator.DefaultingValidator":{validate:[0,3,1,""]},"api.serializers":{ConfigFileContentSerializer:[0,1,1,""],ConfigFileSerializer:[0,1,1,""],ConfigSerializer:[0,1,1,""],EmergencyContactSerializer:[0,1,1,""],TimeDataSerializer:[0,1,1,""],TokenSerializer:[0,1,1,""],UserPermissionsSerializer:[0,1,1,""],UserSerializer:[0,1,1,""]},"api.serializers.ConfigFileContentSerializer":{Meta:[0,1,1,""],get_content:[0,3,1,""],get_filename:[0,3,1,""]},"api.serializers.ConfigFileContentSerializer.Meta":{fields:[0,2,1,""],model:[0,2,1,""]},"api.serializers.ConfigFileSerializer":{Meta:[0,1,1,""],get_filename:[0,3,1,""],get_username:[0,3,1,""]},"api.serializers.ConfigFileSerializer.Meta":{fields:[0,2,1,""],model:[0,2,1,""]},"api.serializers.EmergencyContactSerializer":{Meta:[0,1,1,""]},"api.serializers.EmergencyContactSerializer.Meta":{fields:[0,2,1,""],model:[0,2,1,""]},"api.serializers.TokenSerializer":{get_config:[0,3,1,""],get_permissions:[0,3,1,""],get_time_data:[0,3,1,""],get_token:[0,3,1,""]},"api.serializers.UserPermissionsSerializer":{can_execute_commands:[0,3,1,""]},"api.serializers.UserSerializer":{Meta:[0,1,1,""]},"api.serializers.UserSerializer.Meta":{fields:[0,2,1,""],model:[0,2,1,""]},"api.signals":{handle_token_deletion:[0,5,1,""]},"api.tests":{test_commander:[1,0,0,"-"],test_lovecsc:[1,0,0,"-"],test_schema_validation:[1,0,0,"-"],tests_auth_api:[1,0,0,"-"],tests_configfile:[1,0,0,"-"],tests_emergencycontact:[1,0,0,"-"]},"api.tests.test_commander":{CommanderTestCase:[1,1,1,""],SalinfoTestCase:[1,1,1,""]},"api.tests.test_commander.CommanderTestCase":{maxDiff:[1,2,1,""],setUp:[1,3,1,""],test_authorized_commander_data:[1,3,1,""],test_unauthorized_commander:[1,3,1,""]},"api.tests.test_commander.SalinfoTestCase":{maxDiff:[1,2,1,""],setUp:[1,3,1,""],test_salinfo_metadata:[1,3,1,""],test_salinfo_topic_data:[1,3,1,""],test_salinfo_topic_data_with_param:[1,3,1,""],test_salinfo_topic_names:[1,3,1,""],test_salinfo_topic_names_with_param:[1,3,1,""]},"api.tests.test_lovecsc":{LOVECscTestCase:[1,1,1,""]},"api.tests.test_lovecsc.LOVECscTestCase":{maxDiff:[1,2,1,""],setUp:[1,3,1,""],test_authorized_lovecsc_data:[1,3,1,""],test_unauthorized_lovecsc:[1,3,1,""]},"api.tests.test_schema_validation":{SchemaValidationTestCase:[1,1,1,""]},"api.tests.test_schema_validation.SchemaValidationTestCase":{maxDiff:[1,2,1,""],script_schema:[1,2,1,""],setUp:[1,3,1,""],test_invalid_config:[1,3,1,""],test_syntax_error:[1,3,1,""],test_valid_config:[1,3,1,""]},"api.tests.tests_auth_api":{AuthApiTestCase:[1,1,1,""]},"api.tests.tests_auth_api.AuthApiTestCase":{get_config_file_sample:[1,3,1,""],setUp:[1,3,1,""],test_user_fails_to_validate_deleted_token:[1,3,1,""],test_user_fails_to_validate_expired_token:[1,3,1,""],test_user_login:[1,3,1,""],test_user_login_failed:[1,3,1,""],test_user_login_twice:[1,3,1,""],test_user_logout:[1,3,1,""],test_user_swap:[1,3,1,""],test_user_swap_forbidden:[1,3,1,""],test_user_swap_no_config:[1,3,1,""],test_user_swap_wrong_credentials:[1,3,1,""],test_user_validate_token:[1,3,1,""],test_user_validate_token_fail:[1,3,1,""],test_user_validate_token_no_config:[1,3,1,""]},"api.tests.tests_configfile":{ConfigFileApiTestCase:[1,1,1,""]},"api.tests.tests_configfile.ConfigFileApiTestCase":{get_config_file_sample:[1,3,1,""],setUp:[1,3,1,""],test_get_config_file:[1,3,1,""],test_get_config_file_content:[1,3,1,""],test_get_config_files_list:[1,3,1,""],test_unauthenticated_cannot_get_config_file:[1,3,1,""]},"api.tests.tests_emergencycontact":{EmergencyContactApiTestCase:[1,1,1,""]},"api.tests.tests_emergencycontact.EmergencyContactApiTestCase":{get_config_file_sample:[1,3,1,""],setUp:[1,3,1,""],test_list_emergency_contacts:[1,3,1,""]},"api.views":{ConfigFileViewSet:[0,1,1,""],CustomObtainAuthToken:[0,1,1,""],CustomSwapAuthToken:[0,1,1,""],EmergencyContactViewSet:[0,1,1,""],commander:[0,5,1,""],get_config:[0,5,1,""],logout:[0,5,1,""],lovecsc_observinglog:[0,5,1,""],salinfo_metadata:[0,5,1,""],salinfo_topic_data:[0,5,1,""],salinfo_topic_names:[0,5,1,""],validate_config_schema:[0,5,1,""],validate_token:[0,5,1,""]},"api.views.ConfigFileViewSet":{basename:[0,2,1,""],content:[0,3,1,""],description:[0,2,1,""],detail:[0,2,1,""],name:[0,2,1,""],queryset:[0,2,1,""],serializer_class:[0,2,1,""],suffix:[0,2,1,""]},"api.views.CustomObtainAuthToken":{login_failed_response:[0,2,1,""],login_response:[0,2,1,""],post:[0,3,1,""]},"api.views.CustomSwapAuthToken":{login_failed_response:[0,2,1,""],login_response:[0,2,1,""],post:[0,3,1,""]},"api.views.EmergencyContactViewSet":{basename:[0,2,1,""],description:[0,2,1,""],detail:[0,2,1,""],name:[0,2,1,""],queryset:[0,2,1,""],serializer_class:[0,2,1,""],suffix:[0,2,1,""]},"manager.settings":{ALLOWED_HOSTS:[3,6,1,""],AUTH_LDAP_SERVER_URI:[3,6,1,""],CHANNEL_LAYERS:[3,6,1,""],DATABASES:[3,6,1,""],LANGUAGE_CODE:[3,6,1,""],MEDIA_URL:[3,6,1,""],PROCESS_CONNECTION_PASS:[3,6,1,""],SECRET_KEY:[3,6,1,""],STATIC_ROOT:[3,6,1,""],STATIC_URL:[3,6,1,""],TESTING:[3,6,1,""],TIME_ZONE:[3,6,1,""],TOKEN_EXPIRED_AFTER_DAYS:[3,6,1,""],TRACE_TIMESTAMPS:[3,6,1,""]},"manager.utils":{assert_time_data:[3,5,1,""],get_tai_to_utc:[3,5,1,""],get_times:[3,5,1,""]},"subscription.apps":{SubscriptionConfig:[5,1,1,""]},"subscription.apps.SubscriptionConfig":{name:[5,2,1,""]},"subscription.auth":{TokenAuthMiddleware:[5,1,1,""],TokenAuthMiddlewareInstance:[5,1,1,""],get_user:[5,2,1,""]},"subscription.consumers":{SubscriptionConsumer:[5,1,1,""]},"subscription.consumers.SubscriptionConsumer":{connect:[5,3,1,""],disconnect:[5,3,1,""],handle_action_message:[5,3,1,""],handle_data_message:[5,3,1,""],handle_heartbeat_message:[5,3,1,""],handle_subscription_message:[5,3,1,""],logout:[5,3,1,""],receive_json:[5,3,1,""],send_heartbeat:[5,3,1,""],subscription_all_data:[5,3,1,""],subscription_data:[5,3,1,""]},"subscription.heartbeat_manager":{HeartbeatManager:[5,1,1,""]},"subscription.heartbeat_manager.HeartbeatManager":{instance:[5,2,1,""]},"subscription.routing":{websocket_urlpatterns:[5,6,1,""]},"ui_framework.apps":{UiFrameworkConfig:[6,1,1,""]},"ui_framework.apps.UiFrameworkConfig":{name:[6,2,1,""],ready:[6,3,1,""]},"ui_framework.models":{BaseModel:[6,1,1,""],OverwriteStorage:[6,1,1,""],View:[6,1,1,""],Workspace:[6,1,1,""],WorkspaceView:[6,1,1,""]},"ui_framework.models.BaseModel":{Meta:[6,1,1,""],creation_timestamp:[6,2,1,""],get_next_by_creation_timestamp:[6,3,1,""],get_next_by_update_timestamp:[6,3,1,""],get_previous_by_creation_timestamp:[6,3,1,""],get_previous_by_update_timestamp:[6,3,1,""],update_timestamp:[6,2,1,""]},"ui_framework.models.BaseModel.Meta":{"abstract":[6,2,1,""]},"ui_framework.models.OverwriteStorage":{get_available_name:[6,3,1,""]},"ui_framework.models.View":{DoesNotExist:[6,4,1,""],MultipleObjectsReturned:[6,4,1,""],data:[6,2,1,""],get_next_by_creation_timestamp:[6,3,1,""],get_next_by_update_timestamp:[6,3,1,""],get_previous_by_creation_timestamp:[6,3,1,""],get_previous_by_update_timestamp:[6,3,1,""],id:[6,2,1,""],name:[6,2,1,""],objects:[6,2,1,""],thumbnail:[6,2,1,""],workspace_views:[6,2,1,""],workspaces:[6,2,1,""]},"ui_framework.models.Workspace":{DoesNotExist:[6,4,1,""],MultipleObjectsReturned:[6,4,1,""],get_next_by_creation_timestamp:[6,3,1,""],get_next_by_update_timestamp:[6,3,1,""],get_previous_by_creation_timestamp:[6,3,1,""],get_previous_by_update_timestamp:[6,3,1,""],get_sorted_views:[6,3,1,""],has_read_permission:[6,3,1,""],id:[6,2,1,""],name:[6,2,1,""],objects:[6,2,1,""],views:[6,2,1,""],workspace_views:[6,2,1,""]},"ui_framework.models.WorkspaceView":{DoesNotExist:[6,4,1,""],MultipleObjectsReturned:[6,4,1,""],get_next_by_creation_timestamp:[6,3,1,""],get_next_by_update_timestamp:[6,3,1,""],get_previous_by_creation_timestamp:[6,3,1,""],get_previous_by_update_timestamp:[6,3,1,""],id:[6,2,1,""],objects:[6,2,1,""],sort_value:[6,2,1,""],view:[6,2,1,""],view_id:[6,2,1,""],view_name:[6,2,1,""],workspace:[6,2,1,""],workspace_id:[6,2,1,""]},"ui_framework.serializers":{Base64ImageField:[6,1,1,""],ViewSerializer:[6,1,1,""],ViewSummarySerializer:[6,1,1,""],WorkspaceFullSerializer:[6,1,1,""],WorkspaceSerializer:[6,1,1,""],WorkspaceViewSerializer:[6,1,1,""],WorkspaceWithViewNameSerializer:[6,1,1,""]},"ui_framework.serializers.Base64ImageField":{get_file_extension:[6,3,1,""],to_internal_value:[6,3,1,""],to_representation:[6,3,1,""]},"ui_framework.serializers.ViewSerializer":{Meta:[6,1,1,""],thumbnail:[6,2,1,""]},"ui_framework.serializers.ViewSerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.serializers.ViewSummarySerializer":{Meta:[6,1,1,""]},"ui_framework.serializers.ViewSummarySerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.serializers.WorkspaceFullSerializer":{Meta:[6,1,1,""]},"ui_framework.serializers.WorkspaceFullSerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.serializers.WorkspaceSerializer":{Meta:[6,1,1,""]},"ui_framework.serializers.WorkspaceSerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.serializers.WorkspaceViewSerializer":{Meta:[6,1,1,""]},"ui_framework.serializers.WorkspaceViewSerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.serializers.WorkspaceWithViewNameSerializer":{Meta:[6,1,1,""]},"ui_framework.serializers.WorkspaceWithViewNameSerializer.Meta":{fields:[6,2,1,""],model:[6,2,1,""]},"ui_framework.signals":{hanlde_view_deletion:[6,5,1,""]},"ui_framework.tests":{test_view_thumbnail:[7,0,0,"-"],tests_api:[7,0,0,"-"],tests_custom_api:[7,0,0,"-"],tests_models:[7,0,0,"-"],utils:[7,0,0,"-"]},"ui_framework.tests.test_view_thumbnail":{ViewThumbnailTestCase:[7,1,1,""]},"ui_framework.tests.test_view_thumbnail.ViewThumbnailTestCase":{setUp:[7,3,1,""],test_delete_view:[7,3,1,""],test_new_view:[7,3,1,""]},"ui_framework.tests.tests_api":{AuthorizedCrudTestCase:[7,1,1,""],UnauthenticatedCrudTestCase:[7,1,1,""],UnauthorizedCrudTestCase:[7,1,1,""]},"ui_framework.tests.tests_api.AuthorizedCrudTestCase":{setUp:[7,3,1,""],test_authorized_create_objects:[7,3,1,""],test_authorized_delete_objects:[7,3,1,""],test_authorized_list_objects:[7,3,1,""],test_authorized_retrieve_objects:[7,3,1,""],test_authorized_update_objects:[7,3,1,""]},"ui_framework.tests.tests_api.UnauthenticatedCrudTestCase":{setUp:[7,3,1,""],test_unauthenticated_create_objects:[7,3,1,""],test_unauthenticated_delete_objects:[7,3,1,""],test_unauthenticated_list_objects:[7,3,1,""],test_unauthenticated_retrieve_objects:[7,3,1,""],test_unauthenticated_update_objects:[7,3,1,""]},"ui_framework.tests.tests_api.UnauthorizedCrudTestCase":{setUp:[7,3,1,""],test_unauthorized_create_objects:[7,3,1,""],test_unauthorized_delete_objects:[7,3,1,""],test_unauthorized_list_objects:[7,3,1,""],test_unauthorized_retrieve_objects:[7,3,1,""],test_unauthorized_update_objects:[7,3,1,""]},"ui_framework.tests.tests_custom_api":{AuthorizedCrudTestCase:[7,1,1,""]},"ui_framework.tests.tests_custom_api.AuthorizedCrudTestCase":{setUp:[7,3,1,""],test_get_full_workspace:[7,3,1,""],test_get_workspaces_with_view_name:[7,3,1,""]},"ui_framework.tests.tests_models":{ViewModelTestCase:[7,1,1,""],WorkspaceAndViewsRelationsTestCase:[7,1,1,""],WorkspaceModelTestCase:[7,1,1,""],WorkspaceViewModelTestCase:[7,1,1,""]},"ui_framework.tests.tests_models.ViewModelTestCase":{setUp:[7,3,1,""],test_create_view:[7,3,1,""],test_delete_view:[7,3,1,""],test_retrieve_view:[7,3,1,""],test_update_view:[7,3,1,""]},"ui_framework.tests.tests_models.WorkspaceAndViewsRelationsTestCase":{setUp:[7,3,1,""],test_add_and_get_views_to_workspace:[7,3,1,""],test_get_workspaces_from_a_view:[7,3,1,""]},"ui_framework.tests.tests_models.WorkspaceModelTestCase":{setUp:[7,3,1,""],test_create_workspace:[7,3,1,""],test_delete_workspace:[7,3,1,""],test_retrieve_workspace:[7,3,1,""],test_update_workspace:[7,3,1,""]},"ui_framework.tests.tests_models.WorkspaceViewModelTestCase":{setUp:[7,3,1,""],test_create_workspace_view:[7,3,1,""],test_delete_workspace_view:[7,3,1,""],test_retrieve_workspace_view:[7,3,1,""],test_update_workspace_view:[7,3,1,""]},"ui_framework.tests.utils":{BaseTestCase:[7,1,1,""],get_dict:[7,5,1,""]},"ui_framework.tests.utils.BaseTestCase":{setUp:[7,3,1,""]},"ui_framework.views":{ViewViewSet:[6,1,1,""],WorkspaceViewSet:[6,1,1,""],WorkspaceViewViewSet:[6,1,1,""]},"ui_framework.views.ViewViewSet":{basename:[6,2,1,""],description:[6,2,1,""],detail:[6,2,1,""],name:[6,2,1,""],queryset:[6,2,1,""],search:[6,3,1,""],serializer_class:[6,2,1,""],suffix:[6,2,1,""],summary:[6,3,1,""]},"ui_framework.views.WorkspaceViewSet":{basename:[6,2,1,""],description:[6,2,1,""],detail:[6,2,1,""],full:[6,3,1,""],name:[6,2,1,""],queryset:[6,2,1,""],serializer_class:[6,2,1,""],suffix:[6,2,1,""],with_view_name:[6,3,1,""]},"ui_framework.views.WorkspaceViewViewSet":{basename:[6,2,1,""],description:[6,2,1,""],detail:[6,2,1,""],name:[6,2,1,""],queryset:[6,2,1,""],serializer_class:[6,2,1,""],suffix:[6,2,1,""]},api:{admin:[0,0,0,"-"],apps:[0,0,0,"-"],authentication:[0,0,0,"-"],middleware:[0,0,0,"-"],models:[0,0,0,"-"],schema_validator:[0,0,0,"-"],serializers:[0,0,0,"-"],signals:[0,0,0,"-"],tests:[1,0,0,"-"],urls:[0,0,0,"-"],views:[0,0,0,"-"]},manager:{asgi:[3,0,0,"-"],routing:[3,0,0,"-"],settings:[3,0,0,"-"],urls:[3,0,0,"-"],utils:[3,0,0,"-"],wsgi:[3,0,0,"-"]},subscription:{apps:[5,0,0,"-"],auth:[5,0,0,"-"],consumers:[5,0,0,"-"],heartbeat_manager:[5,0,0,"-"],routing:[5,0,0,"-"]},ui_framework:{admin:[6,0,0,"-"],apps:[6,0,0,"-"],models:[6,0,0,"-"],serializers:[6,0,0,"-"],signals:[6,0,0,"-"],tests:[7,0,0,"-"],urls:[6,0,0,"-"],views:[6,0,0,"-"]}},objnames:{"0":["py","module","Python module"],"1":["py","class","Python class"],"2":["py","attribute","Python attribute"],"3":["py","method","Python method"],"4":["py","exception","Python exception"],"5":["py","function","Python function"],"6":["py","data","Python data"]},objtypes:{"0":"py:module","1":"py:class","2":"py:attribute","3":"py:method","4":"py:exception","5":"py:function","6":"py:data"},terms:{"abstract":[0,6],"boolean":[0,1],"byte":6,"case":[0,5,6,9],"class":[0,1,3,5,6,7],"default":[0,1,3,9,10,12],"final":0,"float":3,"function":[0,3,5,6],"import":[0,3,6],"int":[0,3,6],"new":[6,7,10],"null":10,"public":12,"return":[0,3,5,6,7,10],"static":[1,3,6],"super":6,"true":[0,1,3,6,10],"try":[6,10],"while":9,And:[0,10],For:[0,3,6,9,10,11],The:[0,3,5,6,9,10,11,12],Then:9,These:[4,9],Use:[0,8,10],Uses:5,__all__:[0,6],abort:10,abov:9,accept:10,access:[0,3,6,9,11],accessor:6,accord:[0,5],ack:10,acknowledg:[9,10,11],act:[9,12],action:[5,8,9],actual:0,add:[0,3,6,7],added:[7,10],adding:7,addit:9,addition:9,additionalproperti:1,additt:0,admin123:3,admin:[3,4,10,12],admin_user_pass:12,afer:1,against:[0,10],alarm:10,alarms_sound:0,alia:[0,6],all:[5,7,9,10,11,12],allow:[3,10],allowed_host:3,allski:10,also:[9,11],alter:0,among:9,ani:[0,10,11],anonymousus:5,anoth:[0,3,6,11],api:[4,5,6,7,8,11],apiconfig:0,apidoc:[0,8,9,10],app:[3,4,9],app_modul:[0,5,6],app_nam:[0,5,6],appconfig:[0,5,6],append:10,appli:[0,9],applic:[0,2,3,5,9,10,12],appliedsettingsmatchstart:10,arg:[0,5,6],argument:[0,6],as_view:[0,3,6],asgi:4,asgi_appl:3,assert:3,assert_time_data:3,associ:[5,6],async:5,asynchron:10,asyncjsonwebsocketconsum:5,atcamera:6,atmount:6,atomic_request:3,atpneumat:10,atptg:10,attach:10,attribut:[0,6],auth:[0,4],auth_ldap_server_uri:[3,12],authapitestcas:1,authent:[1,4,5,8,9,11,12],authenticate_credenti:0,authenticationfail:0,authlist:10,author:[0,1,7,10,11],authorizedcrudtestcas:7,authtoken:0,autocommit:3,autocomplet:0,autogener:[0,6],autoupd:[0,6],avail:[0,6,7,10],back:11,backend:[3,10],base64:6,base64imagefield:6,base:[0,1,3,5,6,7,10,11],base_url:6,basemodel:[0,6],basenam:[0,6],basetestcas:7,bash:12,basic:9,becaus:9,been:0,befor:[0,1,12],behavior:7,behind:10,being:[0,3],below:[6,9],between:[0,3,5,10,11],blog:[0,3,6],bool:[0,3,6],both:11,browsabl:10,build:5,built:6,call:5,callabl:3,camera:[6,10],camfe:10,can:[0,1,7,9,10,12],can_execute_command:0,cannot:[1,7,12],categori:[5,10],ceh:3,cell:0,central:6,certain:10,chang:10,channel:[3,5,10,11,12],channel_lay:3,channels_redi:3,character:10,charg:9,charset:3,check:[0,6,11],checkpoint:1,child:6,children:6,classmethod:0,cleanup:1,client:[7,9,10,11],close:5,close_cod:5,cmd:[10,12],cmd_acknowledg:10,cmd_user_pass:12,code:[0,6,8,10,12],collat:3,com:[0,1,3,6],combin:10,command:[0,1,5,8,11,12],command_data:[0,10],command_nam:[0,10],command_name_1:10,command_name_2:10,commandertestcas:1,common:7,commun:[9,11],compar:9,compon:[5,9,10],compos:[9,10,12],concaten:6,conext:10,confi:0,config:[0,1,3,5,6],config_fil:0,config_path:10,configfil:[0,9],configfileapitestcas:1,configfilecontentseri:0,configfileseri:0,configfileviewset:0,configseri:0,configur:[0,1,3,5,6,9,10,12],confirm:10,conn_max_ag:3,connect:[3,5,8,9,11,12],constitut:6,consum:[0,4,10,11],contact:0,contact_info:0,contain:[0,3,5,6,9,10,12],content:[4,8,9,10],contian:10,contrib:[0,6],copi:[0,12],core:[0,3,6],correct:3,correspond:[0,5,6,9,10,12],could:[0,10],creat:[0,3,7,9],create_doc:12,create_forward_many_to_many_manag:6,creation:[0,6,10],creation_timestamp:[0,6,10],credenti:[1,9,10,11],critic:[0,10],crud:[7,9,10],csc:[0,5,10,11],csc_1:10,csc_2:10,cscg:6,cscsummari:6,currenlti:0,current:[0,3,5,6,9,10],custom:[0,5,6,7],customobtainauthtoken:0,customswapauthtoken:0,dai:3,dalet:7,data1:10,data2:10,data:[0,1,3,5,6,9,11],data_dict:0,databas:[0,3,7,12],date:[0,3,5,10],datetimefield:[0,6],db_engin:12,db_host:12,db_name:12,db_pass:12,db_port:12,db_user:12,debug:12,decod:6,decoded_fil:6,def:6,defaultingvalid:0,defaults_valid:0,defer:[0,6],defin:[0,1,3,5,6,7,9,10,12],deleg:6,delet:[0,1,6,7,9],deploy:[3,12],deprec:12,describ:[0,10,12],descript:[0,1,6],detail:[0,6,9,10,11],dev:9,develop:[8,9],dict:[0,3,5,6],dictionari:[0,3,5,6,7,10],diffent:9,differ:[0,1,3,5,9,10,11],directory_permissions_mod:6,disabl:10,disconnect:5,disk:6,dispatch:5,displai:6,divid:9,django:[0,1,2,3,5,6,7,9,11,12],djangoproject:[0,3,6],djangosnippet:6,djangpo:[0,6],doc:[0,3,6],docker:9,docsrc:12,doe:[0,12],doesnotexist:[0,6],dome:6,done:[9,10,11],draft7valid:0,draft:1,drf:[0,11],durat:3,dynam:6,each:[0,1,5,6,9,10],edit:[10,12],either:10,element:6,email:[0,10],emergencycontact:[0,9],emergencycontactapitestcas:1,emergencycontactseri:0,emergencycontactviewset:0,empti:[0,6,10,12],enabl:10,encod:6,end:10,endpoint:[0,6,9,10],engin:[3,12],enhanc:[0,6],entercontrol:10,entrypoint:3,env:12,environ:[3,11],error:[0,9,10],errorcod:10,establish:[9,10,11,12],etc:9,event:[5,11],event_data:[0,10],event_nam:[0,10],event_name_1:10,event_name_2:10,everi:[0,6,9,11,12],exampl:[0,3,6,8,10],except:[0,1,6],exec:12,execut:[0,2,6,9,11,12],execute_command:10,exitcontrol:10,expect:[3,5,6,10],expet:0,expir:[0,1],expires_in:0,expiringtokenauthent:0,explain:9,expos:[0,3,6],extens:6,fail:[0,1],fail_cleanup:1,fail_run:1,failur:10,fals:[0,1,3,6,10],fer:11,field:[0,6,7,10],field_11:10,field_12:10,field_21:10,field_22:10,figur:[9,11],file:[0,1,3,6,7,8,9],file_nam:[0,6],file_permissions_mod:6,filenam:[0,6],filesystemstorag:6,final_valid:0,first:[0,6],fixtur:9,flag:[0,10],folder:12,follow:[0,3,5,9,10,11,12],foreignkei:[0,6],format:[0,3,5,9,10],forward:[6,9,10,11],found:[5,6],framework:[6,7,8,11],free:6,frequenc:10,frmework:9,from:[0,3,5,6,7,9,10,11,12],frontend:[9,11,12],full:[3,6],fulli:[6,7,10],further:11,gencam:10,gener:[0,3,5,6,10,11],genera:9,get:[0,1,3,5,6],get_available_nam:6,get_config:0,get_config_file_sampl:1,get_cont:0,get_dict:7,get_file_extens:6,get_filenam:0,get_next_by_cr:0,get_next_by_creation_timestamp:[0,6],get_next_by_update_timestamp:[0,6],get_permiss:0,get_previous_by_cr:0,get_previous_by_creation_timestamp:[0,6],get_previous_by_update_timestamp:[0,6],get_respons:0,get_sorted_view:6,get_tai_to_utc:3,get_tim:3,get_time_data:[0,5,10],get_token:0,get_us:5,get_usernam:0,gettokenmiddlewar:0,github:[1,6,12],given:[0,5,6,7,9,10],globalpermiss:0,greenwich:[0,3,5,10],group:[0,5,9,10,11],handl:[0,1,5,6,9,11],handle_action_messag:5,handle_data_messag:5,handle_heartbeat_messag:5,handle_subscription_messag:5,handle_token_delet:0,handler:9,hanlde_view_delet:6,has:[0,3,6,9,11,12],has_read_permiss:6,have:[0,5,10,11],header:10,healthstatussummari:6,heartbeat:5,heartbeat_manag:4,heartbeatmanag:5,heavili:6,here:12,hola:10,home:[0,3,6],host:[3,12],hourangl:[0,3,5,10],how:[8,11],howto:3,html:12,http:[0,1,3,6,9,10,11,12],identifi:10,ids:[7,10],imag:[6,9],imagefield:6,implement:[6,12],includ:[0,3,6,10,11],incom:5,index:[0,3,8,10,12],info:[9,11],inform:[0,3,6,9,10],inherit:7,ini:9,initi:9,inner:5,input:[5,10],insid:[9,12],instanc:[0,3,5,6,9,10,11],instead:[10,12],instruct:[0,12],integr:12,intend:[5,10],interfac:[0,6],intermediari:[10,11],intern:[5,6,10],invalid:[0,1,9,10],is_next:[0,6],is_token_expir:0,iso:10,its:[6,7,9,10,12],ivalid:0,join:5,jpg:6,json:[0,1,5,6,9,10],jsonschema:0,julian:[0,3,5,10],kei:[0,3,6],key11:5,key12:5,key1:10,key21:5,key22:5,key2:10,keyword:0,kwarg:[0,5,6],languag:3,language_cod:3,last:[5,10],lastli:11,latiss:6,latter:9,layer:[3,10,12],layout:9,ldap:[3,12],leav:5,length:6,less:0,level:[0,3],librari:9,lightpath:6,like:[6,9,10],list:[0,3,5,6,7,10],load:[0,6,8],local:[0,8],localhost:3,locat:[0,3,5,6,10,12],log:[0,1,11],login:[0,9],login_failed_respons:0,login_respons:0,loglevel:10,logmessag:10,logout:[0,1,5],logslog:6,love:[0,1,3,5,11],love_csc:10,lovecsc_observinglog:0,lovecsctestcas:1,lsst:[1,11,12],mai:10,main:[2,9],make:[0,5,6,9],manag:[0,4,5,6,7,11,12],manager_rcv:5,mani:[6,10],manytomanydescriptor:6,manytomanyfield:6,map:[0,6],match:[0,5,6],max_length:6,maxdiff:1,maximum:6,measur:[0,3],mechan:10,media:[3,10],media_url:[3,6],messag:[0,3,5,8,9,11],meta:[0,6],metadata:[0,1],method:[1,6],methodnam:[1,7],middlewar:[4,5,12],migrat:9,minimum:1,minseveritynotif:10,minseveritysound:10,mirror:3,miss:0,mjd:[0,3,5,10],mock_environ:1,mock_request:1,mode:12,model:[4,7,9,10],modelseri:[0,6],modelviewset:[0,6],modifi:[0,3,5,10],modul:[4,8,9],moment:10,more:[0,3,6,9,11],most:6,mostli:9,mount:[3,6,12],move:12,mtm1m3:10,multipleobjectsreturn:[0,6],must:[9,10,12],mute:10,my_app:[0,3,6],my_dev_password:3,myimagefieldnam:6,mymodelnam:6,name:[0,1,3,5,6,7,12],necessari:10,need:[6,11,12],network:6,never:9,nginx:3,no_config:[0,1,10],no_debug:12,none:[0,1,3,5,6,10],note:0,number:[0,1,3,5,10],numer:10,obj:[0,7],object:[0,1,5,6,7],objectdoesnotexist:[0,6],observ:[0,1,11],observinglog:[6,10],obslog:6,obtain:0,obtainauthtoken:0,off:12,onc:[9,11,12],one:[0,6,10],onli:[5,6,9,10,12],oper:[9,10,11],option:[0,3,5,10],order:[0,5,6,9,10,12],org:[1,6],organ:8,origin:[0,11],other:[0,8,9,11],other_app:[0,3,6],otherwis:[5,12],our:0,out:[10,12],output:[0,1,5,10],outsid:12,over:[6,9,10],overrid:[6,12],overview:[6,8],overwrit:6,overwritestorag:6,packag:[4,8],page:[0,6,8],param:[1,10],paramet:[0,3,5,6,10],parameter_1:10,parameter_2:10,parent:6,pars:5,part:[5,8,11],particular:[5,9,10,11],pass:[1,5,6,10],password:[1,3,9,12],patch:[0,6],path:[0,3,6,10],pattern:5,payload:10,perform:[9,10],period:5,permiss:[0,6,9,10,12],pipe:11,pizza:6,pleas:[0,3,6,9,11,12],plu:10,png:10,port:[3,12],post:[0,6,10],postgr:[3,12],postgresql:[3,9,12],prefer:0,present:0,problem:6,process_connection_pass:[3,12],produc:[3,9,10,11,12],project:[3,4,11,12],properli:7,properti:1,propos:6,provid:[0,9,10,11,12],pull:6,purpos:[7,9,12],put:[0,6,10],pytest:[9,12],python:[9,11],queri:[0,1,6,10],queryset:[0,6],rais:[0,1],random:6,raw:6,react:5,read:[0,3,6,10],readi:[0,6],readm:8,readonli:12,reason:10,rebuild:12,receiv:[0,1,5,6,9,10,11],receive_json:5,recept:5,recommend:12,redi:[3,12],redirect:11,redis_host:12,redis_pass:12,redischannellay:3,redoc:10,ref:[0,3,6],refer:[0,6,9],regist:[0,6],regular:10,reject:5,rel:10,relat:[0,6,10],related_nam:6,relationship:[7,10],relev:[0,3],remain:[0,6],remian:0,remov:0,repli:[10,11],repo:12,repons:11,repositori:12,repres:10,represent:6,request:[0,1,5,6,7,9,11],request_tim:[5,10],requet:[0,6],requier:9,requir:[1,9,10],respect:[0,3,5,9,10],respond:9,respons:[0,6,9,11],rest:[0,6,7,9,10,11],rest_framework:[0,6],restart:12,result:[0,10],retriev:[7,9],revers:6,reversemanytoonedescriptor:6,rout:[0,4,6,9],rule:[3,5],run:[1,3,5,9],runserv:9,runtest:[1,7],sal:[9,11],sal_vers:[0,10],salindex:[5,10],salinfo:[0,1,10],salinfo_metadata:0,salinfo_topic_data:0,salinfo_topic_nam:0,salinfotestcas:1,salobj:9,save:[6,9],scale:[0,3,5,10],schedul:6,schema:[0,1],schema_path:10,schema_valid:4,schemavalidationtestcas:1,scope:5,script:9,script_schema:1,scriptqueu:[5,10],search:[6,8,9],search_text:10,second:[0,1,3,5,10],secret:3,secret_kei:[3,12],section:[9,11],see:[0,3,6,9,10,12],select:10,self:[0,6],send:[0,1,5,9,10,11],send_heartbeat:5,sender:[0,6],sent:[0,1,5,6,10,11],separ:10,serial:4,serializer_class:[0,6],seriou:[0,10],server:[3,9,10,12],server_tim:10,set:[0,4,5,6,7],setauthlist:10,setloglevel:10,settingsappli:10,settingvers:10,setup:[1,7],setvalu:10,sever:0,should:[10,12],showalarm:10,shown:11,side:6,sider:[9,10],sidereal_greenwich:10,sidereal_summit:[0,3,5,10],sidereal_tim:[0,3,5,10],signal:4,similarli:[9,11],simpl:9,simulationmod:10,sky:10,skycam:10,snippet:6,softwar:[5,9],softwarevers:10,solut:6,solv:6,some:[9,10,12],sort:[6,10],sort_valu:[6,10],sound:0,sourc:10,specifi:10,sqlite3:12,sqqtest:6,src:[3,12],stablish:10,standard:[0,10],standbi:10,start:[1,7,10],startproject:3,state:0,static_root:3,static_url:3,statu:[0,10],still:7,storag:6,store:[5,6,9,10],stream1:[5,10],stream2:5,stream:[5,10],string:[0,1,3,5,6,9,10],structur:[3,5,10],submodul:4,subpackag:4,subscirpt:5,subscrib:[0,5,9,10,11],subscript:[4,8,9],subscription_all_data:5,subscription_data:5,subscriptionconfig:5,subscriptionconsum:5,subseri:[6,7,10],subsystem:0,succes:0,succesfulli:10,success:10,suffix:[0,6],suit:1,summar:[6,10],summari:6,summaryst:10,summit:[0,3,5,10],support:5,sure:9,swagger:10,swap:[0,1],symmetric_encryption_kei:3,system:[6,8,10],tabl:10,tai:[0,3,5,9,10],tai_to_utc:[0,3,5,10],target:6,task:5,tbder3gzppu:3,teelemetri:10,tel1:9,tel2:9,telemetri:[5,9,11],telemetry_data:[0,10],telemetry_nam:[0,10],telemetry_name_1:10,telemetry_name_2:10,test:[0,3,4,6,9],test_add_and_get_views_to_workspac:7,test_authorized_commander_data:1,test_authorized_create_object:7,test_authorized_delete_object:7,test_authorized_list_object:7,test_authorized_lovecsc_data:1,test_authorized_retrieve_object:7,test_authorized_update_object:7,test_command:[0,4],test_create_view:7,test_create_workspac:7,test_create_workspace_view:7,test_delete_view:7,test_delete_workspac:7,test_delete_workspace_view:7,test_get_config_fil:1,test_get_config_file_cont:1,test_get_config_files_list:1,test_get_full_workspac:7,test_get_workspaces_from_a_view:7,test_get_workspaces_with_view_nam:7,test_invalid_config:1,test_list_emergency_contact:1,test_lovecsc:[0,4],test_new_view:7,test_retrieve_view:7,test_retrieve_workspac:7,test_retrieve_workspace_view:7,test_salinfo_metadata:1,test_salinfo_topic_data:1,test_salinfo_topic_data_with_param:1,test_salinfo_topic_nam:1,test_salinfo_topic_names_with_param:1,test_schema_valid:[0,4],test_syntax_error:1,test_unauthenticated_cannot_get_config_fil:1,test_unauthenticated_create_object:7,test_unauthenticated_delete_object:7,test_unauthenticated_list_object:7,test_unauthenticated_retrieve_object:7,test_unauthenticated_update_object:7,test_unauthorized_command:1,test_unauthorized_create_object:7,test_unauthorized_delete_object:7,test_unauthorized_list_object:7,test_unauthorized_lovecsc:1,test_unauthorized_retrieve_object:7,test_unauthorized_update_object:7,test_update_view:7,test_update_workspac:7,test_update_workspace_view:7,test_user_fails_to_validate_deleted_token:1,test_user_fails_to_validate_expired_token:1,test_user_login:1,test_user_login_fail:1,test_user_login_twic:1,test_user_logout:1,test_user_swap:1,test_user_swap_forbidden:1,test_user_swap_no_config:1,test_user_swap_wrong_credenti:1,test_user_validate_token:1,test_user_validate_token_fail:1,test_user_validate_token_no_config:1,test_valid_config:1,test_view_thumbnail:[4,6],testcas:[1,7],tests_api:[4,6],tests_auth_api:[0,4],tests_configfil:[0,4],tests_custom_api:[4,6],tests_emergencycontact:[0,4],tests_model:[4,6],testscript:1,text:10,than:0,thei:[0,10],them:[0,10],therefor:11,thi:[0,3,5,6,9,10,11,12],those:0,though:5,throgh:[0,6],through:[1,6,7,9,10,11],thumbnail:[6,7,10],ticket:6,time:[0,1,3,5,6,9,10],time_data:[3,5,10],time_zon:3,timedataseri:0,timedisplai:6,timestamp:[0,3,5,6,10],timezon:3,titl:[0,1,10],to_internal_valu:6,to_represent:6,token:[0,1,3,5,9,11],token_expire_handl:0,token_expired_after_dai:3,tokenauthent:0,tokenauthmiddlewar:5,tokenauthmiddlewareinst:5,tokenseri:0,tokn:0,tomchristi:6,tool:12,top:6,topic:[0,3,6],topic_data:[0,1,10],topic_nam:[0,1,10],trace:3,trace_timestamp:3,transfer:10,transform:6,treat:10,tri:9,trigger:10,truncat:6,ts_salobj:1,turn:[9,11],twie:1,two:0,txt:9,type:[0,1,3,5,6],u3awhhg:3,ui_framework:[4,8,9,10],uiframeworkconfig:6,unacknowledg:10,unauthent:[1,7],unauthenticatedcrudtestcas:7,unauthor:[1,7],unauthorizedcrudtestcas:7,uniqu:9,unix:[0,3,5,10],unmut:10,unpars:1,unsubscrib:[5,10],unsubscript:5,unus:[0,6],updat:[0,6,7,9],update_timestamp:[0,6,10],upload:6,upon:[0,6],url:[4,5,9,10],urlconf:[0,3,6],urlpattern:[0,3,5,6],use:[0,3,5,7,8,12],used:[0,5,6,7,9,10,12],user:[0,1,3,5,6,7,9,12],user_id:0,user_user_pass:12,usernam:[0,9,10],userpermissionsseri:0,userseri:0,uses:[6,11],using:[0,1,3,9,11,12],usr:[3,12],utc:[0,3,5,9,10],util:[4,6],v0d38sjx43s8:3,valid:[0,1,9],validate_config_schema:0,validate_file_extens:0,validate_token:0,validationerror:0,validatorclass:0,valu:[0,3,6,9,10,12],value11:5,value12:5,value1:10,value21:5,value22:5,value2:10,value_11:10,value_12:10,value_21:10,value_22:10,variabl:[3,10],variou:10,vaue:10,version:10,vetween:7,via:10,view:[3,4,7,9],view_1:10,view_2:10,view_3:10,view_i:7,view_id1:10,view_id2:10,view_id3:10,view_id:[6,10],view_nam:[6,10],viewmodeltestcas:7,viewseri:6,viewset:[0,6],viewsummaryseri:6,viewthumbnailtestcas:7,viewviewset:6,visual:11,wai:[10,12],wait:[1,9],wait_tim:1,warn:[0,10],watcher:[6,10],weatherst:6,websocket:[3,5,8,9,11,12],websocket_urlpattern:5,well:10,wether:[0,3,12],when:[0,6,7,9,11],where:[10,11,12],which:[0,9,10,11,12],whise:10,who:[0,10],whole:3,with_view_nam:[6,10],within:[6,10],without:[6,10],work:[0,1,6,8,11],workflow:11,workspac:[6,7],workspace_i:7,workspace_id1:10,workspace_id:[6,10],workspace_view:[6,7],workspaceandviewsrelationstestcas:7,workspacefullseri:6,workspacemodeltestcas:7,workspaceseri:6,workspaceview:[6,7],workspaceviewmodeltestcas:7,workspaceviewseri:6,workspaceviewset:6,workspaceviewviewset:6,workspacewithviewnameseri:6,workview:6,wrapper:[0,6],write:11,written:[6,11],wsgi:4,xml:10,xml_version:[0,10],yaml:[0,1,10],you:[10,12],zuj:3},titles:["5.1. api package","5.1.1.1. api.tests package","5.2. manage module","5.3. manager package","5. ApiDoc","5.4. subscription package","5.5. ui_framework package","5.5.1.1. ui_framework.tests package","Welcome to LOVE-manager\u2019s documentation!","3. How it works","2. How to use it","1. Overview","4. Readme File"],titleterms:{Use:12,action:10,admin:[0,6],api:[0,1,9,10],apidoc:4,app:[0,5,6],asgi:3,auth:[5,9],authent:[0,10],build:12,channel:9,code:9,command:[9,10],config:10,connect:10,consum:[5,9],content:[0,1,3,5,6,7],creat:10,data:10,databas:9,delet:10,develop:12,docker:12,document:[8,12],environ:12,event:10,exampl:9,file:[10,12],framework:[9,10],full:10,get:[10,12],heartbeat:10,heartbeat_manag:5,how:[9,10],imag:12,indic:8,info:10,initi:12,layer:9,load:12,local:12,log:10,logout:10,love:[8,9,10,12],manag:[2,3,8,9,10],messag:10,metadata:10,middlewar:0,model:[0,6],modul:[0,1,2,3,5,6,7],name:10,observ:10,organ:9,other:10,overview:11,packag:[0,1,3,5,6,7],part:[9,12],password:10,readm:12,request:10,respons:10,retriev:10,rout:[3,5],run:12,sal:10,schema:10,schema_valid:0,scheme:10,search:10,serial:[0,6],set:3,signal:[0,6],submodul:[0,1,3,5,6,7],subpackag:[0,6],subscript:[5,10],summari:10,swap:10,system:12,tabl:8,telemetri:10,test:[1,7,12],test_command:1,test_lovecsc:1,test_schema_valid:1,test_view_thumbnail:7,tests_api:7,tests_auth_api:1,tests_configfil:1,tests_custom_api:7,tests_emergencycontact:1,tests_model:7,token:10,topic:10,type:10,ui_framework:[6,7],unauthent:10,unauthor:10,updat:10,url:[0,3,6],use:10,user:10,util:[3,7],valid:10,variabl:12,view:[0,6,10],websocket:10,welcom:8,work:9,workspac:10,workspaceview:10,wsgi:3}}) \ No newline at end of file diff --git a/docsrc/source/apidoc/api.tests.rst b/docsrc/source/apidoc/api.tests.rst index 232076ac..b18a5ad1 100644 --- a/docsrc/source/apidoc/api.tests.rst +++ b/docsrc/source/apidoc/api.tests.rst @@ -12,6 +12,14 @@ api.tests.test\_commander module :undoc-members: :show-inheritance: +api.tests.test\_lovecsc module +------------------------------ + +.. automodule:: api.tests.test_lovecsc + :members: + :undoc-members: + :show-inheritance: + api.tests.test\_schema\_validation module ----------------------------------------- @@ -28,10 +36,18 @@ api.tests.tests\_auth\_api module :undoc-members: :show-inheritance: -api.tests.tests\_config module ------------------------------- +api.tests.tests\_configfile module +---------------------------------- + +.. automodule:: api.tests.tests_configfile + :members: + :undoc-members: + :show-inheritance: + +api.tests.tests\_emergencycontact module +---------------------------------------- -.. automodule:: api.tests.tests_config +.. automodule:: api.tests.tests_emergencycontact :members: :undoc-members: :show-inheritance: From 333fde9e803471142750471b0e8d6dfb8a7010ee Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 12 Jan 2021 17:01:01 -0300 Subject: [PATCH 30/49] Replace self with cls --- manager/api/authentication.py | 11 +++-- manager/subscription/heartbeat_manager.py | 50 +++++++++++------------ 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/manager/api/authentication.py b/manager/api/authentication.py index 107efa34..ebd7e038 100644 --- a/manager/api/authentication.py +++ b/manager/api/authentication.py @@ -55,7 +55,7 @@ def authenticate_credentials(self, key): return (token.user, token) @classmethod - def token_expire_handler(self, token): + def token_expire_handler(cls, token): """Check if a given token is expired or not, if it is the Token is deleted. Params @@ -68,14 +68,13 @@ def token_expire_handler(self, token): boolean, Token: True if it is expired, False if not; and the Token object """ - is_expired = self.is_token_expired(token) + is_expired = cls.is_token_expired(token) if is_expired: token.delete() - # token = Token.objects.create(user=token.user) # This line allows creation of a new token after expiration return is_expired, token @classmethod - def is_token_expired(self, token): + def is_token_expired(cls, token): """Check if a given token is expired or not. Params @@ -88,10 +87,10 @@ def is_token_expired(self, token): boolean True if it is expired, False if not """ - return self.expires_in(token) < timedelta(seconds=0) + return cls.expires_in(token) < timedelta(seconds=0) @classmethod - def expires_in(self, token): + def expires_in(cls, token): """Return the remaining time of a given token. Params diff --git a/manager/subscription/heartbeat_manager.py b/manager/subscription/heartbeat_manager.py index b5364830..6d8b4096 100644 --- a/manager/subscription/heartbeat_manager.py +++ b/manager/subscription/heartbeat_manager.py @@ -25,20 +25,20 @@ class __HeartbeatManager: """Dictionary comntaining the heartbeats data, indexed by source or component, e.g. "Commander".""" @classmethod - def initialize(self): + def initialize(cls): """Initialize the HeartbeatManager Run 2 async tasks in the event loop, one to dispatch the heartbeats periodically, and the other to request the heartbeats from the LOVE-Commander periodically. """ - self.heartbeat_data = {} - if not self.heartbeat_task: - self.heartbeat_task = asyncio.create_task(self.dispatch_heartbeats()) - if not self.commander_heartbeat_task: - self.commander_heartbeat_task = asyncio.create_task(self.query_commander()) + cls.heartbeat_data = {} + if not cls.heartbeat_task: + cls.heartbeat_task = asyncio.create_task(cls.dispatch_heartbeats()) + if not cls.commander_heartbeat_task: + cls.commander_heartbeat_task = asyncio.create_task(cls.query_commander()) @classmethod - def set_heartbeat_timestamp(self, source, timestamp): + def set_heartbeat_timestamp(cls, source, timestamp): """Set a given timestamp as the heartbeat for a given source Parameters @@ -48,10 +48,10 @@ def set_heartbeat_timestamp(self, source, timestamp): timestamp: `float` timestamp of the heartbeat """ - self.heartbeat_data[source] = timestamp + cls.heartbeat_data[source] = timestamp @classmethod - async def query_commander(self): + async def query_commander(cls): """Query the heartbeat from the LOVE-Commander periodically. This is what the `commander_heartbeat_task` does @@ -70,7 +70,7 @@ async def query_commander(self): await asyncio.sleep(3) @classmethod - async def dispatch_heartbeats(self): + async def dispatch_heartbeats(cls): """Dispatch all the heartbeats to the corresponding group in the Channels Layer. This is what the `heartbeat_task` does @@ -79,15 +79,15 @@ async def dispatch_heartbeats(self): while True: try: print('sending data') - self.set_heartbeat_timestamp('Manager', datetime.datetime.now().timestamp()) + cls.set_heartbeat_timestamp('Manager', datetime.datetime.now().timestamp()) data = json.dumps({ 'category': 'heartbeat', 'data': [ { 'csc': heartbeat_source, 'salindex': 0, - 'data': {'timestamp': self.heartbeat_data[heartbeat_source]} - } for heartbeat_source in self.heartbeat_data + 'data': {'timestamp': cls.heartbeat_data[heartbeat_source]} + } for heartbeat_source in cls.heartbeat_data ], 'subscription': 'heartbeat' }) @@ -101,22 +101,22 @@ async def dispatch_heartbeats(self): await asyncio.sleep(3) @classmethod - async def reset(self): + async def reset(cls): """Reset the `HeartbeatManager`, changing the tasks references and heartbeats dictionary back to their default values.""" - if self.heartbeat_task: - self.heartbeat_task = None - if self.commander_heartbeat_task: - self.commander_heartbeat_task = None - self.heartbeat_data = {} - self.commander_heartbeat_task = {} + if cls.heartbeat_task: + cls.heartbeat_task = None + if cls.commander_heartbeat_task: + cls.commander_heartbeat_task = None + cls.heartbeat_data = {} + cls.commander_heartbeat_task = {} @classmethod - async def stop(self): + async def stop(cls): """Stop (cancel) the tasks.""" - if self.heartbeat_task: - self.heartbeat_task.cancel() - if self.commander_heartbeat_task: - self.commander_heartbeat_task.cancel() + if cls.heartbeat_task: + cls.heartbeat_task.cancel() + if cls.commander_heartbeat_task: + cls.commander_heartbeat_task.cancel() instance = None def __init__(self): From 57f0719aa5c11501a00ab33f0c29a5cd3aabfa4f Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 12 Jan 2021 17:01:14 -0300 Subject: [PATCH 31/49] Refactor long function --- manager/api/schema_validator.py | 37 ++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/manager/api/schema_validator.py b/manager/api/schema_validator.py index 58c7a40e..d7f9ce2b 100644 --- a/manager/api/schema_validator.py +++ b/manager/api/schema_validator.py @@ -44,6 +44,25 @@ class DefaultingValidator: * final_validator: a standard validator that does not alter the data being validated. """ + @staticmethod + def set_default_properties(properties, skip_properties, instance): + for prop, subschema in properties.items(): + if not isinstance(subschema, dict): + continue + if not isinstance(instance, dict): + continue + if prop in skip_properties: + continue + if "default" in subschema: + instance.setdefault(prop, subschema["default"]) + elif subschema.get("type") == "object" and "properties" in subschema: + # Handle defaults for one level deep sub-object. + subdefaults = {} + for subpropname, subpropvalue in subschema["properties"].items(): + if "default" in subpropvalue: + subdefaults[subpropname] = subpropvalue["default"] + if subdefaults: + instance.setdefault(prop, subdefaults) def __init__(self, schema, ValidatorClass=jsonschema.Draft7Validator): ValidatorClass.check_schema(schema) @@ -88,23 +107,7 @@ def set_defaults(validator, properties, instance, schema): "uniqueItems", ) ) - for prop, subschema in properties.items(): - if not isinstance(subschema, dict): - continue - if not isinstance(instance, dict): - continue - if prop in skip_properties: - continue - if "default" in subschema: - instance.setdefault(prop, subschema["default"]) - elif subschema.get("type") == "object" and "properties" in subschema: - # Handle defaults for one level deep sub-object. - subdefaults = {} - for subpropname, subpropvalue in subschema["properties"].items(): - if "default" in subpropvalue: - subdefaults[subpropname] = subpropvalue["default"] - if subdefaults: - instance.setdefault(prop, subdefaults) + DefaultingValidator.set_default_properties(properties, skip_properties, instance) for error in validate_properties(validator, properties, instance, schema): yield error From fb654ce698174c45484cb112d7b2a028867bed35 Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 12 Jan 2021 17:02:10 -0300 Subject: [PATCH 32/49] Remove comments --- manager/api/tests/test_lovecsc.py | 11 +---------- manager/api/urls.py | 2 -- manager/manager/settings.py | 2 -- manager/static_files/css/love-ui.css | 4 ---- 4 files changed, 1 insertion(+), 18 deletions(-) diff --git a/manager/api/tests/test_lovecsc.py b/manager/api/tests/test_lovecsc.py index 5a23a2b1..107d05ef 100644 --- a/manager/api/tests/test_lovecsc.py +++ b/manager/api/tests/test_lovecsc.py @@ -51,13 +51,7 @@ def test_authorized_lovecsc_data(self, mock_requests, mock_environ): } with self.assertRaises(ValueError): - response = self.client.post(url, data, format="json") - result = response.json() - - # self.assertEqual(response.status_code, 200) - # self.assertEqual( - # result, {"ack": "Added new observing log to SAL."} - # ) + self.client.post(url, data, format="json") expected_url = f"http://fakehost:fakeport/lovecsc/observinglog" self.assertEqual(mock_requests.call_args, call(expected_url, json=data)) @@ -85,6 +79,3 @@ def test_unauthorized_lovecsc(self, mock_requests, mock_environ): self.assertEqual( result, {"ack": "User does not have permissions to send observing logs."} ) - - # expected_url = f"http://fakehost:fakeport/lovecsc/observinglog" - # self.assertEqual(mock_requests.call_args, call(expected_url, json=data)) diff --git a/manager/api/urls.py b/manager/api/urls.py index 270b269f..a6388e17 100644 --- a/manager/api/urls.py +++ b/manager/api/urls.py @@ -19,8 +19,6 @@ from django.urls import path from rest_framework.routers import DefaultRouter from api.views import ConfigFileViewSet - -# from api.views import validate_token, logout, CustomObtainAuthToken, validate_config_schema, commander, salinfo_metadata import api.views router = DefaultRouter() diff --git a/manager/manager/settings.py b/manager/manager/settings.py index 7e8b2a3d..24ef88c1 100644 --- a/manager/manager/settings.py +++ b/manager/manager/settings.py @@ -162,8 +162,6 @@ "rest_framework.permissions.DjangoModelPermissions", ), "DEFAULT_AUTHENTICATION_CLASSES": ( - # 'rest_framework.authentication.TokenAuthentication', - # 'api.authentication.TokenAuthentication', "api.authentication.ExpiringTokenAuthentication", "rest_framework.authentication.SessionAuthentication", ), diff --git a/manager/static_files/css/love-ui.css b/manager/static_files/css/love-ui.css index d9dce921..269a95fc 100644 --- a/manager/static_files/css/love-ui.css +++ b/manager/static_files/css/love-ui.css @@ -268,10 +268,6 @@ p { border-radius: 6px; } -.panel-title { - -} - .panel-body { border-top-left-radius: 8px; border-top: 2px solid #81939e; From 448037c75c178e5a973b8e9448094a085e696207 Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 12 Jan 2021 17:02:46 -0300 Subject: [PATCH 33/49] Remove unused variables --- manager/api/tests/test_commander.py | 24 +++++-------------- manager/api/tests/tests_auth_api.py | 16 ++++++------- manager/subscription/consumers.py | 1 - manager/subscription/tests/test_connection.py | 4 ++-- .../subscription/tests/test_subscriptions.py | 4 ++-- .../ui_framework/tests/test_view_thumbnail.py | 2 +- 6 files changed, 19 insertions(+), 32 deletions(-) diff --git a/manager/api/tests/test_commander.py b/manager/api/tests/test_commander.py index e9adfa2a..2ecabd59 100644 --- a/manager/api/tests/test_commander.py +++ b/manager/api/tests/test_commander.py @@ -53,9 +53,7 @@ def test_authorized_commander_data(self, mock_requests, mock_environ): } with self.assertRaises(ValueError): - response = self.client.post(url, data, format="json") - fakehostname = "fakehost" - fakeport = "fakeport" + self.client.post(url, data, format="json") expected_url = f"http://fakehost:fakeport/cmd" self.assertEqual(mock_requests.call_args, call(expected_url, json=data)) @@ -123,9 +121,7 @@ def test_salinfo_metadata(self, mock_requests, mock_environ): url = reverse("salinfo-metadata") with self.assertRaises(ValueError): - response = self.client.get(url) - fakehostname = "fakehost" - fakeport = "fakeport" + self.client.get(url) expected_url = f"http://fakehost:fakeport/salinfo/metadata" self.assertEqual(mock_requests.call_args, call(expected_url)) @@ -142,9 +138,7 @@ def test_salinfo_topic_names(self, mock_requests, mock_environ): url = reverse("salinfo-topic-names") with self.assertRaises(ValueError): - response = self.client.get(url) - fakehostname = "fakehost" - fakeport = "fakeport" + self.client.get(url) expected_url = f"http://fakehost:fakeport/salinfo/topic-names" self.assertEqual(mock_requests.call_args, call(expected_url)) @@ -161,9 +155,7 @@ def test_salinfo_topic_names_with_param(self, mock_requests, mock_environ): url = reverse("salinfo-topic-names") + "?categories=telemetry" with self.assertRaises(ValueError): - response = self.client.get(url) - fakehostname = "fakehost" - fakeport = "fakeport" + self.client.get(url) expected_url = ( f"http://fakehost:fakeport/salinfo/topic-names?categories=telemetry" ) @@ -182,9 +174,7 @@ def test_salinfo_topic_data(self, mock_requests, mock_environ): url = reverse("salinfo-topic-data") with self.assertRaises(ValueError): - response = self.client.get(url) - fakehostname = "fakehost" - fakeport = "fakeport" + self.client.get(url) expected_url = f"http://fakehost:fakeport/salinfo/topic-data" self.assertEqual(mock_requests.call_args, call(expected_url)) @@ -201,9 +191,7 @@ def test_salinfo_topic_data_with_param(self, mock_requests, mock_environ): url = reverse("salinfo-topic-data") + "?categories=telemetry" with self.assertRaises(ValueError): - response = self.client.get(url) - fakehostname = "fakehost" - fakeport = "fakeport" + self.client.get(url) expected_url = ( f"http://fakehost:fakeport/salinfo/topic-data?categories=telemetry" ) diff --git a/manager/api/tests/tests_auth_api.py b/manager/api/tests/tests_auth_api.py index e5d4bb6f..973e81ee 100644 --- a/manager/api/tests/tests_auth_api.py +++ b/manager/api/tests/tests_auth_api.py @@ -167,7 +167,7 @@ def test_user_validate_token(self): """Test that a user can validate a token.""" # Arrange: data = {"username": self.username, "password": self.password} - response = self.client.post(self.login_url, data, format="json") + self.client.post(self.login_url, data, format="json") token = Token.objects.filter(user__username=self.username).first() self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) @@ -203,7 +203,7 @@ def test_user_validate_token_no_config(self): """Test that a user can validate a token and not receive the config passing the no_config query param.""" # Arrange: data = {"username": self.username, "password": self.password} - response = self.client.post(self.login_url, data, format="json") + self.client.post(self.login_url, data, format="json") token = Token.objects.filter(user__username=self.username).first() self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) @@ -238,7 +238,7 @@ def test_user_validate_token_fail(self): """Test that a user fails to validate an invalid token.""" # Arrange: data = {"username": self.username, "password": self.password} - response = self.client.post(self.login_url, data, format="json") + self.client.post(self.login_url, data, format="json") token = Token.objects.filter(user__username=self.username).first() self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key + "fake") @@ -252,7 +252,7 @@ def test_user_fails_to_validate_deleted_token(self): """Test that a user fails to validate an deleted token.""" # Arrange: data = {"username": self.username, "password": self.password} - response = self.client.post(self.login_url, data, format="json") + self.client.post(self.login_url, data, format="json") token = Token.objects.filter(user__username=self.username).first() self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) token.delete() @@ -269,7 +269,7 @@ def test_user_fails_to_validate_expired_token(self): initial_time = datetime.datetime.now() with freeze_time(initial_time) as frozen_datetime: data = {"username": self.username, "password": self.password} - response = self.client.post(self.login_url, data, format="json") + self.client.post(self.login_url, data, format="json") token = Token.objects.filter(user__username=self.username).first() token_num_0 = Token.objects.filter(user__username=self.username).count() self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) @@ -290,7 +290,7 @@ def test_user_logout(self): """Test that a user can logout and delete the token.""" # Arrange: data = {"username": self.username, "password": self.password} - response = self.client.post(self.login_url, data, format="json") + self.client.post(self.login_url, data, format="json") token = Token.objects.filter(user__username=self.username).first() old_tokens_count = Token.objects.filter(user__username=self.username).count() self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) @@ -325,7 +325,7 @@ def test_user_swap(self): data = {"username": self.username2, "password": self.password} token = Token.objects.filter(user__username=self.username).first() self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) - response = self.client.post(self.swap_url, data, format="json") + self.client.post(self.swap_url, data, format="json") user_1_tokens_num_1 = Token.objects.filter(user__username=self.username).count() user_2_tokens_num_1 = Token.objects.filter( user__username=self.username2 @@ -392,7 +392,7 @@ def test_user_swap_no_config(self): def test_user_swap_forbidden(self): """Test that a user that's not logged in cannot swap users""" # Arrange logout: - response = self.client.delete(self.logout_url, format="json") + self.client.delete(self.logout_url, format="json") self.client.logout() data = {"username": self.username, "password": self.password} # Act: diff --git a/manager/subscription/consumers.py b/manager/subscription/consumers.py index 368bef84..1a6413aa 100644 --- a/manager/subscription/consumers.py +++ b/manager/subscription/consumers.py @@ -226,7 +226,6 @@ async def handle_data_message(self, message, manager_rcv): """ data = message["data"] category = message["category"] - user = self.scope["user"] producer_snd = message["producer_snd"] if "producer_snd" in message else None # Store pairs of group, message to send: diff --git a/manager/subscription/tests/test_connection.py b/manager/subscription/tests/test_connection.py index e94e225f..2b6ec803 100644 --- a/manager/subscription/tests/test_connection.py +++ b/manager/subscription/tests/test_connection.py @@ -108,7 +108,7 @@ async def test_connection_interrupted_when_logout_message_is_sent(self): # Client 1 should not be able to send and receive messages with pytest.raises(AssertionError): await client1.send_json_to(subscription_msg) - response = await client1.receive_json_from() + await client1.receive_json_from() # Client 2 should be able to send and receive messages await client2.send_json_to(subscription_msg) @@ -157,7 +157,7 @@ async def test_connection_interrupted_when_token_is_deleted(self): # Client 1 should not be able to send and receive messages with pytest.raises(AssertionError): await client1.send_json_to(subscription_msg) - response = await client1.receive_json_from() + await client1.receive_json_from() # Client 2 should be able to send and receive messages await client2.send_json_to(subscription_msg) diff --git a/manager/subscription/tests/test_subscriptions.py b/manager/subscription/tests/test_subscriptions.py index 1b893c2e..ec9dd8ac 100644 --- a/manager/subscription/tests/test_subscriptions.py +++ b/manager/subscription/tests/test_subscriptions.py @@ -182,7 +182,7 @@ async def test_receive_messages_from_every_subscription(self): "category": combination["category"], } await communicator.send_json_to(msg) - response = await communicator.receive_json_from() + await communicator.receive_json_from() # Act for combination in self.combinations: msg, expected = self.build_messages( @@ -213,7 +213,7 @@ async def test_receive_messages_from_all_subscription(self): "stream": "all", } await communicator.send_json_to(msg) - response = await communicator.receive_json_from() + await communicator.receive_json_from() # Act for combination in self.combinations: msg, expected = self.build_messages( diff --git a/manager/ui_framework/tests/test_view_thumbnail.py b/manager/ui_framework/tests/test_view_thumbnail.py index ad458f7a..88fac0f4 100644 --- a/manager/ui_framework/tests/test_view_thumbnail.py +++ b/manager/ui_framework/tests/test_view_thumbnail.py @@ -100,7 +100,7 @@ def test_delete_view(self): "thumbnail": image_data } request_url = reverse('view-list') - response = self.client.post(request_url, request_data, format='json') + self.client.post(request_url, request_data, format='json') # Act # delete the view From 038aab6d24c1e840cc3938c7508ec479aa1d58b1 Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 12 Jan 2021 17:14:45 -0300 Subject: [PATCH 34/49] Minor code fixes --- manager/api/management/commands/createusers.py | 2 -- manager/api/middleware.py | 3 +-- manager/api/serializers.py | 3 ++- manager/api/tests/test_schema_validation.py | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/manager/api/management/commands/createusers.py b/manager/api/management/commands/createusers.py index fc6589bc..a8d7d210 100644 --- a/manager/api/management/commands/createusers.py +++ b/manager/api/management/commands/createusers.py @@ -97,7 +97,6 @@ def handle(self, *args, **options): admin_password = options['adminpass'] user_password = options['userpass'] cmd_password = options['cmduserpass'] - cmd_password = options['cmduserpass'] # Create users admin = self._create_user(admin_username, admin_password) @@ -138,7 +137,6 @@ def _create_user(self, username, password): if password.strip() == '': self.stderr.write("Error: Blank passwords aren't allowed.") password = None - continue user = User.objects.filter(username=username).first() if not user: diff --git a/manager/api/middleware.py b/manager/api/middleware.py index 40415612..521d8759 100644 --- a/manager/api/middleware.py +++ b/manager/api/middleware.py @@ -20,7 +20,6 @@ def __call__(self, request): Response: The corresponding response object """ - if request.META['PATH_INFO'] == '/manager/api/get-token/': - if 'HTTP_COOKIE' in request.META: + if request.META['PATH_INFO'] == '/manager/api/get-token/' and 'HTTP_COOKIE' in request.META: request.META['HTTP_COOKIE'] = '' return self.get_response(request) diff --git a/manager/api/serializers.py b/manager/api/serializers.py index 768eee70..93a6c640 100644 --- a/manager/api/serializers.py +++ b/manager/api/serializers.py @@ -6,6 +6,7 @@ from django.contrib.auth.models import User from manager import utils from api.models import ConfigFile +from typing import Union class UserSerializer(serializers.ModelSerializer): @@ -131,7 +132,7 @@ def get_time_data(self, token) -> dict: return utils.get_times() @swagger_serializer_method(serializer_or_field=serializers.JSONField()) - def get_config(self, token) -> dict: + def get_config(self, token) -> Union[dict, None]: """Return the config file. If the 'no_config' flag is present in the url of the original request, then the file is not read and the return value is None diff --git a/manager/api/tests/test_schema_validation.py b/manager/api/tests/test_schema_validation.py index 2bfab739..0cf1e942 100644 --- a/manager/api/tests/test_schema_validation.py +++ b/manager/api/tests/test_schema_validation.py @@ -77,7 +77,7 @@ def test_valid_config(self): def test_syntax_error(self): """Test validation output of an unparsable config file""" configs = [ - "wait_time: -\na:""", # ScannerError + "wait_time: -\na:", # ScannerError "fail_cleanup: \nw:'", # ScannerError ":" # ParserError ] From d3b565604ce062f87aea72b8012dc14e0d9d520a Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 12 Jan 2021 17:14:59 -0300 Subject: [PATCH 35/49] Add missing HTML metadata --- manager/templates/index.html | 2 +- manager/templates/registration/login.html | 3 ++- manager/templates/test.html | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/manager/templates/index.html b/manager/templates/index.html index ce8e5764..b86ed454 100644 --- a/manager/templates/index.html +++ b/manager/templates/index.html @@ -1,6 +1,6 @@ {% load render_bundle from webpack_loader %} - + diff --git a/manager/templates/registration/login.html b/manager/templates/registration/login.html index 9b13f800..a7e0846d 100644 --- a/manager/templates/registration/login.html +++ b/manager/templates/registration/login.html @@ -1,6 +1,7 @@ {% load static %} - + + diff --git a/manager/templates/test.html b/manager/templates/test.html index 0aa9cd75..6e32f1f6 100644 --- a/manager/templates/test.html +++ b/manager/templates/test.html @@ -1,5 +1,5 @@ - + From c6c1f6e6e3d3a49ab6570a2e948158cf6cf7db8f Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 12 Jan 2021 17:15:32 -0300 Subject: [PATCH 36/49] Cleanup config file tests after running them --- manager/api/tests/tests_configfile.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/manager/api/tests/tests_configfile.py b/manager/api/tests/tests_configfile.py index 461f4f12..9dabe866 100644 --- a/manager/api/tests/tests_configfile.py +++ b/manager/api/tests/tests_configfile.py @@ -12,9 +12,14 @@ from django.conf import settings from manager import utils from django.core.files.base import ContentFile +from django.conf import settings +import tempfile #python manage.py test api.tests.tests_configfile.ConfigFileApiTestCase +def setUp(self): + settings.MEDIA_ROOT = tempfile.mkdtemp() + class ConfigFileApiTestCase(TestCase): """Test suite for config files handling.""" From f6cf3cb10c6e1d9e227e3a4d44e6312f8c60d1c4 Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 19 Jan 2021 17:07:57 -0300 Subject: [PATCH 37/49] Add first version of efd endpoint --- manager/api/urls.py | 1 + manager/api/views.py | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/manager/api/urls.py b/manager/api/urls.py index bfe3d7ce..0a2595a2 100644 --- a/manager/api/urls.py +++ b/manager/api/urls.py @@ -47,6 +47,7 @@ ), path("salinfo/topic-data", api.views.salinfo_topic_data, name="salinfo-topic-data"), path("config", api.views.get_config, name="config"), + path("EFD/timeseries", api.views.query_efd, name="EFD-timeseries"), ] router.register('configfile', ConfigFileViewSet) router.register('emergencycontact', EmergencyContactViewSet) diff --git a/manager/api/views.py b/manager/api/views.py index cf4afdb8..8d57edce 100644 --- a/manager/api/views.py +++ b/manager/api/views.py @@ -389,6 +389,7 @@ def salinfo_topic_names(request): 403: openapi.Response("Unauthorized"), }, ) + @api_view(["GET"]) @permission_classes((IsAuthenticated,)) def salinfo_topic_data(request): @@ -488,4 +489,41 @@ class EmergencyContactViewSet(viewsets.ModelViewSet): """Set of objects to be accessed by queries to this viewsets endpoints""" serializer_class = EmergencyContactSerializer - """Serializer used to serialize View objects""" \ No newline at end of file + """Serializer used to serialize View objects""" + + +@api_view(["GET"]) +@permission_classes((IsAuthenticated,)) +def query_efd(request, *args, **kwargs): + """Queries data from an EFD timeseries by redirecting the request to the Commander + + Params + ------ + request: Request + The Request object + args: list + List of addittional arguments. Currently unused + kwargs: dict + Dictionary with request arguments. Request should contain the following: + start_date (required): String specifying the start of the query range. Default current date minus 10 minutes + timewindow (required): Int specifying the number of minutes to query starting from start_date. Default 10 + topics (required): Dictionary of the form + { + CSC1: { + index: [topic1, topic2...], + }, + CSC2: { + index: [topic1, topic2...], + }, + } + resample (optional): The offset string representing target resample conversion, e.g. '15min', '10S' + + Returns + ------- + Response + The response and status code of the request to the LOVE-Commander + """ + url = f"http://{os.environ.get('COMMANDER_HOSTNAME')}:{os.environ.get('COMMANDER_PORT')}/EFD/timeseries/" + response = requests.get(url, kwargs=kwargs) + + return Response(response.json(), status=response.status_code) \ No newline at end of file From bbca1f89340998f3d7d8fe40af97b2fa2c501b07 Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 19 Jan 2021 17:08:03 -0300 Subject: [PATCH 38/49] Add efd test --- manager/api/tests/test_commander.py | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/manager/api/tests/test_commander.py b/manager/api/tests/test_commander.py index 2ecabd59..8a5b1694 100644 --- a/manager/api/tests/test_commander.py +++ b/manager/api/tests/test_commander.py @@ -6,6 +6,9 @@ import yaml from unittest.mock import patch, call +#python manage.py test api.tests.test_commander.CommanderTestCase +#python manage.py test api.tests.test_commander.SalinfoTestCase +#python manage.py test api.tests.test_commander.EFDTestCase @override_settings(DEBUG=True) class CommanderTestCase(TestCase): @@ -197,3 +200,45 @@ def test_salinfo_topic_data_with_param(self, mock_requests, mock_environ): ) self.assertEqual(mock_requests.call_args, call(expected_url)) + +@override_settings(DEBUG=True) +class EFDTestCase(TestCase): + maxDiff = None + + def setUp(self): + """Define the test suite setup.""" + # Arrange + self.client = APIClient() + self.user = User.objects.create_user( + username="user", + password="password", + email="test@user.cl", + first_name="First", + last_name="Last", + ) + self.token = Token.objects.create(user=self.user) + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + self.user.user_permissions.add( + Permission.objects.get(codename="view_view"), + Permission.objects.get(codename="add_view"), + Permission.objects.get(codename="delete_view"), + Permission.objects.get(codename="change_view"), + ) + + @patch( + "os.environ.get", + side_effect=lambda arg: "fakehost" + if arg == "COMMANDER_HOSTNAME" + else "fakeport", + ) + @patch("requests.get") + def test_timeseries_query(self, mock_requests, mock_environ): + """Test authorized user can query and get a timeseries""" + # Act: + kwargs = {} + url = reverse("EFD-timeseries") + + with self.assertRaises(ValueError): + self.client.get(url, **kwargs) + expected_url = f"http://fakehost:fakeport/EFD/timeseries/" + self.assertEqual(mock_requests.call_args, call(expected_url, kwargs=kwargs)) From df404d2b6d72b4bee3077b5b698866495ebc6ed8 Mon Sep 17 00:00:00 2001 From: spereirag Date: Thu, 21 Jan 2021 20:26:27 -0300 Subject: [PATCH 39/49] Update efd endpoint to POST and lowercase --- manager/api/urls.py | 2 +- manager/api/views.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/manager/api/urls.py b/manager/api/urls.py index 0a2595a2..4dfd0d9b 100644 --- a/manager/api/urls.py +++ b/manager/api/urls.py @@ -47,7 +47,7 @@ ), path("salinfo/topic-data", api.views.salinfo_topic_data, name="salinfo-topic-data"), path("config", api.views.get_config, name="config"), - path("EFD/timeseries", api.views.query_efd, name="EFD-timeseries"), + path("efd/timeseries", api.views.query_efd, name="EFD-timeseries"), ] router.register('configfile', ConfigFileViewSet) router.register('emergencycontact', EmergencyContactViewSet) diff --git a/manager/api/views.py b/manager/api/views.py index 8d57edce..59d9b0e5 100644 --- a/manager/api/views.py +++ b/manager/api/views.py @@ -492,7 +492,7 @@ class EmergencyContactViewSet(viewsets.ModelViewSet): """Serializer used to serialize View objects""" -@api_view(["GET"]) +@api_view(["POST"]) @permission_classes((IsAuthenticated,)) def query_efd(request, *args, **kwargs): """Queries data from an EFD timeseries by redirecting the request to the Commander @@ -523,7 +523,8 @@ def query_efd(request, *args, **kwargs): Response The response and status code of the request to the LOVE-Commander """ - url = f"http://{os.environ.get('COMMANDER_HOSTNAME')}:{os.environ.get('COMMANDER_PORT')}/EFD/timeseries/" - response = requests.get(url, kwargs=kwargs) - - return Response(response.json(), status=response.status_code) \ No newline at end of file + url = f"http://{os.environ.get('COMMANDER_HOSTNAME')}:{os.environ.get('COMMANDER_PORT')}/efd/timeseries" + response = requests.post(url, json=request.data) + if response.status_code == status.HTTP_200_OK: + return Response(response.json(), status=response.status_code) + return Response(response) \ No newline at end of file From 665bb5645c541b68e7a99425e217c051ae6fd349 Mon Sep 17 00:00:00 2001 From: spereirag Date: Fri, 22 Jan 2021 12:07:06 -0300 Subject: [PATCH 40/49] Fix EFD test --- manager/api/tests/test_commander.py | 27 ++++++++++++++++++++++----- manager/api/views.py | 4 +--- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/manager/api/tests/test_commander.py b/manager/api/tests/test_commander.py index 8a5b1694..29a2db66 100644 --- a/manager/api/tests/test_commander.py +++ b/manager/api/tests/test_commander.py @@ -231,14 +231,31 @@ def setUp(self): if arg == "COMMANDER_HOSTNAME" else "fakeport", ) - @patch("requests.get") + @patch("requests.post") def test_timeseries_query(self, mock_requests, mock_environ): """Test authorized user can query and get a timeseries""" # Act: - kwargs = {} + cscs = { + "ATDome": { + "0": { + "topic1": ["field1"] + }, + }, + "ATMCS": { + "1": { + "topic2": ["field2", "field3"] + }, + } + } + data = { + "start_date": "2020-03-16T12:00:00", + "time_window": 15, + "cscs": cscs, + "resample": "1min", + } url = reverse("EFD-timeseries") with self.assertRaises(ValueError): - self.client.get(url, **kwargs) - expected_url = f"http://fakehost:fakeport/EFD/timeseries/" - self.assertEqual(mock_requests.call_args, call(expected_url, kwargs=kwargs)) + self.client.post(url, data, format="json") + expected_url = f"http://fakehost:fakeport/efd/timeseries" + self.assertEqual(mock_requests.call_args, call(expected_url, json=data)) diff --git a/manager/api/views.py b/manager/api/views.py index 59d9b0e5..ed3340c4 100644 --- a/manager/api/views.py +++ b/manager/api/views.py @@ -525,6 +525,4 @@ def query_efd(request, *args, **kwargs): """ url = f"http://{os.environ.get('COMMANDER_HOSTNAME')}:{os.environ.get('COMMANDER_PORT')}/efd/timeseries" response = requests.post(url, json=request.data) - if response.status_code == status.HTTP_200_OK: - return Response(response.json(), status=response.status_code) - return Response(response) \ No newline at end of file + return Response(response.json(), status=response.status_code) \ No newline at end of file From 920420865140a4217d0edcd6ea3f23704fb3dfd6 Mon Sep 17 00:00:00 2001 From: spereirag Date: Fri, 22 Jan 2021 13:45:08 -0300 Subject: [PATCH 41/49] Update documentation --- docsrc/source/modules/how_to_use_it.rst | 68 +++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docsrc/source/modules/how_to_use_it.rst b/docsrc/source/modules/how_to_use_it.rst index 8811e5cc..8916c723 100644 --- a/docsrc/source/modules/how_to_use_it.rst +++ b/docsrc/source/modules/how_to_use_it.rst @@ -1202,3 +1202,71 @@ Delete WorkspaceView { "status": 204 } + + +EFD +============ + +Timeseries +~~~~~~~~~~~~~~~~~~~~ +Endpoint to request EFD timeseries. + +- Url: :code:`/manager/efd/timeseries` +- HTTP Operation: POST +- Message Payload: + +.. code-block:: json + + { + "start_date": "2020-03-16T12:00:00", + "time_window": 15, + "cscs": { + "ATDome": { + 0: { + "topic1": ["field1"] + }, + }, + "ATMCS": { + 1: { + "topic2": ["field2", "field3"] + }, + } + }, + "resample": "1min", + } + + +- Expected Response, if command successful: + +.. code-block:: json + + { + "status": 200, + "data": { + "ATDome-0-topic1": { + "field1": [ + { ts: "2020-03-06 21:49:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:50:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:51:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:52:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:53:41.471000", value: 0.21 } + ] + }, + "ATMCS-1-topic2": { + "field2": [ + { ts: "2020-03-06 21:49:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:50:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:51:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:52:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:53:41.471000", value: 0.21 } + ], + "field3": [ + { ts: "2020-03-06 21:49:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:50:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:51:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:52:41.471000", value: 0.21 }, + { ts: "2020-03-06 21:53:41.471000", value: 0.21 } + ] + } + } + } \ No newline at end of file From c2cedf0684b8680ca268c448951c05e4d5b06815 Mon Sep 17 00:00:00 2001 From: Sebastian Aranda Date: Tue, 26 Jan 2021 19:17:17 -0300 Subject: [PATCH 42/49] Black formatter fixes --- manager/api/__init__.py | 2 +- .../api/management/commands/createusers.py | 44 +-- manager/api/management/commands/tests.py | 62 ++-- manager/api/middleware.py | 7 +- manager/api/migrations/0001_initial.py | 40 ++- .../api/migrations/0002_auto_20190528_1546.py | 12 +- .../api/migrations/0003_auto_20190528_1552.py | 14 +- .../api/migrations/0004_globalpermissions.py | 24 +- .../api/migrations/0005_auto_20190722_1622.py | 12 +- manager/api/migrations/0006_configfile.py | 49 +++- .../api/migrations/0007_emergencycontact.py | 36 ++- manager/api/models.py | 33 ++- manager/api/schema_validator.py | 5 +- manager/api/serializers.py | 15 +- manager/api/signals.py | 6 +- manager/api/tests/test_commander.py | 1 - manager/api/tests/test_lovecsc.py | 2 +- manager/api/tests/test_schema_validation.py | 178 ++++++------ manager/api/tests/tests_auth_api.py | 20 +- manager/api/tests/tests_configfile.py | 39 ++- manager/api/tests/tests_emergencycontact.py | 6 +- manager/api/urls.py | 10 +- manager/api/views.py | 27 +- manager/manage.py | 4 +- manager/manager/routing.py | 8 +- manager/manager/settings.py | 25 +- manager/manager/urls.py | 35 ++- manager/manager/wsgi.py | 2 +- manager/subscription/__init__.py | 2 +- manager/subscription/consumers.py | 7 +- manager/subscription/heartbeat_manager.py | 56 ++-- manager/subscription/tests/test_connection.py | 85 ++++-- manager/subscription/tests/test_heartbeat.py | 81 ++++-- .../tests/test_lovecsc_subscriptions.py | 12 +- manager/subscription/tests/test_time_data.py | 2 +- manager/ui_framework/__init__.py | 2 +- .../ui_framework/migrations/0001_initial.py | 119 ++++++-- .../ui_framework/migrations/0002_view_data.py | 6 +- .../migrations/0003_view_thumbnail.py | 12 +- manager/ui_framework/serializers.py | 9 +- manager/ui_framework/signals.py | 2 +- .../ui_framework/tests/test_view_thumbnail.py | 70 +++-- manager/ui_framework/tests/tests_api.py | 272 ++++++++++-------- .../ui_framework/tests/tests_custom_api.py | 71 +++-- manager/ui_framework/tests/tests_models.py | 253 +++++++++------- manager/ui_framework/tests/utils.py | 181 ++++++------ manager/ui_framework/urls.py | 6 +- 47 files changed, 1199 insertions(+), 767 deletions(-) diff --git a/manager/api/__init__.py b/manager/api/__init__.py index 23ca7a69..23b0108b 100644 --- a/manager/api/__init__.py +++ b/manager/api/__init__.py @@ -1 +1 @@ -default_app_config = 'api.apps.ApiConfig' +default_app_config = "api.apps.ApiConfig" diff --git a/manager/api/management/commands/createusers.py b/manager/api/management/commands/createusers.py index a8d7d210..50fc15b9 100644 --- a/manager/api/management/commands/createusers.py +++ b/manager/api/management/commands/createusers.py @@ -4,11 +4,11 @@ from django.contrib.auth.models import Permission, Group, User from django.core.management.base import BaseCommand -user_username = 'user' -cmd_user_username = 'cmd_user' -admin_username = 'admin' -cmd_groupname = 'cmd' -test_username = 'test' +user_username = "user" +cmd_user_username = "cmd_user" +admin_username = "admin" +cmd_groupname = "cmd" +test_username = "test" class Command(BaseCommand): @@ -41,7 +41,7 @@ class Command(BaseCommand): "cmd_user" and "test" users belong to "cmd_group".""" requires_migrations_checks = True - stealth_options = ('stdin',) + stealth_options = ("stdin",) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -72,16 +72,14 @@ def add_arguments(self, parser): parser for the arguments """ parser.add_argument( - '--adminpass', - help='Specifies the password for the "admin" user.' + "--adminpass", help='Specifies the password for the "admin" user.' ) parser.add_argument( - '--userpass', - help='Specifies the password for the regular users ("user").' + "--userpass", help='Specifies the password for the regular users ("user").' ) parser.add_argument( - '--cmduserpass', - help='Specifies password for the users with cmd permissions ("cmd_user" and "test").' + "--cmduserpass", + help='Specifies password for the users with cmd permissions ("cmd_user" and "test").', ) def handle(self, *args, **options): @@ -94,9 +92,9 @@ def handle(self, *args, **options): kwargs: dict Dictionary with addittional keyword arguments (indexed by keys in the dict) """ - admin_password = options['adminpass'] - user_password = options['userpass'] - cmd_password = options['cmduserpass'] + admin_password = options["adminpass"] + user_password = options["userpass"] + cmd_password = options["cmduserpass"] # Create users admin = self._create_user(admin_username, admin_password) @@ -131,10 +129,10 @@ def _create_user(self, username, password): user: User The User object """ - while password is None or password.strip() == '': - print('Creating {}...'.format(username)) + while password is None or password.strip() == "": + print("Creating {}...".format(username)) password = getpass.getpass() - if password.strip() == '': + if password.strip() == "": self.stderr.write("Error: Blank passwords aren't allowed.") password = None @@ -142,11 +140,13 @@ def _create_user(self, username, password): if not user: user = User.objects.create_user( username=username, - email='{}@fake.com'.format(username), - password=password + email="{}@fake.com".format(username), + password=password, ) else: - self.stderr.write("Warning: The {} user is already created".format(username)) + self.stderr.write( + "Warning: The {} user is already created".format(username) + ) return user def _create_cmd_group(self): @@ -158,7 +158,7 @@ def _create_cmd_group(self): The Group object """ group, created = Group.objects.get_or_create(name=cmd_groupname) - permissions = Permission.objects.filter(codename='command.execute_command') + permissions = Permission.objects.filter(codename="command.execute_command") for permission in permissions: group.permissions.add(permission) return group diff --git a/manager/api/management/commands/tests.py b/manager/api/management/commands/tests.py index abd532e9..3585a10b 100644 --- a/manager/api/management/commands/tests.py +++ b/manager/api/management/commands/tests.py @@ -9,7 +9,7 @@ cmd_groupname, ) -cmd_permission_codename = 'api.command.execute_command' +cmd_permission_codename = "api.command.execute_command" class CreateusersTestCase(TestCase): @@ -23,33 +23,39 @@ def test_command_creates_users(self): command = Command() # Act: options = { - 'adminpass': 'admin_pass', - 'userpass': 'user_pass', - 'cmduserpass': 'cmd_pass', + "adminpass": "admin_pass", + "userpass": "user_pass", + "cmduserpass": "cmd_pass", } command.handle(*[], **options) # Assert: - self.assertEqual(User.objects.count(), old_users_num + 4, 'There are no new users') - self.assertEqual(Group.objects.count(), old_groups_num + 1, 'There is no new group') + self.assertEqual( + User.objects.count(), old_users_num + 4, "There are no new users" + ) + self.assertEqual( + Group.objects.count(), old_groups_num + 1, "There is no new group" + ) admin = User.objects.filter(username=admin_username).first() user = User.objects.filter(username=user_username).first() cmd_user = User.objects.filter(username=cmd_user_username).first() cmd_group = Group.objects.filter(name=cmd_groupname).first() - self.assertTrue(admin, 'The {} user was not created'.format(admin_username)) - self.assertTrue(user, 'The {} user was not created'.format(user_username)) - self.assertTrue(cmd_user, 'The {} user was not created'.format(cmd_user_username)) - self.assertTrue(cmd_group, 'The {} group was not created'.format(cmd_groupname)) + self.assertTrue(admin, "The {} user was not created".format(admin_username)) + self.assertTrue(user, "The {} user was not created".format(user_username)) + self.assertTrue( + cmd_user, "The {} user was not created".format(cmd_user_username) + ) + self.assertTrue(cmd_group, "The {} group was not created".format(cmd_groupname)) self.assertTrue( admin.has_perm(cmd_permission_codename), - '{} user should have cmd_execute permissions'.format(admin_username) + "{} user should have cmd_execute permissions".format(admin_username), ) self.assertFalse( user.has_perm(cmd_permission_codename), - '{} user should not have cmd_execute permissions'.format(user_username) + "{} user should not have cmd_execute permissions".format(user_username), ) self.assertTrue( cmd_user.has_perm(cmd_permission_codename), - '{} user should have cmd_execute permissions'.format(cmd_user_username) + "{} user should have cmd_execute permissions".format(cmd_user_username), ) def test_command_sets_permissions_even_if_users_already_existed(self): @@ -59,42 +65,44 @@ def test_command_sets_permissions_even_if_users_already_existed(self): command = Command() User.objects.create_user( username=admin_username, - email='{}@fake.com'.format(admin_username), - password='dummy-pass' + email="{}@fake.com".format(admin_username), + password="dummy-pass", ) User.objects.create_user( username=user_username, - email='{}@fake.com'.format(user_username), - password='dummy-pass' + email="{}@fake.com".format(user_username), + password="dummy-pass", ) User.objects.create_user( username=cmd_user_username, - email='{}@fake.com'.format(cmd_user_username), - password='dummy-pass' + email="{}@fake.com".format(cmd_user_username), + password="dummy-pass", ) # Act: options = { - 'adminpass': 'admin_pass', - 'userpass': 'user_pass', - 'cmduserpass': 'cmd_pass', + "adminpass": "admin_pass", + "userpass": "user_pass", + "cmduserpass": "cmd_pass", } command.handle(*[], **options) # Assert: - self.assertEqual(Group.objects.count(), old_groups_num + 1, 'There is no new group') + self.assertEqual( + Group.objects.count(), old_groups_num + 1, "There is no new group" + ) admin = User.objects.filter(username=admin_username).first() user = User.objects.filter(username=user_username).first() cmd_user = User.objects.filter(username=cmd_user_username).first() cmd_group = Group.objects.filter(name=cmd_groupname).first() - self.assertTrue(cmd_group, 'The {} group was not created'.format(cmd_groupname)) + self.assertTrue(cmd_group, "The {} group was not created".format(cmd_groupname)) self.assertTrue( admin.has_perm(cmd_permission_codename), - '{} user should have cmd_execute permissions'.format(admin_username) + "{} user should have cmd_execute permissions".format(admin_username), ) self.assertFalse( user.has_perm(cmd_permission_codename), - '{} user should not have cmd_execute permissions'.format(user_username) + "{} user should not have cmd_execute permissions".format(user_username), ) self.assertTrue( cmd_user.has_perm(cmd_permission_codename), - '{} user should have cmd_execute permissions'.format(cmd_user_username) + "{} user should have cmd_execute permissions".format(cmd_user_username), ) diff --git a/manager/api/middleware.py b/manager/api/middleware.py index 521d8759..a47a4224 100644 --- a/manager/api/middleware.py +++ b/manager/api/middleware.py @@ -20,6 +20,9 @@ def __call__(self, request): Response: The corresponding response object """ - if request.META['PATH_INFO'] == '/manager/api/get-token/' and 'HTTP_COOKIE' in request.META: - request.META['HTTP_COOKIE'] = '' + if ( + request.META["PATH_INFO"] == "/manager/api/get-token/" + and "HTTP_COOKIE" in request.META + ): + request.META["HTTP_COOKIE"] = "" return self.get_response(request) diff --git a/manager/api/migrations/0001_initial.py b/manager/api/migrations/0001_initial.py index d984a05b..e142510d 100644 --- a/manager/api/migrations/0001_initial.py +++ b/manager/api/migrations/0001_initial.py @@ -15,17 +15,41 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Token', + name="Token", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')), - ('key', models.CharField(db_index=True, max_length=40, unique=True, verbose_name='Key')), - ('name', models.CharField(max_length=64, verbose_name='Name')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + models.DateTimeField(auto_now_add=True, verbose_name="Created"), + ), + ( + "key", + models.CharField( + db_index=True, max_length=40, unique=True, verbose_name="Key" + ), + ), + ("name", models.CharField(max_length=64, verbose_name="Name")), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="auth_tokens", + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), ], ), migrations.AlterUniqueTogether( - name='token', - unique_together={('user', 'name')}, + name="token", + unique_together={("user", "name")}, ), ] diff --git a/manager/api/migrations/0002_auto_20190528_1546.py b/manager/api/migrations/0002_auto_20190528_1546.py index 82446b6e..0c1059c9 100644 --- a/manager/api/migrations/0002_auto_20190528_1546.py +++ b/manager/api/migrations/0002_auto_20190528_1546.py @@ -6,20 +6,20 @@ class Migration(migrations.Migration): dependencies = [ - ('api', '0001_initial'), + ("api", "0001_initial"), ] operations = [ migrations.AlterModelOptions( - name='token', - options={'verbose_name': 'Token', 'verbose_name_plural': 'Tokens'}, + name="token", + options={"verbose_name": "Token", "verbose_name_plural": "Tokens"}, ), migrations.AlterUniqueTogether( - name='token', + name="token", unique_together=set(), ), migrations.RemoveField( - model_name='token', - name='name', + model_name="token", + name="name", ), ] diff --git a/manager/api/migrations/0003_auto_20190528_1552.py b/manager/api/migrations/0003_auto_20190528_1552.py index c3f18aa1..d86f3b53 100644 --- a/manager/api/migrations/0003_auto_20190528_1552.py +++ b/manager/api/migrations/0003_auto_20190528_1552.py @@ -6,13 +6,19 @@ class Migration(migrations.Migration): dependencies = [ - ('api', '0002_auto_20190528_1546'), + ("api", "0002_auto_20190528_1546"), ] operations = [ migrations.AlterField( - model_name='token', - name='key', - field=models.CharField(blank=True, db_index=True, max_length=40, unique=True, verbose_name='Key'), + model_name="token", + name="key", + field=models.CharField( + blank=True, + db_index=True, + max_length=40, + unique=True, + verbose_name="Key", + ), ), ] diff --git a/manager/api/migrations/0004_globalpermissions.py b/manager/api/migrations/0004_globalpermissions.py index a76c113b..918bc016 100644 --- a/manager/api/migrations/0004_globalpermissions.py +++ b/manager/api/migrations/0004_globalpermissions.py @@ -6,18 +6,32 @@ class Migration(migrations.Migration): dependencies = [ - ('api', '0003_auto_20190528_1552'), + ("api", "0003_auto_20190528_1552"), ] operations = [ migrations.CreateModel( - name='GlobalPermissions', + name="GlobalPermissions", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ], options={ - 'permissions': (('Commands.execute_commands', 'Execute Commands'), ('ScriptQueue.run_scripts', 'Run and Requeue scripts in ScriptQueues')), - 'managed': False, + "permissions": ( + ("Commands.execute_commands", "Execute Commands"), + ( + "ScriptQueue.run_scripts", + "Run and Requeue scripts in ScriptQueues", + ), + ), + "managed": False, }, ), ] diff --git a/manager/api/migrations/0005_auto_20190722_1622.py b/manager/api/migrations/0005_auto_20190722_1622.py index a5dbbd20..487125e7 100644 --- a/manager/api/migrations/0005_auto_20190722_1622.py +++ b/manager/api/migrations/0005_auto_20190722_1622.py @@ -6,12 +6,18 @@ class Migration(migrations.Migration): dependencies = [ - ('api', '0004_globalpermissions'), + ("api", "0004_globalpermissions"), ] operations = [ migrations.AlterModelOptions( - name='globalpermissions', - options={'managed': False, 'permissions': (('command.execute_command', 'Execute Commands'), ('command.run_script', 'Run and Requeue scripts in ScriptQueues'))}, + name="globalpermissions", + options={ + "managed": False, + "permissions": ( + ("command.execute_command", "Execute Commands"), + ("command.run_script", "Run and Requeue scripts in ScriptQueues"), + ), + }, ), ] diff --git a/manager/api/migrations/0006_configfile.py b/manager/api/migrations/0006_configfile.py index b6a67ce7..272abeb6 100644 --- a/manager/api/migrations/0006_configfile.py +++ b/manager/api/migrations/0006_configfile.py @@ -10,22 +10,53 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('api', '0005_auto_20190722_1622'), + ("api", "0005_auto_20190722_1622"), ] operations = [ migrations.CreateModel( - name='ConfigFile', + name="ConfigFile", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('creation_timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Creation time')), - ('update_timestamp', models.DateTimeField(auto_now=True, verbose_name='Last Updated')), - ('file_name', models.CharField(blank=True, max_length=30)), - ('config_file', models.FileField(default='configs/default.json', upload_to='configs/', validators=[api.models.ConfigFile.validate_file_extension])), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='config_files', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "creation_timestamp", + models.DateTimeField( + auto_now_add=True, verbose_name="Creation time" + ), + ), + ( + "update_timestamp", + models.DateTimeField(auto_now=True, verbose_name="Last Updated"), + ), + ("file_name", models.CharField(blank=True, max_length=30)), + ( + "config_file", + models.FileField( + default="configs/default.json", + upload_to="configs/", + validators=[api.models.ConfigFile.validate_file_extension], + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="config_files", + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/manager/api/migrations/0007_emergencycontact.py b/manager/api/migrations/0007_emergencycontact.py index eafb3a42..c43124ca 100644 --- a/manager/api/migrations/0007_emergencycontact.py +++ b/manager/api/migrations/0007_emergencycontact.py @@ -6,23 +6,39 @@ class Migration(migrations.Migration): dependencies = [ - ('api', '0006_configfile'), + ("api", "0006_configfile"), ] operations = [ migrations.CreateModel( - name='EmergencyContact', + name="EmergencyContact", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('creation_timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Creation time')), - ('update_timestamp', models.DateTimeField(auto_now=True, verbose_name='Last Updated')), - ('subsystem', models.CharField(blank=True, max_length=100)), - ('name', models.CharField(blank=True, max_length=100)), - ('contact_info', models.CharField(blank=True, max_length=100)), - ('email', models.EmailField(max_length=254)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "creation_timestamp", + models.DateTimeField( + auto_now_add=True, verbose_name="Creation time" + ), + ), + ( + "update_timestamp", + models.DateTimeField(auto_now=True, verbose_name="Last Updated"), + ), + ("subsystem", models.CharField(blank=True, max_length=100)), + ("name", models.CharField(blank=True, max_length=100)), + ("contact_info", models.CharField(blank=True, max_length=100)), + ("email", models.EmailField(max_length=254)), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/manager/api/models.py b/manager/api/models.py index e2ecf447..772ecb0f 100644 --- a/manager/api/models.py +++ b/manager/api/models.py @@ -32,15 +32,20 @@ class Meta: ) """Update timestamp, autogenerated upon creation and autoupdated on every update""" + class Token(rest_framework.authtoken.models.Token): """Custome Token model with ForeignKey relation to User model. Based on the DRF Token model.""" - key = models.CharField(_("Key"), max_length=40, db_index=True, unique=True, blank=True) + key = models.CharField( + _("Key"), max_length=40, db_index=True, unique=True, blank=True + ) """ Key attribute (the token string). It is no longer primary key, but still indexed and unique""" user = models.ForeignKey( - settings.AUTH_USER_MODEL, related_name='auth_tokens', - on_delete=models.CASCADE, verbose_name=_("User") + settings.AUTH_USER_MODEL, + related_name="auth_tokens", + on_delete=models.CASCADE, + verbose_name=_("User"), ) """ Relation to User model, it is a ForeignKey, so each user can have more than one token""" @@ -65,23 +70,26 @@ class Meta: """boolean: Define wether or not the model will be managed by the ORM (saved in the DB)""" permissions = ( - ('command.execute_command', 'Execute Commands'), - ('command.run_script', 'Run and Requeue scripts in ScriptQueues'), + ("command.execute_command", "Execute Commands"), + ("command.run_script", "Run and Requeue scripts in ScriptQueues"), ) """((string, string)): Tuple defining permissions in the format ((, ))""" + class ConfigFile(BaseModel): """ConfigFile Model, that includes actual configuration files, creation date and user.""" - + def validate_file_extension(value): ext = os.path.splitext(value.name)[1] # [0] returns path+filename - valid_extensions = ['.json', '.sh'] + valid_extensions = [".json", ".sh"] if not ext.lower() in valid_extensions: - raise ValidationError('Unsupported file extension.') + raise ValidationError("Unsupported file extension.") user = models.ForeignKey( - settings.AUTH_USER_MODEL, related_name='config_files', - on_delete=models.CASCADE, verbose_name= "User" + settings.AUTH_USER_MODEL, + related_name="config_files", + on_delete=models.CASCADE, + verbose_name="User", ) """User who created the config file""" @@ -95,9 +103,10 @@ def validate_file_extension(value): ) """Reference to the config file""" + class EmergencyContact(BaseModel): """EmergencyContact Model""" - + subsystem = models.CharField(max_length=100, blank=True) """EC's subsystem""" @@ -108,4 +117,4 @@ class EmergencyContact(BaseModel): """EC's preferred contact information (work number, cell, none)""" email = models.EmailField(max_length=254) - """EC's email""" \ No newline at end of file + """EC's email""" diff --git a/manager/api/schema_validator.py b/manager/api/schema_validator.py index d7f9ce2b..ed791887 100644 --- a/manager/api/schema_validator.py +++ b/manager/api/schema_validator.py @@ -44,6 +44,7 @@ class DefaultingValidator: * final_validator: a standard validator that does not alter the data being validated. """ + @staticmethod def set_default_properties(properties, skip_properties, instance): for prop, subschema in properties.items(): @@ -107,7 +108,9 @@ def set_defaults(validator, properties, instance, schema): "uniqueItems", ) ) - DefaultingValidator.set_default_properties(properties, skip_properties, instance) + DefaultingValidator.set_default_properties( + properties, skip_properties, instance + ) for error in validate_properties(validator, properties, instance, schema): yield error diff --git a/manager/api/serializers.py b/manager/api/serializers.py index 7d310f5a..16c5f664 100644 --- a/manager/api/serializers.py +++ b/manager/api/serializers.py @@ -163,6 +163,7 @@ def get_config(self, token) -> Union[dict, None]: class ConfigFileSerializer(serializers.ModelSerializer): """Serializer to map the Model instance into JSON format.""" + filename = serializers.SerializerMethodField() username = serializers.SerializerMethodField() @@ -178,11 +179,19 @@ class Meta: model = ConfigFile """The model class to serialize""" - fields = ("id", "username", "filename", "creation_timestamp", "update_timestamp") + fields = ( + "id", + "username", + "filename", + "creation_timestamp", + "update_timestamp", + ) """The fields of the model class to serialize""" + class ConfigFileContentSerializer(serializers.ModelSerializer): """Serializer to map the Model instance into JSON format.""" + content = serializers.SerializerMethodField() filename = serializers.SerializerMethodField() @@ -211,5 +220,5 @@ class Meta: model = EmergencyContact """The model class to serialize""" - fields = ("__all__") - """The fields of the model class to serialize""" \ No newline at end of file + fields = "__all__" + """The fields of the model class to serialize""" diff --git a/manager/api/signals.py b/manager/api/signals.py index ead73e11..500ce233 100644 --- a/manager/api/signals.py +++ b/manager/api/signals.py @@ -18,9 +18,9 @@ class of the sender, in this case 'Token' arguments dictionary sent with the signal. It contains the key 'instance' with the Token instance that was deleted """ - deleted_token = str(kwargs['instance']) - groupname = 'token-{}'.format(deleted_token) - payload = {'type': 'logout', 'message': ''} + deleted_token = str(kwargs["instance"]) + groupname = "token-{}".format(deleted_token) + payload = {"type": "logout", "message": ""} loop = None try: loop = asyncio.get_event_loop() diff --git a/manager/api/tests/test_commander.py b/manager/api/tests/test_commander.py index 2ecabd59..da540927 100644 --- a/manager/api/tests/test_commander.py +++ b/manager/api/tests/test_commander.py @@ -196,4 +196,3 @@ def test_salinfo_topic_data_with_param(self, mock_requests, mock_environ): f"http://fakehost:fakeport/salinfo/topic-data?categories=telemetry" ) self.assertEqual(mock_requests.call_args, call(expected_url)) - diff --git a/manager/api/tests/test_lovecsc.py b/manager/api/tests/test_lovecsc.py index 107d05ef..c7a18ec0 100644 --- a/manager/api/tests/test_lovecsc.py +++ b/manager/api/tests/test_lovecsc.py @@ -71,7 +71,7 @@ def test_unauthorized_lovecsc(self, mock_requests, mock_environ): "user": "user", "message": "a message", } - + response = self.client.post(url, data, format="json") result = response.json() diff --git a/manager/api/tests/test_schema_validation.py b/manager/api/tests/test_schema_validation.py index 0cf1e942..ab9f0b51 100644 --- a/manager/api/tests/test_schema_validation.py +++ b/manager/api/tests/test_schema_validation.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import User, Permission import yaml + @override_settings(DEBUG=True) class SchemaValidationTestCase(TestCase): script_schema = """ @@ -40,127 +41,132 @@ def setUp(self): # Arrange self.client = APIClient() self.user = User.objects.create_user( - username='user', - password='password', - email='test@user.cl', - first_name='First', - last_name='Last', + username="user", + password="password", + email="test@user.cl", + first_name="First", + last_name="Last", ) self.token = Token.objects.create(user=self.user) - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) - self.user.user_permissions.add(Permission.objects.get(codename='view_view'), - Permission.objects.get(codename='add_view'), - Permission.objects.get(codename='delete_view'), - Permission.objects.get(codename='change_view')) + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + self.user.user_permissions.add( + Permission.objects.get(codename="view_view"), + Permission.objects.get(codename="add_view"), + Permission.objects.get(codename="delete_view"), + Permission.objects.get(codename="change_view"), + ) def test_valid_config(self): """Test schema validation works for a valid config yaml string""" # Act: - url = reverse('validate-config-schema') - data = { - 'config': "wait_time: 3600", - 'schema': self.script_schema - } - response = self.client.post(url, data, format='json') + url = reverse("validate-config-schema") + data = {"config": "wait_time: 3600", "schema": self.script_schema} + response = self.client.post(url, data, format="json") # Assert: expected_data = { "title": "None", - "output": {'wait_time': 3600, 'fail_cleanup': False, 'fail_run': False} + "output": {"wait_time": 3600, "fail_cleanup": False, "fail_run": False}, } - self.assertEqual( - response.data, - expected_data - ) + self.assertEqual(response.data, expected_data) def test_syntax_error(self): """Test validation output of an unparsable config file""" configs = [ "wait_time: -\na:", # ScannerError "fail_cleanup: \nw:'", # ScannerError - ":" # ParserError + ":", # ParserError ] expected_data = [ - {'error': {'context': None, - 'note': None, - 'problem': 'sequence entries are not allowed here', - 'problem_mark': {'buffer': 'wait_time: -\na:\x00', - 'column': 11, - 'index': 11, - 'line': 0, - 'name': '', - 'pointer': 11}}, - 'title': 'ERROR WHILE PARSING YAML STRING'}, - {'error': {'context': 'while scanning a simple key', - 'note': None, - 'problem': "could not find expected ':'", - 'problem_mark': {'buffer': "fail_cleanup: \nw:'\x00", - 'column': 3, - 'index': 18, - 'line': 1, - 'name': '', - 'pointer': 18}}, - 'title': 'ERROR WHILE PARSING YAML STRING'}, - {'error': {'context': 'while parsing a block mapping', - 'note': None, - 'problem': "expected , but found ':'", - 'problem_mark': {'buffer': ':\x00', - 'column': 0, - 'index': 0, - 'line': 0, - 'name': '', - 'pointer': 0}}, - 'title': 'ERROR WHILE PARSING YAML STRING'} + { + "error": { + "context": None, + "note": None, + "problem": "sequence entries are not allowed here", + "problem_mark": { + "buffer": "wait_time: -\na:\x00", + "column": 11, + "index": 11, + "line": 0, + "name": "", + "pointer": 11, + }, + }, + "title": "ERROR WHILE PARSING YAML STRING", + }, + { + "error": { + "context": "while scanning a simple key", + "note": None, + "problem": "could not find expected ':'", + "problem_mark": { + "buffer": "fail_cleanup: \nw:'\x00", + "column": 3, + "index": 18, + "line": 1, + "name": "", + "pointer": 18, + }, + }, + "title": "ERROR WHILE PARSING YAML STRING", + }, + { + "error": { + "context": "while parsing a block mapping", + "note": None, + "problem": "expected , but found ':'", + "problem_mark": { + "buffer": ":\x00", + "column": 0, + "index": 0, + "line": 0, + "name": "", + "pointer": 0, + }, + }, + "title": "ERROR WHILE PARSING YAML STRING", + }, ] for config, expected_datum in zip(configs, expected_data): # Act: - url = reverse('validate-config-schema') - request_data = { - 'config': config, - 'schema': self.script_schema - } - response = self.client.post(url, request_data, format='json') + url = reverse("validate-config-schema") + request_data = {"config": config, "schema": self.script_schema} + response = self.client.post(url, request_data, format="json") # Assert: - self.assertEqual( - response.data, - expected_datum - ) + self.assertEqual(response.data, expected_datum) def test_invalid_config(self): """Test validation output of an invalid config file""" - configs = [ - "wait_time: 'asd'", - "asdfasfd" - ] + configs = ["wait_time: 'asd'", "asdfasfd"] - expected_data = [{ - 'error': { - 'message': "'asd' is not of type 'number'", - 'path': ['wait_time'], - 'schema_path': ['properties', 'wait_time', 'type'], + expected_data = [ + { + "error": { + "message": "'asd' is not of type 'number'", + "path": ["wait_time"], + "schema_path": ["properties", "wait_time", "type"], + }, + "title": "INVALID CONFIG YAML", + }, + { + "error": { + "message": "asdfasfd is not a dict", + "path": [], + "schema_path": [], + }, + "title": "INVALID CONFIG YAML", }, - 'title': 'INVALID CONFIG YAML' - }, - {'error': {'message': 'asdfasfd is not a dict', 'path': [], 'schema_path': []}, - 'title': 'INVALID CONFIG YAML'} - ] for config, expected_datum in zip(configs, expected_data): # Act: - url = reverse('validate-config-schema') - request_data = { - 'config': config, - 'schema': self.script_schema - } - response = self.client.post(url, request_data, format='json') + url = reverse("validate-config-schema") + request_data = {"config": config, "schema": self.script_schema} + response = self.client.post(url, request_data, format="json") # Assert: - self.assertEqual( - response.data, - expected_datum - ) + self.assertEqual(response.data, expected_datum) diff --git a/manager/api/tests/tests_auth_api.py b/manager/api/tests/tests_auth_api.py index 973e81ee..efc3195f 100644 --- a/manager/api/tests/tests_auth_api.py +++ b/manager/api/tests/tests_auth_api.py @@ -60,9 +60,13 @@ def setUp(self): self.filename = "test.json" self.content = {"key1": "this is the content of the file"} - self.configfile = ConfigFile.objects.create(user=self.user, - config_file=AuthApiTestCase.get_config_file_sample("random_filename", self.content), - file_name=self.filename) + self.configfile = ConfigFile.objects.create( + user=self.user, + config_file=AuthApiTestCase.get_config_file_sample( + "random_filename", self.content + ), + file_name=self.filename, + ) def test_user_login(self): """Test that a user can request a token using name and password.""" @@ -186,7 +190,10 @@ def test_user_validate_token(self): ) self.assertEqual( response.data["user"], - {"username": self.user.username, "email": self.user.email,}, + { + "username": self.user.username, + "email": self.user.email, + }, "The user is not as expected", ) self.assertTrue( @@ -225,7 +232,10 @@ def test_user_validate_token_no_config(self): ) self.assertEqual( response.data["user"], - {"username": self.user.username, "email": self.user.email,}, + { + "username": self.user.username, + "email": self.user.email, + }, "The user is not as expected", ) self.assertTrue( diff --git a/manager/api/tests/tests_configfile.py b/manager/api/tests/tests_configfile.py index 9dabe866..c037e7db 100644 --- a/manager/api/tests/tests_configfile.py +++ b/manager/api/tests/tests_configfile.py @@ -15,11 +15,13 @@ from django.conf import settings import tempfile -#python manage.py test api.tests.tests_configfile.ConfigFileApiTestCase +# python manage.py test api.tests.tests_configfile.ConfigFileApiTestCase + def setUp(self): settings.MEDIA_ROOT = tempfile.mkdtemp() - + + class ConfigFileApiTestCase(TestCase): """Test suite for config files handling.""" @@ -28,11 +30,10 @@ def get_config_file_sample(name, content): f = ContentFile(json.dumps(content).encode("ascii"), name=name) return f - def setUp(self): """Define the test suite setup.""" # Arrange: - + self.client = APIClient() self.user = User.objects.create_user( username="user", @@ -43,9 +44,13 @@ def setUp(self): ) self.filename = "test.json" self.content = {"key1": "this is the content of the file"} - self.configfile = ConfigFile.objects.create(user=self.user, - config_file=ConfigFileApiTestCase.get_config_file_sample("random_filename", self.content), - file_name=self.filename) + self.configfile = ConfigFile.objects.create( + user=self.user, + config_file=ConfigFileApiTestCase.get_config_file_sample( + "random_filename", self.content + ), + file_name=self.filename, + ) self.url = reverse("config") self.token = Token.objects.create(user=self.user) @@ -56,8 +61,8 @@ def test_get_config_files_list(self): self.assertEqual(response.status_code, 200) expected_data = { "id": self.configfile.id, - "username": self.user.username, - "filename": self.filename, + "username": self.user.username, + "filename": self.filename, } self.assertEqual(len(response.data), 1) self.assertEqual(response.data[0]["filename"], expected_data["filename"]) @@ -65,12 +70,14 @@ def test_get_config_files_list(self): def test_get_config_file(self): """Test that an authenticated user can get a config file.""" self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) - response = self.client.get(reverse("configfile-detail", args=[self.configfile.id]), format="json") + response = self.client.get( + reverse("configfile-detail", args=[self.configfile.id]), format="json" + ) self.assertEqual(response.status_code, 200) expected_data = { "id": self.configfile.id, - "username": self.user.username, - "filename": self.filename, + "username": self.user.username, + "filename": self.filename, } self.assertEqual(response.data["id"], expected_data["id"]) self.assertEqual(response.data["username"], expected_data["username"]) @@ -79,12 +86,14 @@ def test_get_config_file(self): def test_get_config_file_content(self): """Test that an authenticated user can get a config file content.""" self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) - response = self.client.get(reverse("configfile-content", args=[self.configfile.id]), format="json") + response = self.client.get( + reverse("configfile-content", args=[self.configfile.id]), format="json" + ) self.assertEqual(response.status_code, 200) expected_data = { "id": self.configfile.id, - "content": self.content, - "filename": self.filename, + "content": self.content, + "filename": self.filename, } self.assertEqual(response.data["id"], expected_data["id"]) self.assertEqual(response.data["content"], expected_data["content"]) diff --git a/manager/api/tests/tests_emergencycontact.py b/manager/api/tests/tests_emergencycontact.py index 0bef916f..a67f99ca 100644 --- a/manager/api/tests/tests_emergencycontact.py +++ b/manager/api/tests/tests_emergencycontact.py @@ -13,7 +13,8 @@ from manager import utils from django.core.files.base import ContentFile -#python manage.py test api.tests.tests_emergencycontact.EmergencyContactApiTestCase +# python manage.py test api.tests.tests_emergencycontact.EmergencyContactApiTestCase + class EmergencyContactApiTestCase(TestCase): """Test suite for config files handling.""" @@ -23,11 +24,10 @@ def get_config_file_sample(name, content): f = ContentFile(json.dumps(content).encode("ascii"), name=name) return f - def setUp(self): """Define the test suite setup.""" # Arrange: - + self.client = APIClient() self.user = User.objects.create_user( username="user", diff --git a/manager/api/urls.py b/manager/api/urls.py index bfe3d7ce..cd63dc01 100644 --- a/manager/api/urls.py +++ b/manager/api/urls.py @@ -40,7 +40,11 @@ ), path("auth/", include("rest_framework.urls", namespace="rest_framework")), path("cmd/", api.views.commander, name="commander"), - path("lovecsc/observinglog", api.views.lovecsc_observinglog, name="lovecsc-observinglog"), + path( + "lovecsc/observinglog", + api.views.lovecsc_observinglog, + name="lovecsc-observinglog", + ), path("salinfo/metadata", api.views.salinfo_metadata, name="salinfo-metadata"), path( "salinfo/topic-names", api.views.salinfo_topic_names, name="salinfo-topic-names" @@ -48,6 +52,6 @@ path("salinfo/topic-data", api.views.salinfo_topic_data, name="salinfo-topic-data"), path("config", api.views.get_config, name="config"), ] -router.register('configfile', ConfigFileViewSet) -router.register('emergencycontact', EmergencyContactViewSet) +router.register("configfile", ConfigFileViewSet) +router.register("emergencycontact", EmergencyContactViewSet) urlpatterns.append(path("", include(router.urls))) diff --git a/manager/api/views.py b/manager/api/views.py index cf4afdb8..393c61db 100644 --- a/manager/api/views.py +++ b/manager/api/views.py @@ -18,7 +18,11 @@ from rest_framework import viewsets, status from api.models import Token from api.serializers import TokenSerializer, ConfigSerializer -from api.serializers import ConfigFileSerializer, ConfigFileContentSerializer, EmergencyContactSerializer +from api.serializers import ( + ConfigFileSerializer, + ConfigFileContentSerializer, + EmergencyContactSerializer, +) from .schema_validator import DefaultingValidator from api.models import ConfigFile, EmergencyContact @@ -238,7 +242,7 @@ def commander(request): ------ request: Request The Request object - + Returns ------- Response @@ -272,7 +276,7 @@ def lovecsc_observinglog(request): ------ request: Request The Request object - + Returns ------- Response @@ -307,12 +311,12 @@ def lovecsc_observinglog(request): def salinfo_metadata(request): """Requests SalInfo.metadata from the commander containing a dict of : { "sal_version": ..., "xml_version": ....} - + Params ------ request: Request The Request object - + Returns ------- Response @@ -349,12 +353,12 @@ def salinfo_metadata(request): def salinfo_topic_names(request): """Requests SalInfo.topic_names from the commander containing a dict of : { "command_names": [], "event_names": [], "telemetry_names": []} - + Params ------ request: Request The Request object - + Returns ------- Response @@ -394,12 +398,12 @@ def salinfo_topic_names(request): def salinfo_topic_data(request): """Requests SalInfo.topic_data from the commander containing a dict of : { "command_data": [], "event_data": [], "telemetry_data": []} - + Params ------ request: Request The Request object - + Returns ------- Response @@ -431,7 +435,7 @@ def get_config(request): ------ request: Request The Request object - + Returns ------- Response @@ -446,7 +450,6 @@ def get_config(request): return Response(serializer.data) - class ConfigFileViewSet(viewsets.ModelViewSet): """GET, POST, PUT, PATCH or DELETE instances the ConfigFile model.""" @@ -488,4 +491,4 @@ class EmergencyContactViewSet(viewsets.ModelViewSet): """Set of objects to be accessed by queries to this viewsets endpoints""" serializer_class = EmergencyContactSerializer - """Serializer used to serialize View objects""" \ No newline at end of file + """Serializer used to serialize View objects""" diff --git a/manager/manage.py b/manager/manage.py index 05b2a2e2..6a5d00ea 100755 --- a/manager/manage.py +++ b/manager/manage.py @@ -4,8 +4,8 @@ import os import sys -if __name__ == '__main__': - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'manager.settings') +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "manager.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/manager/manager/routing.py b/manager/manager/routing.py index 165f580b..a8d3e639 100644 --- a/manager/manager/routing.py +++ b/manager/manager/routing.py @@ -2,8 +2,6 @@ from channels.routing import ProtocolTypeRouter, URLRouter import subscription.routing -application = ProtocolTypeRouter({ - 'websocket': URLRouter( - subscription.routing.websocket_urlpatterns - ) -}) +application = ProtocolTypeRouter( + {"websocket": URLRouter(subscription.routing.websocket_urlpatterns)} +) diff --git a/manager/manager/settings.py b/manager/manager/settings.py index 24ef88c1..fe64402b 100644 --- a/manager/manager/settings.py +++ b/manager/manager/settings.py @@ -106,7 +106,9 @@ TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [os.path.join(BASE_DIR, "templates"),], + "DIRS": [ + os.path.join(BASE_DIR, "templates"), + ], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -133,9 +135,15 @@ { "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",}, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, ] # Password for other processes @@ -226,7 +234,9 @@ else: CHANNEL_LAYERS = { - "default": {"BACKEND": "channels.layers.InMemoryChannelLayer",}, + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + }, } # LDAP @@ -245,7 +255,9 @@ AUTH_LDAP_BIND_PASSWORD = "" AUTH_LDAP_USER_SEARCH = LDAPSearch( - "ou=people,dc=planetexpress,dc=com", ldap.SCOPE_SUBTREE, "(uid=%(user)s)", + "ou=people,dc=planetexpress,dc=com", + ldap.SCOPE_SUBTREE, + "(uid=%(user)s)", ) TRACE_TIMESTAMPS = True @@ -253,4 +265,3 @@ if os.environ.get("HIDE_TRACE_TIMESTAMPS", False): TRACE_TIMESTAMPS = False - diff --git a/manager/manager/urls.py b/manager/manager/urls.py index d8ce598e..5dd143d8 100644 --- a/manager/manager/urls.py +++ b/manager/manager/urls.py @@ -27,7 +27,7 @@ schema_view = get_schema_view( openapi.Info( title="LOVE-manager API", - default_version='v1', + default_version="v1", description="This is the API of LOVE-manager's authentication and UI Framework modules", ), public=True, @@ -35,14 +35,27 @@ ) urlpatterns = [ - path('manager/admin/', admin.site.urls), - path('manager/test/', TemplateView.as_view(template_name="test.html")), - path('manager/login/', TemplateView.as_view(template_name="registration/login.html")), - path('manager/api/', include('api.urls')), - path('manager/ui_framework/', include('ui_framework.urls')), - re_path(r'^manager/apidoc/swagger(?P\.json|\.yaml)$', - schema_view.without_ui(cache_timeout=0), name='schema-json'), - path('manager/apidoc/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), - path('manager/apidoc/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), - path('manager/schema_validation/', TemplateView.as_view(template_name="test.html")), + path("manager/admin/", admin.site.urls), + path("manager/test/", TemplateView.as_view(template_name="test.html")), + path( + "manager/login/", TemplateView.as_view(template_name="registration/login.html") + ), + path("manager/api/", include("api.urls")), + path("manager/ui_framework/", include("ui_framework.urls")), + re_path( + r"^manager/apidoc/swagger(?P\.json|\.yaml)$", + schema_view.without_ui(cache_timeout=0), + name="schema-json", + ), + path( + "manager/apidoc/swagger/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path( + "manager/apidoc/redoc/", + schema_view.with_ui("redoc", cache_timeout=0), + name="schema-redoc", + ), + path("manager/schema_validation/", TemplateView.as_view(template_name="test.html")), ] diff --git a/manager/manager/wsgi.py b/manager/manager/wsgi.py index 6d2fcf8a..a86267d3 100644 --- a/manager/manager/wsgi.py +++ b/manager/manager/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'manager.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "manager.settings") application = get_wsgi_application() diff --git a/manager/subscription/__init__.py b/manager/subscription/__init__.py index aa7522a3..c7fe0352 100644 --- a/manager/subscription/__init__.py +++ b/manager/subscription/__init__.py @@ -1 +1 @@ -default_app_config = 'subscription.apps.SubscriptionConfig' +default_app_config = "subscription.apps.SubscriptionConfig" diff --git a/manager/subscription/consumers.py b/manager/subscription/consumers.py index 1a6413aa..feb209a4 100644 --- a/manager/subscription/consumers.py +++ b/manager/subscription/consumers.py @@ -124,7 +124,7 @@ async def handle_action_message(self, message): Receives an action message and reacts according to each different action. - Currently supported actions: + Currently supported actions: - get_time_data: sends a message with the time_data and passes though a request_time received with the message. - Expected input message: @@ -166,7 +166,10 @@ async def handle_action_message(self, message): request_time = message["request_time"] time_data = utils.get_times() await self.send_json( - {"time_data": time_data, "request_time": request_time,} + { + "time_data": time_data, + "request_time": request_time, + } ) async def handle_heartbeat_message(self, message): diff --git a/manager/subscription/heartbeat_manager.py b/manager/subscription/heartbeat_manager.py index 6d8b4096..1324e11d 100644 --- a/manager/subscription/heartbeat_manager.py +++ b/manager/subscription/heartbeat_manager.py @@ -14,10 +14,10 @@ class HeartbeatManager: """ class __HeartbeatManager: - + heartbeat_task = None """Reference to the task that dispatches the heartbeats.""" - + commander_heartbeat_task = None """Reference to the task that requests the LOVE_COmmander heartbeats.""" @@ -35,7 +35,9 @@ def initialize(cls): if not cls.heartbeat_task: cls.heartbeat_task = asyncio.create_task(cls.dispatch_heartbeats()) if not cls.commander_heartbeat_task: - cls.commander_heartbeat_task = asyncio.create_task(cls.query_commander()) + cls.commander_heartbeat_task = asyncio.create_task( + cls.query_commander() + ) @classmethod def set_heartbeat_timestamp(cls, source, timestamp): @@ -61,9 +63,9 @@ async def query_commander(cls): try: # query commander resp = requests.get(heartbeat_url) - timestamp = resp.json()['timestamp'] - #get timestamp - self.set_heartbeat_timestamp('Commander', timestamp) + timestamp = resp.json()["timestamp"] + # get timestamp + self.set_heartbeat_timestamp("Commander", timestamp) await asyncio.sleep(3) except Exception as e: print(e) @@ -78,22 +80,31 @@ async def dispatch_heartbeats(cls): channel_layer = get_channel_layer() while True: try: - print('sending data') - cls.set_heartbeat_timestamp('Manager', datetime.datetime.now().timestamp()) - data = json.dumps({ - 'category': 'heartbeat', - 'data': [ - { - 'csc': heartbeat_source, - 'salindex': 0, - 'data': {'timestamp': cls.heartbeat_data[heartbeat_source]} - } for heartbeat_source in cls.heartbeat_data - ], - 'subscription': 'heartbeat' - }) + print("sending data") + cls.set_heartbeat_timestamp( + "Manager", datetime.datetime.now().timestamp() + ) + data = json.dumps( + { + "category": "heartbeat", + "data": [ + { + "csc": heartbeat_source, + "salindex": 0, + "data": { + "timestamp": cls.heartbeat_data[ + heartbeat_source + ] + }, + } + for heartbeat_source in cls.heartbeat_data + ], + "subscription": "heartbeat", + } + ) await channel_layer.group_send( - 'heartbeat-manager-0-stream', - {'type': 'send_heartbeat', 'data': data} + "heartbeat-manager-0-stream", + {"type": "send_heartbeat", "data": data}, ) await asyncio.sleep(3) except Exception as e: @@ -119,9 +130,10 @@ async def stop(cls): cls.commander_heartbeat_task.cancel() instance = None + def __init__(self): if not HeartbeatManager.instance: HeartbeatManager.instance = HeartbeatManager.__HeartbeatManager() def __getattr__(self, name): - return getattr(self.instance, name) \ No newline at end of file + return getattr(self.instance, name) diff --git a/manager/subscription/tests/test_connection.py b/manager/subscription/tests/test_connection.py index 2b6ec803..92bcd68d 100644 --- a/manager/subscription/tests/test_connection.py +++ b/manager/subscription/tests/test_connection.py @@ -14,9 +14,13 @@ class TestClientConnection: """Test that clients can or cannot connect depending on different conditions.""" def setup_method(self): - self.user = User.objects.create_user('username', password='123', email='user@user.cl') + self.user = User.objects.create_user( + "username", password="123", email="user@user.cl" + ) self.token = Token.objects.create(user=self.user) - self.user2 = User.objects.create_user('username2', password='123', email='user@user.cl') + self.user2 = User.objects.create_user( + "username2", password="123", email="user@user.cl" + ) self.token2 = Token.objects.create(user=self.user2) @pytest.mark.asyncio @@ -24,12 +28,12 @@ def setup_method(self): async def test_connection_with_token(self): """Test that clients can connect with a valid token.""" # Arrange - url = 'manager/ws/subscription/?token={}'.format(self.token) + url = "manager/ws/subscription/?token={}".format(self.token) communicator = WebsocketCommunicator(application, url) # Act connected, subprotocol = await communicator.connect() # Assert - assert connected, 'Communicator was not connected' + assert connected, "Communicator was not connected" await communicator.disconnect() @pytest.mark.asyncio @@ -38,12 +42,12 @@ async def test_connection_with_password(self): """Test that clients can connect with a valid password.""" # Arrange password = PROCESS_CONNECTION_PASS - url = 'manager/ws/subscription/?password={}'.format(password) + url = "manager/ws/subscription/?password={}".format(password) communicator = WebsocketCommunicator(application, url) # Act connected, subprotocol = await communicator.connect() # Assert - assert connected, 'Communicator was not connected' + assert connected, "Communicator was not connected" await communicator.disconnect() @pytest.mark.asyncio @@ -51,12 +55,12 @@ async def test_connection_with_password(self): async def test_connection_failed_for_invalid_token(self): """Test that clients cannot connect with an invalid token.""" # Arrange - url = 'manager/ws/subscription/?token={}'.format(str(self.token) + 'fake') + url = "manager/ws/subscription/?token={}".format(str(self.token) + "fake") communicator = WebsocketCommunicator(application, url) # Act connected, subprotocol = await communicator.connect() # Assert - assert not connected, 'Communicator should not have connected' + assert not connected, "Communicator should not have connected" await communicator.disconnect() @pytest.mark.asyncio @@ -64,13 +68,13 @@ async def test_connection_failed_for_invalid_token(self): async def test_connection_failed_for_invalid_password(self): """Test that clients cannot connect with an invalid password.""" # Arrange - password = PROCESS_CONNECTION_PASS + '_fake' - url = 'manager/ws/subscription/?password={}'.format(password) + password = PROCESS_CONNECTION_PASS + "_fake" + url = "manager/ws/subscription/?password={}".format(password) communicator = WebsocketCommunicator(application, url) # Act connected, subprotocol = await communicator.connect() # Assert - assert not connected, 'Communicator should not have connected' + assert not connected, "Communicator should not have connected" await communicator.disconnect() @pytest.mark.asyncio @@ -84,25 +88,34 @@ async def test_connection_interrupted_when_logout_message_is_sent(self): "csc": "ScriptQueue", "salindex": 0, "stream": "stream1", - "category": "event" + "category": "event", } - expected_response = 'Successfully subscribed to event-ScriptQueue-0-stream1' + expected_response = "Successfully subscribed to event-ScriptQueue-0-stream1" channel_layer = get_channel_layer() # Connect 3 clients (2 users and 1 with password) - client1 = WebsocketCommunicator(application, 'manager/ws/subscription/?token={}'.format(self.token)) - client2 = WebsocketCommunicator(application, 'manager/ws/subscription/?token={}'.format(self.token2)) - client3 = WebsocketCommunicator(application, 'manager/ws/subscription/?password={}'.format(password)) + client1 = WebsocketCommunicator( + application, "manager/ws/subscription/?token={}".format(self.token) + ) + client2 = WebsocketCommunicator( + application, "manager/ws/subscription/?token={}".format(self.token2) + ) + client3 = WebsocketCommunicator( + application, "manager/ws/subscription/?password={}".format(password) + ) for client in [client1, client2, client3]: connected, subprotocol = await client.connect() - assert connected, 'Error, client was not connected, test could not be completed' + assert ( + connected + ), "Error, client was not connected, test could not be completed" # ACT await channel_layer.group_send( - 'token-{}'.format(str(self.token)), - {'type': 'logout', 'message': ''} + "token-{}".format(str(self.token)), {"type": "logout", "message": ""} ) - await asyncio.sleep(1) # Wait 1 second, to ensure the connection is closed before we continue + await asyncio.sleep( + 1 + ) # Wait 1 second, to ensure the connection is closed before we continue # ASSERT # Client 1 should not be able to send and receive messages @@ -113,12 +126,12 @@ async def test_connection_interrupted_when_logout_message_is_sent(self): # Client 2 should be able to send and receive messages await client2.send_json_to(subscription_msg) response = await client2.receive_json_from() - assert response['data'] == expected_response + assert response["data"] == expected_response # Client 3 should be able to send and receive messages await client3.send_json_to(subscription_msg) response = await client3.receive_json_from() - assert response['data'] == expected_response + assert response["data"] == expected_response # Disconnect all clients await client1.disconnect() @@ -136,22 +149,32 @@ async def test_connection_interrupted_when_token_is_deleted(self): "csc": "ScriptQueue", "salindex": 0, "stream": "stream1", - "category": "event" + "category": "event", } - expected_response = 'Successfully subscribed to event-ScriptQueue-0-stream1' + expected_response = "Successfully subscribed to event-ScriptQueue-0-stream1" # Connect 3 clients (2 users and 1 with password) - client1 = WebsocketCommunicator(application, 'manager/ws/subscription/?token={}'.format(self.token)) - client2 = WebsocketCommunicator(application, 'manager/ws/subscription/?token={}'.format(self.token2)) - client3 = WebsocketCommunicator(application, 'manager/ws/subscription/?password={}'.format(password)) + client1 = WebsocketCommunicator( + application, "manager/ws/subscription/?token={}".format(self.token) + ) + client2 = WebsocketCommunicator( + application, "manager/ws/subscription/?token={}".format(self.token2) + ) + client3 = WebsocketCommunicator( + application, "manager/ws/subscription/?password={}".format(password) + ) for client in [client1, client2, client3]: connected, subprotocol = await client.connect() - assert connected, 'Error, client was not connected, test could not be completed' + assert ( + connected + ), "Error, client was not connected, test could not be completed" # ACT: delete de token # await self.delete_token() await database_sync_to_async(self.token.delete)() - await asyncio.sleep(1) # Wait 1 second, to ensure the connection is closed before we continue + await asyncio.sleep( + 1 + ) # Wait 1 second, to ensure the connection is closed before we continue # ASSERT # Client 1 should not be able to send and receive messages @@ -162,12 +185,12 @@ async def test_connection_interrupted_when_token_is_deleted(self): # Client 2 should be able to send and receive messages await client2.send_json_to(subscription_msg) response = await client2.receive_json_from() - assert response['data'] == expected_response + assert response["data"] == expected_response # Client 3 should be able to send and receive messages await client3.send_json_to(subscription_msg) response = await client3.receive_json_from() - assert response['data'] == expected_response + assert response["data"] == expected_response # Disconnect all clients await client1.disconnect() diff --git a/manager/subscription/tests/test_heartbeat.py b/manager/subscription/tests/test_heartbeat.py index 66bfe0aa..00c27744 100644 --- a/manager/subscription/tests/test_heartbeat.py +++ b/manager/subscription/tests/test_heartbeat.py @@ -16,10 +16,12 @@ class TestHeartbeat: def setup_method(self): """Set up the TestCase, executed before each test of the TestCase.""" - self.user = User.objects.create_user('username', password='123', email='user@user.cl') + self.user = User.objects.create_user( + "username", password="123", email="user@user.cl" + ) self.token = Token.objects.create(user=self.user) - self.user.user_permissions.add(Permission.objects.get(name='Execute Commands')) - self.url = 'manager/ws/subscription/?token={}'.format(self.token) + self.user.user_permissions.add(Permission.objects.get(name="Execute Commands")) + self.url = "manager/ws/subscription/?token={}".format(self.token) @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) @@ -42,10 +44,12 @@ async def test_join_and_leave_subscription(self): response = await communicator.receive_json_from() # Assert 1 - assert response['data'] == f'Successfully subscribed to heartbeat-manager-0-stream' + assert ( + response["data"] == f"Successfully subscribed to heartbeat-manager-0-stream" + ) response = await communicator.receive_json_from(timeout=10) - assert response['data'][0]['data']['timestamp'] is not None + assert response["data"][0]["data"]["timestamp"] is not None # Act 2 (Unsubscribe) msg = { "option": "unsubscribe", @@ -58,7 +62,10 @@ async def test_join_and_leave_subscription(self): response = await communicator.receive_json_from() # Assert 2 - assert response['data'] == f'Successfully unsubscribed to heartbeat-manager-0-stream' + assert ( + response["data"] + == f"Successfully unsubscribed to heartbeat-manager-0-stream" + ) await communicator.disconnect() @@ -77,10 +84,12 @@ async def test_join_and_leave_subscription(self): response = await communicator.receive_json_from() # Assert 1 - assert response['data'] == f'Successfully subscribed to heartbeat-manager-0-stream' + assert ( + response["data"] == f"Successfully subscribed to heartbeat-manager-0-stream" + ) response = await communicator.receive_json_from(timeout=10) - assert response['data'][0]['data']['timestamp'] is not None + assert response["data"][0]["data"]["timestamp"] is not None # Act 2 (Unsubscribe) msg = { "option": "unsubscribe", @@ -93,7 +102,10 @@ async def test_join_and_leave_subscription(self): response = await communicator.receive_json_from() # Assert 2 - assert response['data'] == f'Successfully unsubscribed to heartbeat-manager-0-stream' + assert ( + response["data"] + == f"Successfully unsubscribed to heartbeat-manager-0-stream" + ) await communicator.disconnect() await hb_manager.stop() @@ -118,22 +130,24 @@ async def test_heartbeat_manager_setter(self): response = await communicator.receive_json_from() # Assert 1 - assert response['data'] == f'Successfully subscribed to heartbeat-manager-0-stream' + assert ( + response["data"] == f"Successfully subscribed to heartbeat-manager-0-stream" + ) response = await communicator.receive_json_from(timeout=10) - assert response['data'][0]['data']['timestamp'] is not None + assert response["data"][0]["data"]["timestamp"] is not None # Act 2 Set producer heartbeat timestamp = datetime.datetime.now().timestamp() - hb_manager.set_heartbeat_timestamp('Producer', timestamp) + hb_manager.set_heartbeat_timestamp("Producer", timestamp) response = await communicator.receive_json_from(timeout=4) - + # Assert 2 - heartbeat_sources = [source['csc'] for source in response['data']] - assert 'Producer' in heartbeat_sources + heartbeat_sources = [source["csc"] for source in response["data"]] + assert "Producer" in heartbeat_sources await communicator.disconnect() await hb_manager.stop() - + @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_producer_heartbeat(self): @@ -155,9 +169,11 @@ async def test_producer_heartbeat(self): response = await communicator.receive_json_from() # Assert 1 - assert response['data'] == f'Successfully subscribed to heartbeat-manager-0-stream' + assert ( + response["data"] == f"Successfully subscribed to heartbeat-manager-0-stream" + ) response = await communicator.receive_json_from(timeout=5) - assert response['data'][0]['data']['timestamp'] is not None + assert response["data"][0]["data"]["timestamp"] is not None # Act 2 (Send producer heartbeat through websocket) msg = { @@ -168,14 +184,17 @@ async def test_producer_heartbeat(self): response = await communicator.receive_json_from(timeout=5) # Assert 2 (Get producer heartbeat data) - heartbeat_sources = [source['csc'] for source in response['data']] - assert 'Producer' in heartbeat_sources + heartbeat_sources = [source["csc"] for source in response["data"]] + assert "Producer" in heartbeat_sources await communicator.disconnect() await hb_manager.stop() @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) - @patch('requests.get', return_value = type('obj', (object,), {'json' : lambda: {'timestamp': 123123123}})) + @patch( + "requests.get", + return_value=type("obj", (object,), {"json": lambda: {"timestamp": 123123123}}), + ) async def test_unauthorized_commander(self, mock_requests): # Arrange hb_manager = HeartbeatManager() @@ -195,18 +214,24 @@ async def test_unauthorized_commander(self, mock_requests): response = await communicator.receive_json_from() # Assert 1 - assert response['data'] == f'Successfully subscribed to heartbeat-manager-0-stream' + assert ( + response["data"] == f"Successfully subscribed to heartbeat-manager-0-stream" + ) response = await communicator.receive_json_from(timeout=5) - assert response['data'][0]['data']['timestamp'] is not None + assert response["data"][0]["data"]["timestamp"] is not None # Act 2 (Wait for query to commander) # await asyncio.sleep(3) response = await communicator.receive_json_from(timeout=5) # Assert 2 (Get producer heartbeat data) - heartbeat_sources = [source['csc'] for source in response['data']] - assert 'Commander' in heartbeat_sources - commander_heartbeat = [source['data'] for source in response['data'] if source['csc'] == 'Commander'][0] - commander_timestamp = commander_heartbeat['timestamp'] + heartbeat_sources = [source["csc"] for source in response["data"]] + assert "Commander" in heartbeat_sources + commander_heartbeat = [ + source["data"] + for source in response["data"] + if source["csc"] == "Commander" + ][0] + commander_timestamp = commander_heartbeat["timestamp"] assert commander_timestamp == 123123123 await communicator.disconnect() - await hb_manager.stop() \ No newline at end of file + await hb_manager.stop() diff --git a/manager/subscription/tests/test_lovecsc_subscriptions.py b/manager/subscription/tests/test_lovecsc_subscriptions.py index f7dfa29c..947bfaea 100644 --- a/manager/subscription/tests/test_lovecsc_subscriptions.py +++ b/manager/subscription/tests/test_lovecsc_subscriptions.py @@ -7,7 +7,6 @@ class TestLOVECscSubscriptions: - def setup_method(self): """Set up the TestCase, executed before each test of the TestCase.""" self.user = User.objects.create_user( @@ -41,7 +40,8 @@ async def test_join_and_leave_subscription(self): # Assert 1 assert ( - response["data"] == f"Successfully subscribed to {category}-{csc}-{salindex}-{stream}" + response["data"] + == f"Successfully subscribed to {category}-{csc}-{salindex}-{stream}" ) # Act 2 (Unsubscribe) @@ -66,8 +66,8 @@ async def test_join_and_leave_subscription(self): @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_observinglog_to_lovecsc(self): - """ Test that an observing log sent by a client is - correctly received by a subscribed LOVE-CSC (producer) client """ + """Test that an observing log sent by a client is + correctly received by a subscribed LOVE-CSC (producer) client""" # Arrange client_communicator = WebsocketCommunicator(application, self.url) @@ -100,9 +100,7 @@ async def test_observinglog_to_lovecsc(self): { "csc": "love", "salindex": 0, - "data": { - "observingLog": {"user": "user", "message": "a message"} - }, + "data": {"observingLog": {"user": "user", "message": "a message"}}, } ], } diff --git a/manager/subscription/tests/test_time_data.py b/manager/subscription/tests/test_time_data.py index a59e0c3e..2f1ad30f 100644 --- a/manager/subscription/tests/test_time_data.py +++ b/manager/subscription/tests/test_time_data.py @@ -38,4 +38,4 @@ async def test_get_time_data(self): # Assert 1 assert utils.assert_time_data(time_data) assert request_time == 12312312341123 - await communicator.disconnect() \ No newline at end of file + await communicator.disconnect() diff --git a/manager/ui_framework/__init__.py b/manager/ui_framework/__init__.py index 76b9ba27..5c88eb14 100644 --- a/manager/ui_framework/__init__.py +++ b/manager/ui_framework/__init__.py @@ -1 +1 @@ -default_app_config = 'ui_framework.apps.UiFrameworkConfig' \ No newline at end of file +default_app_config = "ui_framework.apps.UiFrameworkConfig" diff --git a/manager/ui_framework/migrations/0001_initial.py b/manager/ui_framework/migrations/0001_initial.py index 9f9d6c0d..319caa33 100644 --- a/manager/ui_framework/migrations/0001_initial.py +++ b/manager/ui_framework/migrations/0001_initial.py @@ -8,53 +8,118 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='View', + name="View", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('creation_timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Creation time')), - ('update_timestamp', models.DateTimeField(auto_now=True, verbose_name='Last Updated')), - ('name', models.CharField(max_length=20)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "creation_timestamp", + models.DateTimeField( + auto_now_add=True, verbose_name="Creation time" + ), + ), + ( + "update_timestamp", + models.DateTimeField(auto_now=True, verbose_name="Last Updated"), + ), + ("name", models.CharField(max_length=20)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='Workspace', + name="Workspace", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('creation_timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Creation time')), - ('update_timestamp', models.DateTimeField(auto_now=True, verbose_name='Last Updated')), - ('name', models.CharField(max_length=20)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "creation_timestamp", + models.DateTimeField( + auto_now_add=True, verbose_name="Creation time" + ), + ), + ( + "update_timestamp", + models.DateTimeField(auto_now=True, verbose_name="Last Updated"), + ), + ("name", models.CharField(max_length=20)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='WorkspaceView', + name="WorkspaceView", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('creation_timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Creation time')), - ('update_timestamp', models.DateTimeField(auto_now=True, verbose_name='Last Updated')), - ('view_name', models.CharField(blank=True, max_length=20)), - ('sort_value', models.PositiveIntegerField(default=0)), - ('view', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_views', to='ui_framework.View')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_views', to='ui_framework.Workspace')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "creation_timestamp", + models.DateTimeField( + auto_now_add=True, verbose_name="Creation time" + ), + ), + ( + "update_timestamp", + models.DateTimeField(auto_now=True, verbose_name="Last Updated"), + ), + ("view_name", models.CharField(blank=True, max_length=20)), + ("sort_value", models.PositiveIntegerField(default=0)), + ( + "view", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_views", + to="ui_framework.View", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_views", + to="ui_framework.Workspace", + ), + ), ], options={ - 'ordering': ('sort_value',), - 'unique_together': {('workspace', 'view')}, + "ordering": ("sort_value",), + "unique_together": {("workspace", "view")}, }, ), migrations.AddField( - model_name='workspace', - name='views', - field=models.ManyToManyField(related_name='workspaces', through='ui_framework.WorkspaceView', to='ui_framework.View'), + model_name="workspace", + name="views", + field=models.ManyToManyField( + related_name="workspaces", + through="ui_framework.WorkspaceView", + to="ui_framework.View", + ), ), ] diff --git a/manager/ui_framework/migrations/0002_view_data.py b/manager/ui_framework/migrations/0002_view_data.py index 61d655cb..c2236e58 100644 --- a/manager/ui_framework/migrations/0002_view_data.py +++ b/manager/ui_framework/migrations/0002_view_data.py @@ -7,13 +7,13 @@ class Migration(migrations.Migration): dependencies = [ - ('ui_framework', '0001_initial'), + ("ui_framework", "0001_initial"), ] operations = [ migrations.AddField( - model_name='view', - name='data', + model_name="view", + name="data", field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), ), ] diff --git a/manager/ui_framework/migrations/0003_view_thumbnail.py b/manager/ui_framework/migrations/0003_view_thumbnail.py index 336066c3..674ab27f 100644 --- a/manager/ui_framework/migrations/0003_view_thumbnail.py +++ b/manager/ui_framework/migrations/0003_view_thumbnail.py @@ -7,13 +7,17 @@ class Migration(migrations.Migration): dependencies = [ - ('ui_framework', '0002_view_data'), + ("ui_framework", "0002_view_data"), ] operations = [ migrations.AddField( - model_name='view', - name='thumbnail', - field=models.ImageField(default='thumbnails/default.png', storage=ui_framework.models.OverwriteStorage(), upload_to='thumbnails/'), + model_name="view", + name="thumbnail", + field=models.ImageField( + default="thumbnails/default.png", + storage=ui_framework.models.OverwriteStorage(), + upload_to="thumbnails/", + ), ), ] diff --git a/manager/ui_framework/serializers.py b/manager/ui_framework/serializers.py index 1c5a01cf..0f55a671 100644 --- a/manager/ui_framework/serializers.py +++ b/manager/ui_framework/serializers.py @@ -23,9 +23,9 @@ class Base64ImageField(serializers.ImageField): @staticmethod def _get_view_id_from_data(data): - """ Return a view_id integer for building the thumbnail file_namet + """Return a view_id integer for building the thumbnail file_namet by checking whether the id comes in the request data or if a new one - has to be created """ + has to be created""" # id field should come in req data if view exists if "id" in data: return data["id"] @@ -88,7 +88,10 @@ def to_internal_value(self, data): # Get the file name extension: file_extension = self.get_file_extension(file_name, decoded_file) - complete_file_name = "%s.%s" % (file_name, file_extension,) + complete_file_name = "%s.%s" % ( + file_name, + file_extension, + ) data = ContentFile(decoded_file, name=complete_file_name) diff --git a/manager/ui_framework/signals.py b/manager/ui_framework/signals.py index 3417f35a..13163dc8 100644 --- a/manager/ui_framework/signals.py +++ b/manager/ui_framework/signals.py @@ -17,7 +17,7 @@ class of the sender, in this case 'View' arguments dictionary sent with the signal. It contains the key 'instance' with the View instance that was deleted """ - deleted_view = kwargs['instance'] + deleted_view = kwargs["instance"] file_url = settings.MEDIA_BASE + deleted_view.thumbnail.url try: os.remove(file_url) diff --git a/manager/ui_framework/tests/test_view_thumbnail.py b/manager/ui_framework/tests/test_view_thumbnail.py index 88fac0f4..791d0e42 100644 --- a/manager/ui_framework/tests/test_view_thumbnail.py +++ b/manager/ui_framework/tests/test_view_thumbnail.py @@ -22,21 +22,23 @@ def setUp(self): # Arrange self.client = APIClient() self.user = User.objects.create_user( - username='user', - password='password', - email='test@user.cl', - first_name='First', - last_name='Last', + username="user", + password="password", + email="test@user.cl", + first_name="First", + last_name="Last", ) self.token = Token.objects.create(user=self.user) - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) - self.user.user_permissions.add(Permission.objects.get(codename='view_view'), - Permission.objects.get(codename='add_view'), - Permission.objects.get(codename='delete_view'), - Permission.objects.get(codename='change_view')) + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + self.user.user_permissions.add( + Permission.objects.get(codename="view_view"), + Permission.objects.get(codename="add_view"), + Permission.objects.get(codename="delete_view"), + Permission.objects.get(codename="change_view"), + ) # delete existing test thumbnails - thumbnail_files_list = glob.glob(settings.MEDIA_ROOT + '/thumbnails/*') + thumbnail_files_list = glob.glob(settings.MEDIA_ROOT + "/thumbnails/*") for file in thumbnail_files_list: os.remove(file) @@ -45,19 +47,21 @@ def test_new_view(self): # Arrange # read test data (base64 string) old_count = View.objects.count() - mock_location = os.path.join(os.getcwd(), 'ui_framework', 'tests', 'media', 'mock', 'test') + mock_location = os.path.join( + os.getcwd(), "ui_framework", "tests", "media", "mock", "test" + ) with open(mock_location) as f: image_data = f.read() request_data = { "name": "view name", "data": {"key1": "value1"}, - "thumbnail": image_data + "thumbnail": image_data, } # Act 1 # send POST request with data - request_url = reverse('view-list') - response = self.client.post(request_url, request_data, format='json') + request_url = reverse("view-list") + response = self.client.post(request_url, request_data, format="json") # Assert # - response status code 201 @@ -69,43 +73,49 @@ def test_new_view(self): # - thumbnail url view = View.objects.get(name="view name") - self.assertEqual(view.thumbnail.url, '/media/thumbnails/view_1.png') + self.assertEqual(view.thumbnail.url, "/media/thumbnails/view_1.png") # - expected response data expected_response = { - 'id': view.id, - 'name': 'view name', - 'thumbnail': view.thumbnail.url, - 'data': {'key1': 'value1'}, + "id": view.id, + "name": "view name", + "thumbnail": view.thumbnail.url, + "data": {"key1": "value1"}, } self.assertEqual(response.data, expected_response) # - stored file content file_url = settings.MEDIA_BASE + view.thumbnail.url - expected_url = mock_location + '.png' - self.assertTrue(filecmp.cmp(file_url, expected_url), - f'\nThe image was not saved as expected\nsaved at {file_url}\nexpected at {expected_url}') + expected_url = mock_location + ".png" + self.assertTrue( + filecmp.cmp(file_url, expected_url), + f"\nThe image was not saved as expected\nsaved at {file_url}\nexpected at {expected_url}", + ) def test_delete_view(self): """ Test thumbnail behavior when deleting a view """ # Arrange # add view with thumbnail - mock_location = os.path.join(os.getcwd(), 'ui_framework', 'tests', 'media', 'mock', 'test') + mock_location = os.path.join( + os.getcwd(), "ui_framework", "tests", "media", "mock", "test" + ) with open(mock_location) as f: image_data = f.read() request_data = { "name": "view name", "data": {"key1": "value1"}, - "thumbnail": image_data + "thumbnail": image_data, } - request_url = reverse('view-list') - self.client.post(request_url, request_data, format='json') + request_url = reverse("view-list") + self.client.post(request_url, request_data, format="json") # Act # delete the view view = View.objects.get(name="view name") - delete_response = self.client.delete(reverse('view-detail', kwargs={'pk': view.pk})) + delete_response = self.client.delete( + reverse("view-detail", kwargs={"pk": view.pk}) + ) # Assert 2 @@ -115,9 +125,9 @@ def test_delete_view(self): # - file does not exist file_url = settings.MEDIA_BASE + view.thumbnail.url with pytest.raises(FileNotFoundError): - f = open(file_url, 'r') + f = open(file_url, "r") f.close() # - getting the file gives 404 - get_deleted_response = self.client.get('/manager' + view.thumbnail.url) + get_deleted_response = self.client.get("/manager" + view.thumbnail.url) self.assertEqual(get_deleted_response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/manager/ui_framework/tests/tests_api.py b/manager/ui_framework/tests/tests_api.py index e6d28014..ff73434d 100644 --- a/manager/ui_framework/tests/tests_api.py +++ b/manager/ui_framework/tests/tests_api.py @@ -18,77 +18,85 @@ def test_unauthenticated_list_objects(self): """Test that unauthenticated users cannot retrieve the list of objects through the API.""" for case in self.cases: # Act - url = reverse('{}-list'.format(case['key'])) + url = reverse("{}-list".format(case["key"])) response = self.client.get(url) # Assert self.assertEqual( - response.status_code, status.HTTP_401_UNAUTHORIZED, - 'Get list of {} did not return status 401'.format(case['class']) + response.status_code, + status.HTTP_401_UNAUTHORIZED, + "Get list of {} did not return status 401".format(case["class"]), ) def test_unauthenticated_create_objects(self): """Test that unauthenticated users cannot create objects through the API.""" for case in self.cases: # Act - url = reverse('{}-list'.format(case['key'])) - response = self.client.post(url, case['new_data']) + url = reverse("{}-list".format(case["key"])) + response = self.client.post(url, case["new_data"]) # Assert self.assertEqual( - response.status_code, status.HTTP_401_UNAUTHORIZED, - 'Posting a new {} did not return status 401'.format(case['class']) + response.status_code, + status.HTTP_401_UNAUTHORIZED, + "Posting a new {} did not return status 401".format(case["class"]), ) self.assertEqual( - case['class'].objects.count(), case['old_count'], - 'The number of {} should not have changed'.format(case['class']) + case["class"].objects.count(), + case["old_count"], + "The number of {} should not have changed".format(case["class"]), ) def test_unauthenticated_retrieve_objects(self): """Test that unauthenticated users cannot retrieve objects through the API.""" for case in self.cases: # Act - obj = case['class'].objects.first() - url = reverse('{}-detail'.format(case['key']), kwargs={'pk': obj.pk}) + obj = case["class"].objects.first() + url = reverse("{}-detail".format(case["key"]), kwargs={"pk": obj.pk}) response = self.client.get(url) # Assert self.assertEqual( - response.status_code, status.HTTP_401_UNAUTHORIZED, - 'Getting a {} did not return status 401'.format(case['class']) + response.status_code, + status.HTTP_401_UNAUTHORIZED, + "Getting a {} did not return status 401".format(case["class"]), ) def test_unauthenticated_update_objects(self): """Test that unauthenticated users cannot update objects through the API.""" for case in self.cases: # Act - obj = case['class'].objects.first() + obj = case["class"].objects.first() old_data = get_dict(obj) - url = reverse('{}-detail'.format(case['key']), kwargs={'pk': obj.pk}) - response = self.client.put(url, case['new_data']) + url = reverse("{}-detail".format(case["key"]), kwargs={"pk": obj.pk}) + response = self.client.put(url, case["new_data"]) # Assert self.assertEqual( - response.status_code, status.HTTP_401_UNAUTHORIZED, - 'Updating a {} did not return status 401'.format(case['class']) + response.status_code, + status.HTTP_401_UNAUTHORIZED, + "Updating a {} did not return status 401".format(case["class"]), ) - new_data = get_dict(case['class'].objects.get(pk=obj.pk)) + new_data = get_dict(case["class"].objects.get(pk=obj.pk)) self.assertEqual( - new_data, old_data, - 'The object {} should not have been updated'.format(case['class']) + new_data, + old_data, + "The object {} should not have been updated".format(case["class"]), ) def test_unauthenticated_delete_objects(self): """Test that unauthenticated users cannot dalete objects through the API.""" for case in self.cases: # Act - obj = case['class'].objects.first() - url = reverse('{}-detail'.format(case['key']), kwargs={'pk': obj.pk}) + obj = case["class"].objects.first() + url = reverse("{}-detail".format(case["key"]), kwargs={"pk": obj.pk}) response = self.client.delete(url) # Assert self.assertEqual( - response.status_code, status.HTTP_401_UNAUTHORIZED, - 'Deleting a {} did not return status 401'.format(case['class']) + response.status_code, + status.HTTP_401_UNAUTHORIZED, + "Deleting a {} did not return status 401".format(case["class"]), ) self.assertEqual( - case['class'].objects.count(), case['old_count'], - 'The number of {} should not have changed'.format(case['class']) + case["class"].objects.count(), + case["old_count"], + "The number of {} should not have changed".format(case["class"]), ) @@ -99,106 +107,116 @@ def setUp(self): """Set testcase. Inherits from utils.BaseTestCase.""" # Arrange super().setUp() - self.login_url = reverse('login') - self.username = 'test' - self.password = 'password' + self.login_url = reverse("login") + self.username = "test" + self.password = "password" self.user = User.objects.create_user( username=self.username, - password='password', - email='test@user.cl', - first_name='First', - last_name='Last', + password="password", + email="test@user.cl", + first_name="First", + last_name="Last", ) - data = {'username': self.username, 'password': self.password} - self.client.post(self.login_url, data, format='json') + data = {"username": self.username, "password": self.password} + self.client.post(self.login_url, data, format="json") self.token = Token.objects.get(user__username=self.username) - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) def test_unauthorized_list_objects(self): """Test that unauthorized users can still retrieve the list of objects through the API.""" for case in self.cases: # Act - url = reverse('{}-list'.format(case['key'])) + url = reverse("{}-list".format(case["key"])) response = self.client.get(url) # Assert self.assertEqual( - response.status_code, status.HTTP_200_OK, - 'Retrieving list of {} did not return status 200'.format(case['class']) + response.status_code, + status.HTTP_200_OK, + "Retrieving list of {} did not return status 200".format(case["class"]), ) retrieved_data = [dict(data) for data in response.data] self.assertEqual( - retrieved_data, case['current_data'], - 'Retrieved list of {} is not as expected'.format(case['class']) + retrieved_data, + case["current_data"], + "Retrieved list of {} is not as expected".format(case["class"]), ) def test_unauthorized_create_objects(self): """Test that unauthorized users cannot create objects through the API.""" for case in self.cases: # Act - url = reverse('{}-list'.format(case['key'])) - response = self.client.post(url, case['new_data']) + url = reverse("{}-list".format(case["key"])) + response = self.client.post(url, case["new_data"]) # Assert self.assertEqual( - response.status_code, status.HTTP_403_FORBIDDEN, - 'Posting a new {} did not return status 403'.format(case['class']) + response.status_code, + status.HTTP_403_FORBIDDEN, + "Posting a new {} did not return status 403".format(case["class"]), ) self.assertEqual( - case['class'].objects.count(), case['old_count'], - 'The number of {} should not have changed'.format(case['class']) + case["class"].objects.count(), + case["old_count"], + "The number of {} should not have changed".format(case["class"]), ) def test_unauthorized_retrieve_objects(self): """Test that unauthorized users can still retrieve objects through the API.""" for case in self.cases: # Act - obj = case['class'].objects.get(id=case['selected_id']) - url = reverse('{}-detail'.format(case['key']), kwargs={'pk': obj.pk}) + obj = case["class"].objects.get(id=case["selected_id"]) + url = reverse("{}-detail".format(case["key"]), kwargs={"pk": obj.pk}) response = self.client.get(url) # Assert self.assertEqual( - response.status_code, status.HTTP_200_OK, - 'Getting a {} did not return status 200'.format(case['class']) + response.status_code, + status.HTTP_200_OK, + "Getting a {} did not return status 200".format(case["class"]), ) retrieved_data = dict(response.data) self.assertEqual( - retrieved_data, case['current_data'][0], - 'Retrieved list of {} is not as expected'.format(case['class']) + retrieved_data, + case["current_data"][0], + "Retrieved list of {} is not as expected".format(case["class"]), ) def test_unauthorized_update_objects(self): """Test that unauthorized users cannot update objects through the API.""" for case in self.cases: # Act - obj = case['class'].objects.get(id=case['selected_id']) + obj = case["class"].objects.get(id=case["selected_id"]) old_data = get_dict(obj) - url = reverse('{}-detail'.format(case['key']), kwargs={'pk': obj.pk}) - response = self.client.put(url, case['new_data']) + url = reverse("{}-detail".format(case["key"]), kwargs={"pk": obj.pk}) + response = self.client.put(url, case["new_data"]) # Assert self.assertEqual( - response.status_code, status.HTTP_403_FORBIDDEN, - 'Updating a {} did not return status 403'.format(case['class']) + response.status_code, + status.HTTP_403_FORBIDDEN, + "Updating a {} did not return status 403".format(case["class"]), ) - new_data = get_dict(case['class'].objects.get(pk=obj.pk)) + new_data = get_dict(case["class"].objects.get(pk=obj.pk)) self.assertEqual( - new_data, old_data, - 'The object {} should not have been updated'.format(case['class']) + new_data, + old_data, + "The object {} should not have been updated".format(case["class"]), ) def test_unauthorized_delete_objects(self): """Test that unauthorized users cannot dalete objects through the API.""" for case in self.cases: # Act - obj = case['class'].objects.first() - url = reverse('{}-detail'.format(case['key']), kwargs={'pk': obj.pk}) + obj = case["class"].objects.first() + url = reverse("{}-detail".format(case["key"]), kwargs={"pk": obj.pk}) response = self.client.delete(url) # Assert self.assertEqual( - response.status_code, status.HTTP_403_FORBIDDEN, - 'Deleting a {} did not return status 403'.format(case['class']) + response.status_code, + status.HTTP_403_FORBIDDEN, + "Deleting a {} did not return status 403".format(case["class"]), ) self.assertEqual( - case['class'].objects.count(), case['old_count'], - 'The number of {} should not have changed'.format(case['class']) + case["class"].objects.count(), + case["old_count"], + "The number of {} should not have changed".format(case["class"]), ) @@ -209,117 +227,137 @@ def setUp(self): """Set testcase. Inherits from utils.BaseTestCase.""" # Arrange super().setUp() - self.login_url = reverse('login') - self.username = 'test' - self.password = 'password' + self.login_url = reverse("login") + self.username = "test" + self.password = "password" self.user = User.objects.create_user( username=self.username, - password='password', - email='test@user.cl', - first_name='First', - last_name='Last', + password="password", + email="test@user.cl", + first_name="First", + last_name="Last", ) - data = {'username': self.username, 'password': self.password} - self.client.post(self.login_url, data, format='json') + data = {"username": self.username, "password": self.password} + self.client.post(self.login_url, data, format="json") self.token = Token.objects.get(user__username=self.username) - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) def test_authorized_list_objects(self): """Test that authorized users can retrieve the list of objects through the API.""" for case in self.cases: # Arrange - self.user.user_permissions.add(Permission.objects.get(codename='view_{}'.format(case['key']))) + self.user.user_permissions.add( + Permission.objects.get(codename="view_{}".format(case["key"])) + ) # Act - url = reverse('{}-list'.format(case['key'])) + url = reverse("{}-list".format(case["key"])) response = self.client.get(url) # Assert self.assertEqual( - response.status_code, status.HTTP_200_OK, - 'Retrieving list of {} did not return status 200'.format(case['class']) + response.status_code, + status.HTTP_200_OK, + "Retrieving list of {} did not return status 200".format(case["class"]), ) retrieved_data = [dict(data) for data in response.data] - expected_data = case['current_data'] + expected_data = case["current_data"] self.assertEqual( - retrieved_data, expected_data, - 'Retrieved list of {} is not as expected'.format(case['class']) + retrieved_data, + expected_data, + "Retrieved list of {} is not as expected".format(case["class"]), ) def test_authorized_create_objects(self): """Test that authorized users can create objects through the API.""" for case in self.cases: # Arrange - self.user.user_permissions.add(Permission.objects.get(codename='add_{}'.format(case['key']))) + self.user.user_permissions.add( + Permission.objects.get(codename="add_{}".format(case["key"])) + ) # Act - url = reverse('{}-list'.format(case['key'])) - response = self.client.post(url, case['new_data']) + url = reverse("{}-list".format(case["key"])) + response = self.client.post(url, case["new_data"]) # Assert self.assertEqual( - response.status_code, status.HTTP_201_CREATED, - 'Posting a new {} did not return status 201'.format(case['class']) + response.status_code, + status.HTTP_201_CREATED, + "Posting a new {} did not return status 201".format(case["class"]), ) self.assertEqual( - case['class'].objects.count(), case['old_count'] + 1, - 'The number of {} should have increased by 1'.format(case['class']) + case["class"].objects.count(), + case["old_count"] + 1, + "The number of {} should have increased by 1".format(case["class"]), ) def test_authorized_retrieve_objects(self): """Test that authorized users can retrieve objects through the API.""" for case in self.cases: # Arrange - self.user.user_permissions.add(Permission.objects.get(codename='view_{}'.format(case['key']))) + self.user.user_permissions.add( + Permission.objects.get(codename="view_{}".format(case["key"])) + ) # Act - obj = case['class'].objects.get(id=case['selected_id']) - url = reverse('{}-detail'.format(case['key']), kwargs={'pk': obj.pk}) + obj = case["class"].objects.get(id=case["selected_id"]) + url = reverse("{}-detail".format(case["key"]), kwargs={"pk": obj.pk}) response = self.client.get(url) # Assert self.assertEqual( - response.status_code, status.HTTP_200_OK, - 'Getting a {} did not return status 200'.format(case['class']) + response.status_code, + status.HTTP_200_OK, + "Getting a {} did not return status 200".format(case["class"]), ) retrieved_data = dict(response.data) - expected_data = case['current_data'][0] + expected_data = case["current_data"][0] self.assertEqual( - retrieved_data, expected_data, - 'Retrieved list of {} is not as expected'.format(case['class']) + retrieved_data, + expected_data, + "Retrieved list of {} is not as expected".format(case["class"]), ) def test_authorized_update_objects(self): """Test that authorized users can update objects through the API.""" for case in self.cases: # Arrange - self.user.user_permissions.add(Permission.objects.get(codename='change_{}'.format(case['key']))) + self.user.user_permissions.add( + Permission.objects.get(codename="change_{}".format(case["key"])) + ) # Act - obj = case['class'].objects.get(id=case['selected_id']) + obj = case["class"].objects.get(id=case["selected_id"]) old_data = get_dict(obj) - url = reverse('{}-detail'.format(case['key']), kwargs={'pk': obj.pk}) - response = self.client.put(url, case['new_data']) + url = reverse("{}-detail".format(case["key"]), kwargs={"pk": obj.pk}) + response = self.client.put(url, case["new_data"]) # Assert self.assertEqual( - response.status_code, status.HTTP_200_OK, - 'Updating a {} did not return status 200'.format(case['class']) + response.status_code, + status.HTTP_200_OK, + "Updating a {} did not return status 200".format(case["class"]), ) - new_data = get_dict(case['class'].objects.get(pk=obj.pk)) + new_data = get_dict(case["class"].objects.get(pk=obj.pk)) self.assertNotEqual( - new_data, old_data, - 'The object {} should have been updated'.format(case['class']) + new_data, + old_data, + "The object {} should have been updated".format(case["class"]), ) def test_authorized_delete_objects(self): """Test that authorized users can dalete objects through the API.""" for case in self.cases: # Arrange - old_count = case['class'].objects.count() - self.user.user_permissions.add(Permission.objects.get(codename='delete_{}'.format(case['key']))) + old_count = case["class"].objects.count() + self.user.user_permissions.add( + Permission.objects.get(codename="delete_{}".format(case["key"])) + ) # Act - obj = case['class'].objects.first() - url = reverse('{}-detail'.format(case['key']), kwargs={'pk': obj.pk}) + obj = case["class"].objects.first() + url = reverse("{}-detail".format(case["key"]), kwargs={"pk": obj.pk}) response = self.client.delete(url) # Assert self.assertEqual( - response.status_code, status.HTTP_204_NO_CONTENT, - 'Deleting a {} did not return status 204'.format(case['class']) + response.status_code, + status.HTTP_204_NO_CONTENT, + "Deleting a {} did not return status 204".format(case["class"]), ) self.assertEqual( - case['class'].objects.count(), old_count - 1, - 'The number of {} should have decreased by 1'.format(case['class']) + case["class"].objects.count(), + old_count - 1, + "The number of {} should have decreased by 1".format(case["class"]), ) diff --git a/manager/ui_framework/tests/tests_custom_api.py b/manager/ui_framework/tests/tests_custom_api.py index e96b1eae..626f4e17 100644 --- a/manager/ui_framework/tests/tests_custom_api.py +++ b/manager/ui_framework/tests/tests_custom_api.py @@ -15,64 +15,79 @@ def setUp(self): """Set testcase. Inherits from utils.BaseTestCase.""" # Arrange super().setUp() - self.login_url = reverse('login') - self.username = 'test' - self.password = 'password' + self.login_url = reverse("login") + self.username = "test" + self.password = "password" self.user = User.objects.create_user( username=self.username, - password='password', - email='test@user.cl', - first_name='First', - last_name='Last', + password="password", + email="test@user.cl", + first_name="First", + last_name="Last", ) - data = {'username': self.username, 'password': self.password} - self.client.post(self.login_url, data, format='json') + data = {"username": self.username, "password": self.password} + self.client.post(self.login_url, data, format="json") self.token = Token.objects.get(user__username=self.username) - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) def test_get_workspaces_with_view_name(self): """Test that authorized users can retrieve the list of available workspaces, with views ids and names.""" # Arrange - self.user.user_permissions.add(Permission.objects.get(codename='view_workspace')) + self.user.user_permissions.add( + Permission.objects.get(codename="view_workspace") + ) expected_data = [ - {**w, 'views': [{ - 'id': v_pk, - 'name': v.name, - 'thumbnail': settings.MEDIA_URL + v.thumbnail.name, - } for v_pk in w['views'] for v in [View.objects.get(pk=v_pk)]]} + { + **w, + "views": [ + { + "id": v_pk, + "name": v.name, + "thumbnail": settings.MEDIA_URL + v.thumbnail.name, + } + for v_pk in w["views"] + for v in [View.objects.get(pk=v_pk)] + ], + } for w in self.workspaces_data ] # Act - url = reverse('workspace-with-view-name') + url = reverse("workspace-with-view-name") response = self.client.get(url) # Assert self.assertEqual( - response.status_code, status.HTTP_200_OK, - 'Retrieving list of workspaces did not return status 200' + response.status_code, + status.HTTP_200_OK, + "Retrieving list of workspaces did not return status 200", ) retrieved_data = [dict(data) for data in response.data] self.assertEqual( - retrieved_data, expected_data, - 'Retrieved list of workspaces is not as expected' + retrieved_data, + expected_data, + "Retrieved list of workspaces is not as expected", ) def test_get_full_workspace(self): """Test that authorized users can retrieve a workspace with all its views fully subserialized.""" # Arrange - self.user.user_permissions.add(Permission.objects.get(codename='view_workspace')) + self.user.user_permissions.add( + Permission.objects.get(codename="view_workspace") + ) w = self.workspaces_data[0] - expected_data = {**w, 'views': self.views_data[0:2]} + expected_data = {**w, "views": self.views_data[0:2]} # Act - url = reverse('workspace-full', kwargs={'pk': w['id']}) + url = reverse("workspace-full", kwargs={"pk": w["id"]}) response = self.client.get(url) # Assert self.assertEqual( - response.status_code, status.HTTP_200_OK, - 'Retrieving list of workspaces did not return status 200' + response.status_code, + status.HTTP_200_OK, + "Retrieving list of workspaces did not return status 200", ) retrieved_data = dict(response.data) self.assertEqual( - retrieved_data, expected_data, - 'Retrieved list of workspaces is not as expected' + retrieved_data, + expected_data, + "Retrieved list of workspaces is not as expected", ) diff --git a/manager/ui_framework/tests/tests_models.py b/manager/ui_framework/tests/tests_models.py index e26dca8b..a0b88eed 100644 --- a/manager/ui_framework/tests/tests_models.py +++ b/manager/ui_framework/tests/tests_models.py @@ -11,7 +11,7 @@ class WorkspaceModelTestCase(TestCase): def setUp(self): """Testcase setup.""" # Arrange - self.workspace_name = 'My Workspace' + self.workspace_name = "My Workspace" self.old_workspaces_num = Workspace.objects.count() self.creation_timestamp = timezone.now() with freeze_time(self.creation_timestamp): @@ -22,20 +22,22 @@ def test_create_workspace(self): # Assert self.new_workspaces_num = Workspace.objects.count() self.assertEqual( - self.old_workspaces_num + 1, self.new_workspaces_num, - 'There is not a new object in the database' + self.old_workspaces_num + 1, + self.new_workspaces_num, + "There is not a new object in the database", ) self.assertEqual( - self.workspace.name, self.workspace_name, - 'The name is not as expected' + self.workspace.name, self.workspace_name, "The name is not as expected" ) self.assertEqual( - self.workspace.creation_timestamp, self.creation_timestamp, - 'The creation_timestamp is not as expected' + self.workspace.creation_timestamp, + self.creation_timestamp, + "The creation_timestamp is not as expected", ) self.assertEqual( - self.workspace.update_timestamp, self.creation_timestamp, - 'The update_timestamp is not as expected' + self.workspace.update_timestamp, + self.creation_timestamp, + "The update_timestamp is not as expected", ) def test_retrieve_workspace(self): @@ -49,20 +51,22 @@ def test_retrieve_workspace(self): # Assert self.new_workspaces_num = Workspace.objects.count() self.assertEqual( - self.old_workspaces_num, self.new_workspaces_num, - 'The number of objects in the DB should not change' + self.old_workspaces_num, + self.new_workspaces_num, + "The number of objects in the DB should not change", ) self.assertEqual( - self.workspace.name, self.workspace_name, - 'The name is not as expected' + self.workspace.name, self.workspace_name, "The name is not as expected" ) self.assertEqual( - self.workspace.creation_timestamp, self.creation_timestamp, - 'The creation_timestamp is not as expected' + self.workspace.creation_timestamp, + self.creation_timestamp, + "The creation_timestamp is not as expected", ) self.assertEqual( - self.workspace.update_timestamp, self.creation_timestamp, - 'The update_timestamp is not as expected' + self.workspace.update_timestamp, + self.creation_timestamp, + "The update_timestamp is not as expected", ) def test_update_workspace(self): @@ -73,31 +77,34 @@ def test_update_workspace(self): # Act self.update_timestamp = timezone.now() with freeze_time(self.update_timestamp): - self.workspace.name = 'This other name' + self.workspace.name = "This other name" self.workspace.save() # Assert self.workspace = Workspace.objects.get(pk=self.workspace.pk) self.new_workspaces_num = Workspace.objects.count() self.assertEqual( - self.old_workspaces_num, self.new_workspaces_num, - 'The number of objects in the DB should not change' + self.old_workspaces_num, + self.new_workspaces_num, + "The number of objects in the DB should not change", ) self.assertEqual( - self.workspace.name, 'This other name', - 'The name is not as expected' + self.workspace.name, "This other name", "The name is not as expected" ) self.assertEqual( - self.workspace.creation_timestamp, self.creation_timestamp, - 'The creation_timestamp is not as expected' + self.workspace.creation_timestamp, + self.creation_timestamp, + "The creation_timestamp is not as expected", ) self.assertEqual( - self.workspace.update_timestamp, self.update_timestamp, - 'The update_timestamp is not as expected' + self.workspace.update_timestamp, + self.update_timestamp, + "The update_timestamp is not as expected", ) self.assertNotEqual( - self.workspace.creation_timestamp, self.workspace.update_timestamp, - 'The update_timestamp should be updated after updating the object' + self.workspace.creation_timestamp, + self.workspace.update_timestamp, + "The update_timestamp should be updated after updating the object", ) def test_delete_workspace(self): @@ -113,8 +120,9 @@ def test_delete_workspace(self): # Assert self.new_workspaces_num = Workspace.objects.count() self.assertEqual( - self.old_workspaces_num - 1, self.new_workspaces_num, - 'The number of objects in the DB have decreased by 1' + self.old_workspaces_num - 1, + self.new_workspaces_num, + "The number of objects in the DB have decreased by 1", ) with self.assertRaises(Exception): Workspace.objects.get(pk=self.workspace_pk) @@ -126,7 +134,7 @@ class ViewModelTestCase(TestCase): def setUp(self): """Testcase setup.""" # Arrange - self.view_name = 'My View' + self.view_name = "My View" self.old_views_num = View.objects.count() self.creation_timestamp = timezone.now() with freeze_time(self.creation_timestamp): @@ -137,20 +145,20 @@ def test_create_view(self): # Assert self.new_views_num = View.objects.count() self.assertEqual( - self.old_views_num + 1, self.new_views_num, - 'There is not a new object in the database' + self.old_views_num + 1, + self.new_views_num, + "There is not a new object in the database", ) + self.assertEqual(self.view.name, self.view_name, "The name is not as expected") self.assertEqual( - self.view.name, self.view_name, - 'The name is not as expected' + self.view.creation_timestamp, + self.creation_timestamp, + "The creation_timestamp is not as expected", ) self.assertEqual( - self.view.creation_timestamp, self.creation_timestamp, - 'The creation_timestamp is not as expected' - ) - self.assertEqual( - self.view.update_timestamp, self.creation_timestamp, - 'The update_timestamp is not as expected' + self.view.update_timestamp, + self.creation_timestamp, + "The update_timestamp is not as expected", ) def test_retrieve_view(self): @@ -164,20 +172,20 @@ def test_retrieve_view(self): # Assert self.new_views_num = View.objects.count() self.assertEqual( - self.old_views_num, self.new_views_num, - 'The number of objects in the DB should not change' + self.old_views_num, + self.new_views_num, + "The number of objects in the DB should not change", ) + self.assertEqual(self.view.name, self.view_name, "The name is not as expected") self.assertEqual( - self.view.name, self.view_name, - 'The name is not as expected' + self.view.creation_timestamp, + self.creation_timestamp, + "The creation_timestamp is not as expected", ) self.assertEqual( - self.view.creation_timestamp, self.creation_timestamp, - 'The creation_timestamp is not as expected' - ) - self.assertEqual( - self.view.update_timestamp, self.creation_timestamp, - 'The update_timestamp is not as expected' + self.view.update_timestamp, + self.creation_timestamp, + "The update_timestamp is not as expected", ) def test_update_view(self): @@ -188,31 +196,34 @@ def test_update_view(self): # Act self.update_timestamp = timezone.now() with freeze_time(self.update_timestamp): - self.view.name = 'This other name' + self.view.name = "This other name" self.view.save() # Assert self.view = View.objects.get(pk=self.view.pk) self.new_views_num = View.objects.count() self.assertEqual( - self.old_views_num, self.new_views_num, - 'The number of objects in the DB should not change' + self.old_views_num, + self.new_views_num, + "The number of objects in the DB should not change", ) self.assertEqual( - self.view.name, 'This other name', - 'The name is not as expected' + self.view.name, "This other name", "The name is not as expected" ) self.assertEqual( - self.view.creation_timestamp, self.creation_timestamp, - 'The creation_timestamp is not as expected' + self.view.creation_timestamp, + self.creation_timestamp, + "The creation_timestamp is not as expected", ) self.assertEqual( - self.view.update_timestamp, self.update_timestamp, - 'The update_timestamp is not as expected' + self.view.update_timestamp, + self.update_timestamp, + "The update_timestamp is not as expected", ) self.assertNotEqual( - self.view.creation_timestamp, self.view.update_timestamp, - 'The update_timestamp should be updated after updating the object' + self.view.creation_timestamp, + self.view.update_timestamp, + "The update_timestamp should be updated after updating the object", ) def test_delete_view(self): @@ -228,8 +239,9 @@ def test_delete_view(self): # Assert self.new_views_num = View.objects.count() self.assertEqual( - self.old_views_num - 1, self.new_views_num, - 'The number of objects in the DB have decreased by 1' + self.old_views_num - 1, + self.new_views_num, + "The number of objects in the DB have decreased by 1", ) with self.assertRaises(Exception): Workspace.objects.get(pk=self.view_pk) @@ -241,9 +253,9 @@ class WorkspaceViewModelTestCase(TestCase): def setUp(self): """Testcase setup.""" # Arrange - self.workspace_name = 'My Workspace' - self.view_name = 'My View' - self.workspace_view_name = 'My WorkspaceView' + self.workspace_name = "My Workspace" + self.view_name = "My View" + self.workspace_view_name = "My WorkspaceView" self.old_workspace_views_num = WorkspaceView.objects.count() self.creation_timestamp = timezone.now() with freeze_time(self.creation_timestamp): @@ -252,7 +264,7 @@ def setUp(self): self.workspace_view = WorkspaceView.objects.create( view_name=self.workspace_view_name, workspace=self.workspace, - view=self.view + view=self.view, ) def test_create_workspace_view(self): @@ -260,20 +272,24 @@ def test_create_workspace_view(self): # Assert self.new_workspace_views_num = WorkspaceView.objects.count() self.assertEqual( - self.old_workspace_views_num + 1, self.new_workspace_views_num, - 'There is not a new object in the database' + self.old_workspace_views_num + 1, + self.new_workspace_views_num, + "There is not a new object in the database", ) self.assertEqual( - self.workspace_view.view_name, self.workspace_view_name, - 'The name is not as expected' + self.workspace_view.view_name, + self.workspace_view_name, + "The name is not as expected", ) self.assertEqual( - self.workspace_view.creation_timestamp, self.creation_timestamp, - 'The creation_timestamp is not as expected' + self.workspace_view.creation_timestamp, + self.creation_timestamp, + "The creation_timestamp is not as expected", ) self.assertEqual( - self.workspace_view.update_timestamp, self.creation_timestamp, - 'The update_timestamp is not as expected' + self.workspace_view.update_timestamp, + self.creation_timestamp, + "The update_timestamp is not as expected", ) def test_retrieve_workspace_view(self): @@ -287,20 +303,24 @@ def test_retrieve_workspace_view(self): # Assert self.new_workspace_views_num = WorkspaceView.objects.count() self.assertEqual( - self.old_workspace_views_num, self.new_workspace_views_num, - 'The number of objects in the DB should not change' + self.old_workspace_views_num, + self.new_workspace_views_num, + "The number of objects in the DB should not change", ) self.assertEqual( - self.workspace_view.view_name, self.workspace_view_name, - 'The name is not as expected' + self.workspace_view.view_name, + self.workspace_view_name, + "The name is not as expected", ) self.assertEqual( - self.workspace_view.creation_timestamp, self.creation_timestamp, - 'The creation_timestamp is not as expected' + self.workspace_view.creation_timestamp, + self.creation_timestamp, + "The creation_timestamp is not as expected", ) self.assertEqual( - self.workspace_view.update_timestamp, self.creation_timestamp, - 'The update_timestamp is not as expected' + self.workspace_view.update_timestamp, + self.creation_timestamp, + "The update_timestamp is not as expected", ) def test_update_workspace_view(self): @@ -311,7 +331,7 @@ def test_update_workspace_view(self): # Act self.update_timestamp = timezone.now() with freeze_time(self.update_timestamp): - self.workspace_view.view_name = 'This other name' + self.workspace_view.view_name = "This other name" self.workspace_view.sort_value = self.new_sort_value self.workspace_view.save() @@ -319,28 +339,32 @@ def test_update_workspace_view(self): self.workspace_view = WorkspaceView.objects.get(pk=self.workspace_view.pk) self.new_workspace_views_num = WorkspaceView.objects.count() self.assertEqual( - self.old_workspace_views_num, self.new_workspace_views_num, - 'The number of objects in the DB should not change' + self.old_workspace_views_num, + self.new_workspace_views_num, + "The number of objects in the DB should not change", ) self.assertEqual( - self.workspace_view.view_name, 'This other name', - 'The name was not updated' + self.workspace_view.view_name, "This other name", "The name was not updated" ) self.assertEqual( - self.workspace_view.sort_value, self.new_sort_value, - 'The sort value was not updated' + self.workspace_view.sort_value, + self.new_sort_value, + "The sort value was not updated", ) self.assertEqual( - self.workspace_view.creation_timestamp, self.creation_timestamp, - 'The creation_timestamp is not as expected' + self.workspace_view.creation_timestamp, + self.creation_timestamp, + "The creation_timestamp is not as expected", ) self.assertEqual( - self.workspace_view.update_timestamp, self.update_timestamp, - 'The update_timestamp is not as expected' + self.workspace_view.update_timestamp, + self.update_timestamp, + "The update_timestamp is not as expected", ) self.assertNotEqual( - self.workspace_view.creation_timestamp, self.workspace_view.update_timestamp, - 'The update_timestamp should be updated after updating the object' + self.workspace_view.creation_timestamp, + self.workspace_view.update_timestamp, + "The update_timestamp should be updated after updating the object", ) def test_delete_workspace_view(self): @@ -356,8 +380,9 @@ def test_delete_workspace_view(self): # Assert self.new_workspace_views_num = WorkspaceView.objects.count() self.assertEqual( - self.old_workspace_views_num - 1, self.new_workspace_views_num, - 'The number of objects in the DB have decreased by 1' + self.old_workspace_views_num - 1, + self.new_workspace_views_num, + "The number of objects in the DB have decreased by 1", ) with self.assertRaises(Exception): Workspace.objects.get(pk=self.workspace_view_pk) @@ -372,14 +397,14 @@ def setUp(self): self.setup_timestamp = timezone.now() with freeze_time(self.setup_timestamp): self.workspaces = [ - Workspace.objects.create(name='My Workspace 1'), - Workspace.objects.create(name='My Workspace 2'), - Workspace.objects.create(name='My Workspace 3'), + Workspace.objects.create(name="My Workspace 1"), + Workspace.objects.create(name="My Workspace 2"), + Workspace.objects.create(name="My Workspace 3"), ] self.views = [ - View.objects.create(name='My View 1'), - View.objects.create(name='My View 2'), - View.objects.create(name='My View 3'), + View.objects.create(name="My View 1"), + View.objects.create(name="My View 2"), + View.objects.create(name="My View 3"), ] def test_add_and_get_views_to_workspace(self): @@ -394,8 +419,16 @@ def test_add_and_get_views_to_workspace(self): retrieved_views = list(workspace.views.all()) retrieved_sorted_views = list(workspace.get_sorted_views()) # Assert - self.assertEqual(set(retrieved_views), set(self.views), 'The views were not assigned to the workspace') - self.assertEqual(retrieved_sorted_views, sorted_views, 'The views were not sorted properly workspace') + self.assertEqual( + set(retrieved_views), + set(self.views), + "The views were not assigned to the workspace", + ) + self.assertEqual( + retrieved_sorted_views, + sorted_views, + "The views were not sorted properly workspace", + ) def test_get_workspaces_from_a_view(self): """Test that a View can retrieve its Workspaces from the DB.""" @@ -410,4 +443,8 @@ def test_get_workspaces_from_a_view(self): # Act retrieved_workspaces = set(view.workspaces.all()) # Assert - self.assertEqual(retrieved_workspaces, set(workspaces), 'The workspaces from the view are not as expected') + self.assertEqual( + retrieved_workspaces, + set(workspaces), + "The workspaces from the view are not as expected", + ) diff --git a/manager/ui_framework/tests/utils.py b/manager/ui_framework/tests/utils.py index 0f4a336f..ae25b91a 100644 --- a/manager/ui_framework/tests/utils.py +++ b/manager/ui_framework/tests/utils.py @@ -13,27 +13,27 @@ def get_dict(obj): """Return a dictionary with the fields of a given object.""" if type(obj) == Workspace: return { - 'id': obj.id, - 'name': obj.name, - 'creation_timestamp': obj.creation_timestamp, - 'update_timestamp': obj.update_timestamp, + "id": obj.id, + "name": obj.name, + "creation_timestamp": obj.creation_timestamp, + "update_timestamp": obj.update_timestamp, } if type(obj) == View: return { - 'id': obj.id, - 'name': obj.name, - 'data': json.dumps(obj.data), - 'creation_timestamp': obj.creation_timestamp, - 'update_timestamp': obj.update_timestamp, + "id": obj.id, + "name": obj.name, + "data": json.dumps(obj.data), + "creation_timestamp": obj.creation_timestamp, + "update_timestamp": obj.update_timestamp, } if type(obj) == WorkspaceView: return { - 'id': obj.id, - 'view_name': obj.view_name, - 'view': obj.view.pk, - 'workspace': obj.workspace.pk, - 'creation_timestamp': obj.creation_timestamp, - 'update_timestamp': obj.update_timestamp, + "id": obj.id, + "view_name": obj.view_name, + "view": obj.view.pk, + "workspace": obj.workspace.pk, + "creation_timestamp": obj.creation_timestamp, + "update_timestamp": obj.update_timestamp, } @@ -53,71 +53,88 @@ def setUp(self): # Data to be used to create views and workspaces self.views_data = [ { - 'name': 'My View 0', - 'data': json.dumps({"data_name": "My View 0"}), + "name": "My View 0", + "data": json.dumps({"data_name": "My View 0"}), }, { - 'name': 'My View 1', - 'data': json.dumps({"data_name": "My View 1"}), + "name": "My View 1", + "data": json.dumps({"data_name": "My View 1"}), }, { - 'name': 'My View 2', - 'data': json.dumps({"data_name": "My View 2"}), + "name": "My View 2", + "data": json.dumps({"data_name": "My View 2"}), }, { - 'name': 'My View 3', - 'data': json.dumps({"data_name": "My View 3"}), - } + "name": "My View 3", + "data": json.dumps({"data_name": "My View 3"}), + }, ] self.workspaces_data = [ - {'name': 'My Workspace 0'}, - {'name': 'My Workspace 1'}, - {'name': 'My Workspace 2'}, + {"name": "My Workspace 0"}, + {"name": "My Workspace 1"}, + {"name": "My Workspace 2"}, ] self.views = [] self.workspaces = [] self.workspace_views_data = [] - default_thumbnail = settings.MEDIA_URL + View._meta.get_field('thumbnail').get_default() + default_thumbnail = ( + settings.MEDIA_URL + View._meta.get_field("thumbnail").get_default() + ) # Create views, store them in self.views and add auto-generated fields to self.views_data for i in range(0, len(self.views_data)): view = View.objects.create(**self.views_data[i]) - self.views_data[i]['id'] = view.id - self.views_data[i]['thumbnail'] = default_thumbnail + self.views_data[i]["id"] = view.id + self.views_data[i]["thumbnail"] = default_thumbnail self.views.append(view) # Create views, store them in self.views and add auto-generated fields to self.views_data for i in range(0, len(self.workspaces_data)): workspace = Workspace.objects.create(**self.workspaces_data[i]) - self.workspaces_data[i]['id'] = workspace.id - self.workspaces_data[i]['creation_timestamp'] = self.setup_ts_str - self.workspaces_data[i]['update_timestamp'] = self.setup_ts_str - self.workspaces_data[i]['views'] = [self.views[i].pk, self.views[i + 1].pk] + self.workspaces_data[i]["id"] = workspace.id + self.workspaces_data[i]["creation_timestamp"] = self.setup_ts_str + self.workspaces_data[i]["update_timestamp"] = self.setup_ts_str + self.workspaces_data[i]["views"] = [ + self.views[i].pk, + self.views[i + 1].pk, + ] self.workspaces.append(workspace) # Add view[i] - self.workspaces[i].views.add(self.views[i], through_defaults={'view_name': 'v{}'.format(i)}) - aux = WorkspaceView.objects.get(workspace=self.workspaces[i], view=self.views[i]) - self.workspace_views_data.append({ - 'id': aux.id, - 'creation_timestamp': self.setup_ts_str, - 'update_timestamp': self.setup_ts_str, - 'view_name': 'v{}'.format(i), - 'sort_value': 0, - 'view': self.views[i].pk, - 'workspace': self.workspaces[i].pk, - }) + self.workspaces[i].views.add( + self.views[i], through_defaults={"view_name": "v{}".format(i)} + ) + aux = WorkspaceView.objects.get( + workspace=self.workspaces[i], view=self.views[i] + ) + self.workspace_views_data.append( + { + "id": aux.id, + "creation_timestamp": self.setup_ts_str, + "update_timestamp": self.setup_ts_str, + "view_name": "v{}".format(i), + "sort_value": 0, + "view": self.views[i].pk, + "workspace": self.workspaces[i].pk, + } + ) # Add view[i + 1] - self.workspaces[i].views.add(self.views[i + 1], through_defaults={'view_name': 'v{}'.format(i)}) - aux = WorkspaceView.objects.get(workspace=self.workspaces[i], view=self.views[i + 1]) - self.workspace_views_data.append({ - 'id': aux.id, - 'creation_timestamp': self.setup_ts_str, - 'update_timestamp': self.setup_ts_str, - 'view_name': 'v{}'.format(i), - 'sort_value': 0, - 'view': self.views[i + 1].pk, - 'workspace': self.workspaces[i].pk, - }) + self.workspaces[i].views.add( + self.views[i + 1], through_defaults={"view_name": "v{}".format(i)} + ) + aux = WorkspaceView.objects.get( + workspace=self.workspaces[i], view=self.views[i + 1] + ) + self.workspace_views_data.append( + { + "id": aux.id, + "creation_timestamp": self.setup_ts_str, + "update_timestamp": self.setup_ts_str, + "view_name": "v{}".format(i), + "sort_value": 0, + "view": self.views[i + 1].pk, + "workspace": self.workspaces[i].pk, + } + ) # Client to test the API self.client = APIClient() @@ -125,39 +142,39 @@ def setUp(self): # Models to test: self.cases = [ { - 'class': Workspace, - 'key': 'workspace', - 'old_count': Workspace.objects.count(), - 'new_data': { - 'name': 'My new Workspace', + "class": Workspace, + "key": "workspace", + "old_count": Workspace.objects.count(), + "new_data": { + "name": "My new Workspace", }, - 'current_data': self.workspaces_data, - 'list_data': self.workspaces_data, - 'selected_id': self.workspaces_data[0]['id'], + "current_data": self.workspaces_data, + "list_data": self.workspaces_data, + "selected_id": self.workspaces_data[0]["id"], }, { - 'class': View, - 'key': 'view', - 'old_count': View.objects.count(), - 'new_data': { - 'name': 'My new View', - 'data': json.dumps({"dummy_key": "Dummy_value"}), + "class": View, + "key": "view", + "old_count": View.objects.count(), + "new_data": { + "name": "My new View", + "data": json.dumps({"dummy_key": "Dummy_value"}), }, - 'current_data': self.views_data, - 'selected_id': self.views_data[0]['id'], + "current_data": self.views_data, + "selected_id": self.views_data[0]["id"], }, { - 'class': WorkspaceView, - 'key': 'workspaceview', - 'old_count': WorkspaceView.objects.count(), - 'new_data': { - 'view_name': 'New view_name', + "class": WorkspaceView, + "key": "workspaceview", + "old_count": WorkspaceView.objects.count(), + "new_data": { + "view_name": "New view_name", # 'sort_value': 1, - 'view': self.views_data[3]['id'], - 'workspace': self.workspaces_data[0]['id'], + "view": self.views_data[3]["id"], + "workspace": self.workspaces_data[0]["id"], }, - 'current_data': self.workspace_views_data, - 'list_data': self.workspace_views_data, - 'selected_id': self.workspace_views_data[0]['id'], + "current_data": self.workspace_views_data, + "list_data": self.workspace_views_data, + "selected_id": self.workspace_views_data[0]["id"], }, ] diff --git a/manager/ui_framework/urls.py b/manager/ui_framework/urls.py index 7702c2f4..a69f7a74 100644 --- a/manager/ui_framework/urls.py +++ b/manager/ui_framework/urls.py @@ -19,7 +19,7 @@ from ui_framework.views import WorkspaceViewSet, ViewViewSet, WorkspaceViewViewSet router = DefaultRouter() -router.register('workspaces', WorkspaceViewSet) -router.register('views', ViewViewSet) -router.register('workspaceviews', WorkspaceViewViewSet) +router.register("workspaces", WorkspaceViewSet) +router.register("views", ViewViewSet) +router.register("workspaceviews", WorkspaceViewViewSet) urlpatterns = router.urls From fac79552e0676e442d24f327a105f1a0ae276b4c Mon Sep 17 00:00:00 2001 From: Sebastian Aranda Date: Fri, 29 Jan 2021 12:50:45 -0300 Subject: [PATCH 43/49] Fix a small error: using self instead of cls --- manager/subscription/heartbeat_manager.py | 2 +- manager/subscription/tests/test_heartbeat.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/manager/subscription/heartbeat_manager.py b/manager/subscription/heartbeat_manager.py index 6d8b4096..35b1fc19 100644 --- a/manager/subscription/heartbeat_manager.py +++ b/manager/subscription/heartbeat_manager.py @@ -63,7 +63,7 @@ async def query_commander(cls): resp = requests.get(heartbeat_url) timestamp = resp.json()['timestamp'] #get timestamp - self.set_heartbeat_timestamp('Commander', timestamp) + cls.set_heartbeat_timestamp('Commander', timestamp) await asyncio.sleep(3) except Exception as e: print(e) diff --git a/manager/subscription/tests/test_heartbeat.py b/manager/subscription/tests/test_heartbeat.py index 66bfe0aa..d55e373f 100644 --- a/manager/subscription/tests/test_heartbeat.py +++ b/manager/subscription/tests/test_heartbeat.py @@ -200,8 +200,8 @@ async def test_unauthorized_commander(self, mock_requests): assert response['data'][0]['data']['timestamp'] is not None # Act 2 (Wait for query to commander) - # await asyncio.sleep(3) response = await communicator.receive_json_from(timeout=5) + # Assert 2 (Get producer heartbeat data) heartbeat_sources = [source['csc'] for source in response['data']] assert 'Commander' in heartbeat_sources From 335d3924a23ec61f36db87a67754f95169b3761a Mon Sep 17 00:00:00 2001 From: spereirag Date: Tue, 2 Feb 2021 10:38:49 -0300 Subject: [PATCH 44/49] Fix typo --- manager/subscription/heartbeat_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manager/subscription/heartbeat_manager.py b/manager/subscription/heartbeat_manager.py index 6d8b4096..35b1fc19 100644 --- a/manager/subscription/heartbeat_manager.py +++ b/manager/subscription/heartbeat_manager.py @@ -63,7 +63,7 @@ async def query_commander(cls): resp = requests.get(heartbeat_url) timestamp = resp.json()['timestamp'] #get timestamp - self.set_heartbeat_timestamp('Commander', timestamp) + cls.set_heartbeat_timestamp('Commander', timestamp) await asyncio.sleep(3) except Exception as e: print(e) From 6d4050817d932a924c1814a8b24551c92b60da1f Mon Sep 17 00:00:00 2001 From: Sebastian Aranda Date: Fri, 5 Feb 2021 02:52:42 -0300 Subject: [PATCH 45/49] Include pre-commit config file --- .pre-commit-config.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..eb7a18b8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: check-yaml + exclude: conda/meta.yaml + + - repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.9 + hooks: + - id: flake8 \ No newline at end of file From 4bd4c932d26a5db8db70d68971e3a3a96230a547 Mon Sep 17 00:00:00 2001 From: Sebastian Aranda Date: Fri, 5 Feb 2021 12:11:54 -0300 Subject: [PATCH 46/49] Add pre-commit instructions to README --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index b63480b6..02b70e8e 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,18 @@ Once inside the container you will be in the `/usr/src/love/manager` folder, whe cd ../docsrc ./create_docs.sh ``` + +### Linting & Formatting +In order to maintaing code linting and formatting we use `pre-commit` that runs **Flake8** (https://flake8.pycqa.org/) and **Black** (https://github.com/psf/black) using Git Hooks. To enable this you have to: + +1. Install `pre-commit` in your local development environment: +``` +pip install pre-commit +``` + +2. Set up the git hook scripts running: +``` +pre-commit install +``` + +3. Start developing! Linter and Formatter will be executed on every commit you make \ No newline at end of file From a909ebc465f952c7b123cc059bb6ab8353fb71d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Feb 2021 01:39:43 +0000 Subject: [PATCH 47/49] Bump cryptography from 3.2 to 3.3.2 in /manager Bumps [cryptography](https://github.com/pyca/cryptography) from 3.2 to 3.3.2. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/3.2...3.3.2) Signed-off-by: dependabot[bot] --- manager/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manager/requirements.txt b/manager/requirements.txt index 0b99e5db..e13cf0fa 100644 --- a/manager/requirements.txt +++ b/manager/requirements.txt @@ -17,7 +17,7 @@ chardet==3.0.4 constantly==15.1.0 coreapi==2.3.3 coreschema==0.0.4 -cryptography==3.2 +cryptography==3.3.2 daphne==2.4.1 Django==3.0.7 django-auth-ldap==2.1.0 From 98988686e3d80ae1a1d155c5f4c93dafc5d3a3ed Mon Sep 17 00:00:00 2001 From: Sebastian Aranda Date: Thu, 4 Mar 2021 19:16:01 -0300 Subject: [PATCH 48/49] Add ignore W503 rule to flake8 --- .flake8 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.flake8 b/.flake8 index 6aca6ccf..818bf3e3 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,3 @@ [flake8] max-line-length = 120 -ignore = D100 +ignore = D100, W503 From 80c000e1b2984d3617785ecdefdf3caafd4c4e71 Mon Sep 17 00:00:00 2001 From: Sebastian Aranda Date: Thu, 4 Mar 2021 19:16:17 -0300 Subject: [PATCH 49/49] Extend messages capacity and reduce expiry time --- manager/manager/settings.py | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/manager/manager/settings.py b/manager/manager/settings.py index fe64402b..59aa35de 100644 --- a/manager/manager/settings.py +++ b/manager/manager/settings.py @@ -22,7 +22,8 @@ # Define wether the system is being tested or not: TESTING = os.environ.get("TESTING", False) -"""Define wether or not this instance is being created for testing or not, get from the `TESTING` environment variable (`string`)""" +"""Define wether or not this instance is being created for testing or not, +get from the `TESTING` environment variable (`string`)""" # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.getenv( @@ -106,9 +107,7 @@ TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [ - os.path.join(BASE_DIR, "templates"), - ], + "DIRS": [os.path.join(BASE_DIR, "templates")], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -135,20 +134,15 @@ { "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", - }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] # Password for other processes PROCESS_CONNECTION_PASS = os.environ.get("PROCESS_CONNECTION_PASS", "dev_pass") -"""Password that Producers use to connect to eh Manager, read from the `PROCESS_CONNECTION_PASS` environment variable (`string`)""" +"""Password that Producers use to connect to eh Manager, +read from the `PROCESS_CONNECTION_PASS` environment variable (`string`)""" # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ @@ -227,6 +221,8 @@ + "/0" ], "symmetric_encryption_keys": [SECRET_KEY], + "capacity": 1500, + "expiry": 10, }, }, } @@ -234,9 +230,7 @@ else: CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer", - }, + "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}, } # LDAP @@ -255,13 +249,12 @@ AUTH_LDAP_BIND_PASSWORD = "" AUTH_LDAP_USER_SEARCH = LDAPSearch( - "ou=people,dc=planetexpress,dc=com", - ldap.SCOPE_SUBTREE, - "(uid=%(user)s)", + "ou=people,dc=planetexpress,dc=com", ldap.SCOPE_SUBTREE, "(uid=%(user)s)", ) TRACE_TIMESTAMPS = True -"""Define wether or not to add tracing timestamps to websocket messages. Read from TRACE_TIMESTAMPS` environment variable (`bool`)""" +"""Define wether or not to add tracing timestamps to websocket messages. +Read from TRACE_TIMESTAMPS` environment variable (`bool`)""" if os.environ.get("HIDE_TRACE_TIMESTAMPS", False): TRACE_TIMESTAMPS = False
                • ui_framework.urls (module)
                • ui_framework.views (module) @@ -986,9 +1142,19 @@

                  U

                • UnauthorizedCrudTestCase (class in ui_framework.tests.tests_api)
                • -
                • update_timestamp (ui_framework.models.BaseModel attribute) +
                • update_timestamp (api.models.BaseModel attribute) + +
                • +
                • user (api.models.ConfigFile attribute) + +
                • +
                • user_id (api.models.ConfigFile attribute)
                • UserPermissionsSerializer (class in api.serializers)
                • @@ -1005,6 +1171,8 @@

                  V

                • validate() (api.schema_validator.DefaultingValidator method)
                • validate_config_schema() (in module api.views) +
                • +
                • validate_file_extension() (api.models.ConfigFile method)
                • validate_token() (in module api.views)
                • diff --git a/docs/html/modules/how_it_works.html b/docs/html/modules/how_it_works.html index 3909ce37..42ace041 100644 --- a/docs/html/modules/how_it_works.html +++ b/docs/html/modules/how_it_works.html @@ -215,7 +215,7 @@

                  3.2. Example3.3. Code organization

                  Currently the application is divided in the following modules and files:

                    -
                  • api: This module contains the API Django app, which defines the models and API endpoints for authentication (Auth API) and Commander (Commander API) APIs. For more details please refer to the ApiDoc section

                  • +
                  • api: This module contains the API Django app, which defines the models and API endpoints for authentication (Auth API), Commander (Commander API), ConfigFile (ConfigFile API) and EmergencyContact (EmergencyContact API) APIs. For more details please refer to the ApiDoc section

                  • ui_framework: This module contains the UI Framework Django app, which defines the models and API endpoints for the UI Framework views (UI Framework API) API. For more details please refer to the ApiDoc section

                  • subscription: This module contains the Django app that defines the consumers that handle the websocket communication.

                  • manager: This module contains basic Django configuration files, such as urls and channels routing, etc.

                  • diff --git a/docs/html/modules/how_to_use_it.html b/docs/html/modules/how_to_use_it.html index 6758e7da..1e7e94cf 100644 --- a/docs/html/modules/how_to_use_it.html +++ b/docs/html/modules/how_to_use_it.html @@ -278,7 +278,7 @@

                    2.1.1. Request token

                    2.1.2. Validate token

                    Validates a given authorization token, passed through HTTP Headers. -Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuraiton file. +Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuration file. If the no_config flag is added to the end of the URL, then the LOVE config files is not read and the corresponding value is returned as null

                    • Url: <IP>/manager/api/validate-token/ or <IP>/manager/api/validate-token/no_config/

                    • @@ -332,7 +332,7 @@

                      2.1.2. Validate token

                      2.1.3. Swap token

                      Validates a given authorization token, passed through HTTP Headers. -Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuraiton file. +Returns a confirmation of validity, user data, permissions, server_time and (optionally) the LOVE configuration file. If the no_config flag is added to the end of the URL, then the LOVE config files is not read and the corresponding value is returned as null

                      • Url: <IP>/manager/api/swap-token/ or <IP>/manager/api/swap-token/no_config/

                      • diff --git a/docs/html/objects.inv b/docs/html/objects.inv index addfba4ad47aacff1eb2098a5f24e993027a7ef9..6bf88ebc1e7ca62b4fb8416e4132e0f0b09deaf0 100644 GIT binary patch delta 5259 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 z z|3qIl5m~`B&n6@qV^J=nJ|i0bsVtXOSOFbXwX51Dd?FdQk{C};gGB77(S+WsC*22C zM(>?5QPp8+7ezHw4A%Qi)biMCS@hwZjh9V2(SLsBdU=!ydoEku3*_?WuocpOVgleR?&LM!>wGivTf}%PSn{~}VfS4GA05K1^R<7=@jX|1^AvMfttCqKBa z#}da@G{h@>Y*|Eja3PK*>x~sd+if);M}vN^6^p6ZjX+r-LCNst_z|}+XxDN>Eq|v2 z8I}xo_bimAXXX@;WD0*HQWr9Rz;}E~e<60k(wtFo#H<^1z3qp{JJj7MbO34JQTaRe z*FxgmWzs|EAKjA&)PHt26h`=pLgV32Cyx4(=Y9xu*HN!I>Ox542rr}(bsk+hX#ysC zj#2kUAJ8s?Pi+nUaYEI?aQTBtv48cBd!Dgq&*Qa2WNjoBqoY*BWE3(g?V(&SUSl#R z+u^%0uuO+#3I_6HT`WzfGU}J)G>Y(4((Re(zLi`+G%&$eQ$}&rU5u$~FOrS1Y}`q1 zR@cb&5ZVoU#%mzgUq*I1h-^I6vL(bzsUEZG&Oi|r9%;HIlN)vrOF+|S)PEan==-mD zT*!g(lsBX~<9n3qexL8%Jw(pR##bB7yk_B0+6+zD#YNxdoZ#N{E_N7hz#~cxurPcvg>900{q4=?X{&SF2mXD R8B`W=8L!v(Uj$jSJ;v}*L4E)L delta 4819 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 tPython Module Index

                    api.tests.test_commander
                    + api.tests.test_lovecsc +
                    @@ -231,7 +236,12 @@

                Python Module Index

                    - api.tests.tests_config + api.tests.tests_configfile +
                    + api.tests.tests_emergencycontact

    5.1.1. Subpackages5.1.1.1. api.tests package @@ -351,6 +353,212 @@