Browse Source

Merge branch '125-hierarchical-labels' into 'master'

Resolve "hierarchical labels"

Closes #125

See merge request troebs/pycs!108
Eric Tröbs 4 years ago
parent
commit
a8a6cc3d0f

+ 3 - 0
pycs/database/Database.py

@@ -72,11 +72,14 @@ class Database:
                 CREATE TABLE IF NOT EXISTS labels (
                 CREATE TABLE IF NOT EXISTS labels (
                     id        INTEGER PRIMARY KEY,
                     id        INTEGER PRIMARY KEY,
                     project   INTEGER             NOT NULL,
                     project   INTEGER             NOT NULL,
+                    parent    INTEGER,
                     created   INTEGER             NOT NULL,
                     created   INTEGER             NOT NULL,
                     reference TEXT,
                     reference TEXT,
                     name      TEXT                NOT NULL,
                     name      TEXT                NOT NULL,
                     FOREIGN KEY (project) REFERENCES projects(id)
                     FOREIGN KEY (project) REFERENCES projects(id)
                         ON UPDATE CASCADE ON DELETE CASCADE,
                         ON UPDATE CASCADE ON DELETE CASCADE,
+                    FOREIGN KEY (parent) REFERENCES labels(id)
+                        ON UPDATE CASCADE ON DELETE SET NULL,
                     UNIQUE(project, reference)
                     UNIQUE(project, reference)
                 )
                 )
             ''')
             ''')

+ 65 - 3
pycs/database/Label.py

@@ -11,9 +11,10 @@ class Label:
 
 
         self.identifier = row[0]
         self.identifier = row[0]
         self.project_id = row[1]
         self.project_id = row[1]
-        self.created = row[2]
-        self.reference = row[3]
-        self.name = row[4]
+        self.parent_id = row[2]
+        self.created = row[3]
+        self.reference = row[4]
+        self.name = row[5]
 
 
     def project(self):
     def project(self):
         """
         """
@@ -34,6 +35,34 @@ class Label:
             cursor.execute('UPDATE labels SET name = ? WHERE id = ?', (name, self.identifier))
             cursor.execute('UPDATE labels SET name = ? WHERE id = ?', (name, self.identifier))
             self.name = name
             self.name = name
 
 
+    def set_parent(self, parent_id: int):
+        """
+        set this labels parent
+
+        :param parent_id: parent's id
+        :return:
+        """
+
+        # check for cyclic relationships
+        def compare_children(label, identifier):
+            if label.identifier == identifier:
+                return False
+
+            for child in label.children():
+                if not compare_children(child, identifier):
+                    return False
+
+            return True
+
+        if not compare_children(self, parent_id):
+            raise ValueError('parent_id')
+
+        # insert parent id
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('UPDATE labels SET parent = ? WHERE id = ?',
+                           (parent_id, self.identifier))
+            self.parent_id = parent_id
+
     def remove(self):
     def remove(self):
         """
         """
         remove this label from the database
         remove this label from the database
@@ -42,3 +71,36 @@ class Label:
         """
         """
         with closing(self.database.con.cursor()) as cursor:
         with closing(self.database.con.cursor()) as cursor:
             cursor.execute('DELETE FROM labels WHERE id = ?', [self.identifier])
             cursor.execute('DELETE FROM labels WHERE id = ?', [self.identifier])
+
+    def parent(self):
+        """
+        get this labels parent from the database
+
+        :return: parent or None
+        """
+        if self.parent_id is None:
+            return None
+
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM labels WHERE id = ? AND project = ?',
+                           (self.parent_id, self.project_id))
+            row = cursor.fetchone()
+
+            if row is not None:
+                return Label(self.database, row)
+
+        return None
+
+    def children(self):
+        """
+        get this labels children from the database
+
+        :return: list of children
+        """
+        with closing(self.database.con.cursor()) as cursor:
+            cursor.execute('SELECT * FROM labels WHERE parent = ? AND project = ?',
+                           (self.identifier, self.project_id))
+            return list(map(
+                lambda row: Label(self.database, row),
+                cursor.fetchall()
+            ))

+ 7 - 5
pycs/database/Project.py

@@ -77,24 +77,26 @@ class Project:
 
 
             return None
             return None
 
 
