diff --git a/cms/sass/components/_accordion.scss b/cms/sass/components/_accordion.scss index e066ee02f..6d6704040 100644 --- a/cms/sass/components/_accordion.scss +++ b/cms/sass/components/_accordion.scss @@ -1,3 +1,3 @@ .accordion:focus-within { - border: $grapefruit solid; + outline: $grapefruit solid; } \ No newline at end of file diff --git a/cms/sass/components/_filters.scss b/cms/sass/components/_filters.scss index 871fe4a58..44cd00ab2 100644 --- a/cms/sass/components/_filters.scss +++ b/cms/sass/components/_filters.scss @@ -65,9 +65,6 @@ } .filter__choices { - max-height: $spacing-07; - height: auto; - overflow-y: auto; padding-top: $spacing-01; @include unstyled-list; diff --git a/doajtest/testbook/application_forms_rearrangement/application_forms_rearrangement.yml b/doajtest/testbook/application_forms_rearrangement/application_forms_rearrangement.yml new file mode 100644 index 000000000..296e6406c --- /dev/null +++ b/doajtest/testbook/application_forms_rearrangement/application_forms_rearrangement.yml @@ -0,0 +1,95 @@ +suite: Editorial Form Field Placement Changes +testset: Editorial Form Field Placement Changes + +tests: + - title: Admin – Updated Form Layout and Field Behaviour + setup: + - Go to /testdrive/form_rearrangement + context: + role: admin + steps: + - step: Log in as DukeDingleberry, the admin + + - step: Access the journal form at /admin/journal_id + results: + - The plagiarism questions appear in the Best Practice section + - The keywords field is positioned at the end of the Editorial section + - The subject classification field is positioned at the end of the Editorial section + - There is no subject classification modal + - The subject classification question is now displayed directly in the main form body + - step: Remove the currently selected subject + results: + - The change is correctly reflected under the subject browser + - step: Use the search input to find any subject + results: + - The subject browser behaves correctly when searched + - step: Select a subject + results: + - The change is correctly reflected under the subject browser + - step: Save the form + results: + - The new subject is saved correctly + - step: Click "Unlock & Close" + + - step: Access the application form at /application/application_id + results: + - Same checks as above + - step: Click "Unlock & Close" + + - step: Access the update request form at /application/update_request_id + results: + - Same checks as above + - step: Click "Unlock & Close" + - step: Log out of the admin account + + + - title: Editor – Updated Form Layout and Field Behaviour + setup: + - Go to /testdrive/form_rearrangement + context: + role: editor + steps: + - step: Log in as CountessCrumblewhisk, the editor + + - step: Access the application form at /editor/application/application_id + results: + - The plagiarism questions appear in the Best Practice section + - The keywords field is positioned at the end of the Editorial section + - The subject classification field is positioned at the end of the Editorial section + - There is no subject classification modal + - The subject classification question is now displayed directly in the main form body + - step: Remove the currently selected subject + results: + - The change is correctly reflected under the subject browser + - step: Use the search input to find any subject + results: + - The subject browser behaves correctly when searched + - step: Select a subject + results: + - The change is correctly reflected under the subject browser + - step: Save the form + results: + - The new subject is saved correctly + - step: Click "Unlock & Close" + - step: Log out of the editor account + + + - title: Publisher – Fields Remain in Existing Sections + setup: + - Go to /testdrive/form_rearrangement + context: + role: publisher + steps: + - step: Log in as LordSniffleton, the publisher + + - step: Access the update request form at /publisher/update_request/journal_id + results: + - The keywords question remains in the About section (not moved to the Editorial section) + - The plagiarism questions appear in the Best Practice section + - step: Click "Unlock & Close" + + - step: Access the new application form at /apply + results: + - Same checks as above + - step: Log out of the publisher account + diff --git a/doajtest/testdrive/form_rearrangement.py b/doajtest/testdrive/form_rearrangement.py new file mode 100644 index 000000000..5abb5531b --- /dev/null +++ b/doajtest/testdrive/form_rearrangement.py @@ -0,0 +1,106 @@ +from portality import constants +from doajtest.testdrive.factory import TestDrive +from doajtest.fixtures.v2.journals import JournalFixtureFactory +from doajtest.fixtures.v2.applications import ApplicationFixtureFactory +from portality import models + + +class FormRearrangement(TestDrive): + def setup(self) -> dict: + publisher_un = "LordSniffleton" + publisher_pw = self.create_random_str() + publisher_acc = models.Account.make_account( + publisher_un + "@example.com", + publisher_un, + "Publisher " + publisher_un, + [constants.ROLE_PUBLISHER] + ) + publisher_acc.set_password(publisher_pw) + publisher_acc.save() + + admin_un = "DukeDingleberry" + admin_pw = self.create_random_str() + admin_acc = models.Account.make_account( + admin_un + "@example.com", + admin_un, + "Admin " + admin_un, + [constants.ROLE_ADMIN] + ) + admin_acc.set_password(admin_pw) + admin_acc.save() + + editor_un = "CountessCrumblewhisk" + editor_pw = self.create_random_str() + editor_acc = models.Account.make_account( + editor_un + "@example.com", + editor_un, + "Editor " + editor_un, + [constants.ROLE_EDITOR] + ) + editor_acc.set_password(editor_pw) + editor_acc.save() + + # Journal setup + source = JournalFixtureFactory.make_journal_source(in_doaj=True) + j = models.Journal(**source) + j.remove_current_application() + j.set_id(j.makeid()) + j.set_owner(publisher_acc.id) + j.bibjson().eissn = "1987-0007" + j.bibjson().pissn = "3141-5926" + j.bibjson().title = "Annals of Aquatic Diplomacy" + j.bibjson().journal_url = "https://please-dont-splash.org" + del j.bibjson().discontinued_date + j.save(blocking=True) + + # New application setup + source = ApplicationFixtureFactory.make_application_source() + a = models.Application(**source) + a.remove_current_journal() + a.remove_related_journal() + a.application_type = constants.APPLICATION_TYPE_NEW_APPLICATION + a.set_id(a.makeid()) + a.set_editor(editor_acc.id) + a.set_owner(publisher_acc.id) + a.bibjson().eissn = "2718-2818" + a.bibjson().title = "Journal of Speculative Mycology" + a.bibjson().journal_url = "https://fungi-that-might-exist.com" + a.set_application_status(constants.APPLICATION_STATUS_IN_PROGRESS) + a.save(blocking=True) + + # Update request setup + source = ApplicationFixtureFactory.make_update_request_source() + ur = models.Application(**source) + ur.set_id(ur.makeid()) + ur.set_editor(editor_acc.id) + ur.set_owner(publisher_acc.id) + ur.bibjson().pissn = "0042-2718" + ur.bibjson().title = "Proceedings of Recursive Archaeology" + ur.bibjson().journal_url = "https://digging-up-the-future.net" + ur.save(blocking=True) + + return { + "accounts": { + "admin": {"username": admin_acc.id, "password": admin_pw}, + "editor": {"username": editor_acc.id, "password": editor_pw}, + "publisher": {"username": publisher_acc.id, "password": publisher_pw}, + }, + "journals": {j.bibjson().title: j.id}, + "applications": {a.bibjson().title: a.id}, + "update_requests": {ur.bibjson().title: ur.id} + } + + def teardown(self, params) -> dict: + for accid in ["admin", "editor", "publisher"]: + models.Account.remove_by_id(params["accounts"][accid]["username"]) + + for title, id in params["journals"].items(): + models.Journal.remove_by_id(id) + + for title, id in params["applications"].items(): + models.Application.remove_by_id(id) + + for title, id in params["update_requests"].items(): + models.Application.remove_by_id(id) + + return {"status": "success"} diff --git a/portality/forms/application_forms.py b/portality/forms/application_forms.py index aa2efd2ac..588c5b6d5 100644 --- a/portality/forms/application_forms.py +++ b/portality/forms/application_forms.py @@ -2109,7 +2109,6 @@ class FieldSetDefinitions: FieldDefinitions.JOURNAL_URL["name"], FieldDefinitions.PISSN["name"], FieldDefinitions.EISSN["name"], - FieldDefinitions.KEYWORDS["name"], FieldDefinitions.LANGUAGE["name"] ] } @@ -2194,7 +2193,7 @@ class FieldSetDefinitions: FieldDefinitions.AIMS_SCOPE_URL["name"], FieldDefinitions.EDITORIAL_BOARD_URL["name"], FieldDefinitions.AUTHOR_INSTRUCTIONS_URL["name"], - FieldDefinitions.PUBLICATION_TIME_WEEKS["name"] + FieldDefinitions.PUBLICATION_TIME_WEEKS["name"], ] } @@ -2327,11 +2326,21 @@ class FieldSetDefinitions: } # ~~->$ Subject:FieldSet~~ - SUBJECT = { - "name": "subject", - "label": "Subject classification", + SUBJECT_AND_KEYWORDS = { + "name": "subject_and_keywords", + "label": "Subject classification and keywords", "fields": [ - FieldDefinitions.SUBJECT["name"] + FieldDefinitions.SUBJECT["name"], + FieldDefinitions.KEYWORDS["name"] + ] + } + + # ~~->$ Subject:FieldSet~~ + KEYWORDS = { + "name": "keywords", + "label": "Keywords", + "fields": [ + FieldDefinitions.KEYWORDS["name"] ] } @@ -2394,6 +2403,7 @@ class ApplicationContextDefinitions: # ~~->$ NewApplication:FormContext~~ # ~~^-> ApplicationForm:Crosswalk~~ # ~~^-> NewApplication:FormProcessor~~ + PUBLIC = { "name": "public", "fieldsets": [ @@ -2433,6 +2443,9 @@ class ApplicationContextDefinitions: UPDATE["name"] = "update_request" UPDATE["processor"] = application_processors.PublisherUpdateRequest UPDATE["templates"]["form"] = templates.PUBLISHER_UPDATE_REQUEST_FORM + UPDATE["fieldsets"] += [ + FieldSetDefinitions.KEYWORDS["name"], + ] # ~~->$ ReadOnlyApplication:FormContext~~ # ~~^-> NewApplication:FormContext~~ @@ -2440,6 +2453,9 @@ class ApplicationContextDefinitions: READ_ONLY["name"] = "application_read_only" READ_ONLY["processor"] = application_processors.NewApplication # FIXME: enter the real processor READ_ONLY["templates"]["form"] = templates.PUBLISHER_READ_ONLY_APPLICATION + READ_ONLY["fieldsets"] += [ + FieldSetDefinitions.KEYWORDS["name"], + ] # ~~->$ AssociateEditorApplication:FormContext~~ # ~~^-> NewApplication:FormContext~~ @@ -2448,7 +2464,7 @@ class ApplicationContextDefinitions: ASSOCIATE["name"] = "associate_editor" ASSOCIATE["fieldsets"] += [ FieldSetDefinitions.STATUS["name"], - FieldSetDefinitions.SUBJECT["name"], + FieldSetDefinitions.SUBJECT_AND_KEYWORDS["name"], FieldSetDefinitions.NOTES["name"] ] ASSOCIATE["processor"] = application_processors.AssociateApplication @@ -2462,7 +2478,7 @@ class ApplicationContextDefinitions: EDITOR["fieldsets"] += [ FieldSetDefinitions.STATUS["name"], FieldSetDefinitions.REVIEWERS["name"], - FieldSetDefinitions.SUBJECT["name"], + FieldSetDefinitions.SUBJECT_AND_KEYWORDS["name"], FieldSetDefinitions.NOTES["name"] ] EDITOR["processor"] = application_processors.EditorApplication @@ -2480,12 +2496,15 @@ class ApplicationContextDefinitions: FieldSetDefinitions.STATUS["name"], FieldSetDefinitions.REVIEWERS["name"], FieldSetDefinitions.CONTINUATIONS["name"], - FieldSetDefinitions.SUBJECT["name"], + FieldSetDefinitions.SUBJECT_AND_KEYWORDS["name"], FieldSetDefinitions.NOTES["name"], ] MANED["processor"] = application_processors.AdminApplication MANED["templates"]["form"] = templates.MANED_APPLICATION_FORM + # now we can update the Public Context with the correct "About" fieldset + PUBLIC["fieldsets"] += [FieldSetDefinitions.KEYWORDS["name"]] + class JournalContextDefinitions: # ~~->$ ReadOnlyJournal:FormContext~~ @@ -2509,7 +2528,9 @@ class JournalContextDefinitions: FieldSetDefinitions.OTHER_FEES["name"], FieldSetDefinitions.ARCHIVING_POLICY["name"], FieldSetDefinitions.REPOSITORY_POLICY["name"], - FieldSetDefinitions.UNIQUE_IDENTIFIERS["name"] + FieldSetDefinitions.UNIQUE_IDENTIFIERS["name"], + FieldSetDefinitions.SUBJECT_AND_KEYWORDS["name"], + ], "templates": { "form": templates.MANED_READ_ONLY_JOURNAL, @@ -2533,7 +2554,6 @@ class JournalContextDefinitions: # ~~^-> AssEdJournal:FormProcessor~~ ASSOCIATE = deepcopy(ADMIN_READ_ONLY) ASSOCIATE["fieldsets"] += [ - FieldSetDefinitions.SUBJECT["name"], FieldSetDefinitions.NOTES["name"] ] ASSOCIATE["name"] = "associate_editor" diff --git a/portality/scripts/delete_empty_indices.py b/portality/scripts/delete_empty_indices.py new file mode 100755 index 000000000..4aed30b6c --- /dev/null +++ b/portality/scripts/delete_empty_indices.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +# ~~DeleteEmptyIndices:CLI~~ +""" +Script to delete Elasticsearch indices that have 0 documents. +This helps clean up empty indices that may have been created but never used. + +This is mainly of use for test servers with duplicated indexes, of which one is used. + +Run: + DOAJENV=dev python portality/scripts/delete_empty_indices.py [--dry-run] [--exclude PATTERN ...] + +Options: + --dry-run List empty indices without deleting them + --exclude PATTERN Index patterns to exclude from deletion (can be specified multiple times) + Default excludes: .kibana, .security, .monitoring, .apm, .tasks + --include-system Include system indices (those starting with '.') + --keep-latest Keep the latest empty index for each prefix (based on timestamp in name) + +Notes: +- Requires DOAJ config to connect to ES (ELASTICSEARCH_HOSTS, ELASTIC_SEARCH_VERIFY_CERTS) +- Uses the es_connection singleton from portality.core +- Prompts for confirmation before deleting unless --yes flag is provided +""" + +import argparse +import sys +from typing import List, Dict +from collections import defaultdict + +from portality.core import app, es_connection + + +DEFAULT_EXCLUDES = ['.kibana', '.security', '.monitoring', '.apm', '.tasks', '.watches'] + + +def get_all_indices() -> List[Dict]: + """Get list of all indices with their document counts from Elasticsearch.""" + try: + # Use _cat/indices to get index stats including doc count + indices = es_connection.cat.indices(format='json') + return indices + except Exception as e: + print(f'Error fetching indices: {e}', file=sys.stderr) + sys.exit(1) + + +def extract_index_prefix(index_name: str) -> str: + """ + Extract the prefix from an index name, removing the timestamp suffix. + + For example: 'doaj-ur_review_route-20251126_172410_000655' -> 'doaj-ur_review_route' + + Args: + index_name: Full index name with timestamp + + Returns: + Index prefix without timestamp + """ + # Split by hyphen and look for the timestamp pattern (YYYYMMDD_HHMMSS_microseconds) + parts = index_name.split('-') + if len(parts) < 2: + return index_name + + # Check if the last part looks like a timestamp (starts with 8 digits) + last_part = parts[-1] + if len(last_part) >= 8 and last_part[:8].isdigit(): + # Return everything except the last part (timestamp) + return '-'.join(parts[:-1]) + + return index_name + + +def find_empty_indices(indices: List[Dict], exclude_patterns: List[str], include_system: bool, keep_latest: bool) -> List[Dict]: + """ + Filter indices that have 0 documents. + + Args: + indices: List of index dicts from ES + exclude_patterns: Patterns to exclude from results + include_system: Whether to include system indices (starting with '.') + keep_latest: Whether to keep the latest empty index for each prefix + + Returns: + List of empty index dicts with name, size, status, and health + """ + empty_indices = [] + + for idx in indices: + index_name = idx.get('index', '') + doc_count = int(idx.get('docs.count', 0)) + + # Skip if not 0 documents + if doc_count != 0: + continue + + # Skip system indices unless explicitly included + if not include_system and index_name.startswith('.'): + continue + + # Skip if matches any exclude pattern + excluded = False + for pattern in exclude_patterns: + if pattern in index_name or index_name.startswith(pattern): + excluded = True + break + + if not excluded: + empty_indices.append({ + 'name': index_name, + 'size': idx.get('store.size', '0b'), + 'status': idx.get('status', 'unknown'), + 'health': idx.get('health', 'unknown'), + 'docs_deleted': idx.get('docs.deleted', '0') + }) + + # If keep_latest is enabled, filter out all but the latest index for each prefix + if keep_latest and empty_indices: + # Group indices by prefix + prefix_groups = defaultdict(list) + for idx in empty_indices: + prefix = extract_index_prefix(idx['name']) + prefix_groups[prefix].append(idx) + + # For each prefix, keep only the latest (sorted by name, which includes timestamp) + indices_to_delete = [] + for prefix, group in prefix_groups.items(): + # Sort by name (descending) - latest timestamp will be first + sorted_group = sorted(group, key=lambda x: x['name'], reverse=True) + # Keep the first (latest), add the rest to delete list + indices_to_delete.extend(sorted_group[1:]) + + return indices_to_delete + + return empty_indices + + +def delete_index(index_name: str) -> tuple: + """ + Delete a specific index. + + Returns: + Tuple of (success: bool, message: str) + """ + try: + resp = es_connection.indices.delete(index=index_name) + return True, f"Deleted successfully: {resp}" + except Exception as e: + return False, str(e) + + +def main(): + parser = argparse.ArgumentParser( + description='Delete empty Elasticsearch indices (indices with 0 documents)', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='List empty indices without deleting them' + ) + parser.add_argument( + '--exclude', + nargs='*', + default=[], + help='Additional index patterns to exclude from deletion' + ) + parser.add_argument( + '--include-system', + action='store_true', + help='Include system indices (those starting with ".")' + ) + parser.add_argument( + '--yes', '-y', + action='store_true', + help='Skip confirmation prompt and proceed with deletion' + ) + parser.add_argument( + '--keep-latest', + action='store_true', + help='Keep the latest empty index for each prefix (based on timestamp in name)' + ) + + args = parser.parse_args() + + # Build exclude list + exclude_patterns = DEFAULT_EXCLUDES + args.exclude + + print(f'Connecting to Elasticsearch at {app.config["ELASTICSEARCH_HOSTS"]}...') + + # Test connection + try: + cluster_info = es_connection.info() + print(f'Connected to Elasticsearch {cluster_info["version"]["number"]}') + except Exception as e: + print(f'Failed to connect to Elasticsearch: {e}', file=sys.stderr) + sys.exit(1) + + # Get all indices + all_indices = get_all_indices() + print(f'Total indices found: {len(all_indices)}') + + # Find empty indices + empty_indices = find_empty_indices(all_indices, exclude_patterns, args.include_system, args.keep_latest) + + if not empty_indices: + if args.keep_latest: + print('\nNo empty indices to delete (keeping latest for each prefix).') + else: + print('\nNo empty indices found.') + return + + if args.keep_latest: + print(f'\nFound {len(empty_indices)} empty indices to delete (keeping latest for each prefix):') + else: + print(f'\nFound {len(empty_indices)} empty indices:') + print(f'{"Index Name":<60} {"Size":<10} {"Health":<10} {"Status":<10}') + print('-' * 90) + for idx in empty_indices: + print(f'{idx["name"]:<60} {idx["size"]:<10} {idx["health"]:<10} {idx["status"]:<10}') + + if args.dry_run: + print('\n[DRY RUN] No indices were deleted.') + if args.keep_latest: + print('Note: With --keep-latest flag, the latest empty index for each prefix would be kept.') + print(f'\nTo delete these indices, run without --dry-run flag.') + return + + # Confirm deletion + print(f'\nAbout to delete {len(empty_indices)} empty indices.') + print(f'Excluded patterns: {", ".join(exclude_patterns)}') + if args.keep_latest: + print('Keep latest: Enabled (will preserve the most recent empty index for each prefix)') + + if not args.yes: + confirmation = input('\nContinue with deletion? (yes/no): ') + if confirmation.lower() not in ['yes', 'y']: + print('Cancelled.') + return + + # Delete indices + print('\nDeleting empty indices...') + success_count = 0 + error_count = 0 + + for idx in empty_indices: + index_name = idx['name'] + success, message = delete_index(index_name) + + if success: + print(f' ✓ Deleted: {index_name}') + success_count += 1 + else: + print(f' ✗ Failed to delete {index_name}: {message}') + error_count += 1 + + print(f'\n{"=" * 50}') + print(f'Summary:') + print(f' Successfully deleted: {success_count}') + print(f' Failed: {error_count}') + print(f'{"=" * 50}') + + +if __name__ == '__main__': + main() diff --git a/portality/static/js/formulaic.js b/portality/static/js/formulaic.js index 5dfaccf36..269ffdd54 100644 --- a/portality/static/js/formulaic.js +++ b/portality/static/js/formulaic.js @@ -1219,25 +1219,12 @@ var formulaic = { var containerId = edges.css_id(this.ns, "container"); var containerSelector = edges.css_id_selector(this.ns, "container"); var widgetId = edges.css_id(this.ns, this.fieldDef.name); - var modalOpenClass = edges.css_classes(this.ns, "open"); - var closeClass = edges.css_classes(this.ns, "close"); this.input = $("[name=" + this.fieldDef.name + "]"); this.input.hide(); - this.input.after('Open Subject Classifier'); - this.input.after(`