Skip to content
Open
3 changes: 2 additions & 1 deletion documentation/deployment_instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ The full list of settings is as follows:
| `POSTGRES_PASSWORD` | The password for Postgres access. This should be set to `'postgres'`. |
| `POSTGRES_PORT` | The port through which the Postgres is exposed. This should be set to `5432`. |
| `API_BASE_URL` | The URL and port by which the QCrBox tool manager can be accessed. If QCrBox is installed on the same machine as this setup, this should be set to `'http://host.docker.internal:11000'`. |
| `API_VISUALISER_PORT` | The port through which the QCrBox_quality visualiser can be accessed. This should be set to `12008` in most cases. |
| `TRAEFIK_HTTP_PORT` | The port through which the Traefik router (which handles GUI routing) is exposed. For development, this should be set to `12345`. |
| `GUI_DOMAIN_PREFIX` | The prefix for GUI subdomains. This should be set to `.gui.` for default setups. |
| `MAX_LENGTH_API_LOG` | The maximum length of API output to be saved in the logs. As some API outputs can be quite long, this gives the option to truncate them in the logs, making the logs more unwieldy at the cost of losing some debug information. |
| `DJANGO_SUPERUSER_EMAIL` | The email address for the default admin account to be created for the web app. |
| `DJANGO_SUPERUSER_USERNAME` | The username for the default admin account to be created for the web app. |
Expand Down
3 changes: 2 additions & 1 deletion environment.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ POSTGRES_PASSWORD='postgres'
POSTGRES_PORT=5432

API_BASE_URL='http://host.docker.internal:11000'
API_VISUALISER_PORT='12008'
TRAEFIK_HTTP_PORT='12345'
GUI_DOMAIN_PREFIX='.gui.'

MAX_LENGTH_API_LOG=10000

Expand Down
5 changes: 4 additions & 1 deletion qcrbox_frontend/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,10 @@

# API settings
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://127.0.0.1:11000')
API_VISUALISER_PORT = os.environ.get('API_VISUALISER_PORT', '12008')

# Traefik / GUI Routing settings
TRAEFIK_HTTP_PORT = int(os.environ.get('TRAEFIK_HTTP_PORT', '12345') or 12345)
GUI_DOMAIN_PREFIX = os.environ.get('GUI_DOMAIN_PREFIX', '.gui.')

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
Expand Down
11 changes: 9 additions & 2 deletions qcrbox_frontend/qcrbox/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,14 @@ def __init__(self, *args, user, **kwargs):

objs = models.FileMetaData.objects # pylint: disable=no-member
qset = objs.filter(active=True).filter(group__in=permitted_groups)
choices = [(f.pk, f.display_filename) for f in qset.all()]

# Filter to only retain the oldest file in each workflow tree, i.e. files which are
# not the output of any process
process_objs = models.ProcessStep.objects # pylint: disable=no-member
process_outfiles = process_objs.all().values_list('outfile', flat=True)
qset = qset.exclude(pk__in=process_outfiles)

choices = [(f.pk, str(f)) for f in qset.all()]

self.fields['file'].choices = choices

Expand Down Expand Up @@ -377,7 +384,7 @@ def __init__(self, *args, command, dataset, **kwargs):
else:
ext = 'cif'

filepath = filepath + f'_{command.name}.{ext}'
filepath = filepath + f'.{ext}'

