Repository: learningequality/kolibri

Issue:
Facilities should have option to sync KDP even if already registered, and if device does not know it's registered
<!--
Instructions:
 * Fill out the sections below, replace …'s with information about your issue
 * Use the 'preview' function above this text box to verify formatting before submitting
-->

### Observed behavior

1. After a Facility is registered to a KDP project, its `FacilityDataset.registered` value is set to `true`, which then block the user from registering it again with a different token.
1. If a facility is not registered to a project, it can only be synced to other peer devices, but not KDP

### Expected behavior

1. After a Facility is registered to a KDP project, the use can then register it again using a different (or same) token.
1. If a facility is not registered to a project, it can only be synced to other peer devices, and not KDP


### User-facing consequences
<!--
Implications and real-world consequences for learners, coaches, admins, and other users of the application
-->

…

### Errors and logs
<!--
Relevant logs from:
 * the command line
 * ~/.kolibri/logs/kolibri.txt
 * the browser console

Please wrap errors in triple backticks for clean formatting like this:
```
01:10 info: something happened
01:12 error: something bad happened
```
-->

…

### Steps to reproduce
<!--
Precise steps that someone else can follow in order to see this behavior
-->

…

### Context
<!--
Tell us about your environment, including:
 * Kolibri version
 * Operating system
 * Browser
-->

…


Comments:

Patch:
diff --git a/kolibri/core/assets/src/views/TextTruncatorCss.vue b/kolibri/core/assets/src/views/TextTruncatorCss.vue
index 05eb673054..47d75aab01 100644
--- a/kolibri/core/assets/src/views/TextTruncatorCss.vue
+++ b/kolibri/core/assets/src/views/TextTruncatorCss.vue
@@ -1,24 +1,28 @@
 <template>
 
   <!--
-    Text is wrapped in two `div`s to allow parent components adding
+    Text is wrapped in two `spans`s to allow parent components adding
     padding style directly on `<TextTruncatorCss>` component no matter
     of what truncating technique is used. Otherwise adding padding directly
     would break when using technique (B) since text that should be truncated
     would show in padding area.
 
-    Attributes are inherited by the inner `div` to emulate the same behavior
+    Attributes are inherited by the inner `span` to emulate the same behavior
     like if only one element would wrap the text to allow attributes be applied
     as close as possible to the text element.
+
+    Some width information need to be provided to `<span>s` to allow `text-overflow`
+    calculate properly when ellipsis should be added.
   -->
-  <div>
-    <div
+  <span :style="{ display: 'inline-block', maxWidth: '100%' }">
+    <span
       v-bind="$attrs"
+      :style="{ display: 'inline-block', maxWidth: '100%' }"
       :class="$computedClass(truncate)"
     >
       {{ text }}
-    </div>
-  </div>
+    </span>
+  </span>
 
 </template>
 
diff --git a/kolibri/plugins/facility/assets/src/views/DataPage/SyncInterface/index.vue b/kolibri/plugins/facility/assets/src/views/DataPage/SyncInterface/index.vue
index 58282151cc..757428af80 100644
--- a/kolibri/plugins/facility/assets/src/views/DataPage/SyncInterface/index.vue
+++ b/kolibri/plugins/facility/assets/src/views/DataPage/SyncInterface/index.vue
@@ -28,17 +28,49 @@
             <td class="button-col">
               <KButtonGroup style="margin-top: 8px; overflow: visible">
                 <KButton
+                  v-if="!theFacility.dataset.registered"
                   appearance="raised-button"
                   :text="$tr('register')"
-                  :disabled="Boolean(syncTaskId) || theFacility.dataset.registered"
                   @click="displayModal(Modals.REGISTER_FACILITY)"
                 />
                 <KButton
+                  v-else-if="!Boolean(syncTaskId)"
                   appearance="raised-button"
                   :text="$tr('sync')"
-                  :disabled="Boolean(syncTaskId)"
                   @click="displayModal(Modals.SYNC_FACILITY)"
                 />
