From c2900364f8ff425cd592cce8f3b6b4d04b8c8277 Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Mon, 23 Aug 2021 19:46:38 +0000 Subject: [PATCH 01/22] Added report endpoints --- main/models.py | 10 +++ main/rest/__init__.py | 3 + main/rest/report.py | 127 ++++++++++++++++++++++++++ main/rest/save_report_file.py | 54 ++++++++++++ main/schema/__init__.py | 3 + main/schema/_generator.py | 4 + main/schema/components/__init__.py | 4 + main/schema/components/algorithm.py | 2 +- main/schema/components/report.py | 90 +++++++++++++++++++ main/schema/report.py | 132 ++++++++++++++++++++++++++++ main/schema/save_report_file.py | 54 ++++++++++++ 11 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 main/rest/report.py create mode 100644 main/rest/save_report_file.py create mode 100644 main/schema/components/report.py create mode 100644 main/schema/report.py create mode 100644 main/schema/save_report_file.py diff --git a/main/models.py b/main/models.py index ca39721d4..8a4958936 100644 --- a/main/models.py +++ b/main/models.py @@ -1477,6 +1477,16 @@ class AnnouncementToUser(Model): announcement = ForeignKey(Announcement, on_delete=CASCADE) user = ForeignKey(User, on_delete=CASCADE) +class Report(Model): + """ + """ + created_datetime = DateTimeField(auto_now_add=True) + project = ForeignKey(Project, on_delete=CASCADE, db_column='project') + user = ForeignKey(User, on_delete=CASCADE, db_column='user') + name = CharField(max_length=128) + html_file = FileField(upload_to=ProjectBasedFileLocation, null=True, blank=True) + description = CharField(max_length=1024, blank=True) + def type_to_obj(typeObj): """Returns a data object for a given type object""" _dict = { diff --git a/main/rest/__init__.py b/main/rest/__init__.py index 638311541..feaec3f65 100644 --- a/main/rest/__init__.py +++ b/main/rest/__init__.py @@ -63,6 +63,9 @@ from .permalink import PermalinkAPI from .project import ProjectListAPI from .project import ProjectDetailAPI +from .report import ReportListAPI +from .report import ReportDetailAPI +from .save_report_file import SaveReportFileAPI from .section import SectionListAPI from .section import SectionDetailAPI from .section_analysis import SectionAnalysisAPI diff --git a/main/rest/report.py b/main/rest/report.py new file mode 100644 index 000000000..f32eb3162 --- /dev/null +++ b/main/rest/report.py @@ -0,0 +1,127 @@ +import logging +import os + +from django.db import transaction +from django.conf import settings + +from ..models import Report +from ..models import database_qs +from ..schema import ReportListSchema +from ..schema import ReportDetailSchema +from ..schema import parse +from ..schema.components.report import report_fields as fields + +from ._base_views import BaseListView +from ._base_views import BaseDetailView +from ._permissions import ProjectExecutePermission + +logger = logging.getLogger(__name__) + +class ReportListAPI(BaseListView): + schema = ReportListSchema() + permission_classes = [ProjectExecutePermission] + http_method_names = ['get', 'post'] + + def _get(self, params: dict) -> dict: + qs = Report.objects.filter(project=params['project']) + return database_qs(qs) + + def get_queryset(self) -> dict: + params = parse(self.request) + qs = Report.objects.filter(project__id=params['project']) + return qs + + def _post(self, params: dict) -> dict: + # Does the project ID exist? + project_id = params[fields.project] + try: + project = Project.objects.get(pk=project_id) + except Exception as exc: + log_msg = f'Provided project ID ({project_id}) does not exist' + logger.error(log_msg) + raise exc + + # Does the user ID exist? + user_id = params[fields.user] + try: + user = User.objects.get(pk=user_id) + except Exception as exc: + log_msg = f'Provided user ID ({user_id}) does not exist' + logger.error(log_msg) + raise exc + + # Gather the report file and verify it exists on the server in the right project + report_file = os.path.basename(params[fields.html_file]) + report_url = os.path.join(str(project_id), report_file) + report_path = os.path.join(settings.MEDIA_ROOT, report_url) + if not os.path.exists(report_path): + log_msg = f'Provided report ({report_file}) does not exist in {settings.MEDIA_ROOT}' + logging.error(log_msg) + raise ValueError(log_msg) + + # Get the optional fields and to null if need be + description = params.get(fields.description, None) + + new_report = Report.objects.create( + project=project, + user=user, + name=params[fields.name], + description=description, + html_file=report_url) + + return {"message": f"Successfully created report {new_report.id}!.", "id": new_report.id} + +class ReportDetailAPI(BaseDetailView): + schema = ReportDetailSchema() + permission_classes = [ProjectExecutePermission] + http_method_names = ['get', 'patch', 'delete'] + + def safe_delete(self, path: str) -> None: + try: + logger.info(f"Deleting {path}") + os.remove(path) + except: + logger.warning(f"Could not remove {path}") + + def _delete(self, params: dict) -> dict: + # Grab the report object and delete it from the database + report = Report.objects.get(pk=params['id']) + html_file = alg.html_file + report.delete() + + # Delete the correlated manifest file + path = os.path.join(settings.MEDIA_ROOT, html_file) + self.safe_delete(path=path) + + msg = 'Registered report deleted successfully!' + return {'message': msg} + + def _get(self, params): + return database_qs(Report.objects.filter(pk=params['id']))[0] + + @transaction.atomic + def _patch(self, params) -> dict: + report_id = params["id"] + obj = Report.objects.get(pk=alg_id) + + name = params.get(fields.name, None) + if name is not None: + obj.name = name + + user = params.get(fields.user, None) + if user is not None: + user_entry = User.objects.get(pk=user) + obj.user = user_entry + + description = params.get(fields.description, None) + if description is not None: + obj.description = description + + obj.save() + + return {'message': f'Report {report_id} successfully updated!'} + + def get_queryset(self): + """ Returns a queryset of all registered report files + """ + return Report.objects.all() diff --git a/main/rest/save_report_file.py b/main/rest/save_report_file.py new file mode 100644 index 000000000..92f37dcb3 --- /dev/null +++ b/main/rest/save_report_file.py @@ -0,0 +1,54 @@ +import logging +import os +import shutil + +from django.conf import settings + +from ..schema import SaveReportFileSchema +from ..schema.components.report import report_file_fields as fields +from ..download import download_file + +from ._base_views import BaseListView +from ._permissions import ProjectExecutePermission + +logger = logging.getLogger(__name__) + +class SaveReportFileAPI(BaseListView): + """ Saves a HTML report file + """ + + schema = SaveReportFileSchema() + permission_clases = [ProjectExecutePermission] + http_method_names = ['post'] + + def _post(self, params: dict) -> dict: + """ Saves the uploaded report file into the project's permanent storage + """ + + # Verify the provided file has been uploaded + upload_url = params[fields.upload_url] + + # Move the file to the right location using the provided name + project_id = str(params[fields.project]) + basename = os.path.basename(params[fields.name]) + filename, extension = os.path.splitext(basename) + new_filename = filename + extension + final_path = os.path.join(settings.MEDIA_ROOT, project_id, new_filename) + + # Make sure there's not a duplicate of this file. + file_index = -1 + while os.path.exists(final_path): + file_index += 1 + new_filename = f'{filename}_{file_index}{extension}' + final_path = os.path.join(settings.MEDIA_ROOT, project_id, new_filename) + + project_folder = os.path.dirname(final_path) + os.makedirs(project_folder, exist_ok=True) + + # Download the file to the final path. + download_file(upload_url, final_path) + + # Create the response back to the user + new_url = os.path.join(project_id, new_filename) + response = {fields.url: new_url} + return response diff --git a/main/schema/__init__.py b/main/schema/__init__.py index 1704bc12c..c5449a959 100644 --- a/main/schema/__init__.py +++ b/main/schema/__init__.py @@ -65,6 +65,9 @@ from .permalink import PermalinkSchema from .project import ProjectListSchema from .project import ProjectDetailSchema +from .report import ReportListSchema +from .report import ReportDetailSchema +from .save_report_file import SaveReportFileSchema from .section import SectionListSchema from .section import SectionDetailSchema from .section_analysis import SectionAnalysisSchema diff --git a/main/schema/_generator.py b/main/schema/_generator.py index d1fec7768..64d4d6e19 100644 --- a/main/schema/_generator.py +++ b/main/schema/_generator.py @@ -119,6 +119,10 @@ def get_schema(self, request=None, public=True, parser=False): 'ProjectSpec': project_spec, 'ProjectUpdate': project_update, 'Project': project, + 'Report': report, + 'ReportSpec': report_spec, + 'ReportFile': report_file, + 'ReportFileSpec': report_file_spec, 'ResolutionConfig': resolution_config, 'S3StorageConfig': s3_storage_config, 'SectionSpec': section_spec, diff --git a/main/schema/components/__init__.py b/main/schema/components/__init__.py index d386ee351..67e778c3c 100644 --- a/main/schema/components/__init__.py +++ b/main/schema/components/__init__.py @@ -84,6 +84,10 @@ from .project import project_spec from .project import project_update from .project import project +from .report import report +from .report import report_spec +from .report import report_file +from .report import report_file_spec from .section import section_spec from .section import section_update from .section import section diff --git a/main/schema/components/algorithm.py b/main/schema/components/algorithm.py index 3af13d127..cf34f0515 100644 --- a/main/schema/components/algorithm.py +++ b/main/schema/components/algorithm.py @@ -102,7 +102,7 @@ 'type': 'string', }, manifest_fields.upload_url: { - 'description': 'URL of the uploaded file returned from tus upload', + 'description': 'URL of the uploaded file', 'type': 'string', }, }, diff --git a/main/schema/components/report.py b/main/schema/components/report.py new file mode 100644 index 000000000..7ff3d79c4 --- /dev/null +++ b/main/schema/components/report.py @@ -0,0 +1,90 @@ +from types import SimpleNamespace + +report_fields = SimpleNamespace( + created_datetime="created_datetime", + description="description", + html_file="html_file", + id="id", + name="name", + project="project", + user="user") + +report_post_properties = { + report_fields.name: { + "type": "string", + "description": "Name of report" + }, + report_fields.html_file: { + "type": "string", + "description": "Server URL to report HTML file" + }, + report_fields.description: { + "type": "string", + "description": "Description of report" + }, +} + +# Note: While project is required, it's part of the path parameter(s) +report_spec = { + 'type': 'object', + 'description': 'Register report file spec.', + 'properties': { + **report_post_properties, + }, +} + +report = { + "type": "object", + "description": "Report file spec.", + "properties": { + report_fields.id: { + "type": "integer", + "description": "Unique integer identifying the report file", + }, + report_fields.project: { + "type": "integer", + "description": "Unique integer identifying the project associated with the report.", + }, + report_fields.created_datetime: { + 'type': 'string', + 'format': 'date-time', + 'description': 'Datetime this report was created.', + }, + report_fields.user: { + 'type': 'integer', + 'description': 'Unique integer identifying the user who created this report.' + }, + **report_post_properties + }, +} + +report_file_fields = SimpleNamespace( + project="project", + name="name", + upload_url="upload_url", + url="url") + +report_file = { + 'type': 'object', + 'properties': { + report_file_fields.url: { + 'description': 'Name of report file', + 'type': 'string', + } + }, +} + +report_file_spec = { + 'type': 'object', + 'description': 'Report file save spec.', + 'properties': { + report_file_fields.name: { + 'description': 'Name of report file', + 'type': 'string', + }, + report_file_fields.upload_url: { + 'description': 'URL of the uploaded file', + 'type': 'string', + }, + }, +} \ No newline at end of file diff --git a/main/schema/report.py b/main/schema/report.py new file mode 100644 index 000000000..10cb46ebd --- /dev/null +++ b/main/schema/report.py @@ -0,0 +1,132 @@ +from textwrap import dedent + +from rest_framework.schemas.openapi import AutoSchema + +from ._message import message_schema +from ._message import message_with_id_schema +from ._errors import error_responses + +from .components.report import report_fields as fields + +boilerplate = dedent("""\ +Reports are html files that typically display results from workflows. +""") + +class ReportListSchema(AutoSchema): + def get_operation(self, path, method): + operation = super().get_operation(path, method) + if method == 'GET': + operation['operationId'] = 'GetReportList' + elif method == 'POST': + operation['operationId'] = 'RegisterReport' + operation['tags'] = ['Tator'] + return operation + + def get_description(self, path, method): + if method == 'GET': + short_desc = "Get report list." + + elif method == "POST": + short_desc = "Create report." + + return f"{short_desc}\n\n{boilerplate}" + + def _get_path_parameters(self, path, method): + return [{ + 'name': 'project', + 'in': 'path', + 'required': True, + 'description': 'A unique integer identifying a project.', + 'schema': {'type': 'integer'}, + }] + + def _get_filter_parameters(self, path, method): + return {} + + def _get_request_body(self, path, method): + body = {} + if method == 'POST': + body = { + 'required': True, + 'content': {'application/json': { + 'schema': {'$ref': '#/components/schemas/ReportSpec'}, + }}} + + return body + + def _get_responses(self, path, method): + responses = error_responses() + if method == 'GET': + responses['200'] = { + 'description': 'Successful retrieval of report list.', + 'content': {'application/json': {'schema': { + 'type': 'array', + 'items': {'$ref': '#/components/schemas/Report'}, + }}}, + } + elif method == 'POST': + responses['201'] = message_with_id_schema('registered report') + return responses + +class ReportDetailSchema(AutoSchema): + + def get_operation(self, path, method) -> dict: + operation = super().get_operation(path, method) + if method == 'GET': + operation['operationId'] = 'GetReport' + elif method == 'PATCH': + operation['operationId'] = 'UpdateReport' + elif method == 'DELETE': + operation['operationId'] = 'DeleteReport' + operation['tags'] = ['Tator'] + return operation + + def get_description(self, path, method) -> str: + description = '' + if method == 'GET': + description = 'Get registered report file' + elif method == 'PATCH': + description = 'Updated registered report file' + elif method == 'DELETE': + description = 'Delete registered report file' + return description + + def _get_path_parameters(self, path, method) -> list: + parameters = [{ + 'name': 'id', + 'in': 'path', + 'required': True, + 'description': 'A unique integer identifying a registered report file.', + 'schema': {'type': 'integer'}, + }] + + return parameters + + def _get_filter_parameters(self, path, method): + return [] + + def _get_request_body(self, path, method) -> dict: + body = {} + if method == 'PATCH': + body = { + 'required': True, + 'content': {'application/json': { + 'schema': {'$ref': '#/components/schemas/ReportSpec'}, + 'example': { + fields.name: 'New report name', + } + }}} + return body + + def _get_responses(self, path, method): + responses = error_responses() + if method == 'GET': + responses['200'] = { + 'description': 'Successful retrieval of report.', + 'content': {'application/json': {'schema': { + '$ref': '#/components/schemas/Report', + }}}, + } + elif method == 'DELETE': + responses['200'] = message_schema('deletion', 'registered report') + return responses \ No newline at end of file diff --git a/main/schema/save_report_file.py b/main/schema/save_report_file.py new file mode 100644 index 000000000..1a1da674a --- /dev/null +++ b/main/schema/save_report_file.py @@ -0,0 +1,54 @@ +from textwrap import dedent + +from rest_framework.schemas.openapi import AutoSchema + +from ._errors import error_responses +from ._message import message_with_id_schema + +class SaveReportFileSchema(AutoSchema): + def get_operation(self, path, method): + operation = super().get_operation(path, method) + if method == 'POST': + operation['operationId'] = 'SaveReportFile' + operation['tags'] = ['Tator'] + return operation + + def get_description(self, path, method): + return dedent("""\ + Saves an uploaded report file to the desired project's permanent storage. + It is expected this manifest corresponds with a report file registered by another endpoint. + """) + + def _get_path_parameters(self, path, method): + return[{ + 'name': 'project', + 'in': 'path', + 'required': True, + 'description': 'A unique integer identifying a project', + 'schema': {'type': 'integer'}, + }] + + def _get_filter_parameters(self, path, method): + return [] + + def _get_request_body(self, path, method): + body = {} + if method == 'POST': + body = { + 'required': True, + 'content': {'application/json': { + 'schema': {'$ref': '#/components/schemas/ReportFileSpec'}, + }}} + + return body + + def _get_responses(self, path, method): + responses = error_responses() + if method == 'POST': + responses['201'] = { + 'description': 'Successful save of report file.', + 'content': {'application/json': {'schema': { + '$ref': '#/components/schemas/ReportFile', + }}} + } + return responses From c40b53cb791224f56d94412f03e3f72f5dbcbfe1 Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Mon, 23 Aug 2021 19:47:14 +0000 Subject: [PATCH 02/22] Forgot file as part of last commit --- main/urls.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/main/urls.py b/main/urls.py index 32e6d9ffe..12031f505 100644 --- a/main/urls.py +++ b/main/urls.py @@ -351,6 +351,19 @@ 'rest/Project/', ProjectDetailAPI.as_view(), ), + path( + 'rest/Reports/', + ReportListAPI.as_view(), + ), + path( + 'rest/Report/', + ReportDetailAPI.as_view(), + ), + path( + 'rest/SaveReportFile/', + SaveReportFileAPI.as_view(), + name='SaveReportFile', + ), path( 'rest/Sections/', SectionListAPI.as_view(), From 2644d01d34641599a02219b49e27be347da2f5ce Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Tue, 24 Aug 2021 19:09:35 +0000 Subject: [PATCH 03/22] Updated report UI frontend and fixed some related backend bugs --- Makefile | 1 + main/rest/report.py | 10 +- .../css/tator/components/_analysis.scss | 15 ++ .../js/analytics/reports/report-card.js | 63 +++++++++ main/static/js/analytics/reports/reports.js | 129 +++++++++++++++++- main/templates/analytics/reports.html | 2 +- main/templates/tator-base.html | 1 + 7 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 main/static/js/analytics/reports/report-card.js diff --git a/Makefile b/Makefile index 48e7f7792..898a226ea 100644 --- a/Makefile +++ b/Makefile @@ -458,6 +458,7 @@ FILES = \ analytics/collections/collections.js \ analytics/collections/collections-data.js \ analytics/visualization/visualization.js \ + analytics/reports/report-card.js \ analytics/reports/reports.js \ third_party/autocomplete.js \ third_party/webrtcstreamer.js \ diff --git a/main/rest/report.py b/main/rest/report.py index f32eb3162..7b1105604 100644 --- a/main/rest/report.py +++ b/main/rest/report.py @@ -4,7 +4,9 @@ from django.db import transaction from django.conf import settings +from ..models import Project from ..models import Report +from ..models import User from ..models import database_qs from ..schema import ReportListSchema from ..schema import ReportDetailSchema @@ -67,7 +69,7 @@ def _post(self, params: dict) -> dict: user=user, name=params[fields.name], description=description, - html_file=report_url) + html_file=report_path) return {"message": f"Successfully created report {new_report.id}!.", "id": new_report.id} @@ -86,10 +88,10 @@ def safe_delete(self, path: str) -> None: def _delete(self, params: dict) -> dict: # Grab the report object and delete it from the database report = Report.objects.get(pk=params['id']) - html_file = alg.html_file + html_file = report.html_file report.delete() - # Delete the correlated manifest file + # Delete the correlated file path = os.path.join(settings.MEDIA_ROOT, html_file) self.safe_delete(path=path) @@ -102,7 +104,7 @@ def _get(self, params): @transaction.atomic def _patch(self, params) -> dict: report_id = params["id"] - obj = Report.objects.get(pk=alg_id) + obj = Report.objects.get(pk=report_id) name = params.get(fields.name, None) if name is not None: diff --git a/main/static/css/tator/components/_analysis.scss b/main/static/css/tator/components/_analysis.scss index 3bf402c10..0ae887f4d 100644 --- a/main/static/css/tator/components/_analysis.scss +++ b/main/static/css/tator/components/_analysis.scss @@ -255,3 +255,18 @@ body.analysis-annotations-body { background-color: $color-charcoal--medium70; } } + +.reports_list { + box-sizing: border-box; + height: calc(100vh - 62px); + overflow: auto; + width: 450px; +} + +.reports_main { + border-left: 1px solid $color-charcoal--light; + box-sizing: border-box; + height: calc(100vh - 62px); + overflow: auto; + width: 100%; +} \ No newline at end of file diff --git a/main/static/js/analytics/reports/report-card.js b/main/static/js/analytics/reports/report-card.js new file mode 100644 index 000000000..4034fd5ed --- /dev/null +++ b/main/static/js/analytics/reports/report-card.js @@ -0,0 +1,63 @@ +class ReportCard extends TatorElement { + constructor() { + super(); + + this._li = document.createElement("li"); + this._li.style.cursor = "pointer"; + this._li.setAttribute("class", "section d-flex flex-items-center flex-justify-between px-2 rounded-1"); + this._shadow.appendChild(this._li); + + this._link = document.createElement("a"); + this._link.setAttribute("class", "section__link d-flex flex-items-center text-gray"); + this._li.appendChild(this._link); + + this._title = document.createElement("h2"); + this._title.setAttribute("class", "section__name py-1 px-1 css-truncate"); + this._link.appendChild(this._title); + + this._date = document.createElement("span"); + this._date.setAttribute("class", "text-gray f2"); + //this._linkDiv.appendChild(this._date); + + this._user = document.createElement("span"); + this._user.setAttribute("class", "text-gray f2"); + //this._linkDiv.appendChild(this._user); + } + + init(report) { + this._report = report; + this._title.textContent = report.name; + this._date.textContent = report.created_datetime; + this._user.textContent = report.user; + + const svg = document.createElementNS(svgNamespace, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("height", "1em"); + svg.setAttribute("width", "1em"); + svg.setAttribute("fill", "none"); + svg.style.fill = "none"; + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + this._link.insertBefore(svg, this._title); + + const path = document.createElementNS(svgNamespace, "path"); + path.setAttribute("d", "M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"); + svg.appendChild(path); + + const poly = document.createElementNS(svgNamespace, "polyline"); + poly.setAttribute("points", "13 2 13 9 20 9"); + svg.appendChild(poly); + } + + set active(enabled) { + if (enabled) { + this._li.classList.add("is-active"); + } else { + this._li.classList.remove("is-active"); + } + } +} + +customElements.define("report-card", ReportCard); diff --git a/main/static/js/analytics/reports/reports.js b/main/static/js/analytics/reports/reports.js index 44511fb42..d37b7397e 100644 --- a/main/static/js/analytics/reports/reports.js +++ b/main/static/js/analytics/reports/reports.js @@ -1,7 +1,130 @@ class AnalyticsReports extends TatorPage { - constructor() { - super(); + constructor() { + super(); + + const header = document.createElement("div"); + this._headerDiv = this._header._shadow.querySelector("header"); + header.setAttribute("class", "annotation__header d-flex flex-items-center flex-justify-between px-6 f3"); + const user = this._header._shadow.querySelector("header-user"); + user.parentNode.insertBefore(header, user); + + const div = document.createElement("div"); + div.setAttribute("class", "d-flex flex-items-center"); + header.appendChild(div); + + this._breadcrumbs = document.createElement("analytics-breadcrumbs"); + div.appendChild(this._breadcrumbs); + this._breadcrumbs.setAttribute("analytics-name", "Reports"); + + const main = document.createElement("main"); + main.setAttribute("class", "d-flex"); + this._shadow.appendChild(main); + + const section = document.createElement("section"); + section.setAttribute("class", "reports_list py-6 px-5 text-gray"); + main.appendChild(section); + + this._reportCards = document.createElement("ul"); + this._reportCards.setAttribute("class", "sections"); + section.appendChild(this._reportCards); + + const mainSection = document.createElement("section"); + mainSection.setAttribute("class", "reports_main py-3 px-6 d-flex flex-column"); + main.appendChild(mainSection); + + const mainDiv = document.createElement("div"); + mainDiv.setAttribute("class", "py-3"); + mainSection.appendChild(mainDiv); + + const mainHeader = document.createElement("div"); + mainHeader.setAttribute("class", "main__header d-flex flex-justify-between"); + mainDiv.appendChild(mainHeader); + + const nameDiv = document.createElement("div"); + nameDiv.setAttribute("class", "d-flex flex-row flex-items-center"); + mainHeader.appendChild(nameDiv); + + const h1 = document.createElement("h1"); + h1.setAttribute("class", "h1"); + nameDiv.appendChild(h1); + + this._reportTitle = document.createTextNode(""); + h1.appendChild(this._reportTitle); + + this._reportCreatedDatetime = document.createElement("text"); + this._reportCreatedDatetime.setAttribute("class", "d-flex text-gray f2 lh-default"); + mainDiv.appendChild(this._reportCreatedDatetime); + + this._reportDescription = document.createElement("text"); + this._reportDescription.setAttribute("class", "d-flex text-gray f2 lh-default"); + mainDiv.appendChild(this._reportDescription); + + this._reportView = document.createElement("iframe"); + this._reportView.setAttribute("class", "d-flex flex-grow py-3") + mainSection.appendChild(this._reportView); + } + + static get observedAttributes() { + return["project-name", "project-id"].concat(TatorPage.observedAttributes); + } + + /** + * Initialize the page with project specific information + */ + _init() { + const projectId = this.getAttribute("project-id"); + + const reportPromise = fetch("/rest/Reports/" + projectId, { + method: "GET", + credentials: "same-origin", + headers: { + "X-CSRFToken": getCookie("csrftoken"), + "Accept": "application/json", + "Content-Type": "application/json" + } + }); + reportPromise.then((response) => { + const reportData = response.json(); + reportData.then((reports) => { + this._reports = reports; + + for (const report of reports) { + const reportCard = document.createElement("report-card"); + reportCard.init(report); + this._reportCards.appendChild(reportCard); + + reportCard.addEventListener("click", () => { + const allCards = Array.from(this._reportCards.children); + for (const card of allCards) { + card.active = false; + } + reportCard.active = true; + this._setReportView(report); + }); + } + + }); + }); + } + + _setReportView(report) { + this._reportView.src = report.html_file; + this._reportTitle.textContent = report.name; + this._reportDescription.textContent = report.description; + this._reportCreatedDatetime.textContent = new Date(report.created_datetime).toUTCString(); + } + + attributeChangedCallback(name, oldValue, newValue) { + TatorPage.prototype.attributeChangedCallback.call(this, name, oldValue, newValue); + switch (name) { + case "project-name": + this._breadcrumbs.setAttribute("project-name", newValue); + break; + case "project-id": + this._init(); + break; } } - +} + customElements.define("analytics-reports", AnalyticsReports); \ No newline at end of file diff --git a/main/templates/analytics/reports.html b/main/templates/analytics/reports.html index ffff8798e..198877d3a 100644 --- a/main/templates/analytics/reports.html +++ b/main/templates/analytics/reports.html @@ -7,10 +7,10 @@ {% endblock head %} {% block body %} -

Reports

diff --git a/main/templates/tator-base.html b/main/templates/tator-base.html index ceb71a2a3..80cd48332 100644 --- a/main/templates/tator-base.html +++ b/main/templates/tator-base.html @@ -274,6 +274,7 @@ + From 3925252ba0b1c716571b3643dd2451645f0cd4ca Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Thu, 7 Oct 2021 00:04:32 +0000 Subject: [PATCH 04/22] Renamed report_file to html_file. Added dashboard endpoints. --- main/models.py | 36 ++++- main/rest/__init__.py | 4 +- main/rest/dashboard.py | 135 ++++++++++++++++++ main/rest/report.py | 39 ++--- ...{save_report_file.py => save_html_file.py} | 10 +- main/schema/__init__.py | 4 +- main/schema/_generator.py | 9 +- main/schema/components/__init__.py | 6 +- main/schema/components/dashboard.py | 54 +++++++ main/schema/components/html_file.py | 32 +++++ main/schema/components/report.py | 48 ++----- main/schema/dashboard.py | 132 +++++++++++++++++ main/schema/report.py | 2 +- ...{save_report_file.py => save_html_file.py} | 14 +- main/urls.py | 6 +- 15 files changed, 453 insertions(+), 78 deletions(-) create mode 100644 main/rest/dashboard.py rename main/rest/{save_report_file.py => save_html_file.py} (87%) create mode 100644 main/schema/components/dashboard.py create mode 100644 main/schema/components/html_file.py create mode 100644 main/schema/dashboard.py rename main/schema/{save_report_file.py => save_html_file.py} (73%) diff --git a/main/models.py b/main/models.py index 8a4958936..334d9472d 100644 --- a/main/models.py +++ b/main/models.py @@ -1478,14 +1478,40 @@ class AnnouncementToUser(Model): user = ForeignKey(User, on_delete=CASCADE) class Report(Model): + """ Standalone HTML page shown as a report within the analytics view of the project. """ - """ - created_datetime = DateTimeField(auto_now_add=True) - project = ForeignKey(Project, on_delete=CASCADE, db_column='project') - user = ForeignKey(User, on_delete=CASCADE, db_column='user') - name = CharField(max_length=128) + created_datetime = DateTimeField(auto_now_add=True, null=True, blank=True) + """ Datetime when report was created """ + created_by = ForeignKey(User, on_delete=SET_NULL, null=True, blank=True, + related_name='report_created_by', db_column='created_by') + """ User who originally created the report """ + description = CharField(max_length=1024, blank=True) + """Description of the report""" html_file = FileField(upload_to=ProjectBasedFileLocation, null=True, blank=True) + """ HTML file of the report """ + modified_datetime = DateTimeField(auto_now=True, null=True, blank=True) + """ Datetime when report was last modified """ + modified_by = ForeignKey(User, on_delete=SET_NULL, null=True, blank=True, + related_name='report_modified_by', db_column='modified_by') + """ User who last modified the report """ + name = CharField(max_length=128) + """ Project associated with the report """ + project = ForeignKey(Project, on_delete=CASCADE, db_column='project') + """ Project associated with the report """ + +class Dashboard(Model): + """ Standalone HTML page shown as a dashboard within a project. + """ + categories = ArrayField(CharField(max_length=128), default=list, null=True) + """ List of categories associated with the dashboard. This field is currently ignored. """ description = CharField(max_length=1024, blank=True) + """ Description of the dashboard. """ + html_file = FileField(upload_to=ProjectBasedFileLocation, null=True, blank=True) + """ Dashboard's HTML file """ + name = CharField(max_length=128) + """ Name of the dashboard """ + project = ForeignKey(Project, on_delete=CASCADE, db_column='project') + """ Project associated with the dashboard """ def type_to_obj(typeObj): """Returns a data object for a given type object""" diff --git a/main/rest/__init__.py b/main/rest/__init__.py index feaec3f65..ec4c713ff 100644 --- a/main/rest/__init__.py +++ b/main/rest/__init__.py @@ -17,6 +17,8 @@ from .bucket import BucketDetailAPI from .change_log import ChangeLogListAPI from .clone_media import CloneMediaListAPI +from .dashboard import DashboardListAPI +from .dashboard import DashboardDetailAPI from .download_info import DownloadInfoAPI from .email import EmailAPI from .favorite import FavoriteListAPI @@ -65,7 +67,7 @@ from .project import ProjectDetailAPI from .report import ReportListAPI from .report import ReportDetailAPI -from .save_report_file import SaveReportFileAPI +from .save_html_file import SaveHTMLFileAPI from .section import SectionListAPI from .section import SectionDetailAPI from .section_analysis import SectionAnalysisAPI diff --git a/main/rest/dashboard.py b/main/rest/dashboard.py new file mode 100644 index 000000000..782465f27 --- /dev/null +++ b/main/rest/dashboard.py @@ -0,0 +1,135 @@ +import datetime +import logging +import os + +from django.db import transaction +from django.conf import settings + +from ..models import Project +from ..models import Dashboard +from ..models import User +from ..models import database_qs +from ..schema import DashboardListSchema +from ..schema import DashboardDetailSchema +from ..schema import parse +from ..schema.components.dashboard import dashboard_fields as fields + +from ._base_views import BaseListView +from ._base_views import BaseDetailView +from ._permissions import ProjectExecutePermission + +logger = logging.getLogger(__name__) + +class DashboardListAPI(BaseListView): + schema = DashboardListSchema() + permission_classes = [ProjectExecutePermission] + http_method_names = ['get', 'post'] + + def _get(self, params: dict) -> dict: + qs = Dashboard.objects.filter(project=params['project']) + return database_qs(qs) + + def get_queryset(self) -> dict: + params = parse(self.request) + qs = Dashboard.objects.filter(project__id=params['project']) + return qs + + def _post(self, params: dict) -> dict: + # Does the project ID exist? + project_id = params[fields.project] + try: + project = Project.objects.get(pk=project_id) + except Exception as exc: + log_msg = f'Provided project ID ({project_id}) does not exist' + logger.error(log_msg) + raise exc + + # Gather the dashboard file and verify it exists on the server in the right project + dashboard_file = os.path.basename(params[fields.html_file]) + dashboard_url = os.path.join(str(project_id), dashboard_file) + dashboard_path = os.path.join(settings.MEDIA_ROOT, dashboard_url) + if not os.path.exists(dashboard_path): + log_msg = f'Provided dashboard ({dashboard_file}) does not exist in {settings.MEDIA_ROOT}' + logging.error(log_msg) + raise ValueError(log_msg) + + # Get the optional fields and to null if need be + description = params.get(fields.description, None) + categories = params.get(fields.categories, None) + + new_dashboard = Dashboard.objects.create( + categories=categories, + description=description, + html_file=dashboard_path, + name=params[fields.name], + project=project) + + return {"message": f"Successfully created dashboard {new_dashboard.id}!", "id": new_dashboard.id} + +class DashboardDetailAPI(BaseDetailView): + schema = DashboardDetailSchema() + permission_classes = [ProjectExecutePermission] + http_method_names = ['get', 'patch', 'delete'] + + def safe_delete(self, path: str) -> None: + try: + logger.info(f"Deleting {path}") + os.remove(path) + except: + logger.warning(f"Could not remove {path}") + + def _delete(self, params: dict) -> dict: + # Grab the dashboard object and delete it from the database + dashboard = Dashboard.objects.get(pk=params['id']) + html_file = dashboard.html_file + dashboard.delete() + + # Delete the correlated file + path = os.path.join(settings.MEDIA_ROOT, html_file) + self.safe_delete(path=path) + + msg = 'Registered dashboard deleted successfully!' + return {'message': msg} + + def _get(self, params): + return database_qs(Dashboard.objects.filter(pk=params['id']))[0] + + @transaction.atomic + def _patch(self, params) -> dict: + dashboard_id = params["id"] + obj = Dashboard.objects.get(pk=dashboard_id) + + name = params.get(fields.name, None) + if name is not None: + obj.name = name + + description = params.get(fields.description, None) + if description is not None: + obj.description = description + + categories = params.get(fields.categories, None) + if categories is not None: + obj.categories = categories + + html_file = params.get(fields.html_file, None) + if html_file is not None: + dashboard_file = os.path.basename(html_file) + dashboard_url = os.path.join(str(project_id), dashboard_file) + dashboard_path = os.path.join(settings.MEDIA_ROOT, dashboard_url) + if not os.path.exists(dashboard_path): + log_msg = f'Provided dashboard ({dashboard_file}) does not exist in {settings.MEDIA_ROOT}' + logging.error(log_msg) + raise ValueError(log_msg) + + delete_path = os.path.join(settings.MEDIA_ROOT, obj.html_file) + self.safe_delete(path=delete_path) + obj.html_file = dashboard_path + + obj.save() + + return {'message': f'Dashboard {dashboard_id} successfully updated!'} + + def get_queryset(self): + """ Returns a queryset of all registered dashboard files + """ + return Dashboard.objects.all() diff --git a/main/rest/report.py b/main/rest/report.py index 7b1105604..a8b9e18c6 100644 --- a/main/rest/report.py +++ b/main/rest/report.py @@ -1,3 +1,4 @@ +import datetime import logging import os @@ -43,15 +44,6 @@ def _post(self, params: dict) -> dict: logger.error(log_msg) raise exc - # Does the user ID exist? - user_id = params[fields.user] - try: - user = User.objects.get(pk=user_id) - except Exception as exc: - log_msg = f'Provided user ID ({user_id}) does not exist' - logger.error(log_msg) - raise exc - # Gather the report file and verify it exists on the server in the right project report_file = os.path.basename(params[fields.html_file]) report_url = os.path.join(str(project_id), report_file) @@ -66,12 +58,13 @@ def _post(self, params: dict) -> dict: new_report = Report.objects.create( project=project, - user=user, name=params[fields.name], description=description, - html_file=report_path) + html_file=report_path, + created_by=self.request.user, + modified_by=self.request.user) - return {"message": f"Successfully created report {new_report.id}!.", "id": new_report.id} + return {"message": f"Successfully created report {new_report.id}!", "id": new_report.id} class ReportDetailAPI(BaseDetailView): schema = ReportDetailSchema() @@ -110,15 +103,27 @@ def _patch(self, params) -> dict: if name is not None: obj.name = name - user = params.get(fields.user, None) - if user is not None: - user_entry = User.objects.get(pk=user) - obj.user = user_entry - description = params.get(fields.description, None) if description is not None: obj.description = description + html_file = params.get(fields.html_file, None) + if html_file is not None: + report_file = os.path.basename(html_file) + report_url = os.path.join(str(project_id), report_file) + report_path = os.path.join(settings.MEDIA_ROOT, report_url) + if not os.path.exists(report_path): + log_msg = f'Provided report ({report_file}) does not exist in {settings.MEDIA_ROOT}' + logging.error(log_msg) + raise ValueError(log_msg) + + delete_path = os.path.join(settings.MEDIA_ROOT, obj.html_file) + self.safe_delete(path=delete_path) + obj.html_file = report_path + + obj.modified_by = self.request.user + obj.modified_datetime = datetime.datetime.now(datetime.timezone.utc) + obj.save() return {'message': f'Report {report_id} successfully updated!'} diff --git a/main/rest/save_report_file.py b/main/rest/save_html_file.py similarity index 87% rename from main/rest/save_report_file.py rename to main/rest/save_html_file.py index 92f37dcb3..617322dfc 100644 --- a/main/rest/save_report_file.py +++ b/main/rest/save_html_file.py @@ -4,8 +4,8 @@ from django.conf import settings -from ..schema import SaveReportFileSchema -from ..schema.components.report import report_file_fields as fields +from ..schema import SaveHTMLFileSchema +from ..schema.components.html_file import html_file_fields as fields from ..download import download_file from ._base_views import BaseListView @@ -13,11 +13,11 @@ logger = logging.getLogger(__name__) -class SaveReportFileAPI(BaseListView): - """ Saves a HTML report file +class SaveHTMLFileAPI(BaseListView): + """ Saves a HTML file used for reports and dashboards """ - schema = SaveReportFileSchema() + schema = SaveHTMLFileSchema() permission_clases = [ProjectExecutePermission] http_method_names = ['post'] diff --git a/main/schema/__init__.py b/main/schema/__init__.py index c5449a959..f0105a466 100644 --- a/main/schema/__init__.py +++ b/main/schema/__init__.py @@ -18,6 +18,8 @@ from .bucket import BucketDetailSchema from .change_log import ChangeLogListSchema from .clone_media import CloneMediaListSchema +from .dashboard import DashboardListSchema +from .dashboard import DashboardDetailSchema from .download_info import DownloadInfoSchema from .email import EmailSchema from .favorite import FavoriteListSchema @@ -67,7 +69,7 @@ from .project import ProjectDetailSchema from .report import ReportListSchema from .report import ReportDetailSchema -from .save_report_file import SaveReportFileSchema +from .save_html_file import SaveHTMLFileSchema from .section import SectionListSchema from .section import SectionDetailSchema from .section_analysis import SectionAnalysisSchema diff --git a/main/schema/_generator.py b/main/schema/_generator.py index 64d4d6e19..7c74b8195 100644 --- a/main/schema/_generator.py +++ b/main/schema/_generator.py @@ -57,6 +57,8 @@ def get_schema(self, request=None, public=True, parser=False): 'Bucket': bucket, 'ChangeLog': change_log, 'CloneMediaSpec': clone_media_spec, + 'Dashboard': dashboard, + 'DashboardSpec': dashboard_spec, 'DownloadInfoSpec': download_info_spec, 'DownloadInfo': download_info, 'EmailSpec': email_spec, @@ -67,7 +69,12 @@ def get_schema(self, request=None, public=True, parser=False): 'Favorite': favorite, 'FeedDefinition': feed_definition, 'FileDefinition': file_definition, +<<<<<<< HEAD 'FloatArrayQuery': float_array_query, +======= + 'HTMLFile': html_file, + 'HTMLFileSpec': html_file_spec, +>>>>>>> Renamed report_file to html_file. Added dashboard endpoints. 'LiveDefinition': live_definition, 'LiveUpdateDefinition': live_update_definition, 'ImageDefinition': image_definition, @@ -121,8 +128,6 @@ def get_schema(self, request=None, public=True, parser=False): 'Project': project, 'Report': report, 'ReportSpec': report_spec, - 'ReportFile': report_file, - 'ReportFileSpec': report_file_spec, 'ResolutionConfig': resolution_config, 'S3StorageConfig': s3_storage_config, 'SectionSpec': section_spec, diff --git a/main/schema/components/__init__.py b/main/schema/components/__init__.py index 67e778c3c..c3f92907b 100644 --- a/main/schema/components/__init__.py +++ b/main/schema/components/__init__.py @@ -27,6 +27,8 @@ from .bucket import bucket from .change_log import change_log from .clone_media import clone_media_spec +from .dashboard import dashboard +from .dashboard import dashboard_spec from .download_info import download_info_spec from .download_info import download_info from .email import email_spec @@ -34,6 +36,8 @@ from .favorite import favorite_spec from .favorite import favorite_update from .favorite import favorite +from .html_file import html_file +from .html_file import html_file_spec from .invitation import ( invitation_spec, invitation_update, @@ -86,8 +90,6 @@ from .project import project from .report import report from .report import report_spec -from .report import report_file -from .report import report_file_spec from .section import section_spec from .section import section_update from .section import section diff --git a/main/schema/components/dashboard.py b/main/schema/components/dashboard.py new file mode 100644 index 000000000..b64a3d3a6 --- /dev/null +++ b/main/schema/components/dashboard.py @@ -0,0 +1,54 @@ +from types import SimpleNamespace + +dashboard_fields = SimpleNamespace( + description="description", + html_file="html_file", + id="id", + name="name", + project="project", + categories="categories") + +dashboard_post_properties = { + dashboard_fields.name: { + "type": "string", + "description": "Name of dashboard" + }, + dashboard_fields.html_file: { + "type": "string", + "description": "Server URL to dashboard HTML file" + }, + dashboard_fields.description: { + "type": "string", + "description": "Description of dashboard" + }, + dashboard_fields.categories: { + 'type': 'array', + 'description': 'List of categories the dashboard belongs to', + 'items': {'type': 'string'}, + }, +} + +# Note: While project is required, it's part of the path parameter(s) +dashboard_spec = { + 'type': 'object', + 'description': 'Register dashboard spec.', + 'properties': { + **dashboard_post_properties, + }, +} + +dashboard = { + "type": "object", + "description": "Dashboard spec.", + "properties": { + dashboard_fields.id: { + "type": "integer", + "description": "Unique integer identifying the dashboard", + }, + dashboard_fields.project: { + "type": "integer", + "description": "Unique integer identifying the project associated with the dashboard", + }, + **dashboard_post_properties + }, +} diff --git a/main/schema/components/html_file.py b/main/schema/components/html_file.py new file mode 100644 index 000000000..e1ead509a --- /dev/null +++ b/main/schema/components/html_file.py @@ -0,0 +1,32 @@ +from types import SimpleNamespace + +html_file_fields = SimpleNamespace( + project="project", + name="name", + upload_url="upload_url", + url="url") + +html_file = { + 'type': 'object', + 'properties': { + html_file_fields.url: { + 'description': 'Name of HTML file', + 'type': 'string', + } + }, +} + +html_file_spec = { + 'type': 'object', + 'description': 'HTML file save spec.', + 'properties': { + html_file_fields.name: { + 'description': 'Name of HTML file', + 'type': 'string', + }, + html_file_fields.upload_url: { + 'description': 'URL of the uploaded file', + 'type': 'string', + }, + }, +} \ No newline at end of file diff --git a/main/schema/components/report.py b/main/schema/components/report.py index 7ff3d79c4..99fa9bfe6 100644 --- a/main/schema/components/report.py +++ b/main/schema/components/report.py @@ -2,12 +2,14 @@ report_fields = SimpleNamespace( created_datetime="created_datetime", + created_by="created_by", + modified_datetime="modified_datetime", + modified_by="modified_by", description="description", html_file="html_file", id="id", name="name", - project="project", - user="user") + project="project") report_post_properties = { report_fields.name: { @@ -50,41 +52,19 @@ 'format': 'date-time', 'description': 'Datetime this report was created.', }, - report_fields.user: { + report_fields.created_by: { 'type': 'integer', - 'description': 'Unique integer identifying the user who created this report.' + 'description': 'User that created this report.' }, - **report_post_properties - }, -} - -report_file_fields = SimpleNamespace( - project="project", - name="name", - upload_url="upload_url", - url="url") - -report_file = { - 'type': 'object', - 'properties': { - report_file_fields.url: { - 'description': 'Name of report file', - 'type': 'string', - } - }, -} - -report_file_spec = { - 'type': 'object', - 'description': 'Report file save spec.', - 'properties': { - report_file_fields.name: { - 'description': 'Name of report file', + report_fields.modified_datetime: { 'type': 'string', + 'format': 'date-time', + 'description': 'Datetime this report was created.', }, - report_file_fields.upload_url: { - 'description': 'URL of the uploaded file', - 'type': 'string', + report_fields.modified_by: { + 'type': 'integer', + 'description': 'User who last edited this report.' }, + **report_post_properties }, -} \ No newline at end of file +} diff --git a/main/schema/dashboard.py b/main/schema/dashboard.py new file mode 100644 index 000000000..e3d14f143 --- /dev/null +++ b/main/schema/dashboard.py @@ -0,0 +1,132 @@ +from textwrap import dedent + +from rest_framework.schemas.openapi import AutoSchema + +from ._message import message_schema +from ._message import message_with_id_schema +from ._errors import error_responses + +from .components.dashboard import dashboard_fields as fields + +boilerplate = dedent("""\ +Dashboards are customized interfaces (i.e. html files) displayed within the Tator projects. +""") + +class DashboardListSchema(AutoSchema): + def get_operation(self, path, method): + operation = super().get_operation(path, method) + if method == 'GET': + operation['operationId'] = 'GetDashboardList' + elif method == 'POST': + operation['operationId'] = 'RegisterDashboard' + operation['tags'] = ['Tator'] + return operation + + def get_description(self, path, method): + if method == 'GET': + short_desc = "Get dashboard list." + + elif method == "POST": + short_desc = "Create dashboard." + + return f"{short_desc}\n\n{boilerplate}" + + def _get_path_parameters(self, path, method): + return [{ + 'name': 'project', + 'in': 'path', + 'required': True, + 'description': 'A unique integer identifying a project.', + 'schema': {'type': 'integer'}, + }] + + def _get_filter_parameters(self, path, method): + return {} + + def _get_request_body(self, path, method): + body = {} + if method == 'POST': + body = { + 'required': True, + 'content': {'application/json': { + 'schema': {'$ref': '#/components/schemas/DashboardSpec'}, + }}} + + return body + + def _get_responses(self, path, method): + responses = error_responses() + if method == 'GET': + responses['200'] = { + 'description': 'Successful retrieval of dashboard list.', + 'content': {'application/json': {'schema': { + 'type': 'array', + 'items': {'$ref': '#/components/schemas/Dashboard'}, + }}}, + } + elif method == 'POST': + responses['201'] = message_with_id_schema('registered dashboard') + return responses + +class DashboardDetailSchema(AutoSchema): + + def get_operation(self, path, method) -> dict: + operation = super().get_operation(path, method) + if method == 'GET': + operation['operationId'] = 'GetDashboard' + elif method == 'PATCH': + operation['operationId'] = 'UpdateDashboard' + elif method == 'DELETE': + operation['operationId'] = 'DeleteDashboard' + operation['tags'] = ['Tator'] + return operation + + def get_description(self, path, method) -> str: + description = '' + if method == 'GET': + description = 'Get registered dashboard file' + elif method == 'PATCH': + description = 'Updated registered dashboard file' + elif method == 'DELETE': + description = 'Delete registered dashboard file' + return description + + def _get_path_parameters(self, path, method) -> list: + parameters = [{ + 'name': 'id', + 'in': 'path', + 'required': True, + 'description': 'A unique integer identifying a registered dashboard file.', + 'schema': {'type': 'integer'}, + }] + + return parameters + + def _get_filter_parameters(self, path, method): + return [] + + def _get_request_body(self, path, method) -> dict: + body = {} + if method == 'PATCH': + body = { + 'required': True, + 'content': {'application/json': { + 'schema': {'$ref': '#/components/schemas/DashboardSpec'}, + 'example': { + fields.name: 'New dashboard name', + } + }}} + return body + + def _get_responses(self, path, method): + responses = error_responses() + if method == 'GET': + responses['200'] = { + 'description': 'Successful retrieval of dashboard.', + 'content': {'application/json': {'schema': { + '$ref': '#/components/schemas/Dashboard', + }}}, + } + elif method == 'DELETE': + responses['200'] = message_schema('deletion', 'registered dashboard') + return responses \ No newline at end of file diff --git a/main/schema/report.py b/main/schema/report.py index 10cb46ebd..dde9d3d73 100644 --- a/main/schema/report.py +++ b/main/schema/report.py @@ -9,7 +9,7 @@ from .components.report import report_fields as fields boilerplate = dedent("""\ -Reports are html files that typically display results from workflows. +Reports are customized HTML page that typically display results from workflows. """) class ReportListSchema(AutoSchema): diff --git a/main/schema/save_report_file.py b/main/schema/save_html_file.py similarity index 73% rename from main/schema/save_report_file.py rename to main/schema/save_html_file.py index 1a1da674a..189ecb1a2 100644 --- a/main/schema/save_report_file.py +++ b/main/schema/save_html_file.py @@ -5,18 +5,18 @@ from ._errors import error_responses from ._message import message_with_id_schema -class SaveReportFileSchema(AutoSchema): +class SaveHTMLFileSchema(AutoSchema): def get_operation(self, path, method): operation = super().get_operation(path, method) if method == 'POST': - operation['operationId'] = 'SaveReportFile' + operation['operationId'] = 'SaveHTMLFile' operation['tags'] = ['Tator'] return operation def get_description(self, path, method): return dedent("""\ - Saves an uploaded report file to the desired project's permanent storage. - It is expected this manifest corresponds with a report file registered by another endpoint. + Saves an uploaded html file to the desired project's permanent storage. + It is expected that this file will be used in conjunction with either a Report or Dashboard object. """) def _get_path_parameters(self, path, method): @@ -37,7 +37,7 @@ def _get_request_body(self, path, method): body = { 'required': True, 'content': {'application/json': { - 'schema': {'$ref': '#/components/schemas/ReportFileSpec'}, + 'schema': {'$ref': '#/components/schemas/HTMLFileSpec'}, }}} return body @@ -46,9 +46,9 @@ def _get_responses(self, path, method): responses = error_responses() if method == 'POST': responses['201'] = { - 'description': 'Successful save of report file.', + 'description': 'Successful save of html file.', 'content': {'application/json': {'schema': { - '$ref': '#/components/schemas/ReportFile', + '$ref': '#/components/schemas/HTMLFile', }}} } return responses diff --git a/main/urls.py b/main/urls.py index 12031f505..a09268ae3 100644 --- a/main/urls.py +++ b/main/urls.py @@ -360,9 +360,9 @@ ReportDetailAPI.as_view(), ), path( - 'rest/SaveReportFile/', - SaveReportFileAPI.as_view(), - name='SaveReportFile', + 'rest/SaveHTMLFile/', + SaveHTMLFileAPI.as_view(), + name='SaveHTMLFile', ), path( 'rest/Sections/', From d439b07483b3e56e7b2f5bca0f8ffa599575a563 Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Tue, 12 Oct 2021 15:30:13 +0000 Subject: [PATCH 05/22] Added dashboard related HTML pages to the analytics view --- Makefile | 4 +- .../css/tator/components/_analysis.scss | 23 +++++ .../js/analytics/analytics-breadcrumbs.js | 30 ++++-- .../analytics/dashboards/dashboard-portal.js | 96 +++++++++++++++++++ .../analytics/dashboards/dashboard-summary.js | 52 ++++++++++ .../js/analytics/dashboards/dashboard.js | 80 ++++++++++++++++ .../dashboard.js => portal/portal.js} | 33 +++---- .../analytics/visualization/visualization.js | 8 -- .../{visualization.html => analytics.html} | 0 .../templates/analytics/dashboard-portal.html | 18 ++++ main/templates/analytics/dashboard.html | 9 +- main/templates/analytics/portal.html | 18 ++++ main/templates/tator-base.html | 6 +- main/urls.py | 19 +++- main/views.py | 21 +++- 15 files changed, 367 insertions(+), 50 deletions(-) create mode 100644 main/static/js/analytics/dashboards/dashboard-portal.js create mode 100644 main/static/js/analytics/dashboards/dashboard-summary.js create mode 100644 main/static/js/analytics/dashboards/dashboard.js rename main/static/js/analytics/{dashboard/dashboard.js => portal/portal.js} (79%) delete mode 100644 main/static/js/analytics/visualization/visualization.js rename main/templates/analytics/{visualization.html => analytics.html} (100%) create mode 100644 main/templates/analytics/dashboard-portal.html create mode 100644 main/templates/analytics/portal.html diff --git a/Makefile b/Makefile index 898a226ea..4b1550223 100644 --- a/Makefile +++ b/Makefile @@ -446,6 +446,8 @@ FILES = \ annotation/volume-control.js \ analytics/analytics-breadcrumbs.js \ analytics/analytics-settings.js \ + analytics/dashboard/dashboard-portal.js \ + analytics/dashboard/dashboard-summary.js \ analytics/dashboard/dashboard.js \ analytics/localizations/card.js \ analytics/localizations/gallery.js \ @@ -457,7 +459,7 @@ FILES = \ analytics/collections/gallery.js \ analytics/collections/collections.js \ analytics/collections/collections-data.js \ - analytics/visualization/visualization.js \ + analytics/portal/portal.js \ analytics/reports/report-card.js \ analytics/reports/reports.js \ third_party/autocomplete.js \ diff --git a/main/static/css/tator/components/_analysis.scss b/main/static/css/tator/components/_analysis.scss index 0ae887f4d..2ae8c1ea3 100644 --- a/main/static/css/tator/components/_analysis.scss +++ b/main/static/css/tator/components/_analysis.scss @@ -269,4 +269,27 @@ body.analysis-annotations-body { height: calc(100vh - 62px); overflow: auto; width: 100%; +} +.dashboard_main { + overflow: auto; + height: calc(100vh - 62px); + width: 100%; + position: fixed; +} + +.dashboard_summary-icon { + align-items: stretch; + background: $color-charcoal--medium; + width: 50px; + height: 50px; + border: 2px solid; + border-color: $color-charcoal--light; + transition: all 300ms linear; + color: $color-gray--light; + + &:hover{ + border-color: $color-white--50; + background-color: $color-charcoal--light; + color: $color-white; + } } \ No newline at end of file diff --git a/main/static/js/analytics/analytics-breadcrumbs.js b/main/static/js/analytics/analytics-breadcrumbs.js index d8cd0a5ac..a3faa1c98 100644 --- a/main/static/js/analytics/analytics-breadcrumbs.js +++ b/main/static/js/analytics/analytics-breadcrumbs.js @@ -29,27 +29,39 @@ class AnalyticsBreadcrumbs extends TatorElement { this._analyticsText = document.createElement("a"); this._analyticsText.setAttribute("class", "text-gray"); div.appendChild(this._analyticsText); + + this.chevron3 = document.createElement("chevron-right"); + this.chevron3.setAttribute("class", "px-2"); + div.appendChild(this.chevron3); + + this._subAnalyticsText = document.createElement("a"); + this._subAnalyticsText.setAttribute("class", "text-gray"); + div.appendChild(this._subAnalyticsText); + + this.chevron3.hidden = true; + this._subAnalyticsText.hidden = true; } static get observedAttributes() { - return ["project-name", "analytics-name"]; + return["project-name", "analytics-name", "analytics-name-link", "analytics-sub-name"].concat(TatorPage.observedAttributes); } attributeChangedCallback(name, oldValue, newValue) { switch (name) { case "project-name": - this._projectText.textContent = newValue; this._projectText.setAttribute("href", this._detailUrl()); - - break; case "analytics-name": - if (newValue != "Dashboard") { - this._analyticsText.textContent = newValue; - } else { - this.chevron2.hidden = true; - } + this._analyticsText.textContent = newValue; + break; + case "analytics-name-link": + this._analyticsText.setAttribute("href", newValue); + break; + case "analytics-sub-name": + this._subAnalyticsText.textContent = newValue; + this.chevron3.hidden = false; + this._subAnalyticsText.hidden = false; break; } } diff --git a/main/static/js/analytics/dashboards/dashboard-portal.js b/main/static/js/analytics/dashboards/dashboard-portal.js new file mode 100644 index 000000000..5d541f308 --- /dev/null +++ b/main/static/js/analytics/dashboards/dashboard-portal.js @@ -0,0 +1,96 @@ +class DashboardPortal extends TatorPage { + constructor() { + super(); + this._loading = document.createElement("img"); + this._loading.setAttribute("class", "loading"); + this._loading.setAttribute("src", "/static/images/tator_loading.gif"); + this._shadow.appendChild(this._loading); + + // + // Header + // + const header = document.createElement("div"); + this._headerDiv = this._header._shadow.querySelector("header"); + header.setAttribute("class", "annotation__header d-flex flex-items-center flex-justify-between px-6 f3"); + const user = this._header._shadow.querySelector("header-user"); + user.parentNode.insertBefore(header, user); + + const div = document.createElement("div"); + div.setAttribute("class", "d-flex flex-items-center"); + header.appendChild(div); + + this._breadcrumbs = document.createElement("analytics-breadcrumbs"); + div.appendChild(this._breadcrumbs); + this._breadcrumbs.setAttribute("analytics-name", "Dashboards"); + + // + // Main section + // + const main = document.createElement("main"); + main.setAttribute("class", "layout-max py-4"); + this._shadow.appendChild(main); + + const title = document.createElement("div"); + title.setAttribute("class", "main__header d-flex flex-items-center flex-justify-between py-6 px-2"); + main.appendChild(title); + + const h1 = document.createElement("h1"); + h1.setAttribute("class", "h1"); + title.appendChild(h1); + + const h1Text = document.createTextNode("Dashboards"); + h1.appendChild(h1Text); + + this._dashboards = document.createElement("div"); + this._dashboards.setAttribute("class", "d-flex flex-column"); + main.appendChild(this._dashboards); + } + + connectedCallback() { + TatorPage.prototype.connectedCallback.call(this); + } + + static get observedAttributes() { + return ["project-name", "project-id"].concat(TatorPage.observedAttributes); + } + + attributeChangedCallback(name, oldValue, newValue) { + TatorPage.prototype.attributeChangedCallback.call(this, name, oldValue, newValue); + switch (name) { + case "project-name": + this._breadcrumbs.setAttribute("project-name", newValue); + break; + case "project-id": + this._projectId = newValue; + this._getDashboards(); + break; + } + } + + _getDashboards() { + fetch("/rest/Dashboards/" + this._projectId, { + method: "GET", + credentials: "same-origin", + headers: { + "X-CSRFToken": getCookie("csrftoken"), + "Accept": "application/json", + "Content-Type": "application/json" + }, + }) + .then(response => response.json()) + .then(dashboards => { + for (let dashboard of dashboards) { + this._insertDashboardSummary(dashboard); + } + this._loading.style.display = "none"; + }); + } + + _insertDashboardSummary(dashboard) { + const summary = document.createElement("dashboard-summary"); + summary.info = dashboard; + this._dashboards.appendChild(summary); + } + } + + customElements.define("dashboard-portal", DashboardPortal); diff --git a/main/static/js/analytics/dashboards/dashboard-summary.js b/main/static/js/analytics/dashboards/dashboard-summary.js new file mode 100644 index 000000000..7576e9594 --- /dev/null +++ b/main/static/js/analytics/dashboards/dashboard-summary.js @@ -0,0 +1,52 @@ +class DashboardSummary extends TatorElement { + constructor() { + super(); + + const div = document.createElement("div"); + div.setAttribute("class", "projects d-flex flex-items-center rounded-2"); + this._shadow.appendChild(div); + + this._link = document.createElement("a"); + this._link.setAttribute("class", "projects__link d-flex flex-items-center text-white px-2"); + div.appendChild(this._link); + + //this._img = document.createElement("img"); + //this._img.setAttribute("class", "projects__image px-2 rounded-1"); + //this._link.appendChild(this._img); + + this._iconWrapper = document.createElement("div"); + this._iconWrapper.setAttribute("class", "d-flex dashboard_summary-icon rounded-2"); + this._link.appendChild(this._iconWrapper); + + this._icon = document.createElement("div"); + this._icon.style.margin = "auto"; + this._icon.innerHTML = ''; + this._iconWrapper.appendChild(this._icon); + + const text = document.createElement("div"); + text.setAttribute("class", "projects__text px-3 py-2"); + this._link.appendChild(text); + + const h2 = document.createElement("h2"); + h2.setAttribute("class", "text-semibold py-2"); + text.appendChild(h2); + + this._text = document.createTextNode(""); + h2.appendChild(this._text); + + this._description = document.createElement("span"); + this._description.setAttribute("class", "text-gray f2"); + text.appendChild(this._description); + + } + + set info(dashboardObj) { + this._text.nodeValue = dashboardObj.name; + + const url = window.location.origin + "/" + dashboardObj.project + "/dashboards/" + dashboardObj.id; + this._link.setAttribute("href", url); + this._description.textContent = dashboardObj.description; + } +} + +customElements.define("dashboard-summary", DashboardSummary); diff --git a/main/static/js/analytics/dashboards/dashboard.js b/main/static/js/analytics/dashboards/dashboard.js new file mode 100644 index 000000000..ee03b21f4 --- /dev/null +++ b/main/static/js/analytics/dashboards/dashboard.js @@ -0,0 +1,80 @@ +class RegisteredDashboard extends TatorPage { + constructor() { + super(); + this._loading = document.createElement("img"); + this._loading.setAttribute("class", "loading"); + this._loading.setAttribute("src", "/static/images/tator_loading.gif"); + this._shadow.appendChild(this._loading); + + // + // Header + // + const header = document.createElement("div"); + this._headerDiv = this._header._shadow.querySelector("header"); + header.setAttribute("class", "annotation__header d-flex flex-items-center flex-justify-between px-6 f3"); + const user = this._header._shadow.querySelector("header-user"); + user.parentNode.insertBefore(header, user); + + const div = document.createElement("div"); + div.setAttribute("class", "d-flex flex-items-center"); + header.appendChild(div); + + this._breadcrumbs = document.createElement("analytics-breadcrumbs"); + div.appendChild(this._breadcrumbs); + this._breadcrumbs.setAttribute("analytics-name", "Dashboards"); + + // + // Main section of the page + // + const main = document.createElement("main"); + main.setAttribute("class", "dashboard_main d-flex flex-column"); + this._shadow.appendChild(main); + + this._dashboardView = document.createElement("iframe"); + this._dashboardView.setAttribute("class", "d-flex flex-grow") + main.appendChild(this._dashboardView); + } + + static get observedAttributes() { + return["project-name", "project-id", "dashboard-id"].concat(TatorPage.observedAttributes); + } + + attributeChangedCallback(name, oldValue, newValue) { + TatorPage.prototype.attributeChangedCallback.call(this, name, oldValue, newValue); + switch (name) { + case "project-name": + this._breadcrumbs.setAttribute("project-name", newValue); + break; + case "project-id": + this._breadcrumbs.setAttribute("analytics-name-link", window.location.origin + `/${newValue}/dashboards`); + break; + case "dashboard-id": + this._init(newValue); + break; + } + } + + _init(dashboardId) { + this._dashboardId = dashboardId; + const dashboardPromise = fetch("/rest/Dashboard/" + dashboardId, { + method: "GET", + credentials: "same-origin", + headers: { + "X-CSRFToken": getCookie("csrftoken"), + "Accept": "application/json", + "Content-Type": "application/json" + } + }); + dashboardPromise.then((response) => { + const dashboardData = response.json(); + dashboardData.then((dashboard) => { + this._dashboard = dashboard; + this._dashboardView.src = dashboard.html_file; + this._breadcrumbs.setAttribute("analytics-sub-name", dashboard.name); + this._loading.style.display = "none"; + }); + }); + } +} + +customElements.define("registered-dashboard", RegisteredDashboard); diff --git a/main/static/js/analytics/dashboard/dashboard.js b/main/static/js/analytics/portal/portal.js similarity index 79% rename from main/static/js/analytics/dashboard/dashboard.js rename to main/static/js/analytics/portal/portal.js index 32323db14..1287e8614 100644 --- a/main/static/js/analytics/dashboard/dashboard.js +++ b/main/static/js/analytics/portal/portal.js @@ -1,10 +1,10 @@ -class AnalyticsDashboard extends TatorPage { +class AnalyticsPortal extends TatorPage { constructor() { super(); this.projectId = window.location.pathname.split("/")[1]; - // + // // Header // const header = document.createElement("div"); @@ -44,26 +44,21 @@ class AnalyticsDashboard extends TatorPage { this.main.appendChild(collectionsBox); */ - // Visualization - /* - const visualizationBox = this._getDashboardBox({ - name : "Data Visualization", - href : `/${this.projectId}/analytics/visualization`, - iconName: "bar-chart-icon" + // Dashboards + const dashboardsBox = this._getDashboardBox({ + name : "Dashboards", + href : `/${this.projectId}/dashboards`, + iconName: "monitor" }); - this.main.appendChild(visualizationBox); - */ + this.main.appendChild(dashboardsBox); // Reports - /* const reportsBox = this._getDashboardBox({ name : "Reports", - href : ``, + href : `/${this.projectId}/analytics/reports`, iconName: "file-text-icon" }); this.main.appendChild(reportsBox); - reportsBox.setAttribute("disabled", ""); - */ } attributeChangedCallback(name, oldValue, newValue) { @@ -95,7 +90,13 @@ class AnalyticsDashboard extends TatorPage { dashboardIcon.svg.setAttribute("width", width); dashboardIcon.svg.setAttribute("stroke", "white"); iconDiv.appendChild(dashboardIcon); - } else { + } + else if (iconName == "monitor") { + const dashboardIcon = document.createElement("div"); + dashboardIcon.innerHTML = ``; + iconDiv.appendChild(dashboardIcon); + } + else { const dashboardIcon = new SvgDefinition({ iconName, height, width }); iconDiv.appendChild(dashboardIcon); } @@ -126,4 +127,4 @@ class AnalyticsDashboard extends TatorPage { } -customElements.define("analytics-dashboard", AnalyticsDashboard); \ No newline at end of file +customElements.define("analytics-portal", AnalyticsPortal); \ No newline at end of file diff --git a/main/static/js/analytics/visualization/visualization.js b/main/static/js/analytics/visualization/visualization.js deleted file mode 100644 index d07f6d85c..000000000 --- a/main/static/js/analytics/visualization/visualization.js +++ /dev/null @@ -1,8 +0,0 @@ -class AnalyticsVisualization extends TatorPage { - constructor() { - super(); - } - } - - customElements.define("analytics-visualization", AnalyticsVisualization); - \ No newline at end of file diff --git a/main/templates/analytics/visualization.html b/main/templates/analytics/analytics.html similarity index 100% rename from main/templates/analytics/visualization.html rename to main/templates/analytics/analytics.html diff --git a/main/templates/analytics/dashboard-portal.html b/main/templates/analytics/dashboard-portal.html new file mode 100644 index 000000000..d6d2a18fa --- /dev/null +++ b/main/templates/analytics/dashboard-portal.html @@ -0,0 +1,18 @@ +{% extends "tator-base.html" %} + +{% load static %} + +{% block head %} +Tator | Registered Dashboards +{% endblock head %} + +{% block body %} + + + +{% endblock body %} diff --git a/main/templates/analytics/dashboard.html b/main/templates/analytics/dashboard.html index 44a76b707..c2f499a15 100644 --- a/main/templates/analytics/dashboard.html +++ b/main/templates/analytics/dashboard.html @@ -3,16 +3,17 @@ {% load static %} {% block head %} -Tator | Analytics Dashboard +Tator | {{ dashboard.name }} {% endblock head %} {% block body %} - - + token="{{ token }}" + dashboard-id={{ dashboard.id }}> + {% endblock body %} diff --git a/main/templates/analytics/portal.html b/main/templates/analytics/portal.html new file mode 100644 index 000000000..8083a022c --- /dev/null +++ b/main/templates/analytics/portal.html @@ -0,0 +1,18 @@ +{% extends "tator-base.html" %} + +{% load static %} + +{% block head %} +Tator | Registered Dashboards +{% endblock head %} + +{% block body %} + + + +{% endblock body %} diff --git a/main/templates/tator-base.html b/main/templates/tator-base.html index 80cd48332..b833f9875 100644 --- a/main/templates/tator-base.html +++ b/main/templates/tator-base.html @@ -262,7 +262,9 @@ - + + + @@ -273,7 +275,7 @@ - + diff --git a/main/urls.py b/main/urls.py index a09268ae3..ed59d652c 100644 --- a/main/urls.py +++ b/main/urls.py @@ -27,10 +27,11 @@ from .views import AnnotationView from .views import AuthProjectView from .views import AuthAdminView -from .views import AnalyticsDashboardView +from .views import DashboardPortalView +from .views import DashboardView from .views import AnalyticsLocalizationsView from .views import AnalyticsCollectionsView -from .views import AnalyticsVisualizationView +from .views import AnalyticsPortalView from .views import AnalyticsReportsView from .schema import NoAliasRenderer @@ -52,13 +53,15 @@ path('accounts/account-profile/', AccountProfileView.as_view(), name='account-profile'), path('/analytics/', - AnalyticsDashboardView.as_view(), name='analytics-dashboard'), + AnalyticsPortalView.as_view(), name='analytics-portal'), path('/analytics/localizations', AnalyticsLocalizationsView.as_view(), name='analytics-localizations'), path('/analytics/collections', AnalyticsCollectionsView.as_view(), name='analytics-collections'), - path('/analytics/visualization', - AnalyticsVisualizationView.as_view(), name='analytics-visualization'), + path('/dashboards', + DashboardPortalView.as_view(), name='dashboard-portal'), + path('/dashboards/', + DashboardView.as_view(), name='dashboard'), path('/analytics/reports', AnalyticsReportsView.as_view(), name='analytics-reports'), path('organizations/', OrganizationsView.as_view(), name='organizations'), @@ -168,6 +171,12 @@ path('rest/CloneMedia/', CloneMediaListAPI.as_view(), ), + path('rest/Dashboards/', + DashboardListAPI.as_view(), + ), + path('rest/Dashboard/', + DashboardDetailAPI.as_view(), + ), path('rest/DownloadInfo/', DownloadInfoAPI.as_view(), ), diff --git a/main/views.py b/main/views.py index 8453b4127..2759a248e 100644 --- a/main/views.py +++ b/main/views.py @@ -14,6 +14,7 @@ from rest_framework.authentication import TokenAuthentication import yaml +from .models import Dashboard from .models import Organization from .models import Project from .models import Media @@ -141,9 +142,19 @@ def get_context_data(self, **kwargs): raise PermissionDenied return context -class AnalyticsDashboardView(ProjectBase, TemplateView): +class DashboardPortalView(ProjectBase, TemplateView): + template_name = 'analytics/dashboard-portal.html' + + +class DashboardView(ProjectBase, TemplateView): template_name = 'analytics/dashboard.html' + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + dashboard = get_object_or_404(Dashboard, pk=self.kwargs['id']) + context['dashboard'] = dashboard + return context + class AnalyticsLocalizationsView(ProjectBase, TemplateView): template_name = 'analytics/localizations.html' @@ -153,14 +164,14 @@ class AnalyticsCollectionsView(ProjectBase, TemplateView): template_name = 'analytics/collections.html' -class AnalyticsVisualizationView(ProjectBase, TemplateView): - template_name = 'analytics/visualization.html' - - class AnalyticsReportsView(ProjectBase, TemplateView): template_name = 'analytics/reports.html' +class AnalyticsPortalView(ProjectBase, TemplateView): + template_name = 'analytics/portal.html' + + class AnnotationView(ProjectBase, TemplateView): template_name = 'annotation.html' From 390299205f50a985c1bf91c6133930d630fafbae Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Tue, 12 Oct 2021 18:14:20 +0000 Subject: [PATCH 06/22] Fix missed rebase merge --- main/schema/_generator.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/main/schema/_generator.py b/main/schema/_generator.py index 7c74b8195..d32471919 100644 --- a/main/schema/_generator.py +++ b/main/schema/_generator.py @@ -69,12 +69,9 @@ def get_schema(self, request=None, public=True, parser=False): 'Favorite': favorite, 'FeedDefinition': feed_definition, 'FileDefinition': file_definition, -<<<<<<< HEAD 'FloatArrayQuery': float_array_query, -======= 'HTMLFile': html_file, 'HTMLFileSpec': html_file_spec, ->>>>>>> Renamed report_file to html_file. Added dashboard endpoints. 'LiveDefinition': live_definition, 'LiveUpdateDefinition': live_update_definition, 'ImageDefinition': image_definition, From 50abbd0958833bd1c1d5cef08ede2c48f16ca4a6 Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Tue, 12 Oct 2021 23:37:09 +0000 Subject: [PATCH 07/22] [Migration Required] Added report type model --- main/models.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/main/models.py b/main/models.py index 334d9472d..080c7617f 100644 --- a/main/models.py +++ b/main/models.py @@ -1477,6 +1477,19 @@ class AnnouncementToUser(Model): announcement = ForeignKey(Announcement, on_delete=CASCADE) user = ForeignKey(User, on_delete=CASCADE) +class ReportType(Model): + """ Type associated with generating a report + """ + project = ForeignKey(Project, on_delete=CASCADE, null=True, blank=True, db_column='project') + """ Project associated with the report type """ + name = CharField(max_length=64) + """ Name of the report type""" + description = CharField(max_length=256, blank=True) + """ Description of the report type""" + attribute_types = JSONField(default=list, null=True, blank=True) + """ Refer to the attribute_types field for the other *Type models + """ + class Report(Model): """ Standalone HTML page shown as a report within the analytics view of the project. """ @@ -1498,6 +1511,8 @@ class Report(Model): """ Project associated with the report """ project = ForeignKey(Project, on_delete=CASCADE, db_column='project') """ Project associated with the report """ + meta = ForeignKey(ReportType, on_delete=SET_NULL, null=True, blank=True, db_column='meta') + """ Type associated with report """ class Dashboard(Model): """ Standalone HTML page shown as a dashboard within a project. From 6ac6a27b351ca1716b0ab02e1921a4f4aa80155a Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Tue, 12 Oct 2021 23:49:45 +0000 Subject: [PATCH 08/22] Added user-defined attributes to the report model --- main/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main/models.py b/main/models.py index 080c7617f..9f832f88a 100644 --- a/main/models.py +++ b/main/models.py @@ -1513,6 +1513,8 @@ class Report(Model): """ Project associated with the report """ meta = ForeignKey(ReportType, on_delete=SET_NULL, null=True, blank=True, db_column='meta') """ Type associated with report """ + attributes = JSONField(null=True, blank=True) + """ Values of user defined attributes. """ class Dashboard(Model): """ Standalone HTML page shown as a dashboard within a project. From 978b337e6d52cba828fc88d275ad00d59c2a5399 Mon Sep 17 00:00:00 2001 From: Jonathan Takahashi Date: Wed, 13 Oct 2021 03:50:02 +0000 Subject: [PATCH 09/22] Increase node size for install and test --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f96a96e10..266f94177 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -49,7 +49,7 @@ jobs: install-and-test: machine: image: ubuntu-2004:202010-01 - resource_class: large + resource_class: xlarge environment: DOCKER_REGISTRY: cvisionai steps: From 4f870b132bf17f0c8753caba80f5a1fbb5217d83 Mon Sep 17 00:00:00 2001 From: Jonathan Takahashi Date: Wed, 13 Oct 2021 17:55:52 +0000 Subject: [PATCH 10/22] Fix metadata download via front end, closes #509. --- main/static/js/project-detail/media-section.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/main/static/js/project-detail/media-section.js b/main/static/js/project-detail/media-section.js index 22d5c61f8..778c76f1b 100644 --- a/main/static/js/project-detail/media-section.js +++ b/main/static/js/project-detail/media-section.js @@ -437,7 +437,13 @@ class MediaSection extends TatorElement { baseFilename, lastId, idQuery) => { let url = baseUrl + "&type=" + type.id + "&stop=" + batchSize; if (lastId != null) { - url += "&after_id=" + encodeURIComponent(lastId); + let param; + if (url.includes("Medias")) { + param = "after_id"; + } else { + param = "after"; + } + url += `&${param}=` + encodeURIComponent(lastId); } let request; @@ -528,7 +534,7 @@ class MediaSection extends TatorElement { if (mediaTypes == null) { // Get media types. mediaTypes = await getTypes("MediaTypes", "media_types.json"); - mediaFetcher = new MetadataFetcher(mediaTypes, mediaUrl, "medias__", "name"); + mediaFetcher = new MetadataFetcher(mediaTypes, mediaUrl, "medias__", "id"); } else if (localizationTypes == null) { // Get localization types. From d7fe56971e546686bd149c0bf27c3d72593c5957 Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Wed, 13 Oct 2021 19:45:15 +0000 Subject: [PATCH 11/22] Added the ReportType endpoint generation/URLs. Added attribute to report POST --- main/rest/__init__.py | 2 ++ main/schema/__init__.py | 2 ++ main/schema/components/__init__.py | 2 ++ main/schema/components/report.py | 8 +++++++- main/urls.py | 8 ++++++++ 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/main/rest/__init__.py b/main/rest/__init__.py index ec4c713ff..5b5b5d1fb 100644 --- a/main/rest/__init__.py +++ b/main/rest/__init__.py @@ -67,6 +67,8 @@ from .project import ProjectDetailAPI from .report import ReportListAPI from .report import ReportDetailAPI +from .report_type import ReportTypeListAPI +from .report_type import ReportTypeDetailAPI from .save_html_file import SaveHTMLFileAPI from .section import SectionListAPI from .section import SectionDetailAPI diff --git a/main/schema/__init__.py b/main/schema/__init__.py index f0105a466..de58d1745 100644 --- a/main/schema/__init__.py +++ b/main/schema/__init__.py @@ -69,6 +69,8 @@ from .project import ProjectDetailSchema from .report import ReportListSchema from .report import ReportDetailSchema +from .report_type import ReportTypeListSchema +from .report_type import ReportTypeDetailSchema from .save_html_file import SaveHTMLFileSchema from .section import SectionListSchema from .section import SectionDetailSchema diff --git a/main/schema/components/__init__.py b/main/schema/components/__init__.py index c3f92907b..30a8a6fb6 100644 --- a/main/schema/components/__init__.py +++ b/main/schema/components/__init__.py @@ -90,6 +90,8 @@ from .project import project from .report import report from .report import report_spec +from .report_type import report_type_spec +from .report_type import report_type from .section import section_spec from .section import section_update from .section import section diff --git a/main/schema/components/report.py b/main/schema/components/report.py index 99fa9bfe6..9a36096a9 100644 --- a/main/schema/components/report.py +++ b/main/schema/components/report.py @@ -9,7 +9,8 @@ html_file="html_file", id="id", name="name", - project="project") + project="project", + attributes="attributes") report_post_properties = { report_fields.name: { @@ -24,6 +25,11 @@ "type": "string", "description": "Description of report" }, + report_fields.attributes: { + 'description': 'Object containing attribute values.', + 'type': 'object', + 'additionalProperties': {'$ref': '#/components/schemas/AttributeValue'}, + }, } # Note: While project is required, it's part of the path parameter(s) diff --git a/main/urls.py b/main/urls.py index ed59d652c..082e6e012 100644 --- a/main/urls.py +++ b/main/urls.py @@ -368,6 +368,14 @@ 'rest/Report/', ReportDetailAPI.as_view(), ), + path( + 'rest/ReportTypes/', + ReportTypeListAPI.as_view(), + ), + path( + 'rest/ReportType/', + ReportTypeDetailAPI.as_view(), + ), path( 'rest/SaveHTMLFile/', SaveHTMLFileAPI.as_view(), From 3cdfc9662f4a7ec7723e0711621069b79bb88d20 Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Wed, 13 Oct 2021 20:36:42 +0000 Subject: [PATCH 12/22] Forgot to add ReportType to generator --- main/schema/_generator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main/schema/_generator.py b/main/schema/_generator.py index d32471919..810e85539 100644 --- a/main/schema/_generator.py +++ b/main/schema/_generator.py @@ -125,6 +125,8 @@ def get_schema(self, request=None, public=True, parser=False): 'Project': project, 'Report': report, 'ReportSpec': report_spec, + 'ReportType': report, + 'ReportTypeSpec': report_type_spec, 'ResolutionConfig': resolution_config, 'S3StorageConfig': s3_storage_config, 'SectionSpec': section_spec, From f072161b7605b0ff5a745f7b1125f438ce22334b Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Wed, 13 Oct 2021 20:37:08 +0000 Subject: [PATCH 13/22] Add report_type files --- main/rest/report_type.py | 98 +++++++++++++++++++ main/schema/components/report_type.py | 38 +++++++ main/schema/report_type.py | 136 ++++++++++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 main/rest/report_type.py create mode 100644 main/schema/components/report_type.py create mode 100644 main/schema/report_type.py diff --git a/main/rest/report_type.py b/main/rest/report_type.py new file mode 100644 index 000000000..f31b46a1a --- /dev/null +++ b/main/rest/report_type.py @@ -0,0 +1,98 @@ +from django.db import transaction + +from ..models import ReportType +from ..models import Report +from ..models import Project +from ..schema import ReportTypeListSchema +from ..schema import ReportTypeDetailSchema + +from ._base_views import BaseListView +from ._base_views import BaseDetailView +from ._permissions import ProjectFullControlPermission +from ._attribute_keywords import attribute_keywords + +fields = ['id', 'project', 'name', 'description', 'attribute_types'] + +class ReportTypeListAPI(BaseListView): + """ Create or retrieve report types. + + A report type is the metadata definition object for reports. It may have any number of + attribute types associated with it. + """ + schema = ReportTypeListSchema() + permission_classes = [ProjectFullControlPermission] + queryset = ReportType.objects.all() + http_method_names = ['get', 'post'] + + def _get(self, params): + """ Retrieve report types. + """ + response_data = ReportType.objects.filter( + project=self.kwargs['project']).order_by('name').values(*fields) + return list(response_data) + + def _post(self, params): + """ Create report type. + """ + if params['name'] in attribute_keywords: + raise ValueError(f"{params['name']} is a reserved keyword and cannot be used for " + "an attribute name!") + params['project'] = Project.objects.get(pk=params['project']) + del params['body'] + obj = ReportType(**params) + obj.save() + return {'id': obj.id, 'message': 'Report type created successfully!'} + +class ReportTypeDetailAPI(BaseDetailView): + """ Interact with an individual report type. + + A report type is the metadata definition object for report. It includes file format, + name, description, and (like other entity types) may have any number of attribute + types associated with it. + """ + schema = ReportTypeDetailSchema() + permission_classes = [ProjectFullControlPermission] + lookup_field = 'id' + http_method_names = ['get', 'patch', 'delete'] + + def _get(self, params): + """ Get report type. + + A report type is the metadata definition object for report. It includes file format, + name, description, and (like other entity types) may have any number of attribute + types associated with it. + """ + return ReportType.objects.filter(pk=params['id']).values(*fields)[0] + + @transaction.atomic + def _patch(self, params): + """ Update report type. + + A report type is the metadata definition object for report. It includes file format, + name, description, and (like other entity types) may have any number of attribute + types associated with it. + """ + name = params.get('name', None) + description = params.get('description', None) + + obj = ReportType.objects.get(pk=params['id']) + if name is not None: + obj.name = name + if description is not None: + obj.description = description + + obj.save() + return {'message': 'Report type updated successfully!'} + + def _delete(self, params): + """ Delete report type. + + A report type is the metadata definition object for report. It includes file format, + name, description, and (like other entity types) may have any number of attribute + types associated with it. + """ + ReportType.objects.get(pk=params['id']).delete() + return {'message': f'Report type {params["id"]} deleted successfully!'} + + def get_queryset(self): + return ReportType.objects.all() diff --git a/main/schema/components/report_type.py b/main/schema/components/report_type.py new file mode 100644 index 000000000..49aaf8637 --- /dev/null +++ b/main/schema/components/report_type.py @@ -0,0 +1,38 @@ +report_type_properties = { + 'name': { + 'description': 'Name of the report type.', + 'type': 'string', + }, + 'description': { + 'description': 'Description of the report type.', + 'type': 'string', + 'default': '', + }, + 'attribute_types': { + 'description': 'Attribute type definitions.', + 'type': 'array', + 'items': {'$ref': '#/components/schemas/AttributeType'}, + }, +} + +report_type_spec = { + 'type': 'object', + 'required': ['name'], + 'properties': report_type_properties, +} + +report_type = { + 'type': 'object', + 'description': 'Report type.', + 'properties': { + 'id': { + 'type': 'integer', + 'description': 'Unique integer identifying a report type.', + }, + 'project': { + 'type': 'integer', + 'description': 'Unique integer identifying project for this report type.', + }, + **report_type_properties, + }, +} diff --git a/main/schema/report_type.py b/main/schema/report_type.py new file mode 100644 index 000000000..d1cf06157 --- /dev/null +++ b/main/schema/report_type.py @@ -0,0 +1,136 @@ +from textwrap import dedent + +from rest_framework.schemas.openapi import AutoSchema + +from ._errors import error_responses +from ._message import message_schema +from ._message import message_with_id_schema +from ._attribute_type import attribute_type_example + +boilerplate = dedent("""\ +A report type is the metadata definition object for reports. It includes the name, description, +and any associated user defined attributes. +""") + +class ReportTypeListSchema(AutoSchema): + def get_operation(self, path, method): + operation = super().get_operation(path, method) + if method == 'POST': + operation['operationId'] = 'CreateReportType' + elif method == 'GET': + operation['operationId'] = 'GetReportTypeList' + operation['tags'] = ['Tator'] + return operation + + def get_description(self, path, method): + if method == 'GET': + short_desc = 'Get report type list.' + elif method == 'POST': + short_desc = 'Create report type.' + return f"{short_desc}\n\n{boilerplate}" + + def _get_path_parameters(self, path, method): + return [{ + 'name': 'project', + 'in': 'path', + 'required': True, + 'description': 'A unique integer identifying a project.', + 'schema': {'type': 'integer'}, + }] + + def _get_filter_parameters(self, path, method): + return {} + + def _get_request_body(self, path, method): + body = {} + if method == 'POST': + body = { + 'required': True, + 'content': {'application/json': { + 'schema': {'$ref': '#/components/schemas/ReportTypeSpec'}, + 'example': { + 'name': 'My report type', + 'attribute_types': attribute_type_example, + }, + }}} + return body + + def _get_responses(self, path, method): + responses = error_responses() + if method == 'GET': + responses['200'] = { + 'description': 'Successful retrieval of report type list.', + 'content': {'application/json': {'schema': { + 'type': 'array', + 'items': {'$ref': '#/components/schemas/ReportType'}, + }}}, + } + elif method == 'POST': + responses['201'] = message_with_id_schema('report type') + return responses + +class ReportTypeDetailSchema(AutoSchema): + def get_operation(self, path, method): + operation = super().get_operation(path, method) + if method == 'GET': + operation['operationId'] = 'GetReportType' + elif method == 'PATCH': + operation['operationId'] = 'UpdateReportType' + elif method == 'DELETE': + operation['operationId'] = 'DeleteReportType' + operation['tags'] = ['Tator'] + return operation + + def get_description(self, path, method): + long_desc = '' + if method == 'GET': + short_desc = 'Get report type.' + elif method == 'PATCH': + short_desc = 'Update report type.' + elif method == 'DELETE': + short_desc = 'Delete report type.' + long_desc = dedent("""\ + Note that this will also delete any reports associated with the report type. + """) + return f"{short_desc}\n\n{boilerplate}\n\n{long_desc}" + + def _get_path_parameters(self, path, method): + return [{ + 'name': 'id', + 'in': 'path', + 'required': True, + 'description': 'A unique integer identifying an report type.', + 'schema': {'type': 'integer'}, + }] + + def _get_filter_parameters(self, path, method): + return [] + + def _get_request_body(self, path, method): + body = {} + if method == 'PATCH': + body = { + 'required': True, + 'content': {'application/json': { + 'schema': {'$ref': '#/components/schemas/ReportTypeSpec'}, + 'example': { + 'name': 'New name', + 'description': 'New description', + } + }}} + return body + + def _get_responses(self, path, method): + responses = error_responses() + if method == 'GET': + responses['200'] = { + 'description': 'Successful retrieval of report type.', + 'content': {'application/json': {'schema': { + '$ref': '#/components/schemas/ReportType', + }}}, + } + elif method == 'PATCH': + responses['200'] = message_schema('update', 'report type') + elif method == 'DELETE': + responses['200'] = message_schema('deletion', 'report type') + return responses From 037e70ab8d0303cccc254642666d0715d1fdabe0 Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Wed, 13 Oct 2021 21:10:35 +0000 Subject: [PATCH 14/22] Another bugfix with generator --- main/schema/_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/schema/_generator.py b/main/schema/_generator.py index 810e85539..c030a28e4 100644 --- a/main/schema/_generator.py +++ b/main/schema/_generator.py @@ -125,7 +125,7 @@ def get_schema(self, request=None, public=True, parser=False): 'Project': project, 'Report': report, 'ReportSpec': report_spec, - 'ReportType': report, + 'ReportType': report_type, 'ReportTypeSpec': report_type_spec, 'ResolutionConfig': resolution_config, 'S3StorageConfig': s3_storage_config, From ac039024efcad04bda733aa1421e9afc94a981b2 Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Thu, 14 Oct 2021 00:45:37 +0000 Subject: [PATCH 15/22] Fixed dashboard endpoint bugs --- main/rest/dashboard.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/main/rest/dashboard.py b/main/rest/dashboard.py index 782465f27..d54d92f24 100644 --- a/main/rest/dashboard.py +++ b/main/rest/dashboard.py @@ -85,7 +85,7 @@ def _delete(self, params: dict) -> dict: dashboard.delete() # Delete the correlated file - path = os.path.join(settings.MEDIA_ROOT, html_file) + path = os.path.join(settings.MEDIA_ROOT, html_file.name) self.safe_delete(path=path) msg = 'Registered dashboard deleted successfully!' @@ -114,14 +114,14 @@ def _patch(self, params) -> dict: html_file = params.get(fields.html_file, None) if html_file is not None: dashboard_file = os.path.basename(html_file) - dashboard_url = os.path.join(str(project_id), dashboard_file) + dashboard_url = os.path.join(str(obj.project.id), dashboard_file) dashboard_path = os.path.join(settings.MEDIA_ROOT, dashboard_url) if not os.path.exists(dashboard_path): - log_msg = f'Provided dashboard ({dashboard_file}) does not exist in {settings.MEDIA_ROOT}' + log_msg = f'Provided dashboard ({dashboard_path}) does not exist' logging.error(log_msg) - raise ValueError(log_msg) + raise ValueError("Dashboard file does not exist in expected location.") - delete_path = os.path.join(settings.MEDIA_ROOT, obj.html_file) + delete_path = os.path.join(settings.MEDIA_ROOT, obj.html_file.name) self.safe_delete(path=delete_path) obj.html_file = dashboard_path From 141a4d63eee08c65664db13e3cff291da7de6763 Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Thu, 14 Oct 2021 20:14:52 +0000 Subject: [PATCH 16/22] Removed reference to report components --- main/schema/components/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/main/schema/components/__init__.py b/main/schema/components/__init__.py index 30a8a6fb6..da414223b 100644 --- a/main/schema/components/__init__.py +++ b/main/schema/components/__init__.py @@ -88,10 +88,6 @@ from .project import project_spec from .project import project_update from .project import project -from .report import report -from .report import report_spec -from .report_type import report_type_spec -from .report_type import report_type from .section import section_spec from .section import section_update from .section import section From 3bd9c213ffc6954183d62313401e675ba644aef8 Mon Sep 17 00:00:00 2001 From: Jonathan Takahashi Date: Fri, 15 Oct 2021 03:27:11 +0000 Subject: [PATCH 17/22] Exit with code 1 if whl does not exist after running setup.py. Closes #181. --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 574fb453e..ca137977e 100644 --- a/Makefile +++ b/Makefile @@ -555,6 +555,9 @@ python-bindings: tator-image cd scripts/packages/tator-py rm -rf dist python3 setup.py sdist bdist_wheel + if [ ! -f dist/*.whl ]; then + exit 1 + fi cd ../../.. .PHONY: r-docs From e79ce62d455ca3cdabc3db24e92ca002826afd70 Mon Sep 17 00:00:00 2001 From: Jonathan Takahashi Date: Fri, 15 Oct 2021 03:28:30 +0000 Subject: [PATCH 18/22] Update tator-py --- scripts/packages/tator-py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/packages/tator-py b/scripts/packages/tator-py index 61a93e613..d918b4c80 160000 --- a/scripts/packages/tator-py +++ b/scripts/packages/tator-py @@ -1 +1 @@ -Subproject commit 61a93e6139dcbb09b5e753e8e47f5fe9c4569ccf +Subproject commit d918b4c801f66777395f3081918b5606f6837575 From 18494d7242223268834797824f36faaac6f37e09 Mon Sep 17 00:00:00 2001 From: Jonathan Takahashi Date: Fri, 15 Oct 2021 11:26:07 +0000 Subject: [PATCH 19/22] Order dashboard lists by ID --- main/rest/dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/rest/dashboard.py b/main/rest/dashboard.py index d54d92f24..28f4e8735 100644 --- a/main/rest/dashboard.py +++ b/main/rest/dashboard.py @@ -26,7 +26,7 @@ class DashboardListAPI(BaseListView): http_method_names = ['get', 'post'] def _get(self, params: dict) -> dict: - qs = Dashboard.objects.filter(project=params['project']) + qs = Dashboard.objects.filter(project=params['project']).order_by('id') return database_qs(qs) def get_queryset(self) -> dict: From 0cf097610e7b222aae5deb341af265e31ca15607 Mon Sep 17 00:00:00 2001 From: Jonathan Takahashi Date: Fri, 15 Oct 2021 11:26:25 +0000 Subject: [PATCH 20/22] Update tator-py --- scripts/packages/tator-py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/packages/tator-py b/scripts/packages/tator-py index d918b4c80..c90295ead 160000 --- a/scripts/packages/tator-py +++ b/scripts/packages/tator-py @@ -1 +1 @@ -Subproject commit d918b4c801f66777395f3081918b5606f6837575 +Subproject commit c90295ead954ee550dee20f6debf3edae9312de7 From 4fe029fd5a51d58149c664ce22cf9acfcce85add Mon Sep 17 00:00:00 2001 From: Jonathan Takahashi Date: Fri, 15 Oct 2021 13:12:54 +0000 Subject: [PATCH 21/22] Update tator-py --- scripts/packages/tator-py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/packages/tator-py b/scripts/packages/tator-py index c90295ead..89d5ed3bf 160000 --- a/scripts/packages/tator-py +++ b/scripts/packages/tator-py @@ -1 +1 @@ -Subproject commit c90295ead954ee550dee20f6debf3edae9312de7 +Subproject commit 89d5ed3bfc3e824bdf73b6c0fc43180e09dc3782 From cbf8448be226f889480f4b438f117bac27b3774b Mon Sep 17 00:00:00 2001 From: Mark Taipan Date: Fri, 15 Oct 2021 16:43:19 +0000 Subject: [PATCH 22/22] Fix makefile's inclusion of dashboard elements --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index ca137977e..82122619d 100644 --- a/Makefile +++ b/Makefile @@ -446,9 +446,9 @@ FILES = \ annotation/volume-control.js \ analytics/analytics-breadcrumbs.js \ analytics/analytics-settings.js \ - analytics/dashboard/dashboard-portal.js \ - analytics/dashboard/dashboard-summary.js \ - analytics/dashboard/dashboard.js \ + analytics/dashboards/dashboard-portal.js \ + analytics/dashboards/dashboard-summary.js \ + analytics/dashboards/dashboard.js \ analytics/localizations/card.js \ analytics/localizations/gallery.js \ analytics/localizations/panel-data.js \