-    def create_label(self, name: str, reference: str = None) -> Tuple[Optional[Label], bool]:
+    def create_label(self, name: str, reference: str = None,
+                     parent_id: int = None) -> Tuple[Optional[Label], bool]:
         """
         """
         create a label for this project. If there is already a label with the same reference
         create a label for this project. If there is already a label with the same reference
         in the database its name is updated.
         in the database its name is updated.
 
 
         :param name: label name
         :param name: label name
         :param reference: label reference
         :param reference: label reference
+        :param parent_id: parent's identifier
         :return: created or edited label, insert
         :return: created or edited label, insert
         """
         """
         created = int(time())
         created = int(time())
 
 
         with closing(self.database.con.cursor()) as cursor:
         with closing(self.database.con.cursor()) as cursor:
             cursor.execute('''
             cursor.execute('''
-                INSERT INTO labels (project, created, reference, name)
-                VALUES (?, ?, ?, ?)
+                INSERT INTO labels (project, parent, created, reference, name)
+                VALUES (?, ?, ?, ?, ?)
                 ON CONFLICT (project, reference) DO
                 ON CONFLICT (project, reference) DO
-                UPDATE SET name = ?
-            ''', (self.identifier, created, reference, name, name))
+                UPDATE SET parent = ?, name = ?
+            ''', (self.identifier, parent_id, created, reference, name, parent_id, name))
 
 
             # lastrowid is 0 if on conflict clause applies.
             # lastrowid is 0 if on conflict clause applies.
             # If this is the case we do an extra query to receive the row id.
             # If this is the case we do an extra query to receive the row id.

+ 5 - 0
pycs/frontend/WebServer.py

@@ -19,6 +19,7 @@ from pycs.frontend.endpoints.data.UploadFile import UploadFile
 from pycs.frontend.endpoints.jobs.RemoveJob import RemoveJob
 from pycs.frontend.endpoints.jobs.RemoveJob import RemoveJob
 from pycs.frontend.endpoints.labels.CreateLabel import CreateLabel
 from pycs.frontend.endpoints.labels.CreateLabel import CreateLabel
 from pycs.frontend.endpoints.labels.EditLabelName import EditLabelName
 from pycs.frontend.endpoints.labels.EditLabelName import EditLabelName
+from pycs.frontend.endpoints.labels.EditLabelParent import EditLabelParent
 from pycs.frontend.endpoints.labels.ListLabels import ListLabels
 from pycs.frontend.endpoints.labels.ListLabels import ListLabels
 from pycs.frontend.endpoints.labels.RemoveLabel import RemoveLabel
 from pycs.frontend.endpoints.labels.RemoveLabel import RemoveLabel
 from pycs.frontend.endpoints.pipelines.FitModel import FitModel
 from pycs.frontend.endpoints.pipelines.FitModel import FitModel
@@ -148,6 +149,10 @@ class WebServer:
             '/projects/<int:project_id>/labels/<int:label_id>/name',
             '/projects/<int:project_id>/labels/<int:label_id>/name',
             view_func=EditLabelName.as_view('edit_label_name', database, notifications)
             view_func=EditLabelName.as_view('edit_label_name', database, notifications)
         )
         )
