diff --git a/clockwork_frontend_test/test_admin.py b/clockwork_frontend_test/test_admin.py new file mode 100644 index 00000000..782d59c5 --- /dev/null +++ b/clockwork_frontend_test/test_admin.py @@ -0,0 +1,258 @@ +from slurm_state.mongo_client import get_mongo_client +from slurm_state.config import get_config +from playwright.sync_api import Page, expect + +from clockwork_frontend_test.utils import BASE_URL +from clockwork_web.core.users_helper import get_account_fields + + +class UsersFactory: + def __init__(self): + client = get_mongo_client() + db = client[get_config("mongo.database_name")] + self.users_collection = db["users"] + + def _get_users(self) -> list[dict]: + return sorted( + self.users_collection.find({}), key=lambda user: user["mila_email_username"] + ) + + def get_an_admin_user(self) -> dict: + admin_users = [ + user for user in self._get_users() if user.get("admin_access", False) + ] + return admin_users[0] + + def get_a_non_admin_user(self, exclude=()): + users = [ + user for user in self._get_users() if not user.get("admin_access", False) + ] + if exclude: + users = [ + user for user in users if user["mila_email_username"] not in exclude + ] + return users[0] + + +def test_admin_access_for_admin(page: Page): + users_factory = UsersFactory() + admin_user = users_factory.get_an_admin_user() + admin_email = admin_user["mila_email_username"] + random_user = users_factory.get_a_non_admin_user(exclude=admin_email) + # login + page.goto(f"{BASE_URL}/login/testing?user_id={admin_email}") + # Go to settings to set language to english if necessary. + page.goto(f"{BASE_URL}/settings/") + # Get language select. + select = page.locator("select#language_selection") + # Switch to english. + lang = select.input_value() + if lang != "en": + select.select_option("en") + # Check english is selected. + expect(select).to_have_value("en") + + page.goto(f"{BASE_URL}/admin/panel") + expect(page.get_by_text("Administration panel")).to_have_count(1) + page.goto(f"{BASE_URL}/admin/users") + expect(page.get_by_text("Administration panel / Users")).to_have_count(1) + page.goto(f"{BASE_URL}/admin/user?username={random_user['mila_email_username']}") + expect( + page.get_by_text( + f"Administration panel / Users / {random_user['mila_email_username']}" + ) + ).to_have_count(1) + + # Back to default language + if lang != "en": + page.goto(f"{BASE_URL}/settings/") + select = page.locator("select#language_selection") + select.select_option(lang) + expect(select).to_have_value(lang) + + +def test_admin_access_for_admin_french(page: Page): + users_factory = UsersFactory() + admin_user = users_factory.get_an_admin_user() + admin_email = admin_user["mila_email_username"] + random_user = users_factory.get_a_non_admin_user(exclude=admin_email) + # login + page.goto(f"{BASE_URL}/login/testing?user_id={admin_email}") + # Go to settings to set language to english if necessary. + page.goto(f"{BASE_URL}/settings/") + # Get language select. + select = page.locator("select#language_selection") + # Switch to French. + lang = select.input_value() + if lang != "fr": + select.select_option("fr") + # Check english is selected. + expect(select).to_have_value("fr") + + page.goto(f"{BASE_URL}/admin/panel") + expect(page.get_by_text("Panneau d'administration")).to_have_count(1) + page.goto(f"{BASE_URL}/admin/users") + expect(page.get_by_text("Panneau d'administration / Utilisateurs")).to_have_count(1) + page.goto(f"{BASE_URL}/admin/user?username={random_user['mila_email_username']}") + expect( + page.get_by_text( + f"Panneau d'administration / Utilisateurs / {random_user['mila_email_username']}" + ) + ).to_have_count(1) + + # Back to default language + if lang != "fr": + page.goto(f"{BASE_URL}/settings/") + select = page.locator("select#language_selection") + select.select_option(lang) + expect(select).to_have_value(lang) + + +def test_admin_access_for_non_admin(page: Page): + users_factory = UsersFactory() + random_user = users_factory.get_a_non_admin_user() + random_email = random_user["mila_email_username"] + # Login + page.goto(f"{BASE_URL}/login/testing?user_id={random_email}") + # Go to settings to set language to english if necessary. + page.goto(f"{BASE_URL}/settings/") + # Get language select. + select = page.locator("select#language_selection") + # Switch to english. + lang = select.input_value() + if lang != "en": + select.select_option("en") + # Check english is selected. + expect(select).to_have_value("en") + + page.goto(f"{BASE_URL}/admin/panel") + expect(page.get_by_text("Administration panel")).to_have_count(0) + expect(page.get_by_text("Authorization error.")).to_have_count(1) + page.goto(f"{BASE_URL}/admin/users") + expect(page.get_by_text("Administration panel / Users")).to_have_count(0) + expect(page.get_by_text("Authorization error.")).to_have_count(1) + page.goto(f"{BASE_URL}/admin/user?username={random_user['mila_email_username']}") + expect( + page.get_by_text( + f"Administration panel / Users / {random_user['mila_email_username']}" + ) + ).to_have_count(0) + expect(page.get_by_text("Authorization error.")).to_have_count(1) + + # Back to default language + if lang != "en": + page.goto(f"{BASE_URL}/settings/") + select = page.locator("select#language_selection") + select.select_option(lang) + expect(select).to_have_value(lang) + + +def test_admin_pages(page: Page): + users_factory = UsersFactory() + admin_user = users_factory.get_an_admin_user() + admin_email = admin_user["mila_email_username"] + + # Login + page.goto(f"{BASE_URL}/login/testing?user_id={admin_email}") + # Go to settings to set language to english if necessary. + page.goto(f"{BASE_URL}/settings/") + # Get language select. + select = page.locator("select#language_selection") + # Switch to english. + lang = select.input_value() + if lang != "en": + select.select_option("en") + # Check english is selected. + expect(select).to_have_value("en") + + # Select "Admin" menu and click on it + menu_external = page.locator( + "#navbarSupportedContent li.nav-item.dropdown", has_text="EXTERNAL" + ) + expect(menu_external).to_have_count(1) + menu_external.click() + link_admin = menu_external.locator( + "ul.dropdown-menu.show li a.dropdown-item", has_text="Admin" + ) + expect(link_admin).to_have_count(1) + # CLicking on "Admin" link opens a new page we must capture. + # Playwright doc here: https://playwright.dev/python/docs/pages#handling-new-pages + with page.context.expect_page() as new_page_info: + link_admin.click() + new_page = new_page_info.value + expect(new_page).to_have_url(f"{BASE_URL}/admin/panel") + + # Go to page "Manage users" + link_manage_users = new_page.locator("a.btn", has_text="Manage users") + expect(link_manage_users).to_have_count(1) + link_manage_users.click() + expect(new_page).to_have_url(f"{BASE_URL}/admin/users") + + # Select user data from table first row + rows = new_page.locator("table tbody tr") + row = rows.nth(0) + expect(row).to_have_count(1) + columns = row.locator("td") + user_email = columns.nth(0).text_content().strip() + user_edit_button = columns.last.locator("a") + assert user_email.endswith("@mila.quebec") + expect(user_edit_button).to_have_text("edit") + + # Go to edition page for selected user + user_edit_button.click() + expect(new_page).to_have_url(f"{BASE_URL}/admin/user?username={user_email}") + + # Get default usernames + account_fields = get_account_fields() + old_usernames = {} + for user_field in account_fields: + user_input = new_page.locator(f"form table input[name={user_field}]") + expect(user_input).to_have_count(1) + old_usernames[user_field] = user_input.input_value() + + # Edit user + new_usernames = { + user_field: f"{username}_new" for user_field, username in old_usernames.items() + } + for user_field in account_fields: + new_page.locator(f"form table input[name={user_field}]").fill( + new_usernames[user_field] + ) + # Submit form + button_submit = new_page.locator("button", has_text="Save") + expect(button_submit).to_have_count(1) + button_submit.click() + expect(new_page).to_have_url(f"{BASE_URL}/admin/user?username={user_email}") + expect(new_page.get_by_text("User successfully updated.")).to_have_count(1) + + # Check new input values + for user_field in account_fields: + user_input = new_page.locator(f"form table input[name={user_field}]") + expect(user_input).to_have_value(new_usernames[user_field]) + + # Edit user back to default values + for user_field in account_fields: + new_page.locator(f"form table input[name={user_field}]").fill( + old_usernames[user_field] + ) + new_page.locator("button", has_text="Save").click() + expect(new_page).to_have_url(f"{BASE_URL}/admin/user?username={user_email}") + expect(new_page.get_by_text("User successfully updated.")).to_have_count(1) + for user_field in account_fields: + user_input = new_page.locator(f"form table input[name={user_field}]") + expect(user_input).to_have_value(old_usernames[user_field]) + + # Check what happens when submitting form with default values + new_page.locator("button", has_text="Save").click() + expect(new_page).to_have_url(f"{BASE_URL}/admin/user?username={user_email}") + expect(new_page.get_by_text("No change for this user.")).to_have_count(1) + for user_field in account_fields: + user_input = new_page.locator(f"form table input[name={user_field}]") + expect(user_input).to_have_value(old_usernames[user_field]) + + # Back to default language + if lang != "en": + page.goto(f"{BASE_URL}/settings/") + select = page.locator("select#language_selection") + select.select_option(lang) + expect(select).to_have_value(lang) diff --git a/clockwork_web/browser_routes/admin.py b/clockwork_web/browser_routes/admin.py index 7798da80..5824e2de 100644 --- a/clockwork_web/browser_routes/admin.py +++ b/clockwork_web/browser_routes/admin.py @@ -32,8 +32,13 @@ # this is what allows the factorization into many files. from flask import Blueprint +from clockwork_web.core.clusters_helper import get_account_fields from clockwork_web.core.utils import to_boolean, get_custom_array_from_request_args -from clockwork_web.core.users_helper import render_template_with_user_settings +from clockwork_web.core.users_helper import ( + render_template_with_user_settings, + get_users, +) +from ..db import get_db flask_api = Blueprint("admin", __name__) @@ -62,7 +67,7 @@ def decorated(*args, **kwargs): @login_required @admin_access_required def panel(): - """ """ + """Admin home page""" logging.info( f"clockwork browser route: /admin/panel - current_user={current_user.mila_email_username}" ) @@ -75,3 +80,120 @@ def panel(): mila_email_username=current_user.mila_email_username, previous_request_args=previous_request_args, ) + + +@flask_api.route("/users") +@login_required +@admin_access_required +def users(): + """Admin users page""" + logging.info( + f"clockwork browser route: /admin/users - current_user={current_user.mila_email_username}" + ) + + # Initialize the request arguments (it is further transferred to the HTML) + previous_request_args = {} + + # Get users + LD_users = sorted(get_users(), key=lambda user: user["mila_email_username"]) + + # Get the different clusters fields + D_clusters_usernames_fields = get_account_fields() + + return render_template_with_user_settings( + "admin_users.html", + mila_email_username=current_user.mila_email_username, + previous_request_args=previous_request_args, + LD_users=LD_users, + D_clusters_usernames_fields=D_clusters_usernames_fields, + ) + + +@flask_api.route("/user", methods=["POST", "GET"]) +@login_required +@admin_access_required +def user(): + """ + Admin page to edit a specific user + + User to edit is passed as GEt parameter `username` + + Edited values (mila cluster ID and DRAC cluster ID) + are passed as POST form. + """ + logging.info( + f"clockwork browser route: /admin/user - current_user={current_user.mila_email_username}" + ) + + # Initialize the request arguments (it is further transferred to the HTML) + previous_request_args = {} + + # Get user + mila_email_username = request.args.get("username", None) + previous_request_args["username"] = mila_email_username + + if mila_email_username is None: + return ( + render_template_with_user_settings( + "error.html", + error_msg=gettext("Missing argument username."), + previous_request_args=previous_request_args, + ), + 400, # Bad Request + ) + + mc = get_db() + users_collection = mc["users"] + D_users = list(users_collection.find({"mila_email_username": mila_email_username})) + + if len(D_users) != 1: + return ( + render_template_with_user_settings( + "error.html", + error_msg=gettext("Cannot find username: %(username)s") + % {"username": mila_email_username}, + previous_request_args=previous_request_args, + ), + 400, # Bad Request + ) + + (D_user,) = D_users + + # Get the different clusters fields + D_clusters_usernames_fields = get_account_fields() + + # Edition form + user_edit_status = "" + if request.method == "POST": + # Handle edition form + update_needed = False + new_usernames = {} + for cluster_username_field in D_clusters_usernames_fields: + old_username = D_user[cluster_username_field] + + # NB: An empty-string is interpreted as None. + # This allows to set a username to None, + # as username may be None in users db. + new_username = request.form[cluster_username_field].strip() or None + update_needed = update_needed or new_username != old_username + new_usernames[cluster_username_field] = new_username + + if update_needed: + users_collection.update_one( + {"mila_email_username": D_user["mila_email_username"]}, + {"$set": new_usernames}, + ) + for cluster_username_field in D_clusters_usernames_fields: + D_user[cluster_username_field] = new_usernames[cluster_username_field] + user_edit_status = gettext("User successfully updated.") + else: + user_edit_status = gettext("No change for this user.") + + return render_template_with_user_settings( + "admin_user.html", + mila_email_username=current_user.mila_email_username, + previous_request_args=previous_request_args, + D_user=D_user, + user_edit_status=user_edit_status, + D_clusters_usernames_fields=D_clusters_usernames_fields, + ) diff --git a/clockwork_web/static/locales/en/LC_MESSAGES/messages.po b/clockwork_web/static/locales/en/LC_MESSAGES/messages.po new file mode 100644 index 00000000..1fd7c67e --- /dev/null +++ b/clockwork_web/static/locales/en/LC_MESSAGES/messages.po @@ -0,0 +1,28 @@ +# English translations for PROJECT. +# Copyright (C) 2022 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2023-10-12 09:33-0400\n" +"PO-Revision-Date: 2022-07-14 15:23-0400\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.10.3\n" + + +#: clockwork_web/templates/admin_user.html:44 +msgid "mila_cluster_username" +msgstr "Mila cluster ID" + +#: clockwork_web/templates/admin_user.html:44 +msgid "cc_account_username" +msgstr "DRAC cluster ID" diff --git a/clockwork_web/static/locales/fr/LC_MESSAGES/messages.po b/clockwork_web/static/locales/fr/LC_MESSAGES/messages.po index 798e8be1..698d6307 100644 --- a/clockwork_web/static/locales/fr/LC_MESSAGES/messages.po +++ b/clockwork_web/static/locales/fr/LC_MESSAGES/messages.po @@ -386,6 +386,7 @@ msgstr "Liens" #: clockwork_web/templates/jobs_search.html:145 #: clockwork_web/templates/settings.html:253 #: clockwork_web/templates/settings.html:289 +#: clockwork_web/templates/admin_users.html:37 msgid "Actions" msgstr "Actions" @@ -685,3 +686,47 @@ msgstr "Valeur de la propriété" #, python-format msgid "You have not defined any user prop for this job." msgstr "Vous n'avez défini aucune propriété pour ce job." + +#: clockwork_web/browser_routes/admin.py:153 +msgid "Cannot find username: %(username)s" +msgstr "Utilisateur introuvable: %(username)s" + +#: clockwork_web/browser_routes/admin.py:188 +msgid "User successfully updated." +msgstr "Utilisateur mis à jour." + +#: clockwork_web/browser_routes/admin.py:190 +msgid "No change for this user." +msgstr "Pas de changements pour cet utilisateur." + +#: clockwork_web/templates/admin_panel.html:22 +msgid "Administration panel" +msgstr "Panneau d'administration" + +#: clockwork_web/templates/admin_panel.html:29 +msgid "Manage users" +msgstr "Gérer les utilisateurs" + +#: clockwork_web/templates/admin_user.html:24 +msgid "Users" +msgstr "Utilisateurs" + +#: clockwork_web/templates/admin_user.html:58 +msgid "Cancel" +msgstr "Annuler" + +#: clockwork_web/templates/admin_user.html:61 +msgid "Save" +msgstr "Enregistrer" + +#: clockwork_web/templates/admin_user.html:44 +msgid "mila_cluster_username" +msgstr "ID sur le cluster Mila" + +#: clockwork_web/templates/admin_user.html:44 +msgid "cc_account_username" +msgstr "ID sur les clusters DRAC" + +#: clockwork_web/templates/admin_users.html:50 +msgid "edit" +msgstr "modifier" diff --git a/clockwork_web/templates/admin_panel.html b/clockwork_web/templates/admin_panel.html index 39d120e0..2b5ca92a 100644 --- a/clockwork_web/templates/admin_panel.html +++ b/clockwork_web/templates/admin_panel.html @@ -14,8 +14,21 @@ {% endblock %} {% block content %} -
-

