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 (
                     id        INTEGER PRIMARY KEY,
                     project   INTEGER             NOT NULL,
+                    parent    INTEGER,
                     created   INTEGER             NOT NULL,
                     reference TEXT,
                     name      TEXT                NOT NULL,
                     FOREIGN KEY (project) REFERENCES projects(id)
                         ON UPDATE CASCADE ON DELETE CASCADE,
+                    FOREIGN KEY (parent) REFERENCES labels(id)
+                        ON UPDATE CASCADE ON DELETE SET NULL,
                     UNIQUE(project, reference)
                 )
             ''')

+ 65 - 3
pycs/database/Label.py

@@ -11,9 +11,10 @@ class Label:
 
         self.identifier = row[0]
         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):
         """
@@ -34,6 +35,34 @@ class Label:
             cursor.execute('UPDATE labels SET name = ? WHERE id = ?', (name, self.identifier))
             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):
         """
         remove this label from the database
@@ -42,3 +71,36 @@ class Label:
         """
         with closing(self.database.con.cursor()) as cursor:
             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
 
-    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
         in the database its name is updated.
 
         :param name: label name
         :param reference: label reference
+        :param parent_id: parent's identifier
         :return: created or edited label, insert
         """
         created = int(time())
 
         with closing(self.database.con.cursor()) as cursor:
             cursor.execute('''
-                INSERT INTO labels (project, created, reference, name)
-                VALUES (?, ?, ?, ?)
+                INSERT INTO labels (project, parent, created, reference, name)
+                VALUES (?, ?, ?, ?, ?)
                 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.
             # 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.labels.CreateLabel import CreateLabel
 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.RemoveLabel import RemoveLabel
 from pycs.frontend.endpoints.pipelines.FitModel import FitModel
@@ -148,6 +149,10 @@ class WebServer:
             '/projects/<int:project_id>/labels/<int:label_id>/name',
             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
         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:
             abort(400)
 
+        name = data['name']
+        parent = data['parent'] if 'parent' in data else None
+
         # find project
         project = self.db.project(identifier)
         if project is None:
@@ -32,7 +35,7 @@ class CreateLabel(View):
         # start transaction
         with self.db:
             # insert label
-            label, _ = project.create_label(data['name'])
+            label, _ = project.create_label(name, parent_id=parent)
 
         # send notification
         self.nm.create_label(label)

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

@@ -36,7 +36,7 @@ class EditLabelName(View):
 
         # start transaction
         with self.db:
-            # insert label
+            # change name
             label.set_name(data['name'])
 
         # 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:
             abort(404)
 
+        # find children
+        children = label.children()
+
         # start transaction
         with self.db:
+            # remove children's parent entry
+            for child in children:
+                child.set_parent(None)
+                self.nm.edit_label(child)
+
             # remove label
             label.remove()
-
-        # send notification
-        self.nm.remove_label(label)
+            self.nm.remove_label(label)
 
         # return success response
         return make_response()

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

@@ -76,7 +76,8 @@ class ExecuteLabelProvider(View):
         def result(provided_labels):
             with db:
                 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:
                         nm.create_label(created_label)

+ 4 - 2
pycs/interfaces/LabelProvider.py

@@ -32,15 +32,17 @@ class LabelProvider:
         raise NotImplementedError
 
     @staticmethod
-    def create_label(identifier, name):
+    def create_label(identifier, name, parent_identifier=None):
         """
         create a label result
 
         :param identifier: label identifier
         :param name: label name
+        :param parent_identifier: parent's identifier
         :return:
         """
         return {
             '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>
-  <h2>
+  <h2 class="editable-headline">
     <template v-if="!edit">
+      <slot/>
+
       {{ value }}
 
       <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>
-  <!-- TODO missing labeled images -->
-
   <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">
       <input-group>
@@ -39,13 +26,13 @@
 
 <script>
 import TextInput from "@/components/base/text-input";
-import EditableHeadline from "@/components/base/editable-headline";
 import ButtonInput from "@/components/base/button-input";
 import InputGroup from "@/components/base/input-group";
+import LabelTreeView from "@/components/other/LabelTreeView";
 
 export default {
   name: "project-labels-window",
-  components: {InputGroup, ButtonInput, EditableHeadline, TextInput},
+  components: {LabelTreeView, InputGroup, ButtonInput, TextInput},
   created: function () {
     // get labels
     this.getLabels();
@@ -65,12 +52,53 @@ export default {
   data: function () {
     return {
       labels: [],
-      createLabelValue: ''
+      createLabelValue: '',
+      target: false
     }
   },
   computed: {
+    // TODO remove
     sortedLabels: function () {
       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: {
@@ -116,13 +144,18 @@ export default {
       this.$root.socket.post(`/projects/${this.$root.project.identifier}/labels`, {name: 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;
 }
 
-.label {
-  margin-bottom: 1rem;
+h1.target {
+  text-decoration: underline;
 }
 
+/*
 /deep/ .element {
   border: none;
 }
@@ -153,4 +187,5 @@ export default {
 .no-elements {
   margin-top: 0.5rem;
 }
-</style>
+*/
+</style>