From e3e2d0f95b7b7afca7e6a9a2743e1d9fcd81ec96 Mon Sep 17 00:00:00 2001 From: jdbocarsly Date: Wed, 3 Jan 2024 23:46:18 -0600 Subject: [PATCH 01/21] add minimal model for instruments --- pydatalab/pydatalab/models/__init__.py | 13 ++++++++++++- pydatalab/pydatalab/models/equipment.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 pydatalab/pydatalab/models/equipment.py diff --git a/pydatalab/pydatalab/models/__init__.py b/pydatalab/pydatalab/models/__init__.py index f62483822..2d13e371f 100644 --- a/pydatalab/pydatalab/models/__init__.py +++ b/pydatalab/pydatalab/models/__init__.py @@ -4,6 +4,7 @@ from pydatalab.models.cells import Cell from pydatalab.models.collections import Collection +from pydatalab.models.equipment import Equipment from pydatalab.models.files import File from pydatalab.models.people import Person from pydatalab.models.samples import Sample @@ -13,6 +14,16 @@ "samples": Sample, "starting_materials": StartingMaterial, "cells": Cell, + "equipment": Equipment, } -__all__ = ("File", "Sample", "StartingMaterial", "Person", "Cell", "Collection", "ITEM_MODELS") +__all__ = ( + "File", + "Sample", + "StartingMaterial", + "Person", + "Cell", + "Collection", + "Equipment", + "ITEM_MODELS", +) diff --git a/pydatalab/pydatalab/models/equipment.py b/pydatalab/pydatalab/models/equipment.py new file mode 100644 index 000000000..d6c0587a4 --- /dev/null +++ b/pydatalab/pydatalab/models/equipment.py @@ -0,0 +1,20 @@ +from typing import Optional + +from pydantic import Field + +from pydatalab.models.items import Item + + +class Equipment(Item): + """A model for representing an experimental sample.""" + + type: str = Field("equipment", const="equipment", pattern="^equipment$") + + serial_numbers: Optional[str] + """A string describing one or more serial numbers for the instrument.""" + + manufacturer: Optional[str] + """The manufacturer of this piece of equipment""" + + location: Optional[str] + """Place where the equipment is located""" From 22090d07a25adf550dbbcac13bf20e34f5d3c1b2 Mon Sep 17 00:00:00 2001 From: jdbocarsly Date: Sat, 6 Jan 2024 23:33:07 -0600 Subject: [PATCH 02/21] add backend routes for equipment --- pydatalab/pydatalab/routes/v0_1/items.py | 96 +++++++++++++++++++----- 1 file changed, 77 insertions(+), 19 deletions(-) diff --git a/pydatalab/pydatalab/routes/v0_1/items.py b/pydatalab/pydatalab/routes/v0_1/items.py index bb762797f..d6df76840 100644 --- a/pydatalab/pydatalab/routes/v0_1/items.py +++ b/pydatalab/pydatalab/routes/v0_1/items.py @@ -74,6 +74,56 @@ def dereference_files(file_ids: List[Union[str, ObjectId]]) -> Dict[str, Dict]: return results +def get_equipment_summary(): + if not current_user.is_authenticated and not CONFIG.TESTING: + return ( + jsonify( + status="error", + message="Authorization required to access chemical inventory.", + ), + 401, + ) + + _project = { + "_id": 0, + "creators": { + "display_name": 1, + "contact_email": 1, + }, + # "collections": { + # "collection_id": 1, + # "title": 1, + # }, + "item_id": 1, + "name": 1, + "nblocks": {"$size": "$display_order"}, + "characteristic_chemical_formula": 1, + "type": 1, + "date": 1, + "refcode": 1, + "location": 1, + } + + items = [ + doc + for doc in flask_mongo.db.items.aggregate( + [ + { + "$match": { + "type": "equipment", + **get_default_permissions(user_only=False), + } + }, + {"$project": _project}, + ] + ) + ] + return jsonify({"status": "success", "items": items}) + + +get_equipment_summary.methods = ("GET",) # type: ignore + + def get_starting_materials(): if not current_user.is_authenticated and not CONFIG.TESTING: return ( @@ -497,29 +547,36 @@ def _create_sample( 400, ) + sample_list_entry = { + "refcode": data_model.refcode, + "item_id": data_model.item_id, + "nblocks": 0, + "date": data_model.date, + "name": data_model.name, + "creator_ids": data_model.creator_ids, + # TODO: This workaround for creators & collections is still gross, need to figure this out properly + "creators": [json.loads(c.json(exclude_unset=True)) for c in data_model.creators] + if data_model.creators + else [], + "collections": [ + json.loads(c.json(exclude_unset=True, exclude_none=True)) + for c in data_model.collections + ] + if data_model.collections + else [], + "type": data_model.type, + } + + # hack to let us use _create_sample() for equipment too. We probably want to make + # a more general create_item() to more elegantly handle different returns. + if data_model.type == "equipment": + sample_list_entry["location"] = data_model.location + data = ( { "status": "success", "item_id": data_model.item_id, - "sample_list_entry": { - "refcode": data_model.refcode, - "item_id": data_model.item_id, - "nblocks": 0, - "date": data_model.date, - "name": data_model.name, - "creator_ids": data_model.creator_ids, - # TODO: This workaround for creators & collections is still gross, need to figure this out properly - "creators": [json.loads(c.json(exclude_unset=True)) for c in data_model.creators] - if data_model.creators - else [], - "collections": [ - json.loads(c.json(exclude_unset=True, exclude_none=True)) - for c in data_model.collections - ] - if data_model.collections - else [], - "type": data_model.type, - }, + "sample_list_entry": sample_list_entry, }, 201, # 201: Created ) @@ -885,6 +942,7 @@ def search_users(): ENDPOINTS: Dict[str, Callable] = { "/samples/": get_samples, "/starting-materials/": get_starting_materials, + "/equipment/": get_equipment_summary, "/search-items/": search_items, "/search-users/": search_users, "/new-sample/": create_sample, From cab0ed4e86b268a2fc01694b6ea8d6d74fb4fbd0 Mon Sep 17 00:00:00 2001 From: jdbocarsly Date: Sat, 6 Jan 2024 23:17:57 -0600 Subject: [PATCH 03/21] frontend for equipment table page, "new equipment" modal functionality, and EquipmentInformation fix a typo in html of sample table --- .../src/components/CreateEquipmentModal.vue | 246 ++++++++++++++++++ .../src/components/EquipmentInformation.vue | 115 ++++++++ webapp/src/components/EquipmentTable.vue | 107 ++++++++ webapp/src/components/Navbar.vue | 2 + webapp/src/components/SampleTable.vue | 2 +- webapp/src/resources.js | 10 + webapp/src/router/index.js | 7 + webapp/src/server_fetch_utils.js | 15 ++ webapp/src/store/index.js | 9 + webapp/src/views/Equipment.vue | 61 +++++ 10 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 webapp/src/components/CreateEquipmentModal.vue create mode 100644 webapp/src/components/EquipmentInformation.vue create mode 100644 webapp/src/components/EquipmentTable.vue create mode 100644 webapp/src/views/Equipment.vue diff --git a/webapp/src/components/CreateEquipmentModal.vue b/webapp/src/components/CreateEquipmentModal.vue new file mode 100644 index 000000000..8bcb28132 --- /dev/null +++ b/webapp/src/components/CreateEquipmentModal.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/webapp/src/components/EquipmentInformation.vue b/webapp/src/components/EquipmentInformation.vue new file mode 100644 index 000000000..bcfd53f23 --- /dev/null +++ b/webapp/src/components/EquipmentInformation.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/webapp/src/components/EquipmentTable.vue b/webapp/src/components/EquipmentTable.vue new file mode 100644 index 000000000..76f47df3e --- /dev/null +++ b/webapp/src/components/EquipmentTable.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/webapp/src/components/Navbar.vue b/webapp/src/components/Navbar.vue index 165fcbcea..2b564c29e 100644 --- a/webapp/src/components/Navbar.vue +++ b/webapp/src/components/Navbar.vue @@ -13,6 +13,8 @@ Samples | Collections | Inventory | + Equipment | +  Graph View diff --git a/webapp/src/components/SampleTable.vue b/webapp/src/components/SampleTable.vue index 6c8d4765d..9353a7523 100644 --- a/webapp/src/components/SampleTable.vue +++ b/webapp/src/components/SampleTable.vue @@ -33,7 +33,7 @@ enableModifiedClick /> - {{ itemTypes[sample.type].display }} + {{ itemTypes[sample.type].display }} {{ sample.name }} {{ $filters.IsoDatetimeToDate(sample.date) }} diff --git a/webapp/src/resources.js b/webapp/src/resources.js index e8f9091d2..fe624c9f5 100644 --- a/webapp/src/resources.js +++ b/webapp/src/resources.js @@ -16,6 +16,8 @@ import CollectionInformation from "@/components/CollectionInformation"; import SampleCreateModalAddon from "@/components/itemCreateModalAddons/SampleCreateModalAddon"; import CellCreateModalAddon from "@/components/itemCreateModalAddons/CellCreateModalAddon"; +import CollectionInformation from "@/components/CollectionInformation"; +import EquipmentInformation from "@/components/EquipmentInformation"; // Look for values set in .env file. Use defaults if `null` is not explicitly handled elsewhere in the code. export const API_URL = @@ -101,6 +103,14 @@ export const itemTypes = { isCreateable: false, display: "user", }, + equipment: { + itemInformationComponent: EquipmentInformation, + navbarColor: "#c77c02", + navbarName: "Equipment", + lightColor: "#f7d6a1", + labelColor: "#c77c02", + display: "equipment", + }, }; export const SAMPLE_TABLE_TYPES = ["samples", "cells"]; diff --git a/webapp/src/router/index.js b/webapp/src/router/index.js index ab371244f..4e4bc3558 100644 --- a/webapp/src/router/index.js +++ b/webapp/src/router/index.js @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router"; // import Home from '../views/Home.vue' // import Test from '../views/Test.vue' import Samples from "../views/Samples.vue"; +import Equipment from "../views/Equipment.vue"; import SamplesNext from "../views/SamplesNext.vue"; import StartingMaterials from "../views/StartingMaterials.vue"; import StartingMaterialsNext from "../views/StartingMaterialsNext.vue"; @@ -29,6 +30,12 @@ const routes = [ alias: "/", component: Samples, }, + { + path: "/equipment", + name: "equipment", + alias: "/", + component: Equipment, + }, { path: "/next/samples", name: "samples-next", alias: "/next", component: SamplesNext }, { path: "/edit/:id", diff --git a/webapp/src/server_fetch_utils.js b/webapp/src/server_fetch_utils.js index 4840b6437..477d3a3bc 100644 --- a/webapp/src/server_fetch_utils.js +++ b/webapp/src/server_fetch_utils.js @@ -126,6 +126,9 @@ export function createNewItem( if (INVENTORY_TABLE_TYPES.includes(response_json.sample_list_entry.type)) { store.commit("prependToStartingMaterialList", response_json.sample_list_entry); } + if (returned_type === "equipment") { + store.commit("prependToEquipmentList", response_json.sample_list_entry); + } return "success"; }); } @@ -239,6 +242,18 @@ export function getStartingMaterialList() { }); } +export function getEquipmentList() { + return fetch_get(`${API_URL}/equipment/`) + .then(function (response_json) { + store.commit("setEquipmentList", response_json.items); + }) + .catch((error) => { + console.error("Error when fetching equipment list"); + console.error(error); + throw error; + }); +} + export function searchItems(query, nresults = 100, types = null) { // construct a url with parameters: var url = new URL(`${API_URL}/search-items/`); diff --git a/webapp/src/store/index.js b/webapp/src/store/index.js index 720dbe1d6..27bf269f1 100644 --- a/webapp/src/store/index.js +++ b/webapp/src/store/index.js @@ -12,6 +12,7 @@ export default createStore({ all_collection_children: {}, all_collection_parents: {}, sample_list: [], + equipment_list: [], starting_material_list: [], collection_list: [], saved_status_items: {}, @@ -43,6 +44,10 @@ export default createStore({ setDisplayName(state, displayName) { state.currentUserDisplayName = displayName; }, + setEquipmentList(state, equipmentSummaries) { + // equipmentSummary is an array of json objects summarizing the available samples + state.equipment_list = equipmentSummaries; + }, appendToSampleList(state, sampleSummary) { // sampleSummary is a json object summarizing the new sample state.sample_list.push(sampleSummary); @@ -55,6 +60,10 @@ export default createStore({ // sampleSummary is a json object summarizing the new sample state.starting_material_list.unshift(itemSummary); }, + prependToEquipmentList(state, equipmentSummary) { + // sampleSummary is a json object summarizing the new sample + state.equipment_list.unshift(equipmentSummary); + }, prependToCollectionList(state, collectionSummary) { // collectionSummary is a json object summarizing the new collection state.collection_list.unshift(collectionSummary); diff --git a/webapp/src/views/Equipment.vue b/webapp/src/views/Equipment.vue new file mode 100644 index 000000000..5ada3b28a --- /dev/null +++ b/webapp/src/views/Equipment.vue @@ -0,0 +1,61 @@ + + + + + From be6d3ec24f4f01df61a5ad64004d1923a104e665 Mon Sep 17 00:00:00 2001 From: jdbocarsly Date: Mon, 15 Jan 2024 21:00:17 -0600 Subject: [PATCH 04/21] turn on emojis in tinymce fields :grin: --- webapp/src/components/TinyMceInline.vue | 18 +++++++++--------- webapp/src/main.js | 3 ++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/webapp/src/components/TinyMceInline.vue b/webapp/src/components/TinyMceInline.vue index e20957e56..c67a23dd7 100644 --- a/webapp/src/components/TinyMceInline.vue +++ b/webapp/src/components/TinyMceInline.vue @@ -8,14 +8,14 @@ menubar: false, placeholder: 'Add a description', toolbar_location: 'bottom', - plugins: 'hr image link lists charmap table', + plugins: 'hr image link lists charmap table emoticons', table_default_styles: { width: '50%', 'margin-left': '1rem', }, contextmenu: false, toolbar: - 'bold italic underline strikethrough superscript subscript forecolor backcolor removeformat | alignleft aligncenter alignright | bullist numlist indent outdent | headergroup insertgroup | table', + 'bold italic underline strikethrough superscript subscript forecolor backcolor removeformat | alignleft aligncenter alignright | bullist numlist indent outdent | headergroup insertgroup | table', toolbar_groups: { formatgroup: { icon: 'format', @@ -46,11 +46,11 @@