diff --git a/ldvspec/Dockerfile b/ldvspec/Dockerfile index c6db5f535ee6b668fcc701da98eebc8a7d8df8c0..042fdd888b7fd9daec810625b5e94986d7a58a26 100644 --- a/ldvspec/Dockerfile +++ b/ldvspec/Dockerfile @@ -13,6 +13,7 @@ RUN \ apk --purge del .build-deps # collect the static files and make sure that the latest database migrations are done +RUN date +"(%d %b %Y - %H:%M)" >> VERSION RUN python manage.py collectstatic --settings=ldvspec.settings.dev --noinput # run gunicorn diff --git a/ldvspec/VERSION b/ldvspec/VERSION new file mode 100644 index 0000000000000000000000000000000000000000..89589ba54884b40ba703e87e01624929833c9d8e --- /dev/null +++ b/ldvspec/VERSION @@ -0,0 +1 @@ +Version 1.0.0 \ No newline at end of file diff --git a/ldvspec/ldvspec/context_processors.py b/ldvspec/ldvspec/context_processors.py new file mode 100644 index 0000000000000000000000000000000000000000..070d722adf6b695251a4d9e6962e76983ce2dfe1 --- /dev/null +++ b/ldvspec/ldvspec/context_processors.py @@ -0,0 +1,5 @@ +from django.conf import settings + + +def version_string(_): + return {'VERSION_STRING': settings.VERSION_STR} diff --git a/ldvspec/ldvspec/settings/base.py b/ldvspec/ldvspec/settings/base.py index 8d54a412ed14e168cb05b599ea4f06ccdd330d7c..049a53aad3744db5212bf8ae5e884efe4ac01b8a 100644 --- a/ldvspec/ldvspec/settings/base.py +++ b/ldvspec/ldvspec/settings/base.py @@ -3,6 +3,7 @@ Django settings for ldvspec project. """ import os from pathlib import Path +import logging # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -21,7 +22,6 @@ except: ALLOWED_HOSTS = [] - # Application definition INSTALLED_APPS = [ @@ -37,9 +37,14 @@ INSTALLED_APPS = [ 'corsheaders', 'django_filters', 'django_extensions', - 'uws' + 'uws', + 'crispy_forms', + 'crispy_bootstrap5', ] +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" + +CRISPY_TEMPLATE_PACK = "bootstrap5" MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', @@ -65,6 +70,7 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'ldvspec.context_processors.version_string' ], }, }, @@ -72,7 +78,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'ldvspec.wsgi.application' - # Password validation # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators @@ -91,7 +96,6 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] - # Internationalization # https://docs.djangoproject.com/en/4.0/topics/i18n/ @@ -103,7 +107,6 @@ USE_I18N = True USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ @@ -131,8 +134,15 @@ REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 100 } - +LOGIN_REDIRECT_URL = '/ldvspec' # Recommended to use an environment variable to set the broker URL. CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "amqp://guest@localhost:5672") - -UWS_WORKERS = ['lofardata.workers.query.Echo'] \ No newline at end of file +UWS_WORKERS = ['lofardata.workers.query.Echo'] + +VERSION_STR = 'Version 1.0.0 (Unknown)' +version_file_path = BASE_DIR.parent / 'VERSION' +if os.path.exists(version_file_path): + with open(version_file_path, 'r') as f_stream: + VERSION_STR = f_stream.read().strip() +else: + logging.warning('Cannot find version file %s', version_file_path) diff --git a/ldvspec/ldvspec/settings/dev.py b/ldvspec/ldvspec/settings/dev.py index 49cd8b50b60f8e4c93a4a980d7eb5d68def29a1b..e5ae158c35da0876de08daba963086676853e29a 100644 --- a/ldvspec/ldvspec/settings/dev.py +++ b/ldvspec/ldvspec/settings/dev.py @@ -23,3 +23,5 @@ DATABASES = { AUTH_PASSWORD_VALIDATORS = [] +# bypass celery async workers +#CELERY_TASK_ALWAYS_EAGER=True \ No newline at end of file diff --git a/ldvspec/ldvspec/urls.py b/ldvspec/ldvspec/urls.py index 50e17ceaf8ab1d38a69ab4f16f7f466dfb605471..d714ce311875a6319c9b02aeeb4954f30d541d9f 100644 --- a/ldvspec/ldvspec/urls.py +++ b/ldvspec/ldvspec/urls.py @@ -18,6 +18,6 @@ from django.urls import include, path urlpatterns = [ path('ldvspec/', include('lofardata.urls')), - path('ldvspec/admin/', admin.site.urls), + path('ldvspec/admin/', admin.site.urls, name='admin_app'), path('ldvspec/api-auth/', include('rest_framework.urls')), ] diff --git a/ldvspec/lofardata/admin.py b/ldvspec/lofardata/admin.py index 96c7adccc48beb8ea66b0160a75757b740106de9..41676f558be3444e238ef5ba4b76c6d2b0c564c0 100644 --- a/ldvspec/lofardata/admin.py +++ b/ldvspec/lofardata/admin.py @@ -1,8 +1,10 @@ from django.contrib import admin # Register your models here. -from .models import DataProduct, DataProductFilter, ATDBProcessingSite +from .models import DataProduct, DataProductFilter, ATDBProcessingSite, WorkSpecification, DataLocation admin.site.register(DataProduct) admin.site.register(DataProductFilter) +admin.site.register(DataLocation) admin.site.register(ATDBProcessingSite) +admin.site.register(WorkSpecification) \ No newline at end of file diff --git a/ldvspec/lofardata/forms.py b/ldvspec/lofardata/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..f3adf6c2623e79800ae1fdf20037111322407de1 --- /dev/null +++ b/ldvspec/lofardata/forms.py @@ -0,0 +1,20 @@ +from django import forms +from django.forms import ModelForm +from .models import WorkSpecification + + +class WorkSpecificationForm(ModelForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['inputs'].required = False + class Meta: + model = WorkSpecification + fields = ['filters', 'selected_workflow', 'processing_site', 'inputs', 'batch_size'] + labels = { + 'filters': 'Filters', + 'selected_workflow': 'Selected workflow', + 'processing_site': 'Processing Site (ATDB)', + 'inputs': 'Inputs', + 'batch_size': '# of files per ATDB tasks' + } diff --git a/ldvspec/lofardata/migrations/0008_update_models_to_deal_with_gui.py b/ldvspec/lofardata/migrations/0008_update_models_to_deal_with_gui.py new file mode 100644 index 0000000000000000000000000000000000000000..41002ba5826309f7e1b84c8e0653d915da49f5f0 --- /dev/null +++ b/ldvspec/lofardata/migrations/0008_update_models_to_deal_with_gui.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2 on 2022-09-14 10:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('lofardata', '0008_atdbprocessingsite_access_token'), ('lofardata', '0009_dataproductfilter_filter_type'), ('lofardata', '0009_auto_20220906_0856'), ('lofardata', '0010_merge_20220908_0923'), ('lofardata', '0011_workspecification_predecessor_specification')] + + dependencies = [ + ('lofardata', '0007_workspecification_selected_workflow'), + ] + + operations = [ + migrations.AddField( + model_name='atdbprocessingsite', + name='access_token', + field=models.CharField(max_length=1000, null=True), + ), + migrations.AddField( + model_name='dataproductfilter', + name='filter_type', + field=models.CharField(choices=[('Dropdown', 'Dropdown'), ('Free', 'Freeform')], default='Free', max_length=20), + ), + migrations.AddField( + model_name='workspecification', + name='batch_size', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='workspecification', + name='is_auto_submit', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='workspecification', + name='predecessor_task', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='workspecification', + name='purge_policy', + field=models.CharField(choices=[('yes', 'yes'), ('no', 'no'), ('do', 'do')], default='no', max_length=16), + ), + migrations.AddField( + model_name='workspecification', + name='submission_status', + field=models.CharField(choices=[('N', 'not submitted'), ('S', 'submitted'), ('D', 'defining'), ('E', 'error')], default='N', max_length=1), + ), + migrations.AddField( + model_name='workspecification', + name='predecessor_specification', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='lofardata.workspecification'), + ), + ] diff --git a/ldvspec/lofardata/migrations/0009_dataproductfilter_filter_type.py b/ldvspec/lofardata/migrations/0009_dataproductfilter_filter_type.py new file mode 100644 index 0000000000000000000000000000000000000000..b862eb5f73e8b74311d838737da23ae9d6f88914 --- /dev/null +++ b/ldvspec/lofardata/migrations/0009_dataproductfilter_filter_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2022-09-07 07:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lofardata', '0008_atdbprocessingsite_access_token'), + ] + + operations = [ + migrations.AddField( + model_name='dataproductfilter', + name='filter_type', + field=models.CharField(choices=[('Dropdown', 'Dropdown'), ('Free', 'Freeform')], default='Free', max_length=20), + ), + ] diff --git a/ldvspec/lofardata/migrations/0010_merge_20220908_0923.py b/ldvspec/lofardata/migrations/0010_merge_20220908_0923.py new file mode 100644 index 0000000000000000000000000000000000000000..d51b7d26303477c45bf2976664094ec30b799277 --- /dev/null +++ b/ldvspec/lofardata/migrations/0010_merge_20220908_0923.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2 on 2022-09-08 09:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('lofardata', '0009_auto_20220906_0856'), + ('lofardata', '0009_dataproductfilter_filter_type'), + ] + + operations = [ + ] diff --git a/ldvspec/lofardata/migrations/0011_workspecification_predecessor_specification.py b/ldvspec/lofardata/migrations/0011_workspecification_predecessor_specification.py new file mode 100644 index 0000000000000000000000000000000000000000..0c141f514be8db414a01e762b307fb1bb83f82a6 --- /dev/null +++ b/ldvspec/lofardata/migrations/0011_workspecification_predecessor_specification.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2 on 2022-09-08 14:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('lofardata', '0010_merge_20220908_0923'), + ] + + operations = [ + migrations.AddField( + model_name='workspecification', + name='predecessor_specification', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='lofardata.workspecification'), + ), + ] diff --git a/ldvspec/lofardata/models.py b/ldvspec/lofardata/models.py index 15f513231024e81455c74f684f3d26c4ea6312be..428da1d34b670796809501470abbc2559cfcc0e3 100644 --- a/ldvspec/lofardata/models.py +++ b/ldvspec/lofardata/models.py @@ -49,15 +49,15 @@ class DataProduct(models.Model): @staticmethod def insert_dataproduct( - obs_id, - oid_source, - dataproduct_source, - dataproduct_type, - project, - activity, - surl, - filesize, - additional_meta, + obs_id, + oid_source, + dataproduct_source, + dataproduct_type, + project, + activity, + surl, + filesize, + additional_meta, ): scheme, netloc, *_ = urlsplit(surl) @@ -79,17 +79,30 @@ class DataProduct(models.Model): return dp +class DataFilterType(models.TextChoices): + DROPDOWN = 'Dropdown', _('Dropdown') + FREEFORM = 'Free', _('Freeform') + + class DataProductFilter(models.Model): field = models.CharField(max_length=100) name = models.CharField(max_length=20) lookup_type = models.CharField(max_length=100) + filter_type = models.CharField(max_length=20, choices=DataFilterType.choices, default=DataFilterType.FREEFORM) + + def __str__(self): + return f'({self.pk}) "{self.name}" [{self.field} ->{self.lookup_type}]' + class ATDBProcessingSite(models.Model): name = models.CharField(primary_key=True, max_length=100) url = models.URLField() access_token = models.CharField(max_length=1000, null=True) + def __str__(self): + return str(self.name) + ' - ' + str(self.url) + class SUBMISSION_STATUS(models.TextChoices): """Status of Work Specifcation to ATDB""" @@ -127,6 +140,8 @@ class WorkSpecification(models.Model): # Task ID's that were created in ATDB related_tasks = ArrayField(models.IntegerField(), null=True) predecessor_task = models.IntegerField(null=True) + predecessor_specification = models.ForeignKey('self', null=True, on_delete=models.DO_NOTHING, + related_name='successor') # The query for gathering files has been executed is_ready = models.BooleanField(default=False) @@ -143,6 +158,9 @@ class WorkSpecification(models.Model): max_length=16, choices=PURGE_POLICY.choices, default=PURGE_POLICY.NO ) + def __str__(self): + return str(self.id) + ' - ' + str(self.filters) + " (" + str(self.created_on) + ")" + # How many files per task. 0 is single task with all files batch_size = models.IntegerField(default=0, null=False, blank=False) submission_status = models.CharField( @@ -152,7 +170,7 @@ class WorkSpecification(models.Model): ) def save( - self, force_insert=False, force_update=False, using=None, update_fields=None + self, force_insert=False, force_update=False, using=None, update_fields=None ): super(WorkSpecification, self).save( force_insert=force_insert, diff --git a/ldvspec/lofardata/serializers.py b/ldvspec/lofardata/serializers.py index e04ceba8c8e40c338f76461fa6e9f7c0e80da0f0..e45fa2ed30c62282c66ba37e1b4b7950d576e9c8 100644 --- a/ldvspec/lofardata/serializers.py +++ b/ldvspec/lofardata/serializers.py @@ -1,7 +1,13 @@ from abc import ABC from rest_framework import serializers -from .models import DataProduct, DataLocation, WorkSpecification +from .models import DataProduct, DataLocation, WorkSpecification, ATDBProcessingSite + + +class ATDBProcessingSiteSerializer(serializers.ModelSerializer): + class Meta: + model = ATDBProcessingSite + fields = '__all__' class DataLocationSerializer(serializers.ModelSerializer): diff --git a/ldvspec/lofardata/static/lofardata/style.css b/ldvspec/lofardata/static/lofardata/style.css index 86e9ac908dc232210c22807bcabfd36f7bd2efe7..5def068edefb55285a94b216c22b3bff8933791c 100644 --- a/ldvspec/lofardata/static/lofardata/style.css +++ b/ldvspec/lofardata/static/lofardata/style.css @@ -1,111 +1,116 @@ TD { - font-family: Raleway; - font-size: 12pt; + font-family: Raleway, serif; + font-size: 12pt; } -.defining,.staging,.fetching,.processing,.storing,.scrub,.scrubbing,.archiving { - font-style: italic; - color: green; +.defining, .staging, .fetching, .processing, .storing, .scrub, .scrubbing, .archiving { + font-style: italic; + color: green; } -.defined,.staged,.fetched,.processed,.stored,.validated,.scrubbed,.archived,.finished { - background-color: lemonchiffon; - color: blue; +.defined, .staged, .fetched, .processed, .stored, .validated, .scrubbed, .archived, .finished { + background-color: lemonchiffon; + color: blue; } .active { - background-color: lemonchiffon; + background-color: lemonchiffon; } .max { - font-weight: bold; - background-color: lightgreen; + font-weight: bold; + background-color: lightgreen; } .aggregate { - font-weight: bold; - background-color: lightgreen; + font-weight: bold; + background-color: lightgreen; } .aggregate_failed { - font-weight: bold; - color: red; - background-color: lightgreen; + font-weight: bold; + color: red; + background-color: lightgreen; } -.error,.failed,.staging_failed,.staged_failed,.processed_failed,.scrubbed_failed,.stored_failed,.archived_failed,.on_hold { - color: red; - font-weight: bold; +.error, .failed, .staging_failed, .staged_failed, .processed_failed, .scrubbed_failed, .stored_failed, .archived_failed, .on_hold { + color: red; + font-weight: bold; } -.processed,.ok,.running { - color: green; - font-weight: bold; +.processed, .ok, .running { + color: green; + font-weight: bold; } -.scrubbed, { - color: darkgray; - font-style: italic; +.scrubbed { + color: darkgray; + font-style: italic; } -.processing,.processing_copying { - font-weight: bold; - background-color: lightyellow; +.processing, .processing_copying { + font-weight: bold; + background-color: lightyellow; } p.title { - font-family: Raleway; - font-size: 18pt; + font-family: Raleway, serif; + font-size: 18pt; } .footer { - font-family: Arial; - font-size: 10pt; - font-style: italic; + font-family: Arial, serif; + font-size: 10pt; + font-style: italic; } .ml-auto .dropdown-menu { - left: auto !important; - right: 0px; + left: auto !important; + right: 0; } .bigger { - font-size: 13pt; + font-size: 13pt; } .info { - background-color: #E0F8F8; + background-color: #E0F8F8; } .form-signin { - width: 100%; - max-width: 330px; - padding: 15px; - margin: 0 auto; + width: 100%; + max-width: 330px; + padding: 15px; + margin: 0 auto; } + .form-signin .checkbox { - font-weight: 400; + font-weight: 400; } + .form-signin .form-control { - position: relative; - box-sizing: border-box; - height: auto; - padding: 10px; - font-size: 16px; + position: relative; + box-sizing: border-box; + height: auto; + padding: 10px; + font-size: 16px; } + .form-signin .form-control:focus { - z-index: 2; + z-index: 2; } + .form-signin input[type="email"] { - margin-bottom: -1px; - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; } + .form-signin input[type="password"] { - margin-bottom: 10px; - border-top-left-radius: 0; - border-top-right-radius: 0; + margin-bottom: 10px; + border-top-left-radius: 0; + border-top-right-radius: 0; } .modal-body { @@ -122,4 +127,56 @@ p.title { .form-flex { display: flex; +} + +footer { + text-align: center; + padding: .4rem; + vertical-align: center; + display: block; + position: absolute; + bottom: 0; + left: 0; + background-color: #ded; + width: 100%; + + -webkit-box-shadow: 0 -2px 3px rgba(50, 50, 50, 0.75); + -moz-box-shadow: 0 -2px 3px rgba(50, 50, 50, 0.75); + box-shadow: 0 -2px 3px rgba(50, 50, 50, 0.75); +} + +footer p { + margin: auto; + text-shadow: 0 0 25px lightgrey; +} + +pre { + border-radius: 1rem; + padding: 1rem; + box-shadow: 2px 2px forestgreen; + color: black; + font-size: larger; + background-color: palegreen; +} + +#div_id_filters label { + display: none; +} + +#div_id_filters textarea { + width: 20rem; + pointer-events: none; + cursor: not-allowed; + color: gray; + background-color: lightgrey; + margin: 1rem auto; +} + +.info-text { + font-size: 2rem; + margin-bottom: 2rem; +} + +.bottom-bar { + justify-content: center; } \ No newline at end of file diff --git a/ldvspec/lofardata/tasks.py b/ldvspec/lofardata/tasks.py index 148097203af20b8f310dc1e6699474ed10cf3dec..20b160d415f63938d2f95bdd27794f3fc8af5dcc 100644 --- a/ldvspec/lofardata/tasks.py +++ b/ldvspec/lofardata/tasks.py @@ -47,7 +47,10 @@ def define_work_specification(workspecification_id): for dataproduct in dataproducts ] } - specification.inputs = inputs + if specification.inputs is None: + specification.inputs = inputs + else: + specification.inputs.update(inputs) specification.is_ready = True specification.save() diff --git a/ldvspec/lofardata/templates/base.html b/ldvspec/lofardata/templates/base.html deleted file mode 100644 index e5ab1386442bf38a9a3f6bfd4fece547a9658fc5..0000000000000000000000000000000000000000 --- a/ldvspec/lofardata/templates/base.html +++ /dev/null @@ -1,56 +0,0 @@ - -<!DOCTYPE html> -{% load static %} - -<html lang="en"> -<head> - - <!-- Required meta tags --> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> - - <title>{% block myBlockTitle %}LDV Specification{% endblock %}</title> - - <!-- loads the path to static files --> - <link href='https://fonts.googleapis.com/css?family=Raleway' rel='stylesheet' type='text/css'> - <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script> - <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"> - - <link rel="stylesheet" type="text/css" href="{% static 'lofardata/style.css' %}"/> - <link rel="icon" href="{% static 'favicon.ico' %}"> - - {% block extra_js %}{% endblock %} - - -</head> -<body> - <nav class="navbar navbar-expand-lg navbar-light bg-light"> - <div class="container-fluid"> - <ul class="nav navbar-nav"> - <!-- Header --> - <li><a class="navbar-brand" href="{% url 'index' %}"> - <img src="{% static 'lofardata/ldvspec_logo.png' %}" height="30" alt=""> - Specification Service</a> - </li> - - <li><a class="nav-link" href="{% url 'index' %}">Home</a></li> - <li><a class="nav-link" href="{% url 'dataproduct' %}">LOFAR Data</a></li> - - {% if user.is_authenticated %} - <a class="nav-link" href="{% url 'logout' %}" target="_blank">Logout {{ user.get_username }}</a> - {% endif %} - {% if not user.is_authenticated %} - <a class="nav-link" href="{% url 'login' %}" target="_blank">Login</a> - {% endif %} - </div> - - </nav> - - <!-- to add blocks of code --> - {% block myBlock %} - {% endblock %} - -</body> - - -</html> diff --git a/ldvspec/lofardata/templates/lofardata/api.html b/ldvspec/lofardata/templates/lofardata/api.html new file mode 100644 index 0000000000000000000000000000000000000000..1531ab68e54d5e892985a442f9101be5c1495fa9 --- /dev/null +++ b/ldvspec/lofardata/templates/lofardata/api.html @@ -0,0 +1,34 @@ +{% extends 'lofardata/base.html' %} +{% load static %} +{% load crispy_forms_tags %} + +{% block myBlock %} + +<div class="container-fluid details-container"> + + <table class="table table-striped table-bordered table-sm"> + + <tbody> + + <tr><td>atdb_host</td> + <td><ul> + {% for atdb_host in atdb_hosts %} + <li>{{ atdb_host.name }} ( <a href="{{ atdb_host.url }}">{{ atdb_host.url }}</a> )</li> + {% endfor %} + </ul> + </td> + </tr> + <tr><td>api data</td><td><a href="{% url 'dataproduct' %}">{% url 'dataproduct' %}</a></td></tr> + <tr><td>api data-location</td><td><a href="{% url 'datalocation' %}">{% url 'datalocation' %}</a></td></tr> + <tr><td>api work-specification</td><td><a href="{% url 'workspecification-list' %}">{% url 'workspecification-list' %}</a></td></tr> + + <tr><td>api-schema</td><td><a href="{% url 'openapi-schema' %}">{% url 'openapi-schema' %}</a></td></tr> + </tbody> + + </table> +</div> + + + +{% endblock %} + diff --git a/ldvspec/lofardata/templates/lofardata/base.html b/ldvspec/lofardata/templates/lofardata/base.html new file mode 100644 index 0000000000000000000000000000000000000000..66f3bd0ecaffbece17136d6735b6e7e7035ce4c0 --- /dev/null +++ b/ldvspec/lofardata/templates/lofardata/base.html @@ -0,0 +1,69 @@ +<!DOCTYPE html> +{% load static %} + +<html lang="en"> +<head> + + <!-- Required meta tags --> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + + <title>{% block myBlockTitle %}LDV Specification{% endblock %}</title> + + <!-- loads the path to static files --> + <link href='https://fonts.googleapis.com/css?family=Raleway' rel='stylesheet' type='text/css'> + + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" + integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous"> + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" + integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" + crossorigin="anonymous"></script> + <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.4.2/css/all.css" + integrity="sha384-/rXc/GQVaYpyDdyxK+ecHPVYJSN9bmVFBvjA/9eOB+pb3F2w2N6fc5qB9Ew5yIns" crossorigin="anonymous"> + <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> + + <link rel="stylesheet" type="text/css" href="{% static 'lofardata/style.css' %}"/> + <link rel="icon" href="{% static 'favicon.ico' %}"> + + {% block extra_js %}{% endblock %} + + +</head> +<body> +<nav class="navbar navbar-expand-lg navbar-light bg-light"> + <div class="container-fluid"> + <ul class="nav navbar-nav"> + <!-- Header --> + <li><a class="navbar-brand" href="{% url 'index' %}"> + <img src="{% static 'lofardata/ldvspec_logo.png' %}" height="30" alt=""> + Specification Service</a> + </li> + + <li><a class="nav-link" href="{% url 'index' %}">Home</a></li> + {% if user.is_authenticated %} + <li><a class="nav-link" href="{% url 'specification' %}">Specification</a></li> + {% endif %} + <li><a class="nav-link" href="{% url 'api' %}">API</a></li> + <li><a class="nav-link" href="{% url 'admin:index' %}" target="_blank">Admin Page</a></li> + + {% if user.is_authenticated %} + <a class="nav-link" href="{% url 'logout' %}" target="_blank">Logout ({{ user.get_username }})</a> + {% endif %} + {% if not user.is_authenticated %} + <a class="nav-link" href="{% url 'login' %}" target="_blank">Login</a> + {% endif %} + </div> + +</nav> + +<!-- to add blocks of code --> +{% block myBlock %} +{% endblock %} + +<footer> + <p> {{ VERSION_STRING }} </p> +</footer> +</body> + + +</html> diff --git a/ldvspec/lofardata/templates/lofardata/index.html b/ldvspec/lofardata/templates/lofardata/index.html index dd57bac1119189e47e3f8799e491b65b2154883e..18bb35f320c03058fba6f6fcf0f1cdb57434a907 100644 --- a/ldvspec/lofardata/templates/lofardata/index.html +++ b/ldvspec/lofardata/templates/lofardata/index.html @@ -1,35 +1,19 @@ -{% extends 'lofardata/../base.html' %} +{% extends 'lofardata/base.html' %} {% load static %} +{% load crispy_forms_tags %} {% block myBlock %} -<div class="container-fluid details-container"> - - <table class="table table-striped table-bordered table-sm"> - - <tbody> - - <tr><td>atdb_host</td> - <td><ul> - {% for atdb_host in atdb_hosts %} - <li>{{ atdb_host.name }} ( <a href="{{ atdb_host.url }}">{{ atdb_host.url }}</a> )</li> - {% endfor %} - </ul> - </td> - </tr> - <tr><td>api data</td><td><a href="{% url 'dataproduct' %}">{% url 'dataproduct' %}</a></td></tr> - <tr><td>api data-location</td><td><a href="{% url 'datalocation' %}">{% url 'datalocation' %}</a></td></tr> - <tr><td>api work-specification</td><td><a href="{% url 'workspecification' %}">{% url 'workspecification' %}</a></td></tr> - - <tr><td>api-schema</td><td><a href="{% url 'openapi-schema' %}">{% url 'openapi-schema' %}</a></td></tr> - </tbody> - - </table> - <p class="footer"> Version 1.0.0 (15 aug 2022 - 8:00) - -</div> - - + <div class="container-fluid"> + <div class="row justify-content-md-center"> + <div class="col-md-auto mt-5">Welcome to the LDV specification service</div> + </div> + {% if user.is_authenticated %} + <div class="row justify-content-md-center mt-5 mb-5"> + <a class="col-md-2 btn btn-success" href="{% url 'specification' %}">Start specifying your task</a> + </div> + {% endif %} + </div> {% endblock %} diff --git a/ldvspec/lofardata/templates/lofardata/specification.html b/ldvspec/lofardata/templates/lofardata/specification.html new file mode 100644 index 0000000000000000000000000000000000000000..f9eced82e8bb8ebbea256c333b4d45b87b5b704c --- /dev/null +++ b/ldvspec/lofardata/templates/lofardata/specification.html @@ -0,0 +1,294 @@ +{% extends 'lofardata/base.html' %} +{% load static %} +{% load crispy_forms_tags %} + +{% block myBlock %} + <div class="container-fluid details-container"> + <div class="row"> + <div class="col-12"> + <div class="card"> + <div class="card-body"> + <div class="row"> + <div class="col-3"> + <div class="btn-group"> + <button type="button" class="btn btn-primary btn-sm dropdown-toggle" + data-bs-toggle="dropdown" aria-expanded="false"> + Select Specification + </button> + <ul class="dropdown-menu"> + {{ specifications_list | safe }} + </ul> + </div> + </div> + <div class="col-3"> + <a href="{% url 'specification' %}"> + <div> + <button class="btn btn-primary btn-sm"><i class="fas fa-folder-plus"></i> New + Specification + </button> + </div> + </a> + </div> + </div> + <hr> + <div class="row mb-5"> + <div class="col-1"><h4>ID {{ specification.id }} </h4></div> + <div class="btn-group col-3"> + <button type="button" class="btn btn-info btn-sm dropdown-toggle" + data-bs-toggle="dropdown" aria-expanded="false"> + ATDB tasks + </button> + <ul class="dropdown-menu"> + {% for related_task in specification.related_tasks %} + <li> + <a class="dropdown-item" + href="{{ specification.processing_site.url }}tasks/{{ related_task }}">{{ related_task }}</a> + </li> + {% endfor %} + </ul> + </div> + {% if specification.predecessor_specification %} + <h4 class="col-4 predecessor_link"> + Predecessor ID: + + <a href="{% url 'specification-detail' specification.predecessor_specification.pk %}"> + {{ specification.predecessor_specification.pk }}</a> + + </h4> + <div class="btn-group col-3"> + <button type="button" class="btn btn-info btn-sm dropdown-toggle" + data-bs-toggle="dropdown" aria-expanded="false"> + Predecessor ATDB tasks + </button> + <ul class="dropdown-menu"> + {% for related_task in specification.predecessor_specification.related_tasks %} + <li> + <a class="dropdown-item" + href="{{ specification.predecessor_specification.processing_site.url }}tasks/{{ related_task }}">{{ related_task }}</a> + </li> + {% endfor %} + </ul> + </div> + {% endif %} + + {% if specification.successor.count %} + + <div class="btn-group col-3 "> + <button type="button" class="btn btn-info btn-sm dropdown-toggle" + data-bs-toggle="dropdown" aria-expanded="false"> + Successors + </button> + <ul class="dropdown-menu"> + {% for successor in specification.successor.all %} + <li> + <a class="dropdown-item" + href="{% url 'specification-detail' successor.pk %}">Specification {{ successor.pk }}</a> + </li> + {% endfor %} + </ul> + </div> + {% endif %} + </div> + {% if specification.pk %} + <form class="post-form" action="{% url 'specification-detail' specification.pk %}" + method="POST"> + {% else %} + <form class="post-form" action="{% url 'specification' %}" method="POST"> + {% endif %} + + + {% csrf_token %} + <div> + <div class="row"> + + <div class="col-4"> + <row> + {{ form.processing_site|as_crispy_field }} + </row> + + <row> + {{ form.batch_size|as_crispy_field }} + </row> + </div> + + <div class="col-4"> + <label class="form-label" for="id_selected_workflow">Specified workflow + (ATDB)*</label> + <select id="id_selected_workflow" class="select form-select" + name="selected_workflow"> + <option value="---">Empty</option> + </select> + <div class="row">{{ form.filters|as_crispy_field }}</div> + </div> + + <div class="col-4"> + <div class="row"><h3>Filters</h3></div> + {% for filter in filters %} + <div class="row"> + <label class="form-label" + for="id_{{ filter.field }}">{{ filter.name }}*</label> + + {% if filter.filter_type == 'Free' %} + <input class="form-control custom-filter" type="text" + id="id_{{ filter.field }}" name="{{ filter.field }}" + data-filter="{{ filter.lookup_type }}" + value="{{ filter.default }}"> + {% elif filter.filter_type == 'Dropdown' %} + <select class="form-select select custom-filter" + id="id_{{ filter.field }}" name="{{ filter.field }}" + data-filter="{{ filter.lookup_type }}"> + {% for option in filter.choices %} + {% if filter.default == option.0 %} + <option selected + value="{{ option.0 }}">{{ option.0 }}</option> + {% else %} + <option value="{{ option.0 }}">{{ option.0 }}</option> + {% endif %} + {% endfor %} + </select> + {% else %} + <div class="warning">Not supported field</div> + {% endif %} + + </div> + {% endfor %} + + </div> + </div> + + {% if specification.filters is None %} + <div class="row"><span + class="info-text text-center">Please specify one of the filters.</span></div> + {% elif specification.is_ready == False %} + <div class="row"><h4>Loading...</h4></div> + {% else %} + <label for="inputs_result"><h4>Inputs:</h4></label> + {{ specification.inputs|json_script:"inputs" }} + {{ form.inputs|as_crispy_field }} + {% endif %} + <div class="row bottom-bar"> + <div class="col-2"> + <button class="btn btn-success btn-sm" type="submit" name="save" value="save"><i + class="fas fa-check"></i> Save and preview + </button> + </div> + <div class="col-2"> + <button class="btn btn-success btn-sm" type="submit" name="save" + value="save_and_add_successor"><i class="fas fa-check"></i> Save and add + successor + </button> + </div> + <div class="col-3"><h5>Submission + status: {{ specification.get_submission_status_display }}</h5></div> + <div class="col-2"> + <button class="btn btn-success btn-sm" type="submit" name="save" + value="save_and_send_to_atdb"><i class="fas fa-check"></i> Save and send to + ATDB + </button> + </div> + + </div> + </form> + </div> + </div> + + </div> + </div> + <script> + const state = { + selected_workflow: "{{ specification.selected_workflow }}", + specification_id: "{{ specification.pk }}", + is_ready: "{{ specification.is_ready }}", + filters: "{{ specification.filters | safe }}" + }; + + function fetchAvailableWorkflows(ATDBurl) { + fetch(ATDBurl + 'workflows').then((response) => response.json()).then((data) => { + $.each(data.results, function (key, value) { + $('#id_selected_workflow').append($('<option>', {value: value.workflow_uri}).text(value.workflow_uri)); + }); + }).then(() => { + if (state.selected_workflow !== 'None') { + $('#id_selected_workflow').val(state.selected_workflow) + } + }) + } + + function delay(time) { + return new Promise(resolve => setTimeout(resolve, time)); + } + + function updateWorkflows(selectedProcessingSite) { + $('#id_selected_workflow').empty() + if (selectedProcessingSite === '') return + fetch('{% url 'processingsite-detail' 'replace' %}'.replace('replace', selectedProcessingSite)).then( + (response) => response.json()).then( + (data) => fetchAvailableWorkflows(data.url) + ) + } + + function selectedProcessingSite(event) { + let selectedProcessingSite = event.currentTarget.value + console.log('Processing site selected', event.currentTarget.value) + updateWorkflows(selectedProcessingSite); + } + + function set_value_of_filters(item) { + const value = item.value; + const name = item.name; + const query_type = item.getAttribute('data-filter') + const join_sign = query_type === 'exact' ? '' : '_' + query_type + const filters_textbox = $('#id_filters') + const current_filters = filters_textbox.val() !== "null" ? JSON.parse(filters_textbox.val()) : {}; + if (value === '' && current_filters[name + join_sign] !== undefined) { + delete current_filters[name + join_sign] + } else { + current_filters[name + join_sign] = value + } + + filters_textbox.val(JSON.stringify(current_filters, undefined, 2)) + } + + function changeFilter(event) { + set_value_of_filters(event.currentTarget); + } + + function attachSignals() { + $("#id_processing_site").on('change', selectedProcessingSite); + + $(".custom-filter").on('input', changeFilter); + $("select.custom-filter").on('change', changeFilter); + + console.log('current filters', state.filters) + if (state.filters === "None") { + console.log('Still defining task', state); + } else if (state.is_ready === "True") { + console.log('Results are ready', state); + } else { + console.log('It should reload', state); + delay(3000).then(() => location.reload(true)); + + } + } + + function formatJSONField(field_name) { + try { + const data = JSON.parse($(field_name).val()); + $(field_name).text(JSON.stringify(data, undefined, 2)); + } catch (error) { + + } + } + + function onReady() { + attachSignals(); + updateWorkflows($("#id_processing_site")[0].value); + formatJSONField('#id_inputs') + formatJSONField('#id_filters') + } + + $(document).ready(onReady); + </script> + + +{% endblock %} \ No newline at end of file diff --git a/ldvspec/lofardata/templates/registration/login.html b/ldvspec/lofardata/templates/registration/login.html index 6ed19e3c86dfabdbdda5e6edecf52f1ad4da5c55..ad2d056527ccb04598ef34f2d303da4ccb1161f7 100644 --- a/ldvspec/lofardata/templates/registration/login.html +++ b/ldvspec/lofardata/templates/registration/login.html @@ -1,4 +1,4 @@ -{% extends 'lofardata/../base.html' %} +{% extends 'lofardata/base.html' %} {% load static %} diff --git a/ldvspec/lofardata/urls.py b/ldvspec/lofardata/urls.py index 2d4d4b2dac7b429ccdb35215d54dfdd9a1dd17df..c3d0b652f791390acb058086668d62ad5a90e8c9 100644 --- a/ldvspec/lofardata/urls.py +++ b/ldvspec/lofardata/urls.py @@ -1,17 +1,13 @@ -from django.contrib.auth import views as auth_views from django.urls import include, path -from rest_framework.routers import DefaultRouter +from django.contrib.auth import views as auth_views from rest_framework.schemas import get_schema_view - +from rest_framework.routers import DefaultRouter from . import views router = DefaultRouter() -router.register(r'api/v1/workspecification', views.WorkSpecificationViewset, basename="workspecification") - +router.register(r'workspecification', views.WorkSpecificationViewset, basename="workspecification") +router.register(r'processing_site', views.ATDBProcessingSiteView, basename='processingsite') urlpatterns = [ - # Workaround for injecting the urls from the ModelViewSet, which requires a "Router" - *router.urls, - # Perhaps both accounts and login could be moved to the ldv-spec main urls file? # authentication path('accounts/', include('django.contrib.auth.urls')), @@ -31,6 +27,13 @@ urlpatterns = [ description="API description", version="0.0.1" ), name='openapi-schema'), + path('api/v1/', include(router.urls)), # GUI path('', views.index, name='index'), + path('api/', views.api, name='api'), + path('specification/', views.specification_view, name='specification'), + path('specification/<int:pk>/', views.specification_view, name='specification-detail'), + # Workaround for injecting the urls from the ModelViewSet, which requires a "Router" + + ] diff --git a/ldvspec/lofardata/views.py b/ldvspec/lofardata/views.py index ad50d95a09e351722a2849f64e0b2da67e7cd281..2525a1bfbf09107cb7db39a34cc5ebbcc88b1412 100644 --- a/ldvspec/lofardata/views.py +++ b/ldvspec/lofardata/views.py @@ -1,25 +1,19 @@ -from django.conf import settings from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import redirect from django.shortcuts import render +from django.urls import reverse from django_filters import rest_framework as filters from rest_framework import generics, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.schemas.openapi import AutoSchema -from .models import ( - ATDBProcessingSite, - DataLocation, - DataProduct, - DataProductFilter, - WorkSpecification, -) -from .serializers import ( - DataLocationSerializer, - DataProductFlatSerializer, - DataProductSerializer, - WorkSpecificationSerializer, -) +from .forms import WorkSpecificationForm +from .models import DataProduct, DataProductFilter, DataLocation, WorkSpecification, ATDBProcessingSite, DataFilterType +from .serializers import DataProductSerializer, \ + DataProductFlatSerializer, DataLocationSerializer, \ + WorkSpecificationSerializer, ATDBProcessingSiteSerializer from .tasks import insert_task_into_atdb @@ -33,7 +27,7 @@ class DynamicFilterSet(filters.FilterSet): def _load_filters(self): if self.Meta.filter_class is None: - raise Exception("Define filter_class meta attribute") + raise Exception('Define filter_class meta attribute') for item in self.Meta.filter_class.objects.all(): field_obj = self.Meta.model._meta.get_field(item.field) filter_class, *_ = self.filter_for_lookup(field_obj, item.lookup_type) @@ -58,6 +52,82 @@ def index(request): return render(request, "lofardata/index.html", {"atdb_hosts": atdb_hosts}) +def api(request): + atdb_hosts = ATDBProcessingSite.objects.values('name', 'url') + return render(request, "lofardata/api.html", {'atdb_hosts': atdb_hosts}) + + +def construct_specifications_list(): + results = '' + for specification in WorkSpecification.objects.all(): + url = reverse('specification-detail', args=(str(specification.pk),)) + line = f'<li><a class="dropdown-item" href="{url}">{str(specification)}</a></li>' + results += line + return results + + +def handle_specification_view_post(request, specification): + form = WorkSpecificationForm(request.POST, instance=specification) + if form.is_valid(): + object_to_save = request.POST['save'] + + if object_to_save == 'save': + specification.async_task_result = None + specification.is_ready = False + specification.save() + return redirect('specification-detail', specification.pk) + elif object_to_save == 'save_and_send_to_atdb': + specification.save() + res = insert_task_into_atdb.delay(specification.pk) + return redirect('specification-detail', specification.pk) + elif object_to_save == 'save_and_add_successor': + specification.save() + + successor = WorkSpecification() + successor.predecessor_specification = specification + successor.processing_site = specification.processing_site + successor.save() + return redirect('specification-detail', successor.pk) + + +def preprocess_filters_specification_view(specification): + dataproduct_filters = DataProductFilter.objects.all() + for dataproduct_filter in dataproduct_filters: + if specification.filters and dataproduct_filter.field in specification.filters: + dataproduct_filter.default = specification.filters[dataproduct_filter.field] + else: + dataproduct_filter.default = '' + + if dataproduct_filter.filter_type == DataFilterType.DROPDOWN: + dataproduct_filter.choices = DataProduct.objects.distinct(dataproduct_filter.field).values_list( + dataproduct_filter.field) + return dataproduct_filters + + +def specification_view(request, pk=None): + try: + # retrieve existing specification + specification = WorkSpecification.objects.get(pk=pk) + except ObjectDoesNotExist: + # start new specification + specification = WorkSpecification() + + specifications_list = construct_specifications_list() + # a POST means that the form is filled in and should be stored in the + form = WorkSpecificationForm(instance=specification) + if request.method == "POST": + return handle_specification_view_post(request, specification) + + dataproduct_filters = preprocess_filters_specification_view(specification) + + return render(request, "lofardata/specification.html", { + 'form': form, + 'specification': specification, + 'specifications_list': specifications_list, + 'filters': dataproduct_filters + }) + + # ---------- REST API views ---------- class DataProductView(generics.ListCreateAPIView): model = DataProduct @@ -70,6 +140,13 @@ class DataProductView(generics.ListCreateAPIView): filter_class = DataProductFilterSet +class ATDBProcessingSiteView(viewsets.ReadOnlyModelViewSet): + model = ATDBProcessingSite + serializer_class = ATDBProcessingSiteSerializer + + queryset = ATDBProcessingSite.objects.all().order_by('pk') + + class DataProductDetailsView(generics.RetrieveUpdateDestroyAPIView): model = DataProduct serializer_class = DataProductSerializer @@ -116,11 +193,7 @@ class WorkSpecificationViewset(viewsets.ModelViewSet): @action(detail=True, methods=["POST"]) def submit(self, request, pk=None) -> Response: - # TODO: check that there are some matches in the request? - # TODO: how to specify the filter? - res = insert_task_into_atdb.delay(pk) - return Response({"detail": "accepted"}, status=status.HTTP_202_ACCEPTED) diff --git a/ldvspec/makemigrations.bat b/ldvspec/makemigrations.bat new file mode 100644 index 0000000000000000000000000000000000000000..c5a3687057ac7d0650547c3e3804e71739877652 --- /dev/null +++ b/ldvspec/makemigrations.bat @@ -0,0 +1 @@ +python manage.py makemigrations --settings=ldvspec.settings.dev diff --git a/ldvspec/migrate.bat b/ldvspec/migrate.bat new file mode 100644 index 0000000000000000000000000000000000000000..92fcbcaa44b7fe0a77bb1c98309fd8e7bd1ebede --- /dev/null +++ b/ldvspec/migrate.bat @@ -0,0 +1 @@ +python manage.py migrate --settings=ldvspec.settings.dev diff --git a/ldvspec/requirements/base.txt b/ldvspec/requirements/base.txt index 3276dfaea43d7450f8fe414dbe87b5561e9b8754..7fefc133b803df987ff6a7bce0e5673ea7e230f9 100644 --- a/ldvspec/requirements/base.txt +++ b/ldvspec/requirements/base.txt @@ -13,3 +13,5 @@ pyyaml==6.0 uritemplate==4.1.1 sshtunnel==0.4.0 django-uws==0.2.dev355575 +django-crispy-forms==1.14.0 + crispy-bootstrap5==0.6 \ No newline at end of file diff --git a/ldvspec/run.bat b/ldvspec/run.bat new file mode 100644 index 0000000000000000000000000000000000000000..4152923cacb7501bd2dff211e75aca2d77fb602c --- /dev/null +++ b/ldvspec/run.bat @@ -0,0 +1,2 @@ +SET CELERY_BROKER_URL=amqp://guest@raspiastro:5672/ +python manage.py runserver --settings=ldvspec.settings.dev diff --git a/ldvspec/test.bat b/ldvspec/test.bat new file mode 100644 index 0000000000000000000000000000000000000000..fad51aaa0e1e09d34f3a705f40bc6c546d9dbb75 --- /dev/null +++ b/ldvspec/test.bat @@ -0,0 +1 @@ +python manage.py test --settings=ldvspec.settings.dev diff --git a/ldvspec/workers.bat b/ldvspec/workers.bat new file mode 100644 index 0000000000000000000000000000000000000000..41ce73ce2cdae3d6ef862eb839eb5840fa3044ce --- /dev/null +++ b/ldvspec/workers.bat @@ -0,0 +1,2 @@ +SET CELERY_BROKER_URL=amqp://guest@raspiastro:5672 +celery -A ldvspec worker --pool=solo -l INFO