import fnmatch
import logging
import os

from django.core.cache import cache
from sqlalchemy.exc import DatabaseError

from .paths import get_content_database_dir_path
from .sqlalchemybridge import Bridge
from kolibri.core.discovery.utils.filesystem import enumerate_mounted_disk_partitions
from kolibri.utils.uuids import is_valid_uuid

logger = logging.getLogger(__name__)


def get_channel_ids_for_content_dirs(content_dirs):
    database_dir_paths = [
        get_content_database_dir_path(contentfolder=path) for path in content_dirs
    ]
    channel_ids = set()
    for path in database_dir_paths:
        channel_ids.update(get_channel_ids_for_content_database_dir(path))
    return list(channel_ids)


def get_channel_ids_for_content_database_dir(content_database_dir):
    """
    Returns a list of channel IDs for the channel databases that exist in a content database directory.
    """

    # immediately return an empty list if the content database directory doesn't exist
    if not os.path.isdir(content_database_dir):
        return []

    # get a list of all the database files in the directory, and extract IDs
    db_list = fnmatch.filter(os.listdir(content_database_dir), "*.sqlite3")
    db_names = [db.split(".sqlite3", 1)[0] for db in db_list]

    # determine which database names are valid, and only use those ones
    valid_db_names = [name for name in db_names if is_valid_uuid(name)]
    invalid_db_names = set(db_names) - set(valid_db_names)
    if invalid_db_names:
        logger.warning(
            "Ignoring databases in content database directory '{directory}' with invalid names: {names}".format(
                directory=content_database_dir, names=invalid_db_names
            )
        )

    # nonexistent database files are created if we delete the files that have broken symbolic links;
    # empty database files are created if we delete a database file while the server is running and connected to it;
    # here, we delete and exclude such databases to avoid errors when we try to connect to them
    db_files_to_remove = set({})
    for db_name in valid_db_names:
        filename = os.path.join(content_database_dir, "{}.sqlite3".format(db_name))
        if not os.path.exists(filename) or os.path.getsize(filename) == 0:
            db_files_to_remove.add(db_name)
            os.remove(filename)

    if db_files_to_remove:
        err_msg = (
            "Removing nonexistent or empty databases in content database directory "
            "'{directory}' with IDs: {names}.\nPlease import the channels again."
        )
        logger.warning(
            err_msg.format(directory=content_database_dir, names=db_files_to_remove)
        )
    valid_dbs = list(set(valid_db_names) - set(db_files_to_remove))

    return valid_dbs


def enumerate_content_database_file_paths(content_database_dir):
    full_dir_template = os.path.join(content_database_dir, "{}.sqlite3")
    channel_ids = get_channel_ids_for_content_database_dir(content_database_dir)
    return [full_dir_template.format(f) for f in channel_ids]


def read_channel_metadata_from_db_file(channeldbpath):
    # import here to avoid circular imports whenever kolibri.core.content.models imports utils too
    from kolibri.core.content.models import ChannelMetadata

    source = Bridge(sqlite_file_path=channeldbpath)

    ChannelMetadataClass = source.get_class(ChannelMetadata)

    source_channel_metadata = source.session.query(ChannelMetadataClass).all()[0]

    # Use the inferred version from the SQLAlchemy Bridge object, and set it as additional
    # metadata on the channel data

    source_channel_metadata.inferred_schema_version = source.schema_version

    source.end()

    # Adds an attribute `root_id` when `root_id` does not exist to match with
    # the latest schema.
    if not hasattr(source_channel_metadata, "root_id"):
        setattr(
            source_channel_metadata,
            "root_id",
            getattr(source_channel_metadata, "root_pk"),
        )

    return source_channel_metadata


def get_channels_for_data_folder(datafolder):
    channels = []
    for path in enumerate_content_database_file_paths(
        get_content_database_dir_path(datafolder)
    ):
        try:
            channel = read_channel_metadata_from_db_file(path)
        except DatabaseError:
            logger.warning(
                "Tried to import channel from database file {}, but the file was corrupted.".format(
                    path
                )
            )
            continue
        channel_data = {
            "path": path,
            "id": channel.id,
            "name": channel.name,
            "description": channel.description,
            "tagline": channel.tagline,
            "thumbnail": channel.thumbnail,
            "version": channel.version,
            "root": channel.root_id,
            "author": channel.author,
            "last_updated": getattr(channel, "last_updated", None),
            "lang_code": getattr(channel, "lang_code", None),
            "lang_name": getattr(channel, "lang_name", None),
        }
        channels.append(channel_data)
    return channels