+        self.__flask.add_url_rule(
+            '/projects/<int:project_id>/labels/<int:label_id>/parent',
+            view_func=EditLabelParent.as_view('edit_label_parent', database, notifications)
+        )
 
 
         # collections
         # collections
         self.__flask.add_url_rule(
         self.__flask.add_url_rule(

+ 4 - 1
pycs/frontend/endpoints/labels/CreateLabel.py

@@ -24,6 +24,9 @@ class CreateLabel(View):
         if 'name' not in data:
         if 'name' not in data:
             abort(400)
             abort(400)
 
 
+        name = data['name']
+        parent = data['parent'] if 'parent' in data else None
+
         # find project
         # find project
         project = self.db.project(identifier)
         project = self.db.project(identifier)
         if project is None:
         if project is None:
@@ -32,7 +35,7 @@ class CreateLabel(View):
         # start transaction
         # start transaction
         with self.db:
         with self.db:
             # insert label
             # insert label
-            label, _ = project.create_label(data['name'])
+            label, _ = project.create_label(name, parent_id=parent)
 
 
         # send notification
         # send notification
         self.nm.create_label(label)
         self.nm.create_label(label)

+ 1 - 1
pycs/frontend/endpoints/labels/EditLabelName.py

@@ -36,7 +36,7 @@ class EditLabelName(View):
 
 
         # start transaction
         # start transaction
         with self.db:
         with self.db:
-            # insert label
+            # change name
             label.set_name(data['name'])
             label.set_name(data['name'])
 
 
         # send notification
         # send notification

+ 46 - 0
pycs/frontend/endpoints/labels/EditLabelParent.py

@@ -0,0 +1,46 @@
+from flask import request, abort, make_response
+from flask.views import View
+
+from pycs.database.Database import Database
+from pycs.frontend.notifications.NotificationManager import NotificationManager
+
+
+class EditLabelParent(View):
+    """
+    edit a labels name
+    """
+    # pylint: disable=arguments-differ
+    methods = ['POST']
+
+    def __init__(self, db: Database, nm: NotificationManager):
+        # pylint: disable=invalid-name
+        self.db = db
+        self.nm = nm
+
+    def dispatch_request(self, project_id: int, label_id: int):
+        # extract request data
+        data = request.get_json(force=True)
+
+        if 'parent' not in data:
+            abort(400)
+
+        # find project
+        project = self.db.project(project_id)
+        if project is None:
+            abort(404)
+
+        # find label
+        label = project.label(label_id)
+        if label is None:
+            abort(404)
+
+        # start transaction
+        with self.db:
+            # change parent
+            label.set_parent(data['parent'])
+
+        # send notification
+        self.nm.edit_label(label)
+
+        # return success response
+        return make_response()

+ 9 - 3
pycs/frontend/endpoints/labels/RemoveLabel.py

@@ -34,13 +34,19 @@ class RemoveLabel(View):
         if label is None:
         if label is None:
             abort(404)
             abort(404)
 
 
+        # find children
+        children = label.children()
+
         # start transaction
         # start transaction
         with self.db:
         with self.db:
+            # remove children's parent entry
+            for child in children:
+                child.set_parent(None)
+                self.nm.edit_label(child)
+
             # remove label
             # remove label
             label.remove()
             label.remove()
-
-        # send notification
-        self.nm.remove_label(label)
+            self.nm.remove_label(label)
 
 
         # return success response
         # return success response
         return make_response()
         return make_response()

+ 2 - 1
pycs/frontend/endpoints/projects/ExecuteLabelProvider.py

@@ -76,7 +76,8 @@ class ExecuteLabelProvider(View):
         def result(provided_labels):
         def result(provided_labels):
             with db:
             with db:
                 for label in provided_labels:
                 for label in provided_labels:
-                    created_label, insert = project.create_label(label['name'], label['id'])
+                    created_label, insert = project.create_label(label['name'], label['id'],
+                                                                 label['parent'])
 
 
                     if insert:
                     if insert:
                         nm.create_label(created_label)
                         nm.create_label(created_label)

+ 4 - 2
pycs/interfaces/LabelProvider.py

@@ -32,15 +32,17 @@ class LabelProvider:
         raise NotImplementedError
         raise NotImplementedError
 
 
     @staticmethod
     @staticmethod
-    def create_label(identifier, name):
+    def create_label(identifier, name, parent_identifier=None):
         """
         """
         create a label result
         create a label result
 
 
         :param identifier: label identifier
         :param identifier: label identifier
         :param name: label name
         :param name: label name
+        :param parent_identifier: parent's identifier
         :return:
         :return:
         """
         """
         return {
         return {
             'id': identifier,
             'id': identifier,
-            'name': name
+            'name': name,
+            'parent': parent_identifier
         }
         }

+ 3 - 0
webui/src/assets/icons/triangle-down.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
+    <path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"></path>
+</svg>

+ 3 - 0
webui/src/assets/icons/triangle-up.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
+    <path d="M4.427 9.573l3.396-3.396a.25.25 0 01.354 0l3.396 3.396a.25.25 0 01-.177.427H4.604a.25.25 0 01-.177-.427z"></path>
+</svg>

+ 3 - 1
webui/src/components/base/editable-headline.vue

@@ -1,6 +1,8 @@
 <template>
 <template>
-  <h2>
+  <h2 class="editable-headline">
     <template v-if="!edit">
     <template v-if="!edit">
+      <slot/>
+
       {{ value }}
       {{ value }}
 
 
       <img alt="edit" class="edit"
       <img alt="edit" class="edit"

+ 103 - 0
webui/src/components/other/LabelTreeView.vue

@@ -0,0 +1,103 @@
+<template>
+  <div class="label-tree-view"
+       ref="main"
+       :class="{target: target}"
+       draggable="true" @dragstart="dragstart" @dragend="dragend"
+       @dragover="dragover" @dragleave="dragleave" @drop="drop">
+    <editable-headline :value="label.name"
+                       @change="editLabel(label.identifier, $event)"
+                       @remove="removeLabel(label.identifier)">
+      <img v-if="collapse"
+           src="@/assets/icons/triangle-down.svg"
+           :style="{opacity: label.children.length > 0 ? 1 : 0.1}"
+           @click="collapse = false">
+      <img v-else
+           src="@/assets/icons/triangle-up.svg"
+           :style="{opacity: label.children.length > 0 ? 1 : 0.1}"
+           @click="collapse = true">
+    </editable-headline>
+
+    <template v-if="!collapse">
+      <label-tree-view v-for="child of label.children" :key="child.identifier"
+                       :label="child"
+                       :indent="indent"
+                       :targetable="droppable"
+                       :style="margin"/>
+    </template>
+  </div>
+</template>
+
+<script>
+import EditableHeadline from "@/components/base/editable-headline";
+
+export default {
+  name: "LabelTreeView",
+  components: {EditableHeadline},
+  props: ['label', 'indent', 'targetable'],
+  data: function () {
+    return {
+      untouched: true,
+      target: false,
+      collapse: false
+    }
+  },
+  computed: {
+    droppable: function () {
+      return this.targetable && this.untouched;
+    },
+    margin: function () {
+      return {
+        marginLeft: this.indent
+      };
+    }
+  },
+  methods: {
+    editLabel: function (id, value) {
+      // TODO then / error
+      this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels/${id}/name`, {name: value});
+    },
+    removeLabel: function (id) {
+      // TODO then / error
+      this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels/${id}/remove`, {remove: true});
+    },
+    dragstart: function (e) {
+      this.untouched = false;
+      e.dataTransfer.setData('text/identifier', this.label.identifier)
+      e.stopPropagation();
+    },
+    dragend: function () {
+      this.untouched = true;
+    },
+    dragover: function (e) {
+      e.stopPropagation();
+
+      if (this.droppable) {
+        e.preventDefault();
+        this.target = true;
+      }
+    },
+    dragleave: function () {
+      this.target = false;
+    },
+    drop: function (e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.dragleave();
+
+      const element = e.dataTransfer.getData('text/identifier');
+      const parent = this.label.identifier;
+      this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels/${element}/parent`, {parent: parent});
+    }
+  }
+}
+</script>
+
+<style scoped>
+.editable-headline {
+  margin-bottom: 0.5rem;
+}
+
+.target > .editable-headline {
+  text-decoration: underline;
+}
+</style>

+ 70 - 35
webui/src/components/projects/project-labels-window.vue

@@ -1,28 +1,15 @@
 <template>
 <template>
-  <!-- TODO missing labeled images -->
-
   <div class="project-labels-window">
   <div class="project-labels-window">
-    <h1 class="headline">Labels</h1>
-
-    <div class="label" v-for="label in sortedLabels" :key="label.identifier">
-      <editable-headline :value="label.name"
-                         @change="editLabel(label.identifier, $event)"
-                         @remove="removeLabel(label.identifier)">
-        {{ label.name }}
-      </editable-headline>
-
-      <!--
-      <media-selector v-if="labeledImages[label.identifier].length > 0"
-                      :project-id="currentProject.identifier"
-                      :media="labeledImages[label.identifier]"
-                      current="-1"
-                      :socket="socket"></media-selector>
-      <div v-else
-           class="no-elements">
-        No labeled images found...
-      </div>
-      -->
-    </div>
+    <h1 class="headline"
+        :class="{target: target}"
+        @dragover="dragover" @dragleave="dragleave" @drop="drop">
+      Labels
+    </h1>
+
+    <label-tree-view v-for="label in labelTree" :key="label.identifier"
+                     :label="label"
+                     :targetable="true"
+                     indent="2rem"/>
 
 
     <div class="label">
     <div class="label">
       <input-group>
       <input-group>
@@ -39,13 +26,13 @@
 
 
 <script>
 <script>
 import TextInput from "@/components/base/text-input";
 import TextInput from "@/components/base/text-input";
-import EditableHeadline from "@/components/base/editable-headline";
 import ButtonInput from "@/components/base/button-input";
 import ButtonInput from "@/components/base/button-input";
 import InputGroup from "@/components/base/input-group";
 import InputGroup from "@/components/base/input-group";
+import LabelTreeView from "@/components/other/LabelTreeView";
 
 
 export default {
 export default {
   name: "project-labels-window",
   name: "project-labels-window",
-  components: {InputGroup, ButtonInput, EditableHeadline, TextInput},
+  components: {LabelTreeView, InputGroup, ButtonInput, TextInput},
   created: function () {
   created: function () {
     // get labels
     // get labels
     this.getLabels();
     this.getLabels();
@@ -65,12 +52,53 @@ export default {
   data: function () {
   data: function () {
     return {
     return {
       labels: [],
       labels: [],
-      createLabelValue: ''
+      createLabelValue: '',
+      target: false
     }
     }
   },
   },
   computed: {
   computed: {
+    // TODO remove
     sortedLabels: function () {
     sortedLabels: function () {
       return [...this.labels].sort((a, b) => a.name < b.name ? -1 : +1);
       return [...this.labels].sort((a, b) => a.name < b.name ? -1 : +1);
+    },
+    labelTree: function () {
+      const labels = [...this.labels].sort((a, b) => a.name < b.name ? +1 : -1);
+
+      const tree = [];
+      const references = {};
+
+      while (labels.length > 0) {
+        const length = labels.length;
+
+        for (let i = labels.length - 1; i >= 0; i--) {
+          if (labels[i]['parent_id'] === null) {
+            const label = Object.assign({
+              // parent: null,
+              children: []
+            }, labels.splice(i, 1)[0]);
+
+            tree.push(label);
+            references[label.identifier] = label;
+          } else if (labels[i]['parent_id'] in references) {
+            const parent = references[labels[i]['parent_id']];
+            const label = Object.assign({
+              // parent: parent,
+              children: []
+            }, labels.splice(i, 1)[0]);
+
+            parent.children.push(label);
+            references[label.identifier] = label;
+          }
+        }
+
+        if (labels.length === length) {
+          // TODO show in ui
+          console.log('I could not parse all items. Sorry!')
+          return tree;
+        }
+      }
+
+      return tree;
     }
     }
   },
   },
   methods: {
   methods: {
@@ -116,13 +144,18 @@ export default {
       this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels`, {name: this.createLabelValue});
       this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels`, {name: this.createLabelValue});
       this.createLabelValue = '';
       this.createLabelValue = '';
     },
     },
-    removeLabel: function (id) {
-      // TODO then / error
-      this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels/${id}/remove`, {remove: true});
+    dragover: function (e) {
+      e.preventDefault();
+      this.target = true;
     },
     },
-    editLabel: function (id, value) {
-      // TODO then / error
-      this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels/${id}/name`, {name: value});
+    dragleave: function () {
+      this.target = false;
+    },
+    drop: function (e) {
+      this.dragleave();
+
+      const element = e.dataTransfer.getData('text/identifier');
+      this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels/${element}/parent`, {parent: null});
     }
     }
   }
   }
 }
 }
@@ -134,10 +167,11 @@ export default {
   overflow: auto;
   overflow: auto;
 }
 }
 
 
-.label {
-  margin-bottom: 1rem;
+h1.target {
+  text-decoration: underline;
 }
 }
 
 
+/*
 /deep/ .element {
 /deep/ .element {
   border: none;
   border: none;
 }
 }
@@ -153,4 +187,5 @@ export default {
 .no-elements {
 .no-elements {
   margin-top: 0.5rem;
   margin-top: 0.5rem;
 }
 }
-</style>
+*/
+</style>