+                <KIconButton
+                  ref="moreOptionsButton"
+                  data-test="moreOptionsButton"
+                  icon="optionsHorizontal"
+                  :tooltip="coreString('optionsLabel')"
+                  :ariaLabel="coreString('optionsLabel')"
+                  @click="toggleMenu"
+                />
+                <CoreMenu
+                  v-show="isMenuOpen"
+                  ref="menu"
+                  class="menu"
+                  :raised="true"
+                  :isOpen="isMenuOpen"
+                  :containFocus="true"
+                  @close="closeMenu"
+                >
+                  <template #options>
+                    <CoreMenuOption
+                      v-if="theFacility.dataset.registered"
+                      :style="{ 'cursor': 'pointer', textAlign: 'left' }"
+                      :label="$tr('register')"
+                      @select="displayModal(Modals.REGISTER_FACILITY)"
+                    />
+                    <CoreMenuOption
+                      v-else
+                      :style="{ 'cursor': 'pointer', textAlign: 'left' }"
+                      :label="$tr('sync')"
+                      @select="displayModal(Modals.SYNC_FACILITY)"
+                    />
+                  </template>
+                </CoreMenu>
               </KButtonGroup>
             </td>
           </tr>
@@ -89,6 +121,9 @@
   } from 'kolibri.coreVue.componentSets.sync';
   import commonSyncElements from 'kolibri.coreVue.mixins.commonSyncElements';
   import { FacilityTaskResource, FacilityResource } from 'kolibri.resources';
+  import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings';
+  import CoreMenu from 'kolibri.coreVue.components.CoreMenu';
+  import CoreMenuOption from 'kolibri.coreVue.components.CoreMenuOption';
   import { TaskStatuses } from '../../../constants';
   import PrivacyModal from './PrivacyModal';
 
@@ -108,8 +143,10 @@
       RegisterFacilityModal,
       ConfirmationRegisterModal,
       SyncFacilityModalGroup,
+      CoreMenu,
+      CoreMenuOption,
     },
-    mixins: [commonSyncElements],
+    mixins: [commonSyncElements, commonCoreStrings],
     data() {
       return {
         theFacility: null,
@@ -119,6 +156,7 @@
         isSyncing: false,
         syncHasFailed: false,
         Modals,
+        isMenuOpen: false,
       };
     },
     beforeMount() {
@@ -178,6 +216,24 @@
         this.syncHasFailed = true;
         this.closeModal();
       },