Hello, admin world !

- This page is a placeholder. -
+
+
+
+
+ +

{{ gettext("Administration panel") }}

+
+
+
+ +
{% endblock %} diff --git a/clockwork_web/templates/admin_user.html b/clockwork_web/templates/admin_user.html new file mode 100644 index 00000000..7a31191a --- /dev/null +++ b/clockwork_web/templates/admin_user.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %} {{note_title}} {% endblock %} +{% block head %} + {{ super() }} + + + +{% endblock %} +{% block content %} +
+
+
+
+ +

+ {{ gettext("Administration panel") }} / + {{ gettext("Users") }} / + {{ D_user["mila_email_username"] }} +

+
+
+
+ + {% if user_edit_status %} +
+

{{ user_edit_status }}

+
+ {% endif %} + +
+
+ + + {% for cluster_username_field in D_clusters_usernames_fields %} + + + + + {% endfor %} + +
+ {{ gettext(cluster_username_field) }} + + +
+
+ +
+
+ + {{ gettext("Cancel") }} + + +
+
+
+ + +
+{% endblock %} diff --git a/clockwork_web/templates/admin_users.html b/clockwork_web/templates/admin_users.html new file mode 100644 index 00000000..d4adce67 --- /dev/null +++ b/clockwork_web/templates/admin_users.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %} {{note_title}} {% endblock %} +{% block head %} + {{ super() }} + + + +{% endblock %} +{% block content %} +
+
+
+
+ +

