diff --git a/docs/common/customsettings.rst b/docs/common/customsettings.rst index d289adc1b..1e9b721a4 100644 --- a/docs/common/customsettings.rst +++ b/docs/common/customsettings.rst @@ -211,7 +211,7 @@ the documentation on `Creating an Alert Module for the TOM Toolkit `__. `TOM_ALERT_DASH_CLASSES <#tom_alert_dash_classes>`__ -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: diff --git a/docs/customization/index.rst b/docs/customization/index.rst index 87882b1f7..f209e11ca 100644 --- a/docs/customization/index.rst +++ b/docs/customization/index.rst @@ -8,6 +8,7 @@ Customization customize_templates adding_pages customize_template_tags + testing_toms Start here to learn how to customize the look and feel of your TOM or add new functionality. @@ -20,3 +21,5 @@ displaying static html pages or dynamic database-driven content. :doc:`Customizing Template Tags ` - Learn how to write your own template tags to display the data you need. + +:doc:`Testing TOMs ` - Learn how to test your TOM's functionality. \ No newline at end of file diff --git a/docs/customization/testing_toms.rst b/docs/customization/testing_toms.rst new file mode 100644 index 000000000..21ec85588 --- /dev/null +++ b/docs/customization/testing_toms.rst @@ -0,0 +1,313 @@ +Testing TOMs +------------ + +As functionality is added to a TOM system it is highly beneficial to write +`unittests `__ for each +new function and module added. This allows you to easily and repeatably +test that the function behaves as expected, and makes error diagnosis much faster. +Although it can seem like extra work, ultimately writing unittests will save +you time in the long run. For more on why unittests are valuable, +`read this `__. + +The TOM Toolkit provides a built-in framework for writing unittests, +including some TOM-specific factory functions that enable you to +generate examples of TOM database entries for testing. This is, in turn, +built on top of `Django's unittest framework `__, inheriting a wealth of +functionality. This allows test code to be written which acts on a +stand-alone test database, meaning that any input used for testing +doesn't interfere with an operational TOM database. + +Code Structure and Running Tests +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There are two main options on where the test code lives in your TOM, +depending on how you want to run the tests. Here we follow convention and refer to +the top-level directory of your TOM as the `project` and the subdirectory +for the TOM as the `application`. Although they are often called the same +name, the distinction matters because TOMs can have multiple `applications` +within the same `project`. The actual test code will be the same regardless +of which option you use - this is described in the next section. + +Option 1: Use the built-in manage.py application to run the tests ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +With this option, test code is run using ``manage.py``, just like management +commands. ``manage.py`` is designed to consider any file with a filename +prefixed by ``test_`` within the project directory and subdirectories as test code, +and it will find them automatically. + +If you chose this option, then it's good practise to place test code in the subdirectory for +the application that they apply to, in a file called ``tests.py``. The code structure for this option would be: + +:: + + mytom/ + ├── data/ + ├── db.sqlite3 + ├── manage.py + ├── mytom/ + │ ├── __init__.py + │ ├── settings.py + │ ├── urls.py + │ ├── wsgi.py + │ └── tests.py + ├── static. + ├── templates/ + └── tmp/ + +This gives you quite fine-grained control when it comes to running the tests. You can either run +all tests at once: + +:: + +$ ./manage.py test + +...run just the tests for a specific application... + +:: + +$ ./manage.py test mytom.tests + + +...run a specific TestClass (more on these below) for a specific application ... + +:: + +$ ./manage.py test mytom.tests.MyTestCase + +...or run just a particular method of a single TestClass: + +:: + +$ ./manage.py test mytom.test.MyTestClass.test_my_function + +Option 2: Use a test runner ++++++++++++++++++++++++++++ + +A test runner script instead of ``manage.py`` can be useful because it +allows you to have more sophisticated control over settings that can be +used specifically for testing purposes, independently of the settings +used for the TOM in operation. This is commonly used in applications that +are designed to be re-useable. For more information, +see `Testing Reusable Applications. +`_ + +With this option, it's good practise to place your unittest code in a +single subdirectory in the top-level directory of your TOM, normally +called ``tests/``, and add the test runner script in the project directory: + +:: + + mytom/ + ├── data/ + ├── db.sqlite3 + ├── manage.py + ├── runtests.py + ├── mytom/ + │ ├── __init__.py + │ ├── settings.py + │ ├── urls.py + │ └── wsgi.py + ├── static. + ├── templates/ + ├── tmp/ + └── tests/ + │ │ ├── test_settings.py + │ │ ├── tests.py + +All files with test code within the ``tests/`` subdirectory should have +filenames prefixed with ``test_``. + +In addition to the test functions, which are located in ``tests.py``, this +option requires two short files in addition: + +.. code-block:: + + test_settings.py: + + SECRET_KEY = "fake-key" + INSTALLED_APPS = [ + 'whitenoise.runserver_nostatic', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + 'django_extensions', + 'guardian', + 'tom_common', + 'django_comments', + 'bootstrap4', + 'crispy_bootstrap4', + 'crispy_forms', + 'django_filters', + 'django_gravatar', + 'rest_framework', + 'rest_framework.authtoken', + 'tom_targets', + 'tom_alerts', + 'tom_catalogs', + 'tom_observations', + 'tom_dataproducts', + 'mytom', + 'tests', + ] + +Note that you may need to extend the contents of the test_settings.py file, +often by adding the corresponding information from your TOM's main +``settings.py``, depending on the information required for your tests. + +.. code-block:: + + runtests.py: + + import os + import sys + import argparse + import django + from django.conf import settings + from django.test.utils import get_runner + + def get_args(): + + parser = argparse.ArgumentParser() + parser.add_argument('module', help='name of module to test or all') + options = parser.parse_args() + + return options + + if __name__ == "__main__": + options = get_args() + os.environ["DJANGO_SETTINGS_MODULE"] = "tests.test_settings" + django.setup() + TestRunner = get_runner(settings) + test_runner = TestRunner() + if options.module == 'all': + failures = test_runner.run_tests(["tests"]) + else: + failures = test_runner.run_tests([options.module]) + sys.exit(bool(failures)) + +The test runner offers you similarly fine-grained control over whether to +run all of the tests in your application at once, or a single function, +using the following syntax: + +:: + +$ python runtests.py tests +$ python runtests.py tests.test_mytom +$ python runtests.py tests.test_mytom.TestCase +$ python runtests.py tests.test_mytom.TestCase.test_my_function + +Writing Unittests +~~~~~~~~~~~~~~~~~ + +Regardless of how they are run, the anatomy of a unittest will be the same. +Unittests are composed as `classes`, inheriting from Django's ``TestCase`` class. + +.. code-block:: + + tests/test_mytom.py: + + from django.test import TestCase + + class TestMyFunctions(TestCase): + +Each test class needs to have a ``setUp`` method and at least one test +method to be valid. As the name suggests, the ``setUp`` method +configures the parameters of the test, for instance establishing any +input data necessary for the test. These data should then be stored as +attributes of the TestCase instance so that they are available when the +test is run. As a simple example, suppose you have written a function in +your TOM that converts a star's RA, Dec to galactic coordinates called +``calc_gal_coords``. This function is stored in the file ``myfunctions.py``. + +:: + + mytom/ + ├── data/ + ├── db.sqlite3 + ├── manage.py + ├── mytom/ + │ ├── __init__.py + │ ├── settings.py + │ ├── urls.py + │ ├── wsgi.py + │ └── myfunctions.py + │ └── tests.py + ├── static. + ├── templates/ + └── tmp/ + +In order to test this, we need to set up some input data in the form of +coordinates. We could do this just by setting some input RA, Dec values +as purely numerical attributes. However, bearing in +mind that the TOM stores this information as entry in its +database, a more realistic test would present that information in the +form of a :doc:`Target object <../targets/index>`. The Toolkit includes a number of +``factory`` classes designed to make it easy to create realistic input +data for testing purposes. + +.. code-block:: + + tests/test_mytom.py: + + from django.test import TestCase + from mytom.myfunctions import calc_gal_coords + from tom_targets.tests.factories import SiderealTargetFactory + + class TestMyFunctions(TestCase): + def setUp(self): + self.target = SiderealTargetFactory.create() + self.target.name = 'test_target' + self.target.ra = 262.71041667 + self.target.dec = -28.50847222 + +A test method can now be added to complete the TestCase, which calls +the TOM's function with the test input and compares the results from +the function with the expected output using an ``assert`` +statement. Python includes ``assert`` natively, but you can also use +`Numpy's testing suite `__ +or the methods inherited from the ``TestCase`` class. + +.. code-block:: + + tests/test_mytom.py: + + from django.test import TestCase + from mytom.myfunctions import calc_gal_coords + from tom_targets.tests.factories import SiderealTargetFactory + + class TestMyFunctions(TestCase): + def setUp(self): + self.target = SiderealTargetFactory.create() + self.target.name = 'test_target' + self.target.ra = 262.71041667 + self.target.dec = -28.50847222 + + def test_calc_gal_coords(self): + + expected_l = 358.62948127 + expected_b = 2.96696435 + + (test_l, test_b) = calc_gal_coords(self.target.ra, + self.target.dec) + self.assertEqual(test_l, expected_l) + self.assertEqual(test_b, expected_b) + +You can add as many additional test methods to a ``TestCase`` as you like. + +TOM's Built-in Tests and Factory Functions +++++++++++++++++++++++++++++++++++++++++++ + +The Toolkit provides a number of factory functions to generate input +data to test various objects in a TOM system. These can be found in the ``tests`` +subdirectory of the core modules of the TOM Toolkit: + +- Targets: `tom_base/tom_targets/tests/factories.py `__ +- Observations: `tom_base/tom_observations/tests/factories.py `__ + +The ``tests`` subdirectories for the Toolkit's core modules are also a great +resource if you are looking for more complex examples of test code. \ No newline at end of file diff --git a/docs/managing_data/customizing_data_processing.rst b/docs/managing_data/customizing_data_processing.rst index 62b295535..ea6b2a7c5 100644 --- a/docs/managing_data/customizing_data_processing.rst +++ b/docs/managing_data/customizing_data_processing.rst @@ -48,6 +48,20 @@ In order to add new data product types, simply add a new key/value pair, with the value being a 2-tuple. The first tuple item is the database value, and the second is the display value. +When a ``DataProduct`` is uploaded, its path is set by a `data_product_path`. By default this is set to +``{target}/{facility}/{filename}``, but can be overridden by setting the ``DATA_PRODUCT_PATH`` in +``settings.py`` to a dot-separated method path that takes a ``DataProduct`` and filename as arguments. For example, if +you wanted to set the `data_product_path` to ``{target}/{facility}/{observation_id}/{filename}``, you would point the +``DATA_PRODUCT_PATH`` to a custom method that looks something like this: + +.. code:: python + + def custom_data_product_path(data_product, filename): + return f'{data_product.target.name}/' \ + f'{data_product.observation_record.facility}/' \ + f'{data_product.observation_record.observation_id}/' \ + f'{filename}' + All data products are automatically “processed” on upload, as well. Of course, that can mean different things to different TOMs! The TOM has two built-in data processors, both of which simply ingest the data into diff --git a/docs/managing_data/forced_photometry.rst b/docs/managing_data/forced_photometry.rst index 1c3def422..d379bfa4f 100644 --- a/docs/managing_data/forced_photometry.rst +++ b/docs/managing_data/forced_photometry.rst @@ -1,5 +1,5 @@ Integrating Forced Photometry Service Queries ---------------------------------------- +--------------------------------------------- The base TOM Toolkit comes with Atlas, panSTARRS, and ZTF query services. More services can be added by extending the base ForcedPhotometryService implementation. @@ -13,6 +13,7 @@ photometry services. This configuration will go in the ``FORCED_PHOTOMETRY_SERVI shown below: .. code:: python + FORCED_PHOTOMETRY_SERVICES = { 'ATLAS': { 'class': 'tom_dataproducts.forced_photometry.atlas.AtlasForcedPhotometryService', @@ -49,6 +50,7 @@ or `rabbitmq `_ server to act as th a redis server, you would add the following to your ``settings.py``: .. code:: python + INSTALLED_APPS = [ ... 'django_dramatiq', diff --git a/docs/observing/observation_module.rst b/docs/observing/observation_module.rst index 7ed7265d3..5b2db6f2b 100644 --- a/docs/observing/observation_module.rst +++ b/docs/observing/observation_module.rst @@ -114,7 +114,9 @@ from two other classes. logic” for interacting with the remote observatory. This includes methods to submit observations, check observation status, etc. It inherits from ``BaseRoboticObservationFacility``, which contains some -functionality that all observation facility classes will want. +functionality that all observation facility classes will want. You +can access the user within your facility implementation using +``self.user`` if you need it for any api requests. ``MyObservationFacilityForm`` is the class that will display a GUI form for our users to create an observation. We can submit observations diff --git a/docs/observing/strategies.rst b/docs/observing/strategies.rst index 185882884..8e9550d26 100644 --- a/docs/observing/strategies.rst +++ b/docs/observing/strategies.rst @@ -1,5 +1,5 @@ Dynamic Cadences and Observation Templates -================================ +========================================== The TOM has a couple of unique concepts that may be unfamiliar to some at first, that will be describe here before going into detail. diff --git a/poetry.lock b/poetry.lock index 1ee75766c..885b99de1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -181,12 +181,12 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] [[package]] name = "astroplan" -version = "0.8" +version = "0.9.1" description = "Observation planning package for astronomers" optional = false python-versions = ">=3.7" files = [ - {file = "astroplan-0.8.tar.gz", hash = "sha256:0cc463474e034f5f58f7399ef830ff60e91d2fac32e89cf2224e1de50946bdc7"}, + {file = "astroplan-0.9.1.tar.gz", hash = "sha256:d98c5ea58f6131de391aa66c78e0b4d77649359b29dbc8fdee9385e0408c2f4b"}, ] [package.dependencies] @@ -196,10 +196,10 @@ pytz = "*" six = "*" [package.extras] -all = ["astroquery", "matplotlib (>=1.4,<3.3)"] -docs = ["astroquery", "matplotlib (>=1.4,<3.3)", "sphinx-astropy", "sphinx_rtd_theme"] -plotting = ["astroquery", "matplotlib (>=1.4,<3.3)"] -test = ["pytest-astropy"] +all = ["astroquery", "matplotlib (>=1.4)"] +docs = ["astroquery", "matplotlib (>=1.4)", "sphinx-astropy[confv2]", "sphinx-rtd-theme"] +plotting = ["astroquery", "matplotlib (>=1.4)"] +test = ["pytest-astropy", "pytest-mpl"] [[package]] name = "astropy" @@ -668,34 +668,34 @@ django-crispy-forms = ">=2.0" [[package]] name = "cryptography" -version = "41.0.5" +version = "41.0.6" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:da6a0ff8f1016ccc7477e6339e1d50ce5f59b88905585f77193ebd5068f1e797"}, - {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b948e09fe5fb18517d99994184854ebd50b57248736fd4c720ad540560174ec5"}, - {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d38e6031e113b7421db1de0c1b1f7739564a88f1684c6b89234fbf6c11b75147"}, - {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e270c04f4d9b5671ebcc792b3ba5d4488bf7c42c3c241a3748e2599776f29696"}, - {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ec3b055ff8f1dce8e6ef28f626e0972981475173d7973d63f271b29c8a2897da"}, - {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7d208c21e47940369accfc9e85f0de7693d9a5d843c2509b3846b2db170dfd20"}, - {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8254962e6ba1f4d2090c44daf50a547cd5f0bf446dc658a8e5f8156cae0d8548"}, - {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a48e74dad1fb349f3dc1d449ed88e0017d792997a7ad2ec9587ed17405667e6d"}, - {file = "cryptography-41.0.5-cp37-abi3-win32.whl", hash = "sha256:d3977f0e276f6f5bf245c403156673db103283266601405376f075c849a0b936"}, - {file = "cryptography-41.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:73801ac9736741f220e20435f84ecec75ed70eda90f781a148f1bad546963d81"}, - {file = "cryptography-41.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3be3ca726e1572517d2bef99a818378bbcf7d7799d5372a46c79c29eb8d166c1"}, - {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e886098619d3815e0ad5790c973afeee2c0e6e04b4da90b88e6bd06e2a0b1b72"}, - {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:573eb7128cbca75f9157dcde974781209463ce56b5804983e11a1c462f0f4e88"}, - {file = "cryptography-41.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0c327cac00f082013c7c9fb6c46b7cc9fa3c288ca702c74773968173bda421bf"}, - {file = "cryptography-41.0.5-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:227ec057cd32a41c6651701abc0328135e472ed450f47c2766f23267b792a88e"}, - {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:22892cc830d8b2c89ea60148227631bb96a7da0c1b722f2aac8824b1b7c0b6b8"}, - {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5a70187954ba7292c7876734183e810b728b4f3965fbe571421cb2434d279179"}, - {file = "cryptography-41.0.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:88417bff20162f635f24f849ab182b092697922088b477a7abd6664ddd82291d"}, - {file = "cryptography-41.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c707f7afd813478e2019ae32a7c49cd932dd60ab2d2a93e796f68236b7e1fbf1"}, - {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:580afc7b7216deeb87a098ef0674d6ee34ab55993140838b14c9b83312b37b86"}, - {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1e91467c65fe64a82c689dc6cf58151158993b13eb7a7f3f4b7f395636723"}, - {file = "cryptography-41.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0d2a6a598847c46e3e321a7aef8af1436f11c27f1254933746304ff014664d84"}, - {file = "cryptography-41.0.5.tar.gz", hash = "sha256:392cb88b597247177172e02da6b7a63deeff1937fa6fec3bbf902ebd75d97ec7"}, + {file = "cryptography-41.0.6-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:0f27acb55a4e77b9be8d550d762b0513ef3fc658cd3eb15110ebbcbd626db12c"}, + {file = "cryptography-41.0.6-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ae236bb8760c1e55b7a39b6d4d32d2279bc6c7c8500b7d5a13b6fb9fc97be35b"}, + {file = "cryptography-41.0.6-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afda76d84b053923c27ede5edc1ed7d53e3c9f475ebaf63c68e69f1403c405a8"}, + {file = "cryptography-41.0.6-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da46e2b5df770070412c46f87bac0849b8d685c5f2679771de277a422c7d0b86"}, + {file = "cryptography-41.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ff369dd19e8fe0528b02e8df9f2aeb2479f89b1270d90f96a63500afe9af5cae"}, + {file = "cryptography-41.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b648fe2a45e426aaee684ddca2632f62ec4613ef362f4d681a9a6283d10e079d"}, + {file = "cryptography-41.0.6-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5daeb18e7886a358064a68dbcaf441c036cbdb7da52ae744e7b9207b04d3908c"}, + {file = "cryptography-41.0.6-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:068bc551698c234742c40049e46840843f3d98ad7ce265fd2bd4ec0d11306596"}, + {file = "cryptography-41.0.6-cp37-abi3-win32.whl", hash = "sha256:2132d5865eea673fe6712c2ed5fb4fa49dba10768bb4cc798345748380ee3660"}, + {file = "cryptography-41.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:48783b7e2bef51224020efb61b42704207dde583d7e371ef8fc2a5fb6c0aabc7"}, + {file = "cryptography-41.0.6-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8efb2af8d4ba9dbc9c9dd8f04d19a7abb5b49eab1f3694e7b5a16a5fc2856f5c"}, + {file = "cryptography-41.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c5a550dc7a3b50b116323e3d376241829fd326ac47bc195e04eb33a8170902a9"}, + {file = "cryptography-41.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:85abd057699b98fce40b41737afb234fef05c67e116f6f3650782c10862c43da"}, + {file = "cryptography-41.0.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f39812f70fc5c71a15aa3c97b2bbe213c3f2a460b79bd21c40d033bb34a9bf36"}, + {file = "cryptography-41.0.6-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:742ae5e9a2310e9dade7932f9576606836ed174da3c7d26bc3d3ab4bd49b9f65"}, + {file = "cryptography-41.0.6-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:35f3f288e83c3f6f10752467c48919a7a94b7d88cc00b0668372a0d2ad4f8ead"}, + {file = "cryptography-41.0.6-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4d03186af98b1c01a4eda396b137f29e4e3fb0173e30f885e27acec8823c1b09"}, + {file = "cryptography-41.0.6-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b27a7fd4229abef715e064269d98a7e2909ebf92eb6912a9603c7e14c181928c"}, + {file = "cryptography-41.0.6-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:398ae1fc711b5eb78e977daa3cbf47cec20f2c08c5da129b7a296055fbb22aed"}, + {file = "cryptography-41.0.6-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7e00fb556bda398b99b0da289ce7053639d33b572847181d6483ad89835115f6"}, + {file = "cryptography-41.0.6-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:60e746b11b937911dc70d164060d28d273e31853bb359e2b2033c9e93e6f3c43"}, + {file = "cryptography-41.0.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3288acccef021e3c3c10d58933f44e8602cf04dba96d9796d70d537bb2f4bbc4"}, + {file = "cryptography-41.0.6.tar.gz", hash = "sha256:422e3e31d63743855e43e5a6fcc8b4acab860f560f9321b0ee6269cc7ed70cc3"}, ] [package.dependencies] @@ -713,13 +713,13 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "django" -version = "4.2.6" +version = "4.2.8" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.8" files = [ - {file = "Django-4.2.6-py3-none-any.whl", hash = "sha256:a64d2487cdb00ad7461434320ccc38e60af9c404773a2f95ab0093b4453a3215"}, - {file = "Django-4.2.6.tar.gz", hash = "sha256:08f41f468b63335aea0d904c5729e0250300f6a1907bf293a65499496cdbc68f"}, + {file = "Django-4.2.8-py3-none-any.whl", hash = "sha256:6cb5dcea9e3d12c47834d32156b8841f533a4493c688e2718cafd51aa430ba6d"}, + {file = "Django-4.2.8.tar.gz", hash = "sha256:d69d5e36cc5d9f4eb4872be36c622878afcdce94062716cf3e25bcedcb168b62"}, ] [package.dependencies] @@ -809,13 +809,13 @@ Django = ">=3.2" [[package]] name = "django-filter" -version = "23.3" +version = "23.5" description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." optional = false python-versions = ">=3.7" files = [ - {file = "django-filter-23.3.tar.gz", hash = "sha256:015fe155582e1805b40629344e4a6cf3cc40450827d294d040b4b8c1749a9fa6"}, - {file = "django_filter-23.3-py3-none-any.whl", hash = "sha256:65bc5d1d8f4fff3aaf74cb5da537b6620e9214fb4b3180f6c560776b1b6dccd0"}, + {file = "django-filter-23.5.tar.gz", hash = "sha256:67583aa43b91fe8c49f74a832d95f4d8442be628fd4c6d65e9f811f5153a4e5c"}, + {file = "django_filter-23.5-py3-none-any.whl", hash = "sha256:99122a201d83860aef4fe77758b69dda913e874cc5e0eaa50a86b0b18d708400"}, ] [package.dependencies] @@ -873,13 +873,13 @@ files = [ [[package]] name = "docutils" -version = "0.17.1" +version = "0.20.1" description = "Docutils -- Python Documentation Utilities" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.7" files = [ - {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, - {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, + {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, + {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, ] [[package]] @@ -943,13 +943,13 @@ typing-extensions = {version = ">=3.10.0.1", markers = "python_version <= \"3.8\ [[package]] name = "fits2image" -version = "0.4.6" +version = "0.4.7" description = "Common libraries for the conversion and scaling of fits images" optional = false python-versions = "*" files = [ - {file = "fits2image-0.4.6-py3-none-any.whl", hash = "sha256:79a1fdba40de10b1378c03463ba7f2ec67b6b14c3f2b320945584919108ebe1e"}, - {file = "fits2image-0.4.6.tar.gz", hash = "sha256:1721ee540a725e3d29a3835d121209581e447f54d639b7050b65b0019b90c76f"}, + {file = "fits2image-0.4.7-py3-none-any.whl", hash = "sha256:0727d0dfef482a1493399f3b44b59085fd0beb1b9d81aa32fd1882272561dae5"}, + {file = "fits2image-0.4.7.tar.gz", hash = "sha256:25a29c0b442c4d025d6bf546c677b7539d3a57cedfc65ee87315c618feece387"}, ] [package.dependencies] @@ -959,19 +959,19 @@ Pillow = "*" [[package]] name = "flake8" -version = "6.0.0" +version = "6.1.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.8.1" files = [ - {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, - {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, + {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, + {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, ] [package.dependencies] mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.10.0,<2.11.0" -pyflakes = ">=3.0.0,<3.1.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.1.0,<3.2.0" [[package]] name = "gevent" @@ -1607,13 +1607,13 @@ twisted = ["twisted"] [[package]] name = "pycodestyle" -version = "2.10.0" +version = "2.11.1" description = "Python style guide checker" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, - {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, + {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, + {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, ] [[package]] @@ -1685,13 +1685,13 @@ test = ["pytest", "pytest-doctestplus (>=0.7)"] [[package]] name = "pyflakes" -version = "3.0.1" +version = "3.1.0" description = "passive checker of Python programs" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, - {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, + {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, + {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, ] [[package]] @@ -1921,23 +1921,22 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "responses" -version = "0.23.3" +version = "0.24.1" description = "A utility library for mocking out the `requests` Python library." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "responses-0.23.3-py3-none-any.whl", hash = "sha256:e6fbcf5d82172fecc0aa1860fd91e58cbfd96cee5e96da5b63fa6eb3caa10dd3"}, - {file = "responses-0.23.3.tar.gz", hash = "sha256:205029e1cb334c21cb4ec64fc7599be48b859a0fd381a42443cdd600bfe8b16a"}, + {file = "responses-0.24.1-py3-none-any.whl", hash = "sha256:a2b43f4c08bfb9c9bd242568328c65a34b318741d3fab884ac843c5ceeb543f9"}, + {file = "responses-0.24.1.tar.gz", hash = "sha256:b127c6ca3f8df0eb9cc82fd93109a3007a86acb24871834c47b77765152ecf8c"}, ] [package.dependencies] pyyaml = "*" requests = ">=2.30.0,<3.0" -types-PyYAML = "*" urllib3 = ">=1.25.10,<3.0" [package.extras] -tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-requests"] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] [[package]] name = "scipy" @@ -2083,26 +2082,26 @@ test = ["coverage", "graphviz", "matplotlib", "pytest-astropy", "pytest-cov", "s [[package]] name = "sphinx" -version = "5.3.0" +version = "7.1.2" description = "Python documentation generator" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "Sphinx-5.3.0.tar.gz", hash = "sha256:51026de0a9ff9fc13c05d74913ad66047e104f56a129ff73e174eb5c3ee794b5"}, - {file = "sphinx-5.3.0-py3-none-any.whl", hash = "sha256:060ca5c9f7ba57a08a1219e547b269fadf125ae25b06b9fa7f66768efb652d6d"}, + {file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"}, + {file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"}, ] [package.dependencies] alabaster = ">=0.7,<0.8" babel = ">=2.9" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.14,<0.20" +docutils = ">=0.18.1,<0.21" imagesize = ">=1.3" importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} Jinja2 = ">=3.0" packaging = ">=21.0" -Pygments = ">=2.12" -requests = ">=2.5.0" +Pygments = ">=2.13" +requests = ">=2.25.0" snowballstemmer = ">=2.0" sphinxcontrib-applehelp = "*" sphinxcontrib-devhelp = "*" @@ -2113,26 +2112,27 @@ sphinxcontrib-serializinghtml = ">=1.1.5" [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-bugbear", "flake8-comprehensions", "flake8-simplify", "isort", "mypy (>=0.981)", "sphinx-lint", "types-requests", "types-typed-ast"] -test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] +lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"] +test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"] [[package]] name = "sphinx-rtd-theme" -version = "1.0.0" +version = "2.0.0" description = "Read the Docs theme for Sphinx" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +python-versions = ">=3.6" files = [ - {file = "sphinx_rtd_theme-1.0.0-py2.py3-none-any.whl", hash = "sha256:4d35a56f4508cfee4c4fb604373ede6feae2a306731d533f409ef5c3496fdbd8"}, - {file = "sphinx_rtd_theme-1.0.0.tar.gz", hash = "sha256:eec6d497e4c2195fa0e8b2016b337532b8a699a68bcb22a512870e16925c6a5c"}, + {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"}, + {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"}, ] [package.dependencies] -docutils = "<0.18" -sphinx = ">=1.6" +docutils = "<0.21" +sphinx = ">=5,<8" +sphinxcontrib-jquery = ">=4,<5" [package.extras] -dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] +dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] [[package]] name = "sphinxcontrib-applehelp" @@ -2179,6 +2179,20 @@ files = [ lint = ["docutils-stubs", "flake8", "mypy"] test = ["html5lib", "pytest"] +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +description = "Extension to include jQuery on newer Sphinx releases" +optional = false +python-versions = ">=2.7" +files = [ + {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, + {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, +] + +[package.dependencies] +Sphinx = ">=1.8" + [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" @@ -2253,17 +2267,6 @@ files = [ [package.extras] doc = ["reno", "sphinx", "tornado (>=4.5)"] -[[package]] -name = "types-pyyaml" -version = "6.0.12.12" -description = "Typing stubs for PyYAML" -optional = false -python-versions = "*" -files = [ - {file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"}, - {file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"}, -] - [[package]] name = "typing-extensions" version = "4.8.0" @@ -2457,4 +2460,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<3.12" -content-hash = "21f64667b3ee5e0c82022c18a15930f7082045a50280255f84ba773a0453c440" +content-hash = "d208e4f6700685be807766e562884d1dcc346f2eb8ba661f2248b8abc2e8f34a" diff --git a/pyproject.toml b/pyproject.toml index dad6f8053..9f3090626 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ classifiers = [ python = ">=3.8.1,<3.12" numpy = "~1.24,<2" astroquery = "<1" -astroplan = "~0.8" +astroplan = ">=0.8,<0.10" astropy = ">=3,<6" django = "<5" djangorestframework = "<4" @@ -78,20 +78,20 @@ requests = "<3" specutils = "<2" [tool.poetry.group.test.dependencies] -responses = "~0.23" +responses = ">=0.23,<0.25" factory_boy = ">3.2.1,<3.4" [tool.poetry.group.docs.dependencies] recommonmark = "~0.7" sphinx = ">=4,<8" -sphinx-rtd-theme = "~1.0" +sphinx-rtd-theme = ">=1.0,<2.1" [tool.poetry.group.coverage.dependencies] coverage = "~6" # coveralls needs ~6 even though 7.3.2 is latest coveralls = "~3" [tool.poetry.group.lint.dependencies] -flake8 = "~6.0" +flake8 = ">=6.0,<6.2" [tool.poetry-dynamic-versioning] enable = true diff --git a/tom_alerts/brokers/alerce.py b/tom_alerts/brokers/alerce.py index ca78e3df5..bd3e7df33 100644 --- a/tom_alerts/brokers/alerce.py +++ b/tom_alerts/brokers/alerce.py @@ -332,13 +332,13 @@ def fetch_alerts(self, parameters): def fetch_alert(self, alert_id): """ The response for a single alert is as follows: + { + 'oid':'ZTF20acnsdjd', + ... + 'firstmjd':59149.1119328998, + ... + } - { - 'oid':'ZTF20acnsdjd', - ... - 'firstmjd':59149.1119328998, - ... - } """ response = requests.get(f'{ALERCE_SEARCH_URL}/objects/{alert_id}') response.raise_for_status() diff --git a/tom_alerts/brokers/scout.py b/tom_alerts/brokers/scout.py index 51b5a7d6d..e768445ad 100644 --- a/tom_alerts/brokers/scout.py +++ b/tom_alerts/brokers/scout.py @@ -18,8 +18,11 @@ def __init__(self, *args, **kwargs): self.helper.layout = Layout( HTML('''