self.fields[param.name] = forms.CharField(
initial=filepath,
Expand Down
30 changes: 29 additions & 1 deletion qcrbox_frontend/qcrbox/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,36 @@ class FileMetaData(models.Model):

def __str__(self):
'''Return the filename when an instance of this is parsed as string'''
max_len = 30

return str(self.display_filename)
if len(self.display_filename) < max_len:
return str(self.display_filename)
return str(str(self.display_filename)[:max_len-3]+'...')

def get_newest_descendant(self):
'''Get the most recently created FileMetaData object which is a direct
descendant of this FileMetaData'''

related_pks = [self.pk]
last_gen_pks = [self.pk]

process_step_objs = ProcessStep.objects # pylint: disable=no-member

# Recursively scan all related process_steps for descendant pks
while len(last_gen_pks)>0:
gen_processes = process_step_objs.filter(infile__pk__in=last_gen_pks)
this_gen_pks = gen_processes.values_list('outfile__pk', flat=True)
related_pks += this_gen_pks
last_gen_pks = this_gen_pks

# Fetch the object corresponding to the most recently created related
# FileMetaData
file_metadata_objs = FileMetaData.objects # pylint: disable=no-member

related_objs = file_metadata_objs.filter(pk__in=related_pks)
recent_obj = related_objs.order_by('creation_time').last()

return recent_obj


class Application(models.Model):
Expand Down
4 changes: 2 additions & 2 deletions qcrbox_frontend/qcrbox/templates/initial.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
{{ loadfile_form.as_p }}
<br/>
<button type="submit" class="btn btn-primary" id="load-button">
<i class="fas fa-download"></i> Load
Load&nbsp&nbsp<i class="fas fa-arrow-right"></i>
</button>
</form>
</td>
Expand All @@ -28,7 +28,7 @@
{{ newfile_form.as_p }}
<br/>
<button type="submit" class="btn btn-primary" id="upload-button">
<i class="fas fa-upload"></i> Upload
Upload&nbsp&nbsp<i class="fas fa-upload"></i>
</button>
</form>
</td>
Expand Down
58 changes: 36 additions & 22 deletions qcrbox_frontend/qcrbox/templates/workflow.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@

</style>

<div class="container">
<div class="row justify-content-center">
<div class="container-fluid">
<div class="row">
<div id="cached-interactive-session-id" title={{app_session_id}}></div>
<table width=100%>
<tr>
Expand All @@ -38,29 +38,29 @@
<tr id="ancestor-row">
<td align='right'>
{% if prior_step.infile.active %}
<a href="{% url 'workflow' prior_step.infile.pk %}" id="workflow-link-{{prior_step.infile.display_filename}}"><i class="fa-regular fa-square"></i></a>
<span title="Load File"><a href="{% url 'workflow' prior_step.infile.pk %}" id="workflow-link-{{prior_step.infile.display_filename}}"><i class="fa-regular fa-square"></i></a></span>
{% else %}
<i class="fa-regular fa-square"></i>
{% endif %}
</td>
<td align='left' padding=15px>
{% if prior_step.infile.active %}
{{prior_step.infile.display_filename}}
{{prior_step.infile}}
{% else %}
<i>Deleted</i>
{% endif %}
</td>
<td align='left' >
{% if prior_step.infile.active %}
<a href="{% url 'download' prior_step.infile.pk %}" id="download-link-{{prior_step.infile.display_filename}}">
<span title="Download File"><a href="{% url 'download' prior_step.infile.pk %}" id="download-link-{{prior_step.infile.display_filename}}">
<i class="fas fa-download"></i>
</a>&nbsp
<a href="{% url 'visualise' prior_step.infile.pk %}" target="_blank" id="visualise-link-{{prior_step.infile.display_filename}}">
</a></span>&nbsp
<span title="Visualise Data"><a href="{% url 'visualise' prior_step.infile.pk %}" target="_blank" id="visualise-link-{{prior_step.infile.display_filename}}">
<i class="fas fa-eye"></i>
</a>&nbsp
<a href="{% url 'dataset_history' prior_step.infile.pk %}" id="history-link-{{prior_step.infile.display_filename}}">
</a></span>&nbsp
<span title="Data History"><a href="{% url 'dataset_history' prior_step.infile.pk %}" id="history-link-{{prior_step.infile.display_filename}}">
<i class="fas fa-clock"></i>
</a>
</a></span>
{% endif %}
</td>
</tr>
Expand All @@ -69,7 +69,7 @@
<i class="fa-solid fa-arrow-down"></i>
</td>
<td align='left'>
<i>&nbsp{{prior_step.application.name}}</i>
<i>{{prior_step.command.app.name}}: {{prior_step.command}}</i>
</td>
</tr>
{% endfor %}
Expand All @@ -78,18 +78,18 @@
<i class="fa-solid fa-square"></i>
</td>
<td align='left'>
<b id='current-filename'>{{file.display_filename}}</b>
<b id='current-filename'>{{file}}</b>
</td>
<td align='left'>
<a href="{% url 'download' file.pk %}" id="download-link-current">
<span title="Download File"><a href="{% url 'download' file.pk %}" id="download-link-current">
<i class="fas fa-download"></i>
</a>&nbsp
<a href="{% url 'visualise' file.pk %}" target="_blank" id="visualise-link-current">
</a></span>&nbsp
<span title="Visualise Data"><a href="{% url 'visualise' file.pk %}" target="_blank" id="visualise-link-current">
<i class="fas fa-eye"></i>
</a>&nbsp
<a href="{% url 'dataset_history' file.pk %}" id="history-link-current">
</a></span>&nbsp
<span title="Data History"><a href="{% url 'dataset_history' file.pk %}" id="history-link-current">
<i class="fas fa-clock"></i>
</a>
</a></span>
</td>
</tr>
</table>
Expand All @@ -100,7 +100,7 @@
{% if not current_command %}
<h4>Dataset Metadata</h4>
<ul>
<li>Dataset ID: {{file.backend_uuid}}</li>
<li>Filename: {{file.display_filename}}</li>
<li>Filetype: {{file.filetype}}</li>
</ul>
<i>Please select a command from the workflow to continue processing.</i>
Expand Down Expand Up @@ -146,13 +146,21 @@ <h5><b><i>Calculation in progress, do not close this tab!</i></b></h5>
{% endif %}
{% if session_in_progress or calculation_in_progress %}
{% else %}
<hr>
{% if current_command %}
</div></div>
<br/>
<div class="card">
<div class="card-body">
<h4>Select Different Command</h4>
{% else %}
<hr>
{% endif %}
<center>
<form action="" method="POST" enctype="multipart/form-data">
{% csrf_token %}
{{ select_command_form.as_p }}
<button type="submit" class="btn btn-primary" id="select-app-button">
Select Application
Select Command
</button>
</form>
</center>
Expand All @@ -168,8 +176,14 @@ <h5><b><i>Calculation in progress, do not close this tab!</i></b></h5>
{% if current_command.interactive %}
<script>
let hostname = window.location.hostname;
let appSlug = "{{ current_command.app.slug }}".replace(/_/g, "-");
let domainPrefix = "{{ gui_domain_prefix }}";
let port = "{{ traefik_port }}";
let portSuffix = port ? ":" + port : "";

let url = `http://${appSlug}${domainPrefix}${hostname}${portSuffix}/`;

document.getElementById("start-app-button").addEventListener("click", function(){ window.open(`http://${hostname}:{{ current_command.app.port }}/vnc.html?path=vnc&autoconnect=true&resize=remote&reconnect=true&show_dot=true`, '_blank'); });
document.getElementById("start-app-button").addEventListener("click", function(){ window.open(url, '_blank'); });
</script>
{% elif calculation_in_progress %}
<script>
Expand Down
26 changes: 26 additions & 0 deletions qcrbox_frontend/qcrbox/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

'''

import re
import textwrap

from django.core.exceptions import PermissionDenied
Expand Down Expand Up @@ -255,3 +256,28 @@ def check_user_view_file_permission(user, load_file):

else:
raise PermissionDenied

def get_next_valid_filename(filename):
''' Check whether a proposed output filename already exists on record and,
if so, modify it to prevent a clash.'''

file_metas = models.FileMetaData.objects # pylint: disable=no-member
inv_filenames = file_metas.values_list('filename', flat=True)
if not filename in inv_filenames:
return filename

fname_components = filename.split('.')
fn_root = fname_components[0]
fn_ext = fname_components[-1]

fn_root = re.sub(r'\(\d+\)$', '', fn_root)
if not f'{fn_root}.{fn_ext}' in inv_filenames:
return f'{fn_root}.{fn_ext}'

f_ind = 1

# Find the next valid pattern 'filename_root(x).ext' which is not already in use
while True:
if not f'{fn_root}({f_ind}).{fn_ext}' in inv_filenames:
return f'{fn_root}({f_ind}).{fn_ext}'
f_ind += 1
11 changes: 9 additions & 2 deletions qcrbox_frontend/qcrbox/views/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,15 @@ def visualise(request, dataset_id):

# Get host name without port, manually prepend http:// to stop django
# treating this as a relative URL
hostname = 'http://' + request.get_host().split(':')[0]
v_url = f'{hostname}:{settings.API_VISUALISER_PORT}/retrieve/{visualise_file_meta.backend_uuid}'
hostname = request.get_host().split(':')[0]

# Construct the visualiser domain URL
# Format: http://[app-slug].gui.[hostname]:[traefik-port]/
visualiser_domain = f'qcrbox-quality{settings.GUI_DOMAIN_PREFIX}{hostname}'

port_suffix = f':{settings.TRAEFIK_HTTP_PORT}' if settings.TRAEFIK_HTTP_PORT else ''

v_url = f'http://{visualiser_domain}{port_suffix}/retrieve/{visualise_file_meta.backend_uuid}'
LOGGER.info(
'Opening Visualiser at "%s"',
v_url,
Expand Down
10 changes: 9 additions & 1 deletion qcrbox_frontend/qcrbox/views/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ def initialise_workflow(request):
## Handle file loading here.

# If user chose to load pre-existing file, fetch that file's ID
redirect_pk = request.POST['file']
mod_objs = models.FileMetaData.objects # pylint: disable=no-member
selected_file = mod_objs.get(pk=request.POST['file'])

# Find most recently created descendant of selected file
redirect_pk = selected_file.get_newest_descendant().pk

else:

Expand Down Expand Up @@ -224,6 +228,10 @@ def workflow(request, file_id):
else:
context['app_session_id'] = None

# Pass Traefik config to template
context['traefik_port'] = settings.TRAEFIK_HTTP_PORT
context['gui_domain_prefix'] = settings.GUI_DOMAIN_PREFIX

return render(request, 'workflow.html', context)


Expand Down
3 changes: 3 additions & 0 deletions qcrbox_frontend/qcrbox/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,9 @@ def handle_command(request, command, infile):
output_dtypes = ('QCrBox.output_path','QCrBox.output_cif')
for i in cps.filter(dtype__in=output_dtypes).values_list('name',flat=True):
params[i] = params[i].replace('/','_')
print(params[i])
params[i] = utility.get_next_valid_filename(params[i])
print(params[i])

# If the command corresponds to an interactive session, launch it
if command.interactive:
Expand Down