# Use this to cache mounted drive information when
# it has already been fetched for querying by drive id
MOUNTED_DRIVES_CACHE_KEY = "mounted_drives_cache_key"


def get_mounted_drives_with_channel_info():
    drives = enumerate_mounted_disk_partitions()
    for drive in drives.values():
        drive.metadata["channels"] = (
            get_channels_for_data_folder(drive.datafolder) if drive.datafolder else []
        )
    cache.set(MOUNTED_DRIVES_CACHE_KEY, drives, 3600)
    return drives


def get_mounted_drive_by_id(drive_id):
    drives = cache.get(MOUNTED_DRIVES_CACHE_KEY)
    if drives is None or drives.get(drive_id, None) is None:
        drives = get_mounted_drives_with_channel_info()
    return drives[drive_id]

kolibri/core/content/utils/channels.py
import fnmatch
import logging
import os

from django.core.cache import cache
from sqlalchemy.exc import DatabaseError

from .paths import get_content_database_dir_path
from .sqlalchemybridge import Bridge
from kolibri.core.discovery.utils.filesystem import enumerate_mounted_disk_partitions
from kolibri.utils.uuids import is_valid_uuid

logger = logging.getLogger(__name__)


def get_channel_ids_for_content_dirs(content_dirs):
    database_dir_paths = [
        get_content_database_dir_path(contentfolder=path) for path in content_dirs
    ]
    channel_ids = set()
    for path in database_dir_paths:
        channel_ids.update(get_channel_ids_for_content_database_dir(path))
    return list(channel_ids)


def get_channel_ids_for_content_database_dir(content_database_dir):
    """
    Returns a list of channel IDs for the channel databases that exist in a content database directory.
    """

    # immediately return an empty list if the content database directory doesn't exist
    if not os.path.isdir(content_database_dir):
        return []

    # get a list of all the database files in the directory, and extract IDs
    db_list = fnmatch.filter(os.listdir(content_database_dir), "*.sqlite3")
    db_names = [db.split(".sqlite3", 1)[0] for db in db_list]

    # determine which database names are valid, and only use those ones
    valid_db_names = [name for name in db_names if is_valid_uuid(name)]
    invalid_db_names = set(db_names) - set(valid_db_names)
    if invalid_db_names:
        logger.warning(
            "Ignoring databases in content database directory '{directory}' with invalid names: {names}".format(
                directory=content_database_dir, names=invalid_db_names
            )
        )

    # nonexistent database files are created if we delete the files that have broken symbolic links;
    # empty database files are created if we delete a database file while the server is running and connected to it;
    # here, we delete and exclude such databases to avoid errors when we try to connect to them
    db_files_to_remove = set({})
    for db_name in valid_db_names:
        filename = os.path.join(content_database_dir, "{}.sqlite3".format(db_name))
        if not os.path.exists(filename) or os.path.getsize(filename) == 0:
            db_files_to_remove.add(db_name)
            os.remove(filename)

    if db_files_to_remove:
        err_msg = (
            "Removing nonexistent or empty databases in content database directory "
            "'{directory}' with IDs: {names}.\nPlease import the channels again."
        )
        logger.warning(
            err_msg.format(directory=content_database_dir, names=db_files_to_remove)
        )
    valid_dbs = list(set(valid_db_names) - set(db_files_to_remove))

    return valid_dbs


def enumerate_content_database_file_paths(content_database_dir):
    full_dir_template = os.path.join(content_database_dir, "{}.sqlite3")
    channel_ids = get_channel_ids_for_content_database_dir(content_database_dir)
    return [full_dir_template.format(f) for f in channel_ids]


def read_channel_metadata_from_db_file(channeldbpath):
    # import here to avoid circular imports whenever kolibri.core.content.models imports utils too
    from kolibri.core.content.models import ChannelMetadata

    source = Bridge(sqlite_file_path=channeldbpath)

    ChannelMetadataClass = source.get_class(ChannelMetadata)

    source_channel_metadata = source.session.query(ChannelMetadataClass).all()[0]

    # Use the inferred version from the SQLAlchemy Bridge object, and set it as additional
    # metadata on the channel data

    source_channel_metadata.inferred_schema_version = source.schema_version

    source.end()

    # Adds an attribute `root_id` when `root_id` does not exist to match with
    # the latest schema.
    if not hasattr(source_channel_metadata, "root_id"):
        setattr(
            source_channel_metadata,
            "root_id",
            getattr(source_channel_metadata, "root_pk"),
        )

    return source_channel_metadata


def get_channels_for_data_folder(datafolder):
    channels = []
    for path in enumerate_content_database_file_paths(
        get_content_database_dir_path(datafolder)
    ):
        try:
            channel = read_channel_metadata_from_db_file(path)
        except DatabaseError:
            logger.warning(
                "Tried to import channel from database file {}, but the file was corrupted.".format(
                    path
                )
            )
            continue
        channel_data = {
            "path": path,
            "id": channel.id,
            "name": channel.name,
            "description": channel.description,
            "tagline": getattr(channel, "tagline", ""),
            "thumbnail": channel.thumbnail,
            "version": channel.version,
            "root": channel.root_id,
            "author": channel.author,
            "last_updated": getattr(channel, "last_updated", None),
            "lang_code": getattr(channel, "lang_code", None),
            "lang_name": getattr(channel, "lang_name", None),
        }
        channels.append(channel_data)
    return channels


# Use this to cache mounted drive information when
# it has already been fetched for querying by drive id
MOUNTED_DRIVES_CACHE_KEY = "mounted_drives_cache_key"


def get_mounted_drives_with_channel_info():
    drives = enumerate_mounted_disk_partitions()
    for drive in drives.values():
        drive.metadata["channels"] = (
            get_channels_for_data_folder(drive.datafolder) if drive.datafolder else []
        )
    cache.set(MOUNTED_DRIVES_CACHE_KEY, drives, 3600)
    return drives


def get_mounted_drive_by_id(drive_id):
    drives = cache.get(MOUNTED_DRIVES_CACHE_KEY)
    if drives is None or drives.get(drive_id, None) is None:
        drives = get_mounted_drives_with_channel_info()
    return drives[drive_id]

kolibri/core/content/utils/channels.py
diff --git a/kolibri/core/assets/src/views/BottomAppBar.vue b/kolibri/core/assets/src/views/BottomAppBar.vue
index 7c063606fbc..e285bcdd983 100644
--- a/kolibri/core/assets/src/views/BottomAppBar.vue
+++ b/kolibri/core/assets/src/views/BottomAppBar.vue
@@ -65,6 +65,8 @@
   }
 
   .inner-bottom {
+    height: 100%;
+    padding: 10px 0;
     margin: auto;
   }
 
diff --git a/kolibri/core/content/utils/channels.py b/kolibri/core/content/utils/channels.py
index 1e7437c6081..88c22c266fc 100644
--- a/kolibri/core/content/utils/channels.py
+++ b/kolibri/core/content/utils/channels.py
@@ -123,7 +123,7 @@ def get_channels_for_data_folder(datafolder):
             "id": channel.id,
             "name": channel.name,
             "description": channel.description,
-            "tagline": channel.tagline,
+            "tagline": getattr(channel, "tagline", ""),
             "thumbnail": channel.thumbnail,
             "version": channel.version,
             "root": channel.root_id,
diff --git a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateExamPreview.vue b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateExamPreview.vue
index ed3ee525ff4..fbd5a342529 100644
--- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateExamPreview.vue
+++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateExamPreview.vue
@@ -100,7 +100,7 @@
       />
 
       <BottomAppBar style="z-index: 1062;">
-        <KButtonGroup style="margin-top: 8px;">
+        <KButtonGroup>
           <KRouterLink
             appearance="flat-button"
             :text="coreString('goBackAction')"
diff --git a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/index.vue b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/index.vue
index 762abae0713..c8c99acec1c 100644
--- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/index.vue
+++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/index.vue
@@ -128,7 +128,7 @@
         />
       </BottomAppBar>
       <BottomAppBar v-else>
-        <KButtonGroup style="margin-top: 8px;">
+        <KButtonGroup>
           <KRouterLink
             appearance="flat-button"
             :text="coreString('goBackAction')"