- Please see the Scout API + Please see the Scout API Reference for a detailed description of the service. +
+ This form is a placeholder for future broker development. This query will currently retrieve all + available summary data.

'''), self.common_layout, diff --git a/tom_alerts/brokers/tns.py b/tom_alerts/brokers/tns.py index dc37bc772..393e18ac5 100644 --- a/tom_alerts/brokers/tns.py +++ b/tom_alerts/brokers/tns.py @@ -83,10 +83,24 @@ class TNSBroker(GenericBroker): """ The ``TNSBroker`` is the interface to the Transient Name Server. For information regarding the TNS, please see \ https://www.wis-tns.org/ + + Requires the following configuration in settings.py: + :: + BROKERS = { + 'TNS': { + 'api_key': 'YOUR_API_KEY', + 'bot_id': 'YOUR_BOT_ID', + 'bot_name': 'YOUR_BOT_NAME', + 'tns_base_url': 'https://sandbox.wis-tns.org/api', # Note this is the Sandbox URL + 'group_name': 'YOUR_GROUP_NAME', + }, + } + """ name = 'TNS' form = TNSForm + help_url = 'https://tom-toolkit.readthedocs.io/en/latest/api/tom_alerts/brokers.html#module-tom_alerts.brokers.tns' @classmethod def tns_headers(cls): diff --git a/tom_alerts/templates/tom_alerts/query_result.html b/tom_alerts/templates/tom_alerts/query_result.html index b04720fb6..8fab2b972 100644 --- a/tom_alerts/templates/tom_alerts/query_result.html +++ b/tom_alerts/templates/tom_alerts/query_result.html @@ -39,6 +39,6 @@

Query Result for {{ query }}

{% if broker_feedback %} - + {% endif %} {% endblock %} diff --git a/tom_alerts/tests/tests.py b/tom_alerts/tests/tests.py index c4408cf4e..92b0b3a37 100644 --- a/tom_alerts/tests/tests.py +++ b/tom_alerts/tests/tests.py @@ -228,7 +228,7 @@ def test_create_target(self): response = self.client.post(reverse('tom_alerts:create-target'), data=post_data) self.assertEqual(Target.objects.count(), 1) self.assertEqual(Target.objects.first().name, 'Hoth') - self.assertRedirects(response, reverse('tom_targets:update', kwargs={'pk': Target.objects.first().id})) + self.assertRedirects(response, reverse('tom_targets:list')) @override_settings(CACHES={ 'default': { diff --git a/tom_alerts/views.py b/tom_alerts/views.py index 6a769bd79..afd834681 100644 --- a/tom_alerts/views.py +++ b/tom_alerts/views.py @@ -228,14 +228,25 @@ def get_context_data(self, *args, **kwargs): # Do query and get query results (fetch_alerts) # TODO: Should the deepcopy be in the brokers? - alert_query_results = broker_class.fetch_alerts(deepcopy(query.parameters)) - # Check if feedback is available for fetch_alerts, and allow for backwards compatibility if not. - if isinstance(alert_query_results, tuple): - alerts, broker_feedback = alert_query_results - else: - alerts = alert_query_results - broker_feedback = '' - + try: + alert_query_results = broker_class.fetch_alerts(deepcopy(query.parameters)) + + # Check if feedback is available for fetch_alerts, and allow for backwards compatibility if not. + if isinstance(alert_query_results, tuple): + alerts, broker_feedback = alert_query_results + else: + alerts = alert_query_results + broker_feedback = '' + except AttributeError: + # If the broker isn't configured in settings.py, display error instead of query results + alerts = iter(()) + broker_help = getattr(broker_class, 'help_url', + 'https://tom-toolkit.readthedocs.io/en/latest/api/tom_alerts/brokers.html') + broker_feedback = f"""The {broker_class.name} Broker is not properly configured in settings.py. +
+ Please see the documentation for more + information. + """ # Post-query tasks query.last_run = timezone.now() query.save() @@ -300,16 +311,9 @@ def post(self, request, *args, **kwargs): except IntegrityError: messages.warning(request, f'Unable to save {target.name}, target with that name already exists.') errors.append(target.name) - if (len(alerts) == len(errors)): + if len(alerts) == len(errors): return redirect(reverse('tom_alerts:run', kwargs={'pk': query_id})) - elif (len(alerts) == 1): - return redirect(reverse( - 'tom_targets:update', kwargs={'pk': target.id}) - ) - else: - return redirect(reverse( - 'tom_targets:list') - ) + return redirect(reverse('tom_targets:list')) class SubmitAlertUpstreamView(LoginRequiredMixin, FormMixin, ProcessFormView, View): diff --git a/tom_base/settings.py b/tom_base/settings.py index 8eef5e1fb..27a1e2b98 100644 --- a/tom_base/settings.py +++ b/tom_base/settings.py @@ -201,6 +201,8 @@ 'tom_observations.facilities.lco.LCOFacility', 'tom_observations.facilities.gemini.GEMFacility', 'tom_observations.facilities.soar.SOARFacility', + 'tom_observations.facilities.lt.LTFacility', + ] # Define MATCH_MANAGERS here. diff --git a/tom_dataproducts/models.py b/tom_dataproducts/models.py index 1aa3da09f..baad56ca9 100644 --- a/tom_dataproducts/models.py +++ b/tom_dataproducts/models.py @@ -10,6 +10,7 @@ from django.core.exceptions import ValidationError from fits2image.conversions import fits_to_jpg from PIL import Image +from importlib import import_module from tom_targets.models import Target from tom_alerts.models import AlertStreamMessage @@ -73,7 +74,14 @@ def is_fits_image_file(file): def data_product_path(instance, filename): """ - Returns the TOM-style path for a ``DataProduct`` file. Structure is //. + Returns the TOM-style path for a ``DataProduct`` file. + Default behavior can be overridden by user in settings.DATA_PRODUCT_PATH + DATA_PRODUCT_PATH must be a dot separated method name pointing to a method that takes two arguments: + instance: The specific instance of the ``DataProduct`` class. + filename: The filename to add to the path. + The method must return a string representing the path to the file. + + The default structure is //. ``DataProduct`` objects not associated with a facility will save with 'None' as the facility. :param instance: The specific instance of the ``DataProduct`` class. @@ -85,11 +93,21 @@ def data_product_path(instance, filename): :returns: The TOM-style path of the file :rtype: str """ - # Uploads go to MEDIA_ROOT - if instance.observation_record is not None: - return '{0}/{1}/{2}'.format(instance.target.name, instance.observation_record.facility, filename) - else: - return '{0}/none/{1}'.format(instance.target.name, filename) + try: + path_class = settings.DATA_PRODUCT_PATH + try: + mod_name, class_name = path_class.rsplit('.', 1) + mod = import_module(mod_name) + clazz = getattr(mod, class_name) + except (ImportError, AttributeError): + raise ImportError(f'Could not import {path_class}. Did you provide the correct path?') + return clazz(instance, filename) + except AttributeError: + # Uploads go to MEDIA_ROOT + if instance.observation_record is not None: + return f'{instance.target.name}/{instance.observation_record.facility}/{filename}' + else: + return f'{instance.target.name}/none/{filename}' class DataProductGroup(models.Model): @@ -234,8 +252,9 @@ def get_preview(self, size=THUMBNAIL_DEFAULT_SIZE, redraw=False): """ if self.thumbnail: im = Image.open(self.thumbnail) - if im.size != THUMBNAIL_DEFAULT_SIZE: + if im.size != THUMBNAIL_DEFAULT_SIZE and im.size[0] not in THUMBNAIL_DEFAULT_SIZE: redraw = True + logger.critical("Redrawing thumbnail for {0} due to size mismatch".format(im.size)) if not self.thumbnail or redraw: width, height = THUMBNAIL_DEFAULT_SIZE @@ -262,6 +281,9 @@ def create_thumbnail(self, width=None, height=None): :returns: Thumbnail file if created, None otherwise :rtype: file """ + if not self.data: + logger.error(f'Unable to create thumbnail for {self}: No data file found.') + return if is_fits_image_file(self.data.file): tmpfile = tempfile.NamedTemporaryFile(suffix='.jpg') try: diff --git a/tom_dataproducts/templates/tom_dataproducts/partials/query_forced_photometry.html b/tom_dataproducts/templates/tom_dataproducts/partials/query_forced_photometry.html index c038c6f06..3cfd7c917 100644 --- a/tom_dataproducts/templates/tom_dataproducts/partials/query_forced_photometry.html +++ b/tom_dataproducts/templates/tom_dataproducts/partials/query_forced_photometry.html @@ -1,9 +1,6 @@ -{% load bootstrap4 static %} -{% bootstrap_css %} -{% bootstrap_javascript jquery='full' %} - -

Query Forced Photometry Service

{% for service in forced_photometry_services %} {{ service }} +{% empty %} +No forced photometry services are configured. See the TOM documentation for more information. {% endfor %} diff --git a/tom_dataproducts/templates/tom_dataproducts/partials/saved_dataproduct_list_for_observation.html b/tom_dataproducts/templates/tom_dataproducts/partials/saved_dataproduct_list_for_observation.html index 8a1a490cc..2b41895fb 100644 --- a/tom_dataproducts/templates/tom_dataproducts/partials/saved_dataproduct_list_for_observation.html +++ b/tom_dataproducts/templates/tom_dataproducts/partials/saved_dataproduct_list_for_observation.html @@ -21,7 +21,7 @@ {% endif %} {{ product.created }} - Delete + Delete {% empty %} diff --git a/tom_dataproducts/templatetags/dataproduct_extras.py b/tom_dataproducts/templatetags/dataproduct_extras.py index 7c237b5e2..14208ff6d 100644 --- a/tom_dataproducts/templatetags/dataproduct_extras.py +++ b/tom_dataproducts/templatetags/dataproduct_extras.py @@ -453,12 +453,11 @@ def reduceddatum_sparkline(target, height, spacing=5, color_map=None, limit_y=Tr :type color_map: dict :param limit_y: Whether to limit the y-axis to the min/max of detections. If false, the mix/max will also include - non-detections. Default True. + non-detections. Default True. :type limit_y: bool :param days: The number of days in the past, relative to today, of datapoints to render. Default is 32. :type days: int - """ if not color_map: color_map = { diff --git a/tom_dataproducts/tests/tests.py b/tom_dataproducts/tests/tests.py index eae6b4cc4..95a987dcc 100644 --- a/tom_dataproducts/tests/tests.py +++ b/tom_dataproducts/tests/tests.py @@ -19,7 +19,7 @@ from tom_dataproducts.exceptions import InvalidFileFormatException from tom_dataproducts.forms import DataProductUploadForm -from tom_dataproducts.models import DataProduct, is_fits_image_file, ReducedDatum +from tom_dataproducts.models import DataProduct, is_fits_image_file, ReducedDatum, data_product_path from tom_dataproducts.processors.data_serializers import SpectrumSerializer from tom_dataproducts.processors.photometry_processor import PhotometryProcessor from tom_dataproducts.processors.spectroscopy_processor import SpectroscopyProcessor @@ -40,6 +40,10 @@ def mock_is_fits_image_file(filename): return True +def dp_path(instance, filename): + return f'new_path/{filename}' + + @override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility'], TARGET_PERMISSIONS_ONLY=True) @patch('tom_dataproducts.models.DataProduct.get_preview', return_value='/no-image.jpg') @@ -144,6 +148,35 @@ def test_create_jpeg(self, dp_mock): self.assertEqual(products.count(), 1) +class TestModels(TestCase): + def setUp(self): + self.overide_path = 'tom_dataproducts.tests.tests.dp_path' + self.target = SiderealTargetFactory.create() + self.observation_record = ObservingRecordFactory.create( + target_id=self.target.id, + facility=FakeRoboticFacility.name, + parameters={} + ) + self.data_product = DataProduct.objects.create( + product_id='testproductid', + target=self.target, + observation_record=self.observation_record, + data=SimpleUploadedFile('afile.fits', b'somedata') + ) + + def test_no_path_overide(self): + """Test that the default path is used if no overide is set""" + filename = 'afile.fits' + path = data_product_path(self.data_product, filename) + self.assertIn(f'FakeRoboticFacility/{filename}', path) + + @override_settings(DATA_PRODUCT_PATH='tom_dataproducts.tests.tests.dp_path') + def test_path_overide(self): + """Test that the overide path is used if set""" + path = data_product_path(self.data_product, 'afile.fits') + self.assertIn('new_path/afile.fits', path) + + @override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility'], TARGET_PERMISSIONS_ONLY=False) @patch('tom_dataproducts.models.DataProduct.get_preview', return_value='/no-image.jpg') diff --git a/tom_observations/facilities/gemini.py b/tom_observations/facilities/gemini.py index 31c73355e..4ff3b6f29 100644 --- a/tom_observations/facilities/gemini.py +++ b/tom_observations/facilities/gemini.py @@ -8,6 +8,7 @@ from astropy import units as u from tom_observations.facility import BaseRoboticObservationFacility, BaseRoboticObservationForm +from tom_observations.observation_template import GenericTemplateForm from tom_common.exceptions import ImproperCredentialsException from tom_targets.models import Target @@ -426,6 +427,10 @@ def isodatetime(value): return payloads +class GEMTemplateForm(GenericTemplateForm, GEMObservationForm): + pass + + class GEMFacility(BaseRoboticObservationFacility): """ The ``GEMFacility`` is the interface to the Gemini Telescope. For information regarding Gemini observing and the @@ -440,6 +445,10 @@ class GEMFacility(BaseRoboticObservationFacility): def get_form(self, observation_type): return GEMObservationForm + # TODO: this should be called get_template_form_class + def get_template_form(self, observation_type): + return GEMTemplateForm + @classmethod def submit_observation(clz, observation_payload): obsids = [] diff --git a/tom_observations/facilities/lco.py b/tom_observations/facilities/lco.py index 8b2d225f3..1a3503ebf 100644 --- a/tom_observations/facilities/lco.py +++ b/tom_observations/facilities/lco.py @@ -668,7 +668,8 @@ def __init__(self, *args, **kwargs): if self.fields.get(f'c_{j+1}_ic_{i+1}_grating', None): self.fields[f'c_{j+1}_ic_{i+1}_grating'].help_text = 'Only for SOAR' self.fields[f'c_{j+1}_ic_{i+1}_grating'].choices.insert(0, ('None', 'None')) - self.fields[f'c_{j+1}_ic_{i+1}_slit'].help_text = 'Only for Floyds' + if self.fields.get(f'c_{j+1}_ic_{i+1}_slit', None): + self.fields[f'c_{j+1}_ic_{i+1}_slit'].help_text = 'Only for Floyds' def convert_old_observation_payload_to_fields(self, data): data = super().convert_old_observation_payload_to_fields(data) @@ -753,7 +754,7 @@ class LCOPhotometricSequenceForm(LCOOldStyleObservationForm): configuration of multiple filters, as well as a more intuitive proactive cadence form. """ valid_instruments = ['1M0-SCICAM-SINISTRO', '0M4-SCICAM-SBIG', '2M0-SPECTRAL-AG'] - valid_filters = ['U', 'B', 'V', 'R', 'I', 'up', 'gp', 'rp', 'ip', 'zs', 'w'] + valid_filters = ['U', 'B', 'V', 'R', 'I', 'up', 'gp', 'rp', 'ip', 'zs', 'w', 'unknown'] cadence_frequency = forms.IntegerField(required=True, help_text='in hours') def __init__(self, *args, **kwargs): @@ -800,13 +801,13 @@ def _build_instrument_configs(self): instrument configurations in the appropriate manner. """ instrument_configs = [] - for filter_name in self.valid_filters: - if len(self.cleaned_data[filter_name]) > 0: + for filter_code, _ in self.all_optical_element_choices(): + if len(self.cleaned_data[filter_code]) > 0: instrument_configs.append({ - 'exposure_count': self.cleaned_data[filter_name][1], - 'exposure_time': self.cleaned_data[filter_name][0], + 'exposure_count': self.cleaned_data[filter_code][1], + 'exposure_time': self.cleaned_data[filter_code][0], 'optical_elements': { - 'filter': filter_name + 'filter': filter_code } }) @@ -888,8 +889,8 @@ def layout(self): Column(HTML('Block No.')), ) ) - for filter_name in self.valid_filters: - filter_layout.append(Row(MultiWidgetField(filter_name, attrs={'min': 0}))) + for filter_code, _ in self.all_optical_element_choices(): + filter_layout.append(Row(MultiWidgetField(filter_code, attrs={'min': 0}))) return Row( Column( diff --git a/tom_observations/facilities/lt.py b/tom_observations/facilities/lt.py index 9aa90a867..5ddda21a0 100644 --- a/tom_observations/facilities/lt.py +++ b/tom_observations/facilities/lt.py @@ -7,7 +7,6 @@ class LTQueryForm(GenericObservationForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) target_id = self.initial.get('target_id') - self.helper.inputs.pop() self.helper.layout = Layout( HTML('''