+ {{ gettext("Administration panel") }} / + {{ gettext("Users") }} +

+
+
+
+
+ + + + + {% for cluster_username_field in D_clusters_usernames_fields %} + + {% endfor %} + + + + + {% for D_user in LD_users %} + + + {% for cluster_username_field in D_clusters_usernames_fields %} + + {% endfor %} + + + {% endfor %} + +
{{ gettext("User (@mila.quebec)") }}{{ gettext(cluster_username_field) }}{{ gettext("Actions") }}
{{ D_user["mila_email_username"] }}{{ D_user[cluster_username_field] or "" }} + + {{ gettext("edit") }} + +
+
+
+{% endblock %} diff --git a/scripts/ensure_one_fake_admin_in_db.py b/scripts/ensure_one_fake_admin_in_db.py new file mode 100644 index 00000000..f4408e0b --- /dev/null +++ b/scripts/ensure_one_fake_admin_in_db.py @@ -0,0 +1,32 @@ +""" +Helper script to make sure testing db contains at least 1 admin user. +Used for frontend admin tests. +""" +from slurm_state.mongo_client import get_mongo_client +from slurm_state.config import get_config + + +def main(): + client = get_mongo_client() + db = client[get_config("mongo.database_name")] + users_collection = db["users"] + users = sorted( + users_collection.find({}), key=lambda user: user["mila_email_username"] + ) + admin_users = [user for user in users if user.get("admin_access", False)] + if not admin_users and users: + future_admin_user = users[0] + users_collection.update_one( + {"mila_email_username": future_admin_user["mila_email_username"]}, + {"$set": {"admin_access": True}}, + ) + assert list( + user + for user in users_collection.find({}) + if user.get("admin_access", False) + ) + print("Admin user registered.") + + +if __name__ == "__main__": + main() diff --git a/scripts/launch_frontend_tests_in_clockwork_dev.sh b/scripts/launch_frontend_tests_in_clockwork_dev.sh index 66c84798..d23b966b 100755 --- a/scripts/launch_frontend_tests_in_clockwork_dev.sh +++ b/scripts/launch_frontend_tests_in_clockwork_dev.sh @@ -6,6 +6,9 @@ playwright install chromium echo Store fake data python3 scripts/store_fake_data_in_db.py +echo Ensure at least 1 fake admin user +python3 scripts/ensure_one_fake_admin_in_db.py + echo Launch clockwork web server in background python3 -m flask run --host="0.0.0.0" &