diff --git a/backend/database/datasets.py b/backend/database/datasets.py index 32eabf41..a4c9927a 100644 --- a/backend/database/datasets.py +++ b/backend/database/datasets.py @@ -22,6 +22,7 @@ class DatasetModel(DynamicDocument): annotate_url = StringField(default="") default_annotation_metadata = DictField(default={}) + tags = DictField(default={}) deleted = BooleanField(default=False) deleted_date = DateTimeField() diff --git a/backend/webserver/api/datasets.py b/backend/webserver/api/datasets.py index 916fd88f..e69850fe 100644 --- a/backend/webserver/api/datasets.py +++ b/backend/webserver/api/datasets.py @@ -36,6 +36,7 @@ page_data.add_argument('limit', default=20, type=int) page_data.add_argument('folder', default='', help='Folder for data') page_data.add_argument('order', default='file_name', help='Order to display images') +page_data.add_argument('filters', default='[]', type=str, required=False) delete_data = reqparse.RequestParser() delete_data.add_argument('fully', default=False, type=bool, @@ -52,6 +53,7 @@ update_dataset.add_argument('categories', location='json', type=list, help="New list of categories") update_dataset.add_argument('default_annotation_metadata', location='json', type=dict, help="Default annotation metadata") +update_dataset.add_argument('tags', location='json', type=dict, help="Dataset tags") dataset_generate = reqparse.RequestParser() dataset_generate.add_argument('keywords', location='json', type=list, default=[], @@ -87,7 +89,6 @@ def post(self): return query_util.fix_ids(dataset) - def download_images(output_dir, args): for keyword in args['keywords']: response = gid.googleimagesdownload() @@ -262,6 +263,7 @@ def post(self, dataset_id): categories = args.get('categories') default_annotation_metadata = args.get('default_annotation_metadata') set_default_annotation_metadata = args.get('set_default_annotation_metadata') + tags = args.get('tags') if categories is not None: dataset.categories = CategoryModel.bulk_create(categories) @@ -279,7 +281,11 @@ def post(self, dataset_id): AnnotationModel.objects(dataset_id=dataset.id, deleted=False)\ .update(**update) + if tags is not None: + dataset.tags = tags + dataset.update( + tags = dataset.tags, categories=dataset.categories, default_annotation_metadata=dataset.default_annotation_metadata ) @@ -312,14 +318,28 @@ class DatasetData(Resource): @login_required def get(self): """ Endpoint called by dataset viewer client """ - args = page_data.parse_args() limit = args['limit'] page = args['page'] folder = args['folder'] - - datasets = current_user.datasets.filter(deleted=False) - pagination = Pagination(datasets.count(), limit, page) + filters = args['filters'] + + # Convert filters string to dict mapping unique keys to lists of values + filters_dict = {} + for filter in json.loads(filters): + key, value = filter.split(":") + if key not in filters_dict: + filters_dict[key] = [] + filters_dict[key].append(value) + + # Get filtered list of datasets whose tags include all keys in filters_dict and any of each key's values + datasets = [] + for dataset in current_user.datasets.filter(deleted=False): + tags = dataset.tags + if not filters_dict or all(key in tags and tags[key] in values for key, values in filters_dict.items()): + datasets.append(dataset) + + pagination = Pagination(len(datasets), limit, page) datasets = datasets[pagination.start:pagination.end] datasets_json = [] @@ -343,6 +363,30 @@ def get(self): "categories": query_util.fix_ids(current_user.categories.filter(deleted=False).all()) } + +@api.route('/filters') +class DatasetFilters(Resource): + + @login_required + def get(self): + """ Endpoint called by dataset viewer client """ + + # Get all unique tag items across all datasets per user + # Each tag becomes a string "key:value" that can be used to filter datasets in the client + datasets = current_user.datasets.filter(deleted=False) + filters = [] + + for dataset in datasets: + for key, value in dataset.tags.items(): + if len(value) > 0: + filter_string = key + ":" + value + filters.append(filter_string) + + return { + "filters": list(set(filters)) + } + + @api.route('//data') class DatasetDataId(Resource): diff --git a/client/src/components/Metadata.vue b/client/src/components/Metadata.vue index 30c93d6a..6f5f8df1 100755 --- a/client/src/components/Metadata.vue +++ b/client/src/components/Metadata.vue @@ -2,12 +2,13 @@
-

{{ title }}

+

{{ title }}

+
  • - No items in metadata. + {{ emptyMessage }}
  • -
    -
    +
    +
    -
    +
    + +
    + +
    + +
  • + +
    + {{ errorMessage }} +
@@ -64,20 +81,25 @@ export default { }, keyTitle: { type: String, - default: "Keys" + default: "Key" }, valueTitle: { type: String, - default: "Values" + default: "Value" }, exclude: { type: String, default: "" + }, + emptyMessage: { + type: String, + default: "No items in metadata" } }, data() { return { - metadataList: [] + metadataList: [], + errorMessage: null, }; }, methods: { @@ -102,6 +124,16 @@ export default { createMetadata() { this.metadataList.push({ key: "", value: "" }); }, + deleteMetadata(index) { + delete this.metadataList[index]; + this.metadataList = this.metadataList.filter(metadata => metadata); + this.validateKeys(); + }, + clearEmptyItems() { + this.metadataList = this.metadataList.filter((metadata) => { + return metadata.key || metadata.value; + }) + }, loadMetadata() { if (this.metadata != null) { for (var key in this.metadata) { @@ -116,7 +148,17 @@ export default { this.metadataList.push({ key: key, value: value }); } } - } + }, + validateKeys() { + const keys = this.metadataList.map(metadata => metadata.key).filter(key => key.length); + const uniqueKeys = [...new Set(keys)]; + + this.errorMessage = keys.length !== uniqueKeys.length + ? "Keys must be unique" + : null; + + this.$emit("error", !!this.errorMessage); + }, }, watch: { metadata() { @@ -139,9 +181,9 @@ export default { } .meta-item { - padding: 3px; background-color: inherit; - height: 40px; + padding-top: 2px !important; + padding-bottom: 2px !important; border: none; } diff --git a/client/src/components/TagsInput.vue b/client/src/components/TagsInput.vue index cc220843..9d31ef17 100644 --- a/client/src/components/TagsInput.vue +++ b/client/src/components/TagsInput.vue @@ -143,18 +143,16 @@ export default { type: Function, default: () => true }, - addTagsOnComma: { type: Boolean, default: false }, - wrapperClass: { type: String, default: "tags-input-wrapper-default" } }, - + data() { return { badgeId: 0, @@ -268,7 +266,7 @@ export default { // Emit events this.$emit("tag-added", slug); - this.$emit("tags-updated"); + this.$emit("tags-updated", this.tags); }, removeLastTag() { @@ -285,7 +283,7 @@ export default { // Emit events this.$emit("tag-removed", slug); - this.$emit("tags-updated"); + this.$emit("tags-updated", this.tags); }, searchTag() { @@ -448,5 +446,6 @@ export default { diff --git a/client/src/components/cards/DatasetCard.vue b/client/src/components/cards/DatasetCard.vue index 2bf569e3..66d6806c 100755 --- a/client/src/components/cards/DatasetCard.vue +++ b/client/src/components/cards/DatasetCard.vue @@ -127,13 +127,22 @@ :typeahead-activation-threshold="0" /> +
+
+ + @@ -143,6 +152,7 @@ class="btn btn-success" @click="onSave" data-dismiss="modal" + :disabled="!allowSave" > Save @@ -237,9 +247,11 @@ export default { imageError: false, selectedCategories: [], defaultMetadata: this.dataset.default_annotation_metadata, + datasetTags: this.dataset.tags, noImageUrl: require("@/assets/no-image.png"), notFoundImageUrl: require("@/assets/404-image.png"), - sharedUsers: [] + sharedUsers: [], + allowSave: true, }; }, methods: { @@ -278,13 +290,19 @@ export default { this.$parent.updatePage(); }); }, + onValidationError(error) { + this.allowSave = !error; + }, onSave() { this.dataset.categories = this.selectedCategories; + this.$refs.defaultAnnotation.clearEmptyItems(); + this.$refs.datasetTags.clearEmptyItems(); axios .post("/api/dataset/" + this.dataset.id, { categories: this.selectedCategories, - default_annotation_metadata: this.$refs.defaultAnnotation.export() + default_annotation_metadata: this.$refs.defaultAnnotation.export(), + tags: this.$refs.datasetTags.export() }) .then(() => { this.$parent.updatePage(); @@ -389,6 +407,7 @@ p { padding: 2px; background-color: #4b5162; } + .icon-more { width: 10%; margin: 3px 0; @@ -401,6 +420,7 @@ p { margin: 0 5px 7px 5px; height: 5px; } + .card-footer { padding: 2px; font-size: 11px; diff --git a/client/src/models/datasets.js b/client/src/models/datasets.js index 392ffaca..fa3b1b16 100644 --- a/client/src/models/datasets.js +++ b/client/src/models/datasets.js @@ -6,10 +6,17 @@ export default { allData(params) { return axios.get(`${baseURL}/data`, { params: { - ...params + ...params, } }); }, + getFilterOptions(params) { + return axios.get(`${baseURL}/filters`, { + params: { + ...params + } + }) + }, getData(id, params) { return axios.get(`${baseURL}/${id}/data`, { params: { diff --git a/client/src/views/Datasets.vue b/client/src/views/Datasets.vue index 13215a39..d88d9cbf 100755 --- a/client/src/views/Datasets.vue +++ b/client/src/views/Datasets.vue @@ -46,6 +46,20 @@ +
+
+ +
+
+

You need to create a dataset! @@ -207,6 +221,8 @@ export default { datasets: [], subdirectories: [], categories: [], + selectedFilters: [], + filterOptions: [], users: [] }; }, @@ -221,13 +237,19 @@ export default { Datasets.allData({ limit: this.limit, - page: page + page: page, + filters: JSON.stringify(this.selectedFilters), }).then(response => { this.datasets = response.data.datasets; this.categories = response.data.categories; this.subdirectories = response.data.subdirectories; this.pages = response.data.pagination.pages; this.page = response.data.pagination.page; + + Datasets.getFilterOptions().then(response => { + this.filterOptions = response.data.filters; + }); + AdminPanel.getUsers(this.limit) .then(response => { this.users = response.data.users; @@ -254,6 +276,10 @@ export default { error.response.data.message ); }); + }, + setFilters(filters) { + this.selectedFilters = filters; + this.updatePage(); } }, watch: { @@ -273,6 +299,13 @@ export default { }); return tags; }, + filterTags() { + let tags = {} + this.filterOptions.forEach(filter => { + tags[filter] = filter + }) + return tags; + }, validDatasetName() { if (this.create.name.length === 0) return "Dataset name is required"; return "";