@@ -19,13 +18,33 @@ def __init__(self, *args, **kwargs): ) +class LTTemplateForm(LTQueryForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper.layout = Layout( + HTML(''' +

+ This plugin is a stub for the Liverpool Telescope plugin. In order to install the full plugin, please + see the instructions here. +

+ '''), + HTML('''Back''') + ) + + class LTFacility(GenericObservationFacility): name = 'LT' + observation_forms = { + 'ALL': LTQueryForm, + } observation_types = [('Default', '')] def get_form(self, observation_type): return LTQueryForm + def get_template_form(self, observation_type): + return LTTemplateForm + def submit_observation(self, observation_payload): return diff --git a/tom_observations/facilities/ocs.py b/tom_observations/facilities/ocs.py index 463284d65..99c900802 100644 --- a/tom_observations/facilities/ocs.py +++ b/tom_observations/facilities/ocs.py @@ -5,7 +5,7 @@ from astropy import units as u from crispy_forms.bootstrap import Accordion, AccordionGroup, TabHolder, Tab, Alert -from crispy_forms.layout import Div, HTML, Layout +from crispy_forms.layout import Div, HTML, Layout, ButtonHolder, Submit from dateutil.parser import parse from django import forms from django.conf import settings @@ -32,6 +32,22 @@ class OCSSettings(): 'max_instrument_configs': 5, 'max_configurations': 5 } + default_instrument_config = {'No Instrument Found': { + 'type': 'NONE', + 'optical_elements': {'filters': [{ + 'name': 'Unknown Filter', + 'code': 'unknown', + 'schedulable': True, + 'default': True}]}, + 'configuration_types': { + 'None': { + 'name': 'No Configurations found', + 'code': 'NONE', + } + }, + 'default_configuration_type': 'None', + }} + # These class variables describe default help text for a variety of OCS fields. # Override them as desired for a specific OCS implementation. ipp_value_help = """ @@ -81,6 +97,12 @@ def __init__(self, facility_name): def get_setting(self, key): return settings.FACILITIES.get(self.facility_name, self.default_settings).get(key, self.default_settings[key]) + def get_unconfigured_settings(self): + """ + Check that the settings for this facility are present, and return list of any required settings that are blank. + """ + return [key for key in self.default_settings.keys() if not self.get_setting(key)] + def get_observing_states(self): return [ 'PENDING', 'COMPLETED', 'WINDOW_EXPIRED', 'CANCELED', 'FAILURE_LIMIT_REACHED', 'NOT_ATTEMPTED' @@ -191,15 +213,17 @@ def target_group_choices(self, include_self=True): def _get_instruments(self): cached_instruments = cache.get(f'{self.facility_settings.facility_name}_instruments') - if not cached_instruments: logger.warning("Instruments not cached, getting them again!!!") - response = make_request( - 'GET', - urljoin(self.facility_settings.get_setting('portal_url'), '/api/instruments/'), - headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))} - ) - cached_instruments = {k: v for k, v in response.json().items()} + try: + response = make_request( + 'GET', + urljoin(self.facility_settings.get_setting('portal_url'), '/api/instruments/'), + headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))} + ) + cached_instruments = {k: v for k, v in response.json().items()} + except ImproperCredentialsException: + cached_instruments = self.facility_settings.default_instrument_config cache.set(f'{self.facility_settings.facility_name}_instruments', cached_instruments, 3600) return cached_instruments @@ -253,11 +277,14 @@ def configuration_type_choices(self): def proposal_choices(self): cached_proposals = cache.get(f'{self.facility_settings.facility_name}_proposals') if not cached_proposals: - response = make_request( - 'GET', - urljoin(self.facility_settings.get_setting('portal_url'), '/api/profile/'), - headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))} - ) + try: + response = make_request( + 'GET', + urljoin(self.facility_settings.get_setting('portal_url'), '/api/profile/'), + headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))} + ) + except ImproperCredentialsException: + return [(0, 'No proposals found')] cached_proposals = [] for p in response.json()['proposals']: if p['current']: @@ -963,6 +990,20 @@ def __init__(self, *args, **kwargs): if isinstance(self, CadenceForm): self.helper.layout.insert(2, self.cadence_layout()) + def button_layout(self): + """ + Override Button layout from BaseObservationForm. + Submit button will be disabled if there are any unconfigured settings found by get_unconfigured_settings(). + """ + target_id = self.initial.get('target_id') + + return ButtonHolder( + Submit('submit', 'Submit', disabled=bool(self.facility_settings.get_unconfigured_settings())), + Submit('validate', 'Validate'), + HTML(f''' + Back''') + ) + def form_name(self): return 'base' diff --git a/tom_observations/facility.py b/tom_observations/facility.py index e7a7254c1..9832430ad 100644 --- a/tom_observations/facility.py +++ b/tom_observations/facility.py @@ -19,7 +19,6 @@ 'tom_observations.facilities.lco.LCOFacility', 'tom_observations.facilities.gemini.GEMFacility', 'tom_observations.facilities.soar.SOARFacility', - 'tom_observations.facilities.lt.LTFacility' ] try: @@ -186,6 +185,12 @@ class BaseObservationFacility(ABC): """ name = 'BaseObservation' + def __init__(self): + self.user = None + + def set_user(self, user): + self.user = user + def all_data_products(self, observation_record): from tom_dataproducts.models import DataProduct products = {'saved': [], 'unsaved': []} diff --git a/tom_observations/management/commands/updatestatus.py b/tom_observations/management/commands/updatestatus.py index 6ef98e8e6..2863cb98f 100644 --- a/tom_observations/management/commands/updatestatus.py +++ b/tom_observations/management/commands/updatestatus.py @@ -1,5 +1,6 @@ from django.core.management.base import BaseCommand from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth.models import User from tom_targets.models import Target from tom_observations import facility @@ -18,19 +19,31 @@ def add_arguments(self, parser): '--target_id', help='Update observation statuses for a single target' ) + parser.add_argument( + '--username', + required=False, + help='The username of a user to use if the facility uses per user-based authentication for its API calls' + ) def handle(self, *args, **options): target = None + user = None if options['target_id']: try: target = Target.objects.get(pk=options['target_id']) except ObjectDoesNotExist: raise Exception('Invalid target id provided') + if options.get('username'): + try: + user = User.objects.get(username=options['username']) + except User.DoesNotExist: + raise Exception('Invalid username provided') failed_records = {} for facility_name in facility.get_service_classes(): - clazz = facility.get_service_class(facility_name) - failed_records[facility_name] = clazz().update_all_observation_statuses(target=target) + instance = facility.get_service_class(facility_name)() + instance.set_user(user) + failed_records[facility_name] = instance.update_all_observation_statuses(target=target) success = True for facility_name, errors in failed_records.items(): if len(errors) > 0: diff --git a/tom_observations/templates/tom_observations/observation_form.html b/tom_observations/templates/tom_observations/observation_form.html index d7cf88156..d2634e488 100644 --- a/tom_observations/templates/tom_observations/observation_form.html +++ b/tom_observations/templates/tom_observations/observation_form.html @@ -7,7 +7,10 @@ {% endblock %} {% block content %} {{ form|as_crispy_errors }} -