+      closeMenu({ focusMoreOptionsButton = true } = {}) {
+        this.isMenuOpen = false;
+        if (!focusMoreOptionsButton) {
+          return;
+        }
+        this.$nextTick(() => {
+          this.$refs.moreOptionsButton.$el.focus();
+        });
+      },
+      toggleMenu() {
+        this.isMenuOpen = !this.isMenuOpen;
+        if (!this.isMenuOpen) {
+          return;
+        }
+        this.$nextTick(() => {
+          this.$refs.menu.$el.focus();
+        });
+      },
     },
     $trs: {
       syncData: {
diff --git a/kolibri/plugins/learn/assets/src/views/LearningActivityBar.vue b/kolibri/plugins/learn/assets/src/views/LearningActivityBar.vue
index 3351c67567..97aa3e9ddb 100644
--- a/kolibri/plugins/learn/assets/src/views/LearningActivityBar.vue
+++ b/kolibri/plugins/learn/assets/src/views/LearningActivityBar.vue
@@ -5,7 +5,7 @@
       :value="isCoachContent"
       style="margin-top: 8px; width: auto;"
     />
-    <KLabeledIcon :style="{ 'margin-top': '8px', 'width': 'auto' }">
+    <KLabeledIcon :style="{ 'margin-top': '8px' }">
       <template #icon>
         <LearningActivityIcon
           data-test="learningActivityIcon"
@@ -13,14 +13,12 @@
           :shaded="true"
         />
       </template>
-      <TextTruncator
+      <TextTruncatorCss
         :text="resourceTitle"
-        :maxHeight="26"
+        :maxLines="1"
       />
-      <template #iconAfter>
-        <ProgressIcon :progress="contentProgress" class="progress-icon" />
-      </template>
     </KLabeledIcon>
+    <ProgressIcon :progress="contentProgress" class="progress-icon" />
 
     <template #icon>
       <KIconButton
@@ -107,7 +105,7 @@
   import CoreMenuOption from 'kolibri.coreVue.components.CoreMenuOption';
   import ProgressIcon from 'kolibri.coreVue.components.ProgressIcon';
   import UiToolbar from 'kolibri.coreVue.components.UiToolbar';
-  import TextTruncator from 'kolibri.coreVue.components.TextTruncator';
+  import TextTruncatorCss from 'kolibri.coreVue.components.TextTruncatorCss';
   import { validateLearningActivity } from 'kolibri.utils.validators';
   import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings';
   import LearningActivityIcon from './LearningActivityIcon.vue';
@@ -120,7 +118,7 @@
       CoachContentLabel,
       CoreMenu,
       CoreMenuOption,
-      TextTruncator,
+      TextTruncatorCss,
       LearningActivityIcon,
       MarkAsCompleteModal,
       ProgressIcon,
@@ -361,8 +359,33 @@
     transform: translateY(16px);
   }
 
+  /*
+    Make truncation via text ellipsis work well in UIToolbar's body flex item:
+    By default, `min-width` is `auto`  for a flex item which means it
+    cannot be smaller than the size of its content which causes the whole
+    title being visible even in cases when it should be already truncated.
+    Overriding it to `0` allows the title to be shrinked and then truncated
+    properly. Labeled icon wrapper needs to have this set too for its parent
+    flex item to shrink.
+  */
+  /deep/ .ui-toolbar__body,
+  /deep/ .labeled-icon-wrapper {
+    min-width: 0;
+  }
+
+  /deep/ .ui-toolbar__body {
+    flex-grow: 0; // make sure that the completion icon is right next to the title
+    align-items: center;
+  }
+
+  /deep/ .ui-toolbar__right {
+    // never shrink controls on the right side of the toolbar
+    flex-shrink: 0;
+  }
+
   /deep/ .progress-icon .ui-icon {
     margin-top: -2px;
+    margin-left: 16px;
 
     svg {
       width: 18px;
diff --git a/kolibri/utils/constants/__init__.py b/kolibri/utils/constants/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/kolibri/utils/constants/installation_types.py b/kolibri/utils/constants/installation_types.py
new file mode 100644
index 0000000000..29e6c273e1
--- /dev/null
+++ b/kolibri/utils/constants/installation_types.py
@@ -0,0 +1,28 @@
+"""
+This module contains constants representing the type of "installers" used to install Kolibri.
+"""
+from __future__ import unicode_literals
+
+APK = "apk"
+DEB = "deb"
+FLATPAK = "flatpak"
+GNOME = "gnome"
+KOLIBRI_SERVER = "kolibriserver"
+MACOS = "mac"
+PEX = "pex"
+WHL = "whl"
+WINDOWS = "windows"
+WINDOWS_APP = "windowsapp"
+
+install_type_map = {
+    APK: "apk - {}",
+    DEB: "deb kolibri - {}",
+    FLATPAK: "Flatpak - {}",
+    GNOME: "GNOME - {}",
+    KOLIBRI_SERVER: "deb kolibri-server - {}",
+    MACOS: "Mac - {}",
+    PEX: "pex",
+    WHL: "whl",
+    WINDOWS: "Windows - {}",
+    WINDOWS_APP: "Windows App - {}",
+}
diff --git a/kolibri/utils/server.py b/kolibri/utils/server.py
index 4f4bd079bd..227cd9c346 100644
--- a/kolibri/utils/server.py
+++ b/kolibri/utils/server.py
@@ -25,6 +25,7 @@ from zeroconf import get_all_addresses
 from zeroconf import InterfaceChoice
 
 import kolibri
+from .constants import installation_types
 from .system import become_daemon
 from .system import pid_exists
 from kolibri.utils import conf
@@ -913,77 +914,116 @@ def get_urls(listen_port=None):
         return e.status_code, []
 
 
+def get_installer_version(installer_type):  # noqa: C901
+    def get_debian_pkg_version(package):
+        """
+        In case we want to distinguish between dpkg and apt installations
+        we can use apt-cache show madison and compare versions with dpkg
+        if dpkg > madison, it's dpkg otherwise it's apt
+        """
+        try:
+            output = check_output(["dpkg", "-s", package])
+            if hasattr(output, "decode"):  # needed in python 2.x
+                output = output.decode("utf-8")
+            package_info = output.split("\n")
+            version_info = [output for output in package_info if "Version" in output]
+            if version_info:
+                version = version_info[0].split(":")[1].strip()
+                return version
+        except CalledProcessError:  # package not installed!
+            pass  # will return None
+        return None
+
+    def get_deb_kolibriserver_version():
+        return get_debian_pkg_version("kolibri-server")
+
+    def get_deb_version():
+        return get_debian_pkg_version("kolibri")
+
+    def get_apk_version():
+        return os.environ.get("KOLIBRI_APK_VERSION_NAME")
+
+    installer_version = os.environ.get("KOLIBRI_INSTALLER_VERSION")
+    if installer_version:
+        return installer_version
+
+    version_funcs = {
+        installation_types.DEB: get_deb_version,
+        installation_types.KOLIBRI_SERVER: get_deb_kolibriserver_version,
+        installation_types.APK: get_apk_version,
+    }
+
+    if installer_type in version_funcs:
+        return version_funcs[installer_type]()
+    else:
+        return None
+
+
 def installation_type(cmd_line=None):  # noqa:C901
     """
     Tries to guess how the running kolibri server was installed
 
     :returns: install_type is the type of detected installation
     """
+
+    install_type = os.environ.get("KOLIBRI_INSTALLATION_TYPE", "Unknown")
+
     if cmd_line is None:
         cmd_line = sys.argv
-    install_type = "Unknown"
 
     def is_debian_package():
         # find out if this is from the debian package
-        install_type = "dpkg"
+        install_type = installation_types.DEB
         try:
-            check_output(["apt-cache", "show", "kolibri"])
-            apt_repo = str(check_output(["apt-cache", "madison", "kolibri"]))
-            if len(apt_repo) > 4:  # repo will have at least http
-                install_type = "apt"
+            check_output(["dpkg", "-s", "kolibri"])
         except (
             CalledProcessError,
             FileNotFoundError,
         ):  # kolibri package not installed!
             if sys.path[-1] != "/usr/lib/python3/dist-packages":
-                install_type = "whl"
+                install_type = installation_types.WHL
         return install_type
 
     def is_kolibri_server():
         # running under uwsgi, finding out if we are using kolibri-server
-        install_type = ""
+        install_type = "Unknown"
         try:
-            package_info = (
-                check_output(["apt-cache", "show", "kolibri-server"])
-                .decode("utf-8")
-                .split("\n")
-            )
-            version = [output for output in package_info if "Version" in output]
-            install_type = "kolibri-server {}".format(version[0])
+            check_output(["dpkg", "-s", "kolibri-server"])
+            install_type = installation_types.KOLIBRI_SERVER
         except CalledProcessError:  # kolibri-server package not installed!
-            install_type = "uwsgi"
+            install_type = installation_types.WHL
         return install_type
 
-    if len(cmd_line) > 1 or "uwsgi" in cmd_line:
-        launcher = cmd_line[0]
-        if launcher.endswith(".pex"):
-            install_type = "pex"
-        elif "runserver" in cmd_line:
-            install_type = "devserver"
-        elif launcher == "/usr/bin/kolibri":
-            install_type = is_debian_package()
-        elif launcher == "uwsgi":
-            package = is_debian_package()
-            if package != "whl":
-                kolibri_server = is_kolibri_server()
-                install_type = "kolibri({kolibri_type}) with {kolibri_server}".format(
-                    kolibri_type=package, kolibri_server=kolibri_server
-                )
-        elif "\\Scripts\\kolibri" in launcher:
-            paths = sys.path
-            for path in paths:
-                if "kolibri.exe" in path:
-                    install_type = "Windows"
-                    break
-        elif "start" in cmd_line:
-            install_type = "whl"
-    if on_android():
-
-        version_name = os.environ.get("KOLIBRI_APK_VERSION_NAME")
-
-        if version_name:
-            install_type = "apk - {}".format(version_name)
+    # in case the KOLIBRI_INSTALLATION_TYPE is not set, let's use the old method:
+    if install_type == "Unknown":
+        if on_android():
+            install_type = installation_types.APK
+        elif len(cmd_line) > 1 or "uwsgi" in cmd_line:
+            launcher = cmd_line[0]
+            if launcher.endswith(".pex"):
+                install_type = installation_types.PEX
+            elif "runserver" in cmd_line:
+                install_type = "devserver"
+            elif launcher == "/usr/bin/kolibri":
+                install_type = is_debian_package()
+            elif launcher == "uwsgi":
+                package = is_debian_package()
+                if package != "whl":
+                    install_type = is_kolibri_server()
+            elif "\\Scripts\\kolibri" in launcher:
+                paths = sys.path
+                for path in paths:
+                    if "kolibri.exe" in path:
+                        install_type = installation_types.WINDOWS
+                        break
+            elif "start" in cmd_line:
+                install_type = installation_types.WHL
+
+    if install_type in installation_types.install_type_map:
+        version = get_installer_version(install_type)
+        if version:
+            return installation_types.install_type_map[install_type].format(version)
         else:
-            install_type = "apk"
+            return installation_types.install_type_map[install_type].split(" - ")[0]
 
     return install_type


Test patch:
diff --git a/kolibri/utils/tests/test_server.py b/kolibri/utils/tests/test_server.py
index 02e815eb5e..4884ddb380 100755
--- a/kolibri/utils/tests/test_server.py
+++ b/kolibri/utils/tests/test_server.py
@@ -14,13 +14,14 @@ import pytest
 from kolibri.core.tasks.scheduler import Scheduler
 from kolibri.core.tasks.test.base import connection
 from kolibri.utils import server
+from kolibri.utils.constants import installation_types
 
 
 class TestServerInstallation(object):
     @mock.patch("sys.argv", ["kolibri-0.9.3.pex", "start"])
     def test_pex(self):
         install_type = server.installation_type()
-        assert install_type == "pex"
+        assert install_type == installation_types.PEX
 
     def test_dev(self):
         sys_args = [
@@ -36,27 +37,38 @@ class TestServerInstallation(object):
             assert install_type == "devserver"
 
     @mock.patch("sys.argv", ["/usr/bin/kolibri", "start"])
+    @mock.patch("os.environ", {"KOLIBRI_INSTALLER_VERSION": "1.0"})
     def test_dpkg(self):
         with mock.patch("kolibri.utils.server.check_output", return_value=""):
             install_type = server.installation_type()
-            assert install_type == "dpkg"
+            assert install_type == installation_types.install_type_map[
+                installation_types.DEB
+            ].format("1.0")
 
     @mock.patch("sys.argv", ["/usr/bin/kolibri", "start"])
+    @mock.patch("os.environ", {"KOLIBRI_INSTALLER_VERSION": "1.0"})
     def test_apt(apt):
         with mock.patch("kolibri.utils.server.check_output", return_value="any repo"):
             install_type = server.installation_type()
-            assert install_type == "apt"
+            assert install_type == installation_types.install_type_map[
+                installation_types.DEB
+            ].format("1.0")
 
     @mock.patch("sys.argv", ["C:\\Python34\\Scripts\\kolibri", "start"])
     @mock.patch("sys.path", ["", "C:\\Program Files\\Kolibri\\kolibri.exe"])
+    @mock.patch("os.environ", {"KOLIBRI_INSTALLER_VERSION": "1.0"})
     def test_windows(self):
         install_type = server.installation_type()
-        assert install_type == "Windows"
+        assert install_type == installation_types.install_type_map[
+            installation_types.WINDOWS
+        ].format("1.0")
 
     @mock.patch("sys.argv", ["/usr/local/bin/kolibri", "start"])
     def test_whl(self):
         install_type = server.installation_type()
-        assert install_type == "whl"
+        assert (
+            install_type == installation_types.install_type_map[installation_types.WHL]
+        )
 
 
 @pytest.fixture