Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update DANDI API Workflow #480

Merged
merged 6 commits into from
Oct 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion schemas/json/dandi/global.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"staging_api_key": {
"type": "string",
"format": "password",
"description": "Your DANDI API key from the <a href='https://gui-staging.dandiarchive.org' target='_blank'>staging (testing) server</a>"
"description": "Your DANDI API key from the <a href='https://gui-staging.dandiarchive.org' target='_blank'>staging server</a>"
}
},
"required": ["main_api_key"]
Expand Down
2 changes: 1 addition & 1 deletion schemas/json/dandi/upload.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"properties": {
"dandiset_id": {
"type": "string",
"description": "The unique identifier for your dandiset. Will automatically determine whether to upload to the main DANDI archive or the development staging server."
"description": "The unique identifier for your Dandiset, manually created on the <a href='https://dandiarchive.org' target='_blank'>main archive</a> or <a href='https://gui-staging.dandiarchive.org' target='_blank'>staging server</a>."
},
"cleanup": {
"type": "boolean",
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/src/stories/Modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface ModalProps {
showCloseButton?: boolean,
width?: string
height?: string
closeText?: string
}

export class Modal extends LitElement {
Expand Down Expand Up @@ -128,6 +129,10 @@ export class Modal extends LitElement {
height: {
type: String,
reflext: true
},
closeText: {
type: String,
reflect: true
}
};
}
Expand All @@ -141,6 +146,8 @@ export class Modal extends LitElement {
declare width: ModalProps['width']
declare height: ModalProps['height']

declare closeText: ModalProps['closeText']

constructor(props: ModalProps = {}) {
super();

Expand All @@ -150,6 +157,7 @@ export class Modal extends LitElement {
this.onClose = props.onClose
this.onOpen = props.onOpen
this.showCloseButton = props.showCloseButton ?? true
this.closeText = props.closeText

this.width = props.width
this.height = props.height
Expand All @@ -173,7 +181,7 @@ export class Modal extends LitElement {
<div class="modal-content ${this.open ? 'open' : ''}" style="${this.width ? `width: ${this.width};` : ''} ${this.height ? `height: ${this.height};` : ''}">
<div class="modal-header">
<span title="${this.header}">${this.header}</span>
${this.showCloseButton ? html`<nwb-button secondary size="extra-small" @click="${this.toggle}">Close</nwb-button>` : ''}
${this.showCloseButton ? html`<nwb-button secondary size="extra-small" @click="${this.toggle}">${this.closeText ?? 'Close'}</nwb-button>` : ''}
</div>
<div class="modal-body">
<slot>No content</slot>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/stories/pages/guided-mode/GuidedStart.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class GuidedStartPage extends Page {
</p>
<h4>4. Final Review</h4>
<p>
Finally, you will upload your conversion to DANDI and review the resulting dandiset.
Finally, you will upload your conversion to DANDI and review the resulting Dandiset.
</p>

<br>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,14 @@ export class GuidedStubPreviewPage extends Page {
next: "Run Conversion",
onNext: async () => {
await this.save(); // Save in case the conversion fails

delete this.info.globalState.conversion;
this.info.globalState.conversion = await this.runConversions({}, true, {
title: "Running all conversions",
});

await this.save(); // Save the conversion results

this.to(1);
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { onThrow } from "../../../../errors";
import { merge } from "../../utils.js";
import Swal from "sweetalert2";
import dandiUploadSchema from "../../../../../../../schemas/json/dandi/upload.json";
import { uploadToDandi } from "../../uploads/UploadsPage.js";
import { dandisetInfoContent, uploadToDandi } from "../../uploads/UploadsPage.js";
import { InfoBox } from "../../../InfoBox.js";

export class GuidedUploadPage extends Page {
constructor(...args) {
Expand All @@ -19,7 +20,7 @@ export class GuidedUploadPage extends Page {
const globalState = this.info.globalState;
const isNewDandiset = globalState.upload?.dandiset_id !== this.localState.dandiset_id;
merge({ upload: this.localState }, globalState); // Merge the local and global states
if (isNewDandiset) delete globalState.upload.results; // Clear the preview results entirely if a new dandiset
if (isNewDandiset) delete globalState.upload.results; // Clear the preview results entirely if a new Dandiset
};

header = {
Expand All @@ -36,7 +37,7 @@ export class GuidedUploadPage extends Page {
const globalState = this.info.globalState;
const globalUploadInfo = globalState.upload;

// Catch if dandiset is already uploaded
// Catch if Dandiset is already uploaded
if ("results" in globalUploadInfo) {
const result = await Swal.fire({
title: "This pipeline has already uploaded to DANDI",
Expand Down Expand Up @@ -70,7 +71,10 @@ export class GuidedUploadPage extends Page {
onThrow,
});

return html` ${this.form} `;
return html`${new InfoBox({
header: "How do I create a Dandiset?",
content: dandisetInfoContent,
})}<br /><br />${this.form} `;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export class GuidedResultsPage extends Page {
render() {
const { conversion } = this.info.globalState;

console.log(this.info.globalState);

if (!conversion)
return html`<div style="text-align: center;"><p>Your conversion failed. Please try again.</p></div>`;

Expand Down
28 changes: 12 additions & 16 deletions src/renderer/src/stories/pages/settings/SettingsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { onThrow } from "../../../errors";
import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json";
import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" };

import { validateDANDIApiKey } from "../../../validation/dandi";

const schema = {
properties: {
output_locations: projectGlobalSchema,
Expand All @@ -17,13 +19,10 @@ import { global } from "../../../progress/index.js";
import { merge } from "../utils.js";

import { notyf } from "../../../dependencies/globals.js";
import { header } from "../../forms/utils";

const dandiAPITokenRegex = /^[a-f0-9]{40}$/;

const setUndefinedIfNotDeclared = (schema, resolved) => {
for (let prop in schema.properties) {
const propInfo = schema.properties[prop];
const setUndefinedIfNotDeclared = (schemaProps, resolved) => {
for (const prop in schemaProps) {
const propInfo = schemaProps[prop]?.properties;
if (propInfo) setUndefinedIfNotDeclared(propInfo, resolved[prop]);
else if (!(prop in resolved)) resolved[prop] = undefined;
}
Expand All @@ -46,15 +45,13 @@ export class SettingsPage extends Page {
return (this.#notification = this.notify(message, type));
};

beforeSave = () => {
beforeSave = async () => {
await this.form.validate();

const { resolved } = this.form;
for (let prop in schema.properties) {
const propInfo = schema.properties[prop];
const res = resolved[prop];
if (propInfo) setUndefinedIfNotDeclared(propInfo, res);
}
setUndefinedIfNotDeclared(schema.properties, resolved);

merge(this.form.resolved, global.data);
merge(resolved, global.data);

global.save(); // Save the changes, even if invalid on the form
this.#openNotyf("Global settings changes saved", "success");
Expand All @@ -77,10 +74,9 @@ export class SettingsPage extends Page {
schema,
mode: "accordion",
onUpdate: () => (this.unsavedUpdates = true),
validateOnChange: (name, parent) => {
validateOnChange: async (name, parent) => {
const value = parent[name];
if (value && name.includes("api_key") && !dandiAPITokenRegex.test(value))
return [{ type: "error", message: `${header(name)} must be a 40 character hexadecimal string` }];
if (name.includes("api_key")) return await validateDANDIApiKey(value, name.includes("staging"));
return true;
},
onThrow,
Expand Down
92 changes: 82 additions & 10 deletions src/renderer/src/stories/pages/uploads/UploadsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,95 @@ import { global } from "../../../progress/index.js";
import { merge } from "../utils.js";

import { run } from "../guided-mode/options/utils.js";
import Swal from "sweetalert2";
import { Modal } from "../../Modal";
import { DandiResults } from "../../DandiResults.js";

import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json";
import { JSONSchemaInput } from "../../JSONSchemaInput.js";
import { header } from "../../forms/utils";

import { validateDANDIApiKey } from "../../../validation/dandi";
import { InfoBox } from "../../InfoBox.js";

export const isStaging = (id) => parseInt(id) >= 100000;

export const dandisetInfoContent = html`<span>
You can create a new Dandiset on the <a href="http://dandiarchive.org" target="_blank">main DANDI archive</a>.
This Dandiset can be fully public or embargoed according to NIH policy. When you create a Dandiset, a permanent
ID is automatically assigned to it.
</span>
<hr />
<small
>To prevent the production server from being inundated with test Dandisets, we encourage developers to develop
against the <a href="http://gui-staging.dandiarchive.org" target="_blank">development server</a>. Note that the
development server should not be used to stage your data. All data are uploaded as draft and can be adjusted
before publishing on the production server. The development server is primarily used by users learning to use
DANDI or by developers.</small
> `;

export async function uploadToDandi(info, type = "project" in info ? "project" : "") {
const { dandiset_id } = info;

const staging = isStaging(dandiset_id); // Automatically detect staging IDs

const whichAPIKey = staging ? "staging_api_key" : "main_api_key";
const api_key = global.data.DANDI?.api_keys?.[whichAPIKey];

if (!api_key) {
await Swal.fire({
title: `Your DANDI API key (${whichAPIKey}) is not configured.`,
html: "Edit your settings to include this value.",
icon: "warning",
confirmButtonText: "Go to Settings",
let api_key = global.data.DANDI?.api_keys?.[whichAPIKey];

const errors = await validateDANDIApiKey(api_key, staging);

const isInvalid = !errors || errors.length;

if (isInvalid) {
const modal = new Modal({
header: `${api_key ? "Update" : "Provide"} your ${header(whichAPIKey)}`,
open: true,
});

return this.to("settings");
const input = new JSONSchemaInput({
path: [whichAPIKey],
info: dandiGlobalSchema.properties.api_keys.properties[whichAPIKey],
});

input.style.padding = "25px";

modal.append(input);

let notification;

const notify = (message, type) => {
if (notification) this.dismiss(notification);
return (notification = this.notify(message, type));
};

modal.onClose = async () => notify("Your DANDI API key was not set", "error");

api_key = await new Promise((resolve) => {
const button = new Button({
label: "Save",
primary: true,
onClick: async () => {
const value = input.value;
if (value) {
const errors = await validateDANDIApiKey(input.value, staging);
if (!errors || !errors.length) {
modal.remove();
global.data.DANDI.api_keys[whichAPIKey] = value;
global.save();
resolve(value);
} else {
notify(errors[0].message, "error");
return false;
}
} else {
notify("Your DANDI API key was not set", "error");
}
},
});

modal.footer = button;

document.body.append(modal);
});
}

const result = await run(
Expand Down Expand Up @@ -117,6 +183,12 @@ export class UploadsPage extends Page {
});

return html`
${new InfoBox({
header: "How do I create a Dandiset?",
content: dandisetInfoContent,
})}
<br />
<br />
${this.form}
<hr />
${button}
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/stories/preview/inspector/InspectorList.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { LitElement, css, html } from "lit";
import { List } from "../../List";
import { getMessageType, isErrorImportance } from "../../../validation";

import { unsafeHTML } from "lit/directives/unsafe-html.js";

const sortList = (items) => {
return items
.sort((a, b) => {
Expand Down Expand Up @@ -140,7 +142,9 @@ export class InspectorListItem extends LitElement {

return html`
${hasMetadata ? html`<span id="objectType">${hasObjectType ? `${this.object_type}` : ""} </span>` : ""}
${hasMetadata ? html`<span id="message">${this.message}</span>` : html`<p>${this.message}</p>`}
${hasMetadata
? html`<span id="message">${unsafeHTML(this.message)}</span>`
: html`<p>${unsafeHTML(this.message)}</p>`}
${this.file_path
? html`<span id="filepath"
>${this.files && this.files.length > 1
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/src/validation/dandi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@

const dandiAPITokenRegex = /^[a-f0-9]{40}$/;


export const validateDANDIApiKey = async (apiKey: string, staging = false) => {
if (apiKey) {

if (!dandiAPITokenRegex.test(apiKey)) return [{ type: "error", message: `Invalid API key format. Must be a 40 character hexadecimal string` }];

const authFailedError = {type: 'error', message: `Authorization failed. Make sure you're providing an API key for the <a href='https://${staging ? 'gui-staging.' : ''}dandiarchive.org' target='_blank'>${staging ? 'staging' : 'main'} archive</a>.`}

return fetch(`https://api${staging ? '-staging' : ''}.dandiarchive.org/api/auth/token/`, {headers: {Authorization: `token ${apiKey}`}})
.then((res) => {
if (!res.ok) return [authFailedError]
return true
})
.catch(() => [authFailedError])
}
}
Loading