Submit an observation to {{ form.facility.value }}

+

Submit an observation to {{ form.facility.value }} for {{target.name}}

+{% if missing_configurations %} +
Some {{ form.facility.value }} Facility settings ({{ missing_configurations }}) are not configured.
+{% endif %} {% if target.type == 'SIDEREAL' %}
diff --git a/tom_observations/templates/tom_observations/observationtemplate_form.html b/tom_observations/templates/tom_observations/observationtemplate_form.html index 2bae4d716..65f5165d4 100644 --- a/tom_observations/templates/tom_observations/observationtemplate_form.html +++ b/tom_observations/templates/tom_observations/observationtemplate_form.html @@ -3,5 +3,8 @@ {% block title %}Create an Observation Template{% endblock %} {% block content %}

Create a new Observation Template for {{ form.facility.value }}

+{% if missing_configurations %} +
Some {{ form.facility.value }} Facility settings ({{ missing_configurations }}) are not configured.
+{% endif %} {% crispy form %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/tom_observations/templates/tom_observations/partials/observation_list.html b/tom_observations/templates/tom_observations/partials/observation_list.html index 8013053d8..8e8e865c2 100644 --- a/tom_observations/templates/tom_observations/partials/observation_list.html +++ b/tom_observations/templates/tom_observations/partials/observation_list.html @@ -1,9 +1,14 @@ - + {% for observation in observations %} + {% if observation.url %} + + {% else %} + + {% endif %} diff --git a/tom_observations/templatetags/observation_extras.py b/tom_observations/templatetags/observation_extras.py index 60a3d5d95..40091dfa2 100644 --- a/tom_observations/templatetags/observation_extras.py +++ b/tom_observations/templatetags/observation_extras.py @@ -79,18 +79,19 @@ def observation_type_tabs(context): } -@register.inclusion_tag('tom_observations/partials/facility_observation_form.html') -def facility_observation_form(target, facility, observation_type): +@register.inclusion_tag('tom_observations/partials/facility_observation_form.html', takes_context=True) +def facility_observation_form(context, target, facility, observation_type): """ Displays a form for submitting an observation for a specific facility and observation type, e.g., imaging. """ - facility_class = get_service_class(facility)() + facility_instance = get_service_class(facility)() + facility_instance.set_user(context['request'].user) initial_fields = { 'target_id': target.id, 'facility': facility, 'observation_type': observation_type } - obs_form = facility_class.get_form(observation_type)(initial=initial_fields) + obs_form = facility_instance.get_form(observation_type)(initial=initial_fields) obs_form.helper.form_action = reverse('tom_observations:create', kwargs={'facility': facility}) return {'obs_form': obs_form} @@ -259,8 +260,8 @@ def observation_distribution(observations): return {'figure': figure} -@register.inclusion_tag('tom_observations/partials/facility_status.html') -def facility_status(): +@register.inclusion_tag('tom_observations/partials/facility_status.html', takes_context=True) +def facility_status(context): """ Collect the facility status from the registered facilities and pass them to the facility_status.html partial template. @@ -271,6 +272,7 @@ def facility_status(): facility_statuses = [] for facility_class in get_service_classes().values(): facility = facility_class() + facility.set_user(context['request'].user) weather_urls = facility.get_facility_weather_urls() status = facility.get_facility_status() @@ -286,11 +288,12 @@ def facility_status(): return {'facilities': facility_statuses} -@register.inclusion_tag('tom_observations/partials/facility_map.html') -def facility_map(): +@register.inclusion_tag('tom_observations/partials/facility_map.html', takes_context=True) +def facility_map(context): facility_locations = [] for facility_class in get_service_classes().values(): facility = facility_class() + facility.set_user(context['request'].user) sites = facility.get_observing_sites() # Flatten each facility site dictionary and add text label for use in facility map diff --git a/tom_observations/tests/tests.py b/tom_observations/tests/tests.py index ff61f8739..d305eedaf 100644 --- a/tom_observations/tests/tests.py +++ b/tom_observations/tests/tests.py @@ -144,6 +144,25 @@ def test_submit_observation_robotic(self): self.assertTrue(ObservationRecord.objects.filter(observation_id='fakeid').exists()) self.assertEqual(ObservationRecord.objects.filter(observation_id='fakeid').first().user, self.user) + @mock.patch('tom_observations.tests.utils.FakeRoboticFacility.set_user') + def test_submit_observation_robotic_gets_user(self, mock_method): + form_data = { + 'target_id': self.target.id, + 'test_input': 'gnomes', + 'facility': 'FakeRoboticFacility', + 'observation_type': 'OBSERVATION' + } + self.client.post( + '{}?target_id={}'.format( + reverse('tom_observations:create', kwargs={'facility': 'FakeRoboticFacility'}), + self.target.id + ), + data=form_data, + follow=True + ) + calls = [mock.call(self.user)] + mock_method.assert_has_calls(calls) + # TODO: this test # def test_submit_observation_cadence(self): # form_data = { diff --git a/tom_observations/views.py b/tom_observations/views.py index 7508d9eb0..fa7481575 100644 --- a/tom_observations/views.py +++ b/tom_observations/views.py @@ -222,10 +222,16 @@ def get_context_data(self, **kwargs): context['target'] = target # allow the Facility class to add data to the context - facility_class = self.get_facility_class() - facility_context = facility_class().get_facility_context_data(target=target) + facility = self.get_facility_class()() + facility.set_user(self.request.user) + facility_context = facility.get_facility_context_data(target=target) context.update(facility_context) + try: + context['missing_configurations'] = ", ".join(facility.facility_settings.get_unconfigured_settings()) + except AttributeError: + context['missing_configurations'] = '' + return context def get_form_class(self): @@ -240,8 +246,10 @@ def get_form_class(self): observation_type = self.request.GET.get('observation_type') elif self.request.method == 'POST': observation_type = self.request.POST.get('observation_type') + facility = self.get_facility_class()() + facility.set_user(self.request.user) form_class = type(f'Composite{observation_type}Form', - (self.get_facility_class()().get_form(observation_type), self.get_cadence_strategy_form()), + (facility.get_form(observation_type), self.get_cadence_strategy_form()), {}) return form_class @@ -309,9 +317,10 @@ def form_valid(self, form): :type form: subclass of GenericObservationForm """ # Submit the observation - facility = self.get_facility_class() + facility = self.get_facility_class()() + facility.set_user(self.request.user) target = self.get_target() - observation_ids = facility().submit_observation(form.observation_payload()) + observation_ids = facility.submit_observation(form.observation_payload()) records = [] for observation_id in observation_ids: @@ -377,6 +386,7 @@ def get(self, request, *args, **kwargs): obsr_id = self.kwargs.get('pk') obsr = ObservationRecord.objects.get(id=obsr_id) facility = get_service_class(obsr.facility)() + facility.set_user(request.user) try: success = facility.cancel_observation(obsr.observation_id) if success: @@ -462,9 +472,9 @@ def form_valid(self, form): ) observation_id = form.cleaned_data['observation_id'] messages.success(self.request, f'Successfully associated observation record {observation_id}') - return redirect(reverse( - 'tom_targets:detail', kwargs={'pk': form.cleaned_data['target_id']}) - ) + base_url = reverse('tom_targets:detail', kwargs={'pk': form.cleaned_data['target_id']}) + query_params = urlencode({'tab': 'observations'}) + return redirect(f'{base_url}?{query_params}') class ObservationRecordDetailView(DetailView): @@ -500,10 +510,11 @@ def get_context_data(self, *args, **kwargs): """ context = super().get_context_data(*args, **kwargs) context['form'] = AddProductToGroupForm() - service_class = get_service_class(self.object.facility) - context['editable'] = isinstance(service_class(), BaseManualObservationFacility) - context['data_products'] = service_class().all_data_products(self.object) - context['can_be_cancelled'] = self.object.status not in service_class().get_terminal_observing_states() + facility = get_service_class(self.object.facility)() + facility.set_user(self.request.user) + context['editable'] = isinstance(facility, BaseManualObservationFacility) + context['data_products'] = facility.all_data_products(self.object) + context['can_be_cancelled'] = self.object.status not in facility.get_terminal_observing_states() newest_image = None for data_product in context['data_products']['saved']: newest_image = data_product if (not newest_image or data_product.modified > newest_image.modified) and \ @@ -600,6 +611,17 @@ class ObservationTemplateCreateView(FormView): def get_facility_name(self): return self.kwargs['facility'] + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + + facility = get_service_class(self.get_facility_name())() + # Check configuration of facility and pass names of missing settings to context as 'missing_configurations'. + try: + context['missing_configurations'] = ", ".join(facility.facility_settings.get_unconfigured_settings()) + except AttributeError: + context['missing_configurations'] = '' + return context + def get_form_class(self): facility_name = self.get_facility_name() @@ -607,7 +629,9 @@ def get_form_class(self): raise ValueError('Must provide a facility name') # TODO: modify this to work with all LCO forms - return get_service_class(facility_name)().get_template_form(None) + facility = get_service_class(facility_name)() + facility.set_user(self.request.user) + return facility.get_template_form(None) def get_form(self, form_class=None): form = super().get_form() @@ -637,7 +661,9 @@ def get_object(self): def get_form_class(self): self.object = self.get_object() - return get_service_class(self.object.facility)().get_template_form(None) + facility = get_service_class(self.object.facility)() + facility.set_user(self.request.user) + return facility.get_template_form(None) def get_form(self, form_class=None): form = super().get_form() diff --git a/tom_setup/templates/tom_setup/settings.tmpl b/tom_setup/templates/tom_setup/settings.tmpl index 400821226..13e31f024 100644 --- a/tom_setup/templates/tom_setup/settings.tmpl +++ b/tom_setup/templates/tom_setup/settings.tmpl @@ -151,7 +151,7 @@ USE_L10N = False USE_TZ = True -DATETIME_FORMAT = 'Y-m-d H:m:s' +DATETIME_FORMAT = 'Y-m-d H:i:s' DATE_FORMAT = 'Y-m-d' @@ -239,7 +239,6 @@ TOM_FACILITY_CLASSES = [ 'tom_observations.facilities.lco.LCOFacility', 'tom_observations.facilities.gemini.GEMFacility', 'tom_observations.facilities.soar.SOARFacility', - 'tom_observations.facilities.lt.LTFacility' ] TOM_ALERT_CLASSES = [ diff --git a/tom_targets/templates/tom_targets/partials/module_buttons.html b/tom_targets/templates/tom_targets/partials/module_buttons.html new file mode 100644 index 000000000..42c72d682 --- /dev/null +++ b/tom_targets/templates/tom_targets/partials/module_buttons.html @@ -0,0 +1,3 @@ +{% for button in button_list %} + {{button.text}} +{% endfor %} diff --git a/tom_targets/templates/tom_targets/partials/target_buttons.html b/tom_targets/templates/tom_targets/partials/target_buttons.html index 321bca67d..50e00ea8f 100644 --- a/tom_targets/templates/tom_targets/partials/target_buttons.html +++ b/tom_targets/templates/tom_targets/partials/target_buttons.html @@ -1,5 +1,8 @@ +{% load targets_extras %} Update {% if sharing %} Share {% endif %} Delete + +{% get_buttons target %} diff --git a/tom_targets/templates/tom_targets/partials/target_unknown_statuses.html b/tom_targets/templates/tom_targets/partials/target_unknown_statuses.html index 9c86b26a7..9c2d5714b 100644 --- a/tom_targets/templates/tom_targets/partials/target_unknown_statuses.html +++ b/tom_targets/templates/tom_targets/partials/target_unknown_statuses.html @@ -1,3 +1,5 @@ -
+{% if num_unknown_statuses %} +
There are {{ num_unknown_statuses }} observations with unknown status. -
\ No newline at end of file +
+{% endif %} diff --git a/tom_targets/templates/tom_targets/target_detail.html b/tom_targets/templates/tom_targets/target_detail.html index 5fdac4eac..23006fece 100644 --- a/tom_targets/templates/tom_targets/target_detail.html +++ b/tom_targets/templates/tom_targets/target_detail.html @@ -7,20 +7,31 @@ {% endblock %} {% block content %}
@@ -39,7 +50,7 @@ {% if object.type == 'SIDEREAL' %} {% aladin object %} {% endif %} - +
diff --git a/tom_targets/templatetags/targets_extras.py b/tom_targets/templatetags/targets_extras.py index 2fe5b6d0a..c40e5a091 100644 --- a/tom_targets/templatetags/targets_extras.py +++ b/tom_targets/templatetags/targets_extras.py @@ -318,3 +318,26 @@ def target_table(targets, all_checked=False): """ return {'targets': targets, 'all_checked': all_checked} + + +@register.inclusion_tag('tom_targets/partials/module_buttons.html') +def get_buttons(target): + """ + Returns a list of buttons from imported modules to be displayed on the target detail page. + In order to add a button to the target detail page, an app must contain an integration points attribute. + The Integration Points attribute must be a dictionary with a key of 'target_detail_button': + 'target_detail_button' = {'namespace': <>, + 'title': <
FacilityCreatedStatusScheduledSaved dataView
FacilityObservation IDCreatedStatusScheduledSaved dataView
{{ observation.facility }}{{ observation.observation_id }}{{ observation.observation_id }}{{ observation.created }} {{ observation.status }} {{ observation.scheduled_start }}