{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# User Migration\n",
    "\n",
    "Administrators may have need to migrate users' content from one authentication directory (aka \"datasource\") to another. For example, you may need to migrate from on-premise Windows Active Directory to Azure Active Directory (AAD). Since Azure Active Directory usernames are a somewhat obfuscated alphanumeric sequence, administrators cannot easily proactively create AAD accounts in order to perform the migration. It is easier to quickly migrate the old account's content to the new account _after_ the new account has been created (as a result of the user logging in for the first time).\n",
    "\n",
    "This script scans all users in the old and new directory/datasource, and if they can be matched, it will migrate the content from old to new and delete the old account. This means that, from the user's standpoint, they log in with their new account, and then some time later (say, 15 minutes) all of their folders, analyses, topics, projects appear in the **My Folder** section, and all of their access control is \"restored\" to their new account.\n",
    "\n",
    "Generally, you will want to run this script on a schedule so that the user's content is migrated within, say, 15 minutes of logging into their new account. See the final cell below for instructions on how to do this.\n",
    "\n",
    "⚠️ Only Seeq Administrators can run this notebook.\n",
    "\n",
    "⚠️ This notebook makes irreversible changes to the system. Set `dry_run = True` and review the output after making\n",
    "any changes to the configuration or implementation of this notebook."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "from collections import namedtuple\n",
    "\n",
    "from pandas import DataFrame\n",
    "\n",
    "from seeq import sdk, spy\n",
    "\n",
    "# Set the compatibility option so that you maximize the chance that SPy will remain compatible with your notebook/script\n",
    "spy.options.compatibility = 193"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Authentication\n",
    "\n",
    "Log into Seeq Server if you're not using Seeq Data Lab"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "spy.login(url='http://localhost:34216', credentials_file='../credentials.key', force=False)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Configuration\n",
    "\n",
    "- `dry_run`: When `True` the migration details will be displayed, but the migration will NOT be executed.\n",
    "- `criteria`: Users where ANY of these properties match between datasources will be migrated.\n",
    "- `old_datasource_name`: The name of the datasource to migrate users from.\n",
    "- `new_datasource_name`: The name of the datasource to migrate users to."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dry_run = True\n",
    "criteria = ['email', 'fullname', 'username']\n",
    "old_datasource_name = 'Old SSO Provider'\n",
    "new_datasource_name = 'New SSO Provider'"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Implementation"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "users_api = sdk.UsersApi(spy.client)\n",
    "NormalizedUser = namedtuple('NormalizedUser', ['id', 'email', 'fullname', 'username'])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def normalize_str(s):\n",
    "    return s.strip().lower() if isinstance(s, str) else s\n",
    "\n",
    "\n",
    "def normalize_user(user):\n",
    "    return NormalizedUser(\n",
    "        id=user.id,\n",
    "        email=normalize_str(user.email),\n",
    "        fullname=(normalize_str(user.first_name), normalize_str(user.last_name)),\n",
    "        username=user.username,\n",
    "    )\n",
    "\n",
    "\n",
    "def get_user_map(datasource_name):\n",
    "    users = {}\n",
    "    limit = 100\n",
    "    offset = 0\n",
    "    while True:\n",
    "        page = users_api.get_users(datasource_name_search=datasource_name, offset=offset, limit=limit)\n",
    "        for user in page.users:\n",
    "            # datasource_name_search can result in unwanted results when the user datasource name\n",
    "            # being searched for is a subset of another user datasource name on the server\n",
    "            if user.datasource_name == datasource_name:\n",
    "                normalized_user = normalize_user(user)\n",
    "                for criterion in criteria:\n",
    "                    users[(criterion, getattr(normalized_user, criterion))] = normalized_user\n",
    "        if not page.next:\n",
    "            return users\n",
    "        offset += limit"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "old_user_map = get_user_map(old_datasource_name)\n",
    "new_user_map = get_user_map(new_datasource_name)\n",
    "\n",
    "matches = sorted(dict(\n",
    "    (old_user_map[common_key], new_user_map[common_key])\n",
    "    for common_key in old_user_map.keys() & new_user_map.keys()\n",
    ").items())\n",
    "\n",
    "df = DataFrame([{\n",
    "    'Old ID': old.id,\n",
    "    'Old Username': old.username,\n",
    "    'Old Email': old.email,\n",
    "    'Old Name': old.fullname,\n",
    "    '': '->',\n",
    "    'New ID': new.id,\n",
    "    'New Username': new.username,\n",
    "    'New Email': new.email,\n",
    "    'New Name': new.fullname,\n",
    "} for old, new in matches])\n",
    "\n",
    "dfStyler = df.style.set_properties(**{'text-align': 'left', 'white-space': 'nowrap', })\n",
    "dfStyler.set_table_styles([dict(selector='th', props=[('text-align', 'left'), ('white-space', 'nowrap')])])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "results = []\n",
    "\n",
    "for old, new in matches:\n",
    "    result_args = [old.id, '->', new.id]\n",
    "\n",
    "    if dry_run:\n",
    "        results.append([*result_args, 'SKIPPED', None])\n",
    "    else:\n",
    "        try:\n",
    "            users_api.delete_user(id=old.id, new_owner_id=new.id, transfer_acl_and_group_membership=True)\n",
    "        except sdk.rest.ApiException as e:\n",
    "            results.append([*result_args, 'FAILURE', e.body])\n",
    "        else:\n",
    "            results.append([*result_args, 'SUCCESS', None])\n",
    "\n",
    "df = DataFrame(results, columns=['Old ID', '', 'New ID', 'Status', 'Error'])\n",
    "\n",
    "dfStyler = df.style.set_properties(**{'text-align': 'left', 'white-space': 'nowrap'})\n",
    "dfStyler.set_table_styles([dict(selector='th', props=[('text-align', 'left'), ('white-space', 'nowrap')])])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Scheduling\n",
    "\n",
    "You will generally want this script to run on a schedule so that it is constantly trying to migrate users whenever it can. This reduces the time between a user logging in using their new credentials and them seeing all of their content from their old account.\n",
    "\n",
    "To schedule, remove the '#' comment character from the cell below and execute it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "# spy.jobs.schedule('every 15 minutes')"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3.11",
   "language": "python",
   "name": "python311"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.8"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 1
}