diff --git a/kolibri/plugins/coach/assets/src/views/plan/assignments/AssignmentDetailsModal.vue b/kolibri/plugins/coach/assets/src/views/plan/assignments/AssignmentDetailsModal.vue
index f8edbd2ac69..ffb1e9b3b99 100644
--- a/kolibri/plugins/coach/assets/src/views/plan/assignments/AssignmentDetailsModal.vue
+++ b/kolibri/plugins/coach/assets/src/views/plan/assignments/AssignmentDetailsModal.vue
@@ -53,7 +53,7 @@
     </form>
 
     <BottomAppBar>
-      <KButtonGroup style="margin-top: 8px;">
+      <KButtonGroup>
         <KButton
           :text="coreString('cancelAction')"
           appearance="flat-button"
diff --git a/kolibri/plugins/device/assets/src/app.js b/kolibri/plugins/device/assets/src/app.js
index 2a2e4214a6b..7a6ab75cc3b 100644
--- a/kolibri/plugins/device/assets/src/app.js
+++ b/kolibri/plugins/device/assets/src/app.js
@@ -17,7 +17,7 @@ class DeviceManagementModule extends KolibriApp {
   ready() {
     // reset module states after leaving their respective page
     router.beforeEach((to, from, next) => {
-      if (this.store.getters.isSuperuser && this.store.state.core.facilities.length === 0) {
+      if (this.store.state.core.facilities.length === 0) {
         this.store.dispatch('getFacilities').then(next, next);
       }
       next();
diff --git a/kolibri/plugins/device/assets/src/views/ManageContentPage/SelectionBottomBar.vue b/kolibri/plugins/device/assets/src/views/ManageContentPage/SelectionBottomBar.vue
index d14c5111d73..85f395a01e2 100644
--- a/kolibri/plugins/device/assets/src/views/ManageContentPage/SelectionBottomBar.vue
+++ b/kolibri/plugins/device/assets/src/views/ManageContentPage/SelectionBottomBar.vue
@@ -1,9 +1,9 @@
 <template>
 
   <BottomAppBar>
-    <span class="message">{{ selectedMessage }}</span>
     <template v-if="actionType === 'manage'">
-      <KButtonGroup style="margin-top: 8px;">
+      <KButtonGroup>
+        <span class="message">{{ selectedMessage }}</span>
         <KButton
           :disabled="$attrs.disabled || buttonsDisabled"
           :text="coreString('deleteAction')"
@@ -19,13 +19,15 @@
       </KButtonGroup>
     </template>
 
-    <KButton
-      v-else
-      :disabled="$attrs.disabled || buttonsDisabled"
-      :text="confirmButtonLabel"
-      :primary="true"
-      @click="$emit('clickconfirm')"
-    />
+    <template v-else>
+      <span class="message">{{ selectedMessage }}</span>
+      <KButton
+        :disabled="$attrs.disabled || buttonsDisabled"
+        :text="confirmButtonLabel"
+        :primary="true"
+        @click="$emit('clickconfirm')"
+      />
+    </template>
   </BottomAppBar>
 
 </template>
diff --git a/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue b/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue
index 82226a81bf6..e476e8c431a 100644
--- a/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue
+++ b/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue
@@ -68,7 +68,7 @@
       // This used to determine Select Source workflow to enter into
       importedFacility() {
         const [facility] = this.$store.state.core.facilities;
-        if (facility.last_synced !== null) {
+        if (facility && facility.last_synced !== null) {
           return facility;
         }
         return null;
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 392a2745ed7..b52f0010a4b 100644
--- a/kolibri/plugins/facility/assets/src/views/DataPage/SyncInterface/index.vue
+++ b/kolibri/plugins/facility/assets/src/views/DataPage/SyncInterface/index.vue
@@ -3,7 +3,6 @@
   <KPageContainer>
 
     <h1>{{ $tr('syncData') }}</h1>
-    <p>{{ $tr('access') }}</p>
     <p>
       <KButton
         appearance="basic-link"
@@ -131,8 +130,6 @@
     },
     $trs: {
       syncData: 'Sync facility data',
-      access:
-        'This is an experimental feature. You can use it if you have access to the Kolibri Data Portal.',
       learnMore: 'Usage and privacy',
       facility: 'Facility',
       register: 'Register',
diff --git a/kolibri/plugins/facility/assets/src/views/UserCreatePage.vue b/kolibri/plugins/facility/assets/src/views/UserCreatePage.vue
index 85d9c052585..8d8b497e8d2 100644
--- a/kolibri/plugins/facility/assets/src/views/UserCreatePage.vue
+++ b/kolibri/plugins/facility/assets/src/views/UserCreatePage.vue
@@ -215,11 +215,14 @@
       submitForm() {
         this.formSubmitted = true;
         let password = this.password;
-        if (!this.formIsValid) {
-          return this.focusOnInvalidField();
-        }
+
         if (!this.showPasswordInput) {
           password = 'NOT_SPECIFIED';
+          this.passwordValid = true;
+        }
+
+        if (!this.formIsValid) {
+          return this.focusOnInvalidField();
         }
         this.busy = true;
         this.$store
diff --git a/kolibri/plugins/learn/assets/src/views/AssessmentWrapper/index.vue b/kolibri/plugins/learn/assets/src/views/AssessmentWrapper/index.vue
index a04166272d4..832531b9dc0 100644
--- a/kolibri/plugins/learn/assets/src/views/AssessmentWrapper/index.vue
+++ b/kolibri/plugins/learn/assets/src/views/AssessmentWrapper/index.vue
@@ -50,7 +50,6 @@ oriented data synchronization.
         <KIcon
           icon="mastered"
           :color="success ? $themeTokens.mastered : $themePalette.grey.v_200"
-          style="margin-bottom: -6px;"
         />
         <div class="overall-status-text">
           <span v-if="success" class="completed" :style="{ color: $themeTokens.annotation }">
diff --git a/kolibri/plugins/setup_wizard/assets/src/modules/pluginModule.js b/kolibri/plugins/setup_wizard/assets/src/modules/pluginModule.js
index 48956b20e7e..4dc03f08746 100644
--- a/kolibri/plugins/setup_wizard/assets/src/modules/pluginModule.js
+++ b/kolibri/plugins/setup_wizard/assets/src/modules/pluginModule.js
@@ -12,7 +12,7 @@ const SetupStrings = createTranslator('SetupStrings', {
   },
 });
 
-const { NOT_SPECIFIED } = DemographicConstants;
+const { DEFERRED } = DemographicConstants;
 
 export default {
   state: {
@@ -44,8 +44,10 @@ export default {
         full_name: '',
         username: '',
         password: '',
-        gender: NOT_SPECIFIED,
-        birth_year: NOT_SPECIFIED,
+        // Superusers are not required to provide this info and are not
+        // nudged to update the profile when on Learn page
+        gender: DEFERRED,
+        birth_year: DEFERRED,
       },
     },
     loading: false,
diff --git a/kolibri/plugins/setup_wizard/assets/test/views/SuperuserCredentialsForm.spec.js b/kolibri/plugins/setup_wizard/assets/test/views/SuperuserCredentialsForm.spec.js
index f5e19b62578..de3555375b7 100644
--- a/kolibri/plugins/setup_wizard/assets/test/views/SuperuserCredentialsForm.spec.js
+++ b/kolibri/plugins/setup_wizard/assets/test/views/SuperuserCredentialsForm.spec.js
@@ -33,8 +33,8 @@ describe('SuperuserCredentialsForm', () => {
       full_name: 'Schoolhouse Rock',
       username: 'schoolhouse_rock',
       password: 'password',
-      birth_year: 'NOT_SPECIFIED',
-      gender: 'NOT_SPECIFIED',
+      birth_year: 'DEFERRED',
+      gender: 'DEFERRED',
     });
     expect(wrapper.vm.$emit).toHaveBeenCalledWith('click_next', {
       full_name: 'Schoolhouse Rock',
diff --git a/kolibri/plugins/user/assets/src/views/SignInPage.vue b/kolibri/plugins/user/assets/src/views/SignInPage.vue
index 1e59e11e92e..0c69e6f113d 100644
--- a/kolibri/plugins/user/assets/src/views/SignInPage.vue
+++ b/kolibri/plugins/user/assets/src/views/SignInPage.vue
@@ -179,7 +179,7 @@
             ref="username"
             v-model="username"
             autocomplete="username"
-            :autofocus="!hasMultipleFacilities"
+            :autofocus="true"
             :label="coreString('usernameLabel')"
             :invalid="usernameIsInvalid"
             :invalidText="usernameIsInvalidText"

import footer styling regression


### Observed behavior

![image](https://user-images.githubusercontent.com/2367265/85073073-fef1cd80-b16e-11ea-98c5-8e2342430929.png)

### Expected behavior

button and text should be vertically centered, or the footer should be shorter in height



### Steps to reproduce

import

### Context


0.14 beta 3
