diff --git a/config/common/prep_types.json b/config/common/prep_types.json index 057b9601e1b..0ea89d0c3b3 100644 --- a/config/common/prep_types.json +++ b/config/common/prep_types.json @@ -77,5 +77,15 @@ { "name": "EtOH", "isloanable": true }, { "name": "Skeleton", "isloanable": true }, { "name": "X-Ray", "isloanable": true } + ], + "geology": [ + { "name": "Hand Sample", "isloanable": true }, + { "name": "Thin Section", "isloanable": true }, + { "name": "Polished Section", "isloanable": true }, + { "name": "Grain Mount", "isloanable": true }, + { "name": "Polished Slab", "isloanable": true }, + { "name": "Core Segment", "isloanable": true }, + { "name": "Cutting", "isloanable": true }, + { "name": "Powder", "isloanable": true } ] } diff --git a/specifyweb/backend/businessrules/rules/user_rules.py b/specifyweb/backend/businessrules/rules/user_rules.py index c019a7cca5b..60bd3e8455a 100644 --- a/specifyweb/backend/businessrules/rules/user_rules.py +++ b/specifyweb/backend/businessrules/rules/user_rules.py @@ -30,11 +30,12 @@ def added_user(sender, instance, created, raw, **kwargs): grouptype=user.usertype, ) - for gp in group_principals: - cursor.execute( - 'insert into specifyuser_spprincipal(specifyuserid, spprincipalid) values (%s, %s)', - [user.id, gp.id] - ) + # TODO: UNCOMMENT THIS. Commented specifically for testing PR https://github.com/specify/specify7/pull/6671 + # for gp in group_principals: + # cursor.execute( + # 'insert into specifyuser_spprincipal(specifyuserid, spprincipalid) values (%s, %s)', + # [user.id, gp.id] + # ) @receiver(signals.pre_delete, sender=Specifyuser) diff --git a/specifyweb/backend/interactions/tests/test_modify_update_of_interaction_sibling_preps.py b/specifyweb/backend/interactions/tests/test_modify_update_of_interaction_sibling_preps.py index f5cf417c3f3..54ce324aab7 100644 --- a/specifyweb/backend/interactions/tests/test_modify_update_of_interaction_sibling_preps.py +++ b/specifyweb/backend/interactions/tests/test_modify_update_of_interaction_sibling_preps.py @@ -4,11 +4,11 @@ from specifyweb.backend.interactions.tests.test_cog_consolidated_prep_sibling_context import ( TestCogConsolidatedPrepSiblingContext, ) -from specifyweb.specify.api.api_utils import strict_uri_to_model +from specifyweb.specify.api.serializers import obj_to_data +from specifyweb.specify.api_utils import strict_uri_to_model from specifyweb.specify.models import ( Borrow, Disposal, - Disposalpreparation, Gift, Giftpreparation, Loan, @@ -16,8 +16,6 @@ ) import copy -from specifyweb.specify.api.serializers import obj_to_data - PrepGetter = Callable[["TestModifyUpdateInteractionSiblingPreps"], list[Any]] PrepGetterFromPreps = Callable[[list[Any]], list[Any]] diff --git a/specifyweb/backend/interactions/views.py b/specifyweb/backend/interactions/views.py index 20434b10785..59b63c4764b 100644 --- a/specifyweb/backend/interactions/views.py +++ b/specifyweb/backend/interactions/views.py @@ -15,13 +15,11 @@ from specifyweb.backend.permissions.permissions import check_table_permissions, table_permissions_checker from specifyweb.specify.api.api_utils import strict_uri_to_model from specifyweb.specify.models import Collectionobject, Loan, Loanpreparation, \ - Loanreturnpreparation, Preparation, Recordset, Recordsetitem + Loanreturnpreparation, Preparation, Recordset from specifyweb.specify.api.serializers import toJson from specifyweb.specify.views import login_maybe_required -from django.db.models import F, Q, Sum -from django.db.models.functions import Coalesce -from django.http import JsonResponse +from django.db.models import Q @require_POST # NOTE: why is this a POST request? @login_maybe_required diff --git a/specifyweb/backend/setup_tool/api.py b/specifyweb/backend/setup_tool/api.py new file mode 100644 index 00000000000..d4650bf0ec6 --- /dev/null +++ b/specifyweb/backend/setup_tool/api.py @@ -0,0 +1,419 @@ +""" +API for creating database setup resources (Institution, Discipline, etc.). +These will be called in the correct order by the background setup task in setup_tasks.py. +""" + +import json +from django.http import (JsonResponse) +from django.db.models import Max +from django.db import transaction +from django.apps import apps + +from specifyweb.backend.permissions.models import UserPolicy +from specifyweb.specify.models import Spversion +from specifyweb.specify import models +from specifyweb.backend.setup_tool.utils import normalize_keys, resolve_uri_or_fallback +from specifyweb.backend.setup_tool.schema_defaults import apply_schema_defaults +from specifyweb.backend.setup_tool.picklist_defaults import create_default_picklists +from specifyweb.backend.setup_tool.prep_type_defaults import create_default_prep_types +from specifyweb.backend.setup_tool.setup_tasks import setup_database_background, get_active_setup_task, get_last_setup_error, set_last_setup_error +from specifyweb.celery_tasks import MissingWorkerError +from specifyweb.backend.setup_tool.tree_defaults import create_default_tree, update_tree_scoping +from specifyweb.specify.models import Institution, Discipline +from specifyweb.specify.migration_utils.default_cots import (create_default_collection_types) +from specifyweb.backend.businessrules.uniqueness_rules import apply_default_uniqueness_rules + +import logging +logger = logging.getLogger(__name__) + +APP_VERSION = "7" +SCHEMA_VERSION = "2.10" + +class SetupError(Exception): + """Raised by any setup tasks.""" + pass + +def get_setup_progress() -> dict: + """Returns a dictionary of the status of the database setup.""" + # Check if setup is currently in progress + active_setup_task, busy = get_active_setup_task() + + completed_resources = None + last_error = None + # Get setup progress if its currently in progress. + if active_setup_task: + info = getattr(active_setup_task, "info", None) or getattr(active_setup_task, "result", None) + if isinstance(info, dict): + completed_resources = info.get('progress', None) + last_error = info.get('error', get_last_setup_error()) + if last_error is not None: + set_last_setup_error(last_error) + + if completed_resources is None: + completed_resources = get_setup_resource_progress() + last_error = get_last_setup_error() + + return { + "resources": completed_resources, + "last_error": last_error, + "busy": busy, + } + +def get_setup_resource_progress() -> dict: + """Returns a dictionary of the status of database setup resources.""" + return { + "institution": models.Institution.objects.exists(), + "storageTreeDef": models.Storagetreedef.objects.exists(), + "division": models.Division.objects.exists(), + "discipline": models.Discipline.objects.exists(), + "geographyTreeDef": models.Geographytreedef.objects.exists(), + "taxonTreeDef": models.Taxontreedef.objects.exists(), + "collection": models.Collection.objects.exists(), + "specifyUser": models.Specifyuser.objects.exists(), + } + +def _guided_setup_condition(request) -> bool: + from specifyweb.specify.models import Specifyuser + if Specifyuser.objects.exists(): + is_auth = request.user.is_authenticated + user = Specifyuser.objects.filter(id=request.user.id).first() + if not user or not is_auth or not user.usertype in ('Admin', 'Manager'): + return False + return True + +def handle_request(request, create_resource, direct=False): + """Generic handler for any setup resource POST request.""" + # Check permission + if not _guided_setup_condition(request): + return JsonResponse({"error": "Not permitted"}, status=401) + + raw_data = json.loads(request.body) + data = normalize_keys(raw_data) + + try: + response = create_resource(data) + return JsonResponse({"success": True, "setup_progress": get_setup_progress(), **response}, status=200) + + except Exception as e: + return JsonResponse({"error": str(e)}, status=400) + +def setup_database(request, direct=False): + """Creates all database setup resources sequentially in the background. Atomic.""" + # Check permission + if not _guided_setup_condition(request): + return JsonResponse({"error": "Not permitted"}, status=401) + + # Check that there isn't another setup task running. + active_setup_task, busy = get_active_setup_task() + if busy: + return JsonResponse({"error": "Database setup is already in progress."}, status=409) + + try: + logger.debug("Starting Database Setup.") + raw_data = json.loads(request.body) + data = normalize_keys(raw_data) + + task_id = setup_database_background(data) + + logger.debug("Database setup started successfully.") + return JsonResponse({"success": True, "setup_progress": get_setup_progress(), "task_id": task_id}, status=200) + except MissingWorkerError as e: + logger.exception(str(e)) + return JsonResponse({'error': str(e)}, status=503) + except Exception as e: + logger.exception(str(e)) + return JsonResponse({'error': 'An internal server error occurred.'}, status=500) + +def create_institution(data): + from specifyweb.specify.models import Institution, Address + + # Check that there are no institutions. Only one should ever exist. + if Institution.objects.count() > 0: + raise SetupError('An institution already exists, cannot create another.') + + # Get address fields (if any) + address_data = data.pop('address', None) + + # New DB: force id = 1 + data['id'] = 1 + + # Create address + with transaction.atomic(): + if address_data: + address_obj = Address.objects.create(**address_data) + data['address_id'] = address_obj.id + + # Create institution + new_institution = Institution.objects.create(**data) + Spversion.objects.create(appversion=APP_VERSION, schemaversion=SCHEMA_VERSION) + return {'institution_id': new_institution.id} + +def create_division(data): + from specifyweb.specify.models import Division, Institution + + # If division_id is provided and exists, return success + existing_id = data.pop('division_id', None) + if existing_id: + existing_division = Division.objects.filter(id=existing_id).first() + if existing_division: + return {"division_id": existing_division.id} + + # Determine new Division ID + max_id = Division.objects.aggregate(Max('id'))['id__max'] or 0 + data['id'] = max_id + 1 + + # Normalize abbreviation + data['abbrev'] = data.pop('abbreviation', None) or data.get('abbrev', '') + + # Handle institution assignment + institution_url = data.pop('institution', None) + institution = resolve_uri_or_fallback(institution_url, None, Institution) + data['institution_id'] = institution.id if institution else None + + # Remove unwanted keys + for key in ['_tablename', 'success']: + data.pop(key, None) + + # Create new division + try: + new_division = Division.objects.create(**data) + return {"division_id": new_division.id} + except Exception as e: + logger.exception(f'Division error: {e}') + raise SetupError(e) + +def create_discipline(data): + from specifyweb.specify.models import ( + Division, Datatype, Geographytreedef, + Geologictimeperiodtreedef, Taxontreedef + ) + + # Check if discipline_id is provided and already exists + existing_id = data.pop('discipline_id', None) + if existing_id: + existing_discipline = Discipline.objects.filter(id=existing_id).first() + if existing_discipline: + return {"discipline_id": existing_discipline.id} + + # Resolve division + division_url = data.get('division') + division = resolve_uri_or_fallback(division_url, None, Division) + if not division: + raise SetupError("No Division available to assign") + + data['division'] = division + + # Ensure required foreign key objects exist + datatype = Datatype.objects.last() or Datatype.objects.create(id=1, name='Biota') + geographytreedef_url = data.pop('geographytreedef', None) + geologictimeperiodtreedef_url = data.pop('geologictimeperiodtreedef', None) + geographytreedef = resolve_uri_or_fallback(geographytreedef_url, None, Geographytreedef) + geologictimeperiodtreedef = resolve_uri_or_fallback(geologictimeperiodtreedef_url, None, Geologictimeperiodtreedef) + + if geographytreedef is None or geologictimeperiodtreedef is None: + raise SetupError("A Geography tree and Chronostratigraphy tree must exist before creating a discipline.") + + # Assign a taxon tree. Not required, but its eventually needed for collection object type. + taxontreedef_url = data.get('taxontreedef', None) + taxontreedef = resolve_uri_or_fallback(taxontreedef_url, None, Taxontreedef) + if taxontreedef is not None: + data['taxontreedef_id'] = taxontreedef.id + + data.update({ + 'datatype_id': datatype.id, + 'geographytreedef_id': geographytreedef.id, + 'geologictimeperiodtreedef_id': geologictimeperiodtreedef.id, + 'taxontreedef_id': taxontreedef.id if taxontreedef else None + }) + + # Assign new Discipline ID + max_id = Discipline.objects.aggregate(Max('id'))['id__max'] or 0 + data['id'] = max_id + 1 + + # Remove unwanted keys + for key in ['_tablename', 'success', 'datatype']: + data.pop(key, None) + + # Create new Discipline + try: + new_discipline = Discipline.objects.create(**data) + + # Create Splocalecontainers for all datamodel tables + apply_schema_defaults(new_discipline) + + # Apply default uniqueness rules + apply_default_uniqueness_rules(new_discipline) + + # Update tree scoping + update_tree_scoping(geographytreedef, new_discipline.id) + update_tree_scoping(geologictimeperiodtreedef, new_discipline.id) + + return {"discipline_id": new_discipline.id} + + except Exception as e: + raise SetupError(e) + +def create_collection(data): + from specifyweb.specify.models import Collection, Discipline + + # If collection_id is provided and exists, return success + existing_id = data.pop('collection_id', None) + if existing_id: + existing_collection = Collection.objects.filter(id=existing_id).first() + if existing_collection: + return {"collection_id": existing_collection.id} + + # Assign new Collection ID + max_id = Collection.objects.aggregate(Max('id'))['id__max'] or 0 + data['id'] = max_id + 1 + + # Handle discipline reference from URL + discipline_id = data.get('discipline_id', None) + discipline_url = data.pop('discipline', None) + discipline = resolve_uri_or_fallback(discipline_url, discipline_id, Discipline) + if discipline is not None: + data['discipline_id'] = discipline.id + else: + raise SetupError("No discipline available") + + # The discipline needs a Taxon Tree in order for the Collection Object Type to be created. + if not discipline.taxontreedef_id: + raise SetupError("The collection's discipline needs a taxontreedef in order for the Collection Object type to be created.") + + # Remove keys that should not be passed to model + for key in ['_tablename', 'success']: + data.pop(key, None) + + # Create new Collection + try: + new_collection = Collection.objects.create(**data) + + # Create Preparation Types + create_default_prep_types(new_collection, discipline.type) + # Create picklists + create_default_picklists(new_collection, discipline.type) + # Create Collection Object Type + create_default_collection_types(apps) + + return {"collection_id": new_collection.id} + except Exception as e: + raise SetupError(e) + +def create_specifyuser(data): + """Creates the first admin user during the initial database setup.""" + from specifyweb.specify.models import Specifyuser, Agent, Division + + # Assign ID manually + max_id = Specifyuser.objects.aggregate(Max('id'))['id__max'] or 0 + data['id'] = max_id + 1 + + username = data.get('username') + + last_name = data.pop('lastname', username) + if not last_name: + last_name = username + first_name = data.pop('firstname', '') + + # Create agent. We can assume no agents already exist. + agent = Agent.objects.create( + id=1, + agenttype=1, + lastname=last_name, + firstname=first_name, + division=Division.objects.last(), + ) + + try: + # Create user + new_user = Specifyuser.objects.create(**data) + new_user.set_password(new_user.password) + new_user.save() + + # Grant permissions + UserPolicy.objects.create( + specifyuser=new_user, + collection=None, + resource='%', + action='%' + ) + + # Link agent to user + agent.specifyuser = new_user + agent.save() + + return {"user_id": new_user.id} + + except Exception as e: + raise SetupError(e) + +# Trees +def create_storage_tree(data): + return create_tree('Storage', data) + +def create_geography_tree(data, global_tree: bool = False): + return create_tree('Geography', data) + +def create_taxon_tree(data): + return create_tree('Taxon', data) + +def create_geologictimeperiod_tree(data): + return create_tree('Geologictimeperiod', data) + +def create_lithostrat_tree(data): + return create_tree('Lithostrat', data) + +def create_tectonicunit_tree(data): + return create_tree('Tectonicunit', data) + +def create_tree(name: str, data: dict) -> dict: + # TODO: Use trees/create_default_trees + # https://github.com/specify/specify7/pull/6429 + + # Figure out which scoping field should be used. + use_institution = False + use_discipline = True + if name == 'Storage': + use_institution = True + use_discipline = False + + # Handle institution assignment + institution = None + if use_institution: + institution_id = data.pop('institution_id', None) + institution_url = data.pop('institution', None) + institution = resolve_uri_or_fallback(institution_url, institution_id, Institution) + + # Handle discipline reference from URL + discipline = None + if use_discipline: + discipline_id = data.get('discipline_id', None) + discipline_url = data.get('discipline', None) + discipline = resolve_uri_or_fallback(discipline_url, discipline_id, Discipline) + + # Get tree configuration + ranks = data.pop('ranks', dict()) + + # Pre-load Default Tree + # TODO: trees/create_default_trees + preload_tree = data.pop('default', None) + + try: + kwargs = {} + kwargs['fullnamedirection'] = data.get('fullnamedirection', 1) + if use_institution: + kwargs['institution'] = institution + if use_discipline and discipline is not None: + kwargs['discipline'] = discipline + + treedef = create_default_tree(name, kwargs, ranks, preload_tree) + + # Set as the primary tree in the discipline if its the first one + if use_discipline and discipline: + field_name = f'{name.lower()}treedef_id' + if getattr(discipline, field_name) is None: + setattr(discipline, field_name, treedef.id) + discipline.save() + + return {'treedef_id': treedef.id} + except Exception as e: + raise SetupError(e) \ No newline at end of file diff --git a/specifyweb/backend/setup_tool/app_resource_defaults.py b/specifyweb/backend/setup_tool/app_resource_defaults.py new file mode 100644 index 00000000000..56a543bf762 --- /dev/null +++ b/specifyweb/backend/setup_tool/app_resource_defaults.py @@ -0,0 +1,33 @@ +from typing import Optional +from specifyweb.specify.models import Spappresource, Spappresourcedata, Spappresourcedir, Specifyuser +import logging +logger = logging.getLogger(__name__) + +def create_app_resource_defaults() -> None: + """Adds initial app resource files to the database. Only Global Preferences need to be created.""" + create_global_prefs() + +def create_global_prefs(user: Optional[Specifyuser] = None) -> None: + """Create a blank Global Prefs file.""" + directory, _ = Spappresourcedir.objects.get_or_create( + usertype='Global Prefs', + defaults={ + 'ispersonal': False + } + ) + + # This function is intended to be used during setup, so there should be one user. + # DBs created in Specify 6 set specifyuser to NULL for global prefs. + admin_user = user or Specifyuser.objects.first() + + resource = Spappresource.objects.create( + spappresourcedir=directory, + specifyuser=admin_user, + level=0, + name='preferences' + ) + + Spappresourcedata.objects.create( + spappresource=resource, + data=b'' + ) diff --git a/specifyweb/backend/setup_tool/picklist_defaults.py b/specifyweb/backend/setup_tool/picklist_defaults.py new file mode 100644 index 00000000000..198444f19b7 --- /dev/null +++ b/specifyweb/backend/setup_tool/picklist_defaults.py @@ -0,0 +1,91 @@ +import json +from pathlib import Path +from django.db import transaction +from .utils import load_json_from_file + +import logging + +from specifyweb.specify.models import ( + Collection, + Picklist, + Picklistitem +) + +logger = logging.getLogger(__name__) + +def create_default_picklists(collection: Collection, discipline_type: str | None): + """ + Creates defaults picklists for -one- collection, including discipline specific picklists. + """ + # Create global picklists + logger.debug('Creating default global picklists.') + defaults = load_json_from_file(Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'global_picklists.json') + + global_picklists = defaults.get('picklists', None) if defaults is not None else None + if global_picklists is None: + logger.exception('No global picklists found in global_picklists.json.') + return + + create_picklists(global_picklists, collection) + + # Get discipline picklists + logger.debug('Creating default discipline picklists.') + if discipline_type is None: + return + discipline_defaults = load_json_from_file(Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'picklists.json') + + discipline_picklists = discipline_defaults.get(discipline_type, None) + if discipline_picklists is None: + logger.warning(f'No picklists found for discipline "{discipline_type}" in picklists.json.') + return + + create_picklists(discipline_picklists, collection) + +def create_picklists(configuration: list, collection: Collection): + """ + Create a set of picklists from a configuration list. + """ + # Create picklists from a list of picklist configuration dicts. + try: + with transaction.atomic(): + # Create picklists in bulk + picklists_bulk = [] + for picklist in configuration: + picklists_bulk.append( + Picklist( + collection=collection, + name=picklist.get('name'), + issystem=picklist.get('issystem', True), + type=picklist.get('type'), + tablename=picklist.get('tablename'), + fieldname=picklist.get('fieldname'), + readonly=picklist.get('readonly'), + sizelimit=picklist.get('sizelimit'), + ) + ) + Picklist.objects.bulk_create(picklists_bulk, ignore_conflicts=True) + + # Create picklist items in bulk + names = [p.name for p in picklists_bulk] + picklist_records = Picklist.objects.filter( + collection=collection, + name__in=names + ) + name_to_obj = {pl.name: pl for pl in picklist_records} + + picklistitems_bulk = [] + for picklist in configuration: + parent = name_to_obj.get(picklist['name']) + for picklistitem in picklist['items']: + picklistitems_bulk.append( + Picklistitem( + picklist=parent, + title=picklistitem.get('title'), + value=picklistitem.get('value'), + ) + ) + Picklistitem.objects.bulk_create(picklistitems_bulk, ignore_conflicts=True) + + except Exception: + logger.exception('An error occured when creating default picklists.') + raise \ No newline at end of file diff --git a/specifyweb/backend/setup_tool/prep_type_defaults.py b/specifyweb/backend/setup_tool/prep_type_defaults.py new file mode 100644 index 00000000000..354ca1fcd03 --- /dev/null +++ b/specifyweb/backend/setup_tool/prep_type_defaults.py @@ -0,0 +1,40 @@ +import json +from pathlib import Path + +import logging + +from specifyweb.specify.models import ( + Collection, + Preptype +) +from .utils import load_json_from_file + +logger = logging.getLogger(__name__) + +def create_default_prep_types(collection: Collection, discipline_type: str): + """ + Load default collection prep types from the prep_types file. + """ + logger.debug('Creating default prep types.') + prep_type_list = load_json_from_file(Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'prep_types.json') + if prep_type_list is None: + return + + # Get prep types for this collection's discipline type. + prep_type_discipline_list = prep_type_list.get(discipline_type, None) + + if prep_type_discipline_list is None: + return + + prep_type_bulk = [] + for prep_type in prep_type_discipline_list: + prep_type_bulk.append( + Preptype( + collection=collection, + name=prep_type.get('name'), + isloanable=prep_type.get('isloanable') + ) + ) + + Preptype.objects.bulk_create(prep_type_bulk, ignore_conflicts=True) + \ No newline at end of file diff --git a/specifyweb/backend/setup_tool/schema_defaults.py b/specifyweb/backend/setup_tool/schema_defaults.py new file mode 100644 index 00000000000..5c4240a834e --- /dev/null +++ b/specifyweb/backend/setup_tool/schema_defaults.py @@ -0,0 +1,55 @@ +from typing import Optional +from specifyweb.specify.models_utils.models_by_table_id import model_names_by_table_id +from specifyweb.specify.migration_utils.update_schema_config import update_table_schema_config_with_defaults +from .utils import load_json_from_file +from specifyweb.specify.models import Discipline + +from pathlib import Path + +import logging +logger = logging.getLogger(__name__) + +def apply_schema_defaults(discipline: Discipline): + """ + Apply schema config localization defaults for this discipline. + """ + # Get default schema localization + defaults = load_json_from_file(Path(__file__).parent.parent.parent.parent / 'config' / 'common' / 'schema_localization_en.json') + + # Read schema overrides file for the discipline, if it exists + schema_overrides_path = Path(__file__).parent.parent.parent.parent / 'config' / discipline.type / 'schema_overrides.json' + overrides = None + if schema_overrides_path.exists(): + overrides = load_json_from_file(schema_overrides_path) + + # Apply overrides to defaults + if overrides is not None: + # Overrides contains a dict for each table with overrides + for table_name, table in overrides.items(): + # Items contains a list of dicts (item). + for item in table.get('items', []): + # Each item is a dict with only one entry. + for key, override_dict in item.items(): + defaults[table_name][key.lower()] = override_dict + # Replace other properties + for key, v in table.items(): + if key == 'items': + continue + defaults[key] = v + + # Update the schema for each table individually. + for model_name in model_names_by_table_id.values(): + logger.debug(f'Applying schema defaults for {model_name}. Using defaults: {overrides is not None}.') + + # Table information + table_defaults = defaults.get(model_name.lower()) + table_description = None + if table_defaults: + table_description = table_defaults.get('desc') + + update_table_schema_config_with_defaults( + table_name=model_name, + discipline_id=discipline.id, + description=table_description, + defaults=table_defaults, + ) \ No newline at end of file diff --git a/specifyweb/backend/setup_tool/setup_tasks.py b/specifyweb/backend/setup_tool/setup_tasks.py new file mode 100644 index 00000000000..c0e25fe77a7 --- /dev/null +++ b/specifyweb/backend/setup_tool/setup_tasks.py @@ -0,0 +1,156 @@ +""" +A Celery task for setting up the database in the background. +""" + +from django.db import transaction +from specifyweb.celery_tasks import app +from typing import Tuple, Optional +from celery.result import AsyncResult +from specifyweb.backend.setup_tool import api +from specifyweb.backend.setup_tool.app_resource_defaults import create_app_resource_defaults +from specifyweb.specify.management.commands.run_key_migration_functions import fix_schema_config +from specifyweb.specify.models_utils.model_extras import PALEO_DISCIPLINES, GEOLOGY_DISCIPLINES +from specifyweb.celery_tasks import is_worker_alive, MissingWorkerError + +from uuid import uuid4 +import logging +logger = logging.getLogger(__name__) + +# Keep track of the currently running setup task. There should only ever be one. +_active_setup_task_id: Optional[str] = None + +# Keep track of last error. +_last_error: Optional[str] = None + +def setup_database_background(data: dict) -> str: + global _active_setup_task_id, _last_error + + # Clear any previous error logs. + set_last_setup_error(None) + + if not is_worker_alive(): + set_last_setup_error("The Specify Worker is not running.") + raise MissingWorkerError("The Specify Worker is not running.") + + task_id = str(uuid4()) + logger.debug(f'task_id: {task_id}') + + args = [data] + + task = setup_database_task.apply_async(args, task_id=task_id) + + _active_setup_task_id = task.id + + return task.id + +def get_active_setup_task() -> Tuple[Optional[AsyncResult], bool]: + """Return the current setup task if it is active, and also if it is busy.""" + global _active_setup_task_id + task_id = _active_setup_task_id + + if not task_id: + return None, False + + res = app.AsyncResult(task_id) + busy = res.state in ("PENDING", "RECEIVED", "STARTED", "RETRY", "PROGRESS") + # Check if the last task ended + if not busy and res.state in ("SUCCESS", "FAILURE", "REVOKED"): + # Get error message if any. + if res.state == "FAILURE": + info = getattr(res, "info", None) + if isinstance(info, dict): + error = info.get("error") or info.get("exc_message") or info.get("message") or repr(info) + else: + error = str(info) + set_last_setup_error(error) + # Clear the setup id if its not busy. + # Commented out to allow error messages to be checked multiple times. + # if _active_setup_task_id == task_id: + # _active_setup_task_id = None + return res, busy + +@app.task(bind=True) +def setup_database_task(self, data: dict): + """Execute all database setup steps in order.""" + self.update_state(state='STARTED', meta={'progress': api.get_setup_resource_progress()}) + def update_progress(): + self.update_state(state='STARTED', meta={'progress': api.get_setup_resource_progress()}) + + try: + with transaction.atomic(): + logger.debug('## SETTING UP DATABASE WITH SETTINGS:##') + logger.debug(data) + + logger.debug('Creating institution') + api.create_institution(data['institution']) + update_progress() + + logger.debug('Creating storage tree') + api.create_storage_tree(data['storagetreedef']) + update_progress() + + logger.debug('Creating division') + api.create_division(data['division']) + update_progress() + + discipline_type = data['discipline'].get('type', '') + is_paleo_geo = discipline_type in PALEO_DISCIPLINES or discipline_type in GEOLOGY_DISCIPLINES + default_tree = { + 'fullnamedirection': 1, + 'ranks': { + '0': True + } + } + + # if is_paleo_geo: + # Create an empty chronostrat tree no matter what because discipline needs it. + logger.debug('Creating Chronostratigraphy tree') + default_chronostrat_tree = default_tree.copy() + default_chronostrat_tree['fullnamedirection'] = -1 + api.create_geologictimeperiod_tree(default_chronostrat_tree) + + logger.debug('Creating geography tree') + uses_global_geography_tree = data['institution'].get('issinglegeographytree', False) + api.create_geography_tree(data['geographytreedef'], global_tree=uses_global_geography_tree) + + logger.debug('Creating discipline') + discipline_result = api.create_discipline(data['discipline']) + discipline_id = discipline_result.get('discipline_id') + default_tree['discipline_id'] = discipline_id + update_progress() + + if is_paleo_geo: + logger.debug('Creating Lithostratigraphy tree') + api.create_lithostrat_tree(default_tree.copy()) + + logger.debug('Creating Tectonic Unit tree') + api.create_tectonicunit_tree(default_tree.copy()) + + logger.debug('Creating taxon tree') + if data['taxontreedef'].get('discipline_id') is None: + data['taxontreedef']['discipline_id'] = discipline_id + api.create_taxon_tree(data['taxontreedef']) + update_progress() + + logger.debug('Creating collection') + api.create_collection(data['collection']) + update_progress() + + logger.debug('Creating specify user') + api.create_specifyuser(data['specifyuser']) + + logger.debug('Finalizing database') + fix_schema_config() + create_app_resource_defaults() + update_progress() + except Exception as e: + logger.exception(f'Error setting up database: {e}') + raise + +def get_last_setup_error() -> Optional[str]: + global _last_error + return _last_error + +def set_last_setup_error(error_text: Optional[str]): + global _last_error + _last_error = error_text \ No newline at end of file diff --git a/specifyweb/backend/setup_tool/tree_defaults.py b/specifyweb/backend/setup_tool/tree_defaults.py new file mode 100644 index 00000000000..4cabae84742 --- /dev/null +++ b/specifyweb/backend/setup_tool/tree_defaults.py @@ -0,0 +1,62 @@ +from django.db import transaction +from django.db.models import Model as DjangoModel +from typing import Type, Optional + +from ..trees.utils import get_models + +import logging +logger = logging.getLogger(__name__) + +def create_default_tree(name: str, kwargs: dict, ranks: dict, preload_tree: Optional[str]): + """Creates an initial empty tree. This should not be used outside of the initial database setup.""" + with transaction.atomic(): + tree_def_model, tree_rank_model, tree_node_model = get_models(name) + + if tree_def_model.objects.count() > 0: + raise RuntimeError(f'Tree {name} already exists, cannot create default.') + + # Create tree definition + treedef = tree_def_model.objects.create( + name=name, + **kwargs, + ) + + # Create tree ranks + previous_tree_def_item = None + rank_list = list(int(rank_id) for rank_id, enabled in ranks.items() if enabled) + rank_list.sort() + for rank_id in rank_list: + previous_tree_def_item = tree_rank_model.objects.create( + treedef=treedef, + name=str(rank_id), # TODO: allow rank name configuration + rankid=rank_id, + parent=previous_tree_def_item, + ) + root_tree_def_item, create = tree_rank_model.objects.get_or_create( + treedef=treedef, + rankid=0 + ) + + # Create root node + # TODO: Avoid having duplicated code from add_root endpoint + root_node = tree_node_model.objects.create( + name="Root", + isaccepted=1, + nodenumber=1, + rankid=0, + parent=None, + definition=treedef, + definitionitem=root_tree_def_item, + fullname="Root" + ) + + # TODO: Preload tree + if preload_tree is not None: + pass + + return treedef + +def update_tree_scoping(treedef: Type[DjangoModel], discipline_id: int): + """Trees may be created before a discipline is created. This will update their discipline.""" + setattr(treedef, "discipline_id", discipline_id) + treedef.save(update_fields=["discipline_id"]) \ No newline at end of file diff --git a/specifyweb/backend/setup_tool/urls.py b/specifyweb/backend/setup_tool/urls.py new file mode 100644 index 00000000000..61851a88227 --- /dev/null +++ b/specifyweb/backend/setup_tool/urls.py @@ -0,0 +1,21 @@ + +from django.urls import re_path + +from . import views + +urlpatterns = [ + # check if the db is new at login + re_path(r'^setup_progress/$', views.get_setup_progress), + + re_path(r'^setup_database/create/$', views.setup_database_view), + + # These urls are functional but unused by the setup process. The API can be used instead. + # re_path(r'^institution/create/$', views.create_institution_view), + # re_path(r'^storagetreedef/create/$', views.create_storage_tree_view), + # re_path(f'^division/create/$', views.create_division_view), + # re_path(f'^discipline/create/$', views.create_discipline_view), + # re_path(f'^geographytreedef/create/$', views.create_geography_tree_view), + # re_path(f'^taxontreedef/create/$', views.create_taxon_tree_view), + # re_path(f'^collection/create/$', views.create_collection_view), + # re_path(f'^specifyuser/create/$', views.create_specifyuser_view), +] \ No newline at end of file diff --git a/specifyweb/backend/setup_tool/utils.py b/specifyweb/backend/setup_tool/utils.py new file mode 100644 index 00000000000..12a5300ed39 --- /dev/null +++ b/specifyweb/backend/setup_tool/utils.py @@ -0,0 +1,53 @@ +import json +from pathlib import Path +from django.db.models import Model as DjangoModel +from typing import Optional, Type +from specifyweb.specify.api_utils import strict_uri_to_model + +import logging +logger = logging.getLogger(__name__) + +def resolve_uri_or_fallback(uri: Optional[str], id: Optional[int], table: Type[DjangoModel]) -> Optional[DjangoModel]: + """ + Retrieves a record from a URI or ID, falling back to the last created record if it exists. + """ + if uri is not None: + # Try to resolve uri. It must be valid. + try: + uri_table, uri_id = strict_uri_to_model(uri, table._meta.db_table) + return table.objects.filter(pk=uri_id).first() + except Exception as e: + raise ValueError(e) + elif id is not None: + # Try to use the provided id. It must be valid. + try: + return table.objects.get(pk=id) + except table.DoesNotExist: + raise table.DoesNotExist(f"{table.name} with id {id} not found") + # Fallback to last created record. + return table.objects.last() + +def load_json_from_file(path: Path): + """ + Read a JSON file included within Specify directories. The file is expected to exist. + """ + + if path.exists() and path.is_file(): + try: + with path.open('r', encoding='utf-8') as fh: + return json.load(fh) + except json.JSONDecodeError as e: + logger.exception('Failed to decode JSON from %s: %s', path, e) + return None + except Exception as e: + logger.exception('Failed to decode JSON from %s: %s', path, e) + return None + else: + logger.debug('JSON file at %s does not exist.', path) + return None + +def normalize_keys(obj): + if isinstance(obj, dict): + return {k.lower(): normalize_keys(v) for k, v in obj.items()} + else: + return obj \ No newline at end of file diff --git a/specifyweb/backend/setup_tool/views.py b/specifyweb/backend/setup_tool/views.py new file mode 100644 index 00000000000..5517a00b566 --- /dev/null +++ b/specifyweb/backend/setup_tool/views.py @@ -0,0 +1,144 @@ +from django import http +from specifyweb.specify.views import openapi +from specifyweb.backend.setup_tool import api +from specifyweb.middleware.general import require_GET +from django.views.decorators.http import require_POST + +_SCHEMA_COMPONENTS = { + "schemas": { + "Resources": { + "type": "object", + "properties": { + "institution": {"type": "boolean"}, + "storageTreeDef": {"type": "boolean"}, + "division": {"type": "boolean"}, + "discipline": {"type": "boolean"}, + "geographyTreeDef": {"type": "boolean"}, + "taxonTreeDef": {"type": "boolean"}, + "collection": {"type": "boolean"}, + "specifyUser": {"type": "boolean"} + }, + "required": ["institution", "storageTreeDef", "division", "discipline", "geographyTreeDef", + "taxonTreeDef", "collection", "specifyUser"], + "description": "A list of which required database resources have been created so far." + }, + "SetupProgress": { + "type": "object", + "properties": { + "resources": {"$ref": "#/components/schemas/Resources"}, + "last_error": { + "oneOf": [{"type": "string"}, {"type": "null"}], + "description": "Last error message if any, null otherwise." + }, + "busy": {"type": "boolean", "description": "True if setup is in progress."} + }, + "required": ["resources", "busy"] + }, + } +} + +@openapi( + schema={ + "post": { + "requestBody": { + "description": "Request body for the creation of all database resources. Currently unrestricted.", + "required": False, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Setup task started successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "task_id": {"type": "string"}, + "setup_progress": {"$ref": "#/components/schemas/SetupProgress"} + }, + "required": ["success", "task_id", "setup_progress"] + } + } + } + }, + "409": { + "description": "Database setup is already in progress.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": {"type": "string"}, + }, + "required": ["error"] + } + } + } + } + } + } + }, + components=_SCHEMA_COMPONENTS, +) +@require_POST +def setup_database_view(request): + """Creates all database setup resources sequentially in the background. Atomic.""" + return api.setup_database(request) + +@require_POST +def create_institution_view(request): + return api.handle_request(request, api.create_institution) + +@require_POST +def create_storage_tree_view(request): + return api.handle_request(request, api.create_storage_tree) + +@require_POST +def create_division_view(request): + return api.handle_request(request, api.create_division) + +@require_POST +def create_discipline_view(request): + return api.handle_request(request, api.create_discipline) + +@require_POST +def create_geography_tree_view(request): + return api.handle_request(request, api.create_geography_tree) + +@require_POST +def create_taxon_tree_view(request): + return api.handle_request(request, api.create_taxon_tree) + +@require_POST +def create_collection_view(request): + return api.handle_request(request, api.create_collection) + +@require_POST +def create_specifyuser_view(request): + return api.handle_request(request, api.create_specifyuser) + +# check which resource are present in a new db to define setup step +@openapi( + schema={ + "get": { + "responses": { + "200": { + "description": "Information about the current completion of the database setup.", + "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/SetupProgress"} + }} + } + } + }, + }, + components=_SCHEMA_COMPONENTS, +) +@require_GET +def get_setup_progress(request): + """Returns a dictionary of the status of the database setup.""" + return http.JsonResponse(api.get_setup_progress()) \ No newline at end of file diff --git a/specifyweb/celery_tasks.py b/specifyweb/celery_tasks.py index 74d67617d87..5b8b69e06bd 100644 --- a/specifyweb/celery_tasks.py +++ b/specifyweb/celery_tasks.py @@ -60,3 +60,15 @@ def on_failure(self, exc, task_id, args, kwargs, einfo): logger.exception('Celery task failure!!!1', exc_info=exc) super().on_failure( exc, task_id, args, kwargs, einfo) + +class MissingWorkerError(Exception): + """Raised when worker is not running.""" + pass + +def is_worker_alive(): + """Pings the worker to see if its running.""" + try: + res = app.control.inspect(timeout=1).ping() + return bool(res) + except Exception: + return False \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/Core/VersionMismatch.tsx b/specifyweb/frontend/js_src/lib/components/Core/VersionMismatch.tsx index 85f1fcdb187..1e215f1e1de 100644 --- a/specifyweb/frontend/js_src/lib/components/Core/VersionMismatch.tsx +++ b/specifyweb/frontend/js_src/lib/components/Core/VersionMismatch.tsx @@ -7,9 +7,14 @@ import { Link } from '../Atoms/Link'; import { getSystemInfo } from '../InitialContext/systemInfo'; import { Dialog } from '../Molecules/Dialog'; +/** + * A version mismatch is detected when a database created in Specify 6 does not have a matching database schema version and its last used Specify 6 version. + * For databases created in Specify 7 this check is currently unnessecary. + */ export function VersionMismatch(): JSX.Element | null { const [showVersionMismatch, setShowVersionMismatch] = React.useState( - getSystemInfo().specify6_version !== getSystemInfo().database_version + getSystemInfo().specify6_version !== getSystemInfo().database_version && + getSystemInfo().database_version !== '7' ); return showVersionMismatch ? ( - ajax(`/api/specify/is_new_user/`, { + ajax(`/setup_tool/setup_progress/`, { method: 'GET', - headers: { - Accept: 'application/json', - }, + headers: { Accept: 'application/json' }, errorMode: 'silent', }) .then(({ data }) => data) .catch((error) => { - console.error('Failed to fetch isNewUser:', error); + console.error('Failed to fetch setup progress:', error); return undefined; }), [] @@ -48,9 +49,10 @@ export function Login(): JSX.Element { const nextUrl = parseDjangoDump('next-url') ?? '/specify/'; const providers = parseDjangoDump>('providers') ?? []; - if (isNewUser === true || isNewUser === undefined) { - // Display here the new setup pages - return

Welcome! No institutions are available at the moment.

; + if (setupProgress === undefined) return ; + + if (setupProgress.busy || (setupProgress.hasOwnProperty('resources') && Object.values(setupProgress.resources).includes(false))) { + return ; } return providers.length > 0 ? ( @@ -85,7 +87,7 @@ export function Login(): JSX.Element { } /> ); - }, [isNewUser]); + }, [setupProgress]); } const nextDestination = '/accounts/choose_collection/?next='; diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx new file mode 100644 index 00000000000..a52634f4050 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupForm.tsx @@ -0,0 +1,223 @@ +/** + * Form renderer for setup tool. + */ + +import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; + +import { commonText } from '../../localization/common'; +import { userText } from '../../localization/user'; +import { type RA } from '../../utils/types'; +import { H3 } from '../Atoms'; +import { Input, Label, Select } from '../Atoms/Form'; +import { MIN_PASSWORD_LENGTH } from '../Security/SetPassword'; +import type { FieldConfig, ResourceConfig } from './setupResources'; +import { FIELD_MAX_LENGTH, resources } from './setupResources'; +import type { ResourceFormData } from './types'; + +function getFormValue( + formData: ResourceFormData, + currentStep: number, + fieldName: string +): number | string | undefined { + return formData[resources[currentStep].resourceName][fieldName]; +} + +/** + * Checks if a conditional form should be rendered. + */ +export function checkFormCondition( + formData: ResourceFormData, + resource: ResourceConfig +): boolean { + if (resource.condition === undefined) { + return true; + } + let pass = true; + for (const [resourceName, fields] of Object.entries(resource.condition)) { + for (const [fieldName, requiredValue] of Object.entries(fields)) { + if (formData[resourceName][fieldName] !== requiredValue) { + pass = false; + break; + } + } + if (!pass) break; + } + return pass; +} + +export function renderFormFieldFactory({ + formData, + currentStep, + handleChange, + temporaryFormData, + setTemporaryFormData, + formRef, +}: { + readonly formData: ResourceFormData; + readonly currentStep: number; + readonly handleChange: ( + name: string, + newValue: LocalizedString | boolean + ) => void; + readonly temporaryFormData: ResourceFormData; + readonly setTemporaryFormData: ( + value: React.SetStateAction + ) => void; + readonly formRef: React.MutableRefObject; +}) { + const renderFormField = ( + field: FieldConfig, + parentName?: string + ): JSX.Element => { + const { + name, + label, + type, + required = false, + description, + options, + fields, + passwordRepeat, + } = field; + + const fieldName = parentName === undefined ? name : `${parentName}.${name}`; + + const colSpan = type === 'object' ? 2 : 1; + + return ( +
+ {type === 'boolean' ? ( +
+ + + handleChange(fieldName, isChecked) + } + /> + {label} + +
+ ) : type === 'select' && Array.isArray(options) ? ( +
+ + {label} + + +
+ ) : type === 'password' ? ( + <> + + {label} + { + handleChange(fieldName, value); + if (passwordRepeat !== undefined && formRef.current) { + const target = formRef.current.elements.namedItem( + passwordRepeat.name + ) as HTMLInputElement | null; + + if (target) { + target.setCustomValidity( + target.value && target.value === value + ? '' + : userText.passwordsDoNotMatchError() + ); + } + } + }} + /> + + + {passwordRepeat === undefined ? null : ( + + {passwordRepeat.label} + { + target.setCustomValidity( + target.value === + getFormValue(formData, currentStep, fieldName) + ? '' + : userText.passwordsDoNotMatchError() + ); + }} + onValueChange={(value) => + setTemporaryFormData((previous) => ({ + ...previous, + [passwordRepeat.name]: value, + })) + } + /> + + )} + + ) : type === 'object' ? ( + // Subforms +
+

+ {label} +

+ {fields ? renderFormFields(fields, name) : null} +
+ ) : ( + + {label} + handleChange(fieldName, value)} + /> + + )} +
+ ); + }; + + const renderFormFields = (fields: RA, parentName?: string) => ( +
+ {fields.map((field) => renderFormField(field, parentName))} +
+ ); + + return { renderFormField, renderFormFields }; +} diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx new file mode 100644 index 00000000000..27803308ffb --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/SetupOverview.tsx @@ -0,0 +1,118 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { queryText } from '../../localization/query'; +import { checkFormCondition } from './SetupForm'; +import type { FieldConfig } from './setupResources'; +import { resources } from './setupResources'; +import type { ResourceFormData } from './types'; + +/** + * Displays all previously filled out forms in a grid format. + */ +export function SetupOverview({ + formData, + currentStep, +}: { + readonly formData: ResourceFormData; + readonly currentStep: number; +}): JSX.Element { + return ( +
+ + + + + + + {resources.map((resource, step) => { + // Display only the forms that have been visited. + if ( + (Object.keys(formData[resource.resourceName]).length > 0 || + step <= currentStep) && + checkFormCondition(formData, resource) + ) { + // Decide how to render each field. + const fieldDisplay = ( + field: FieldConfig, + parentName?: string + ) => { + const fieldName = + parentName === undefined + ? field.name + : `${parentName}.${field.name}`; + const rawValue = formData[resource.resourceName]?.[fieldName]; + let value = rawValue?.toString() ?? '-'; + if (field.type === 'object') { + // Construct a sub list of properties + field.fields?.map((child_field) => + fieldDisplay(child_field, field.name) + ); + return ( + + + + + {field.fields?.map((child) => ( + + {fieldDisplay( + child, + parentName + ? `${parentName}.${field.name}` + : field.name + )} + + ))} + + ); + } else if (field.type === 'password') { + value = rawValue ? '***' : '-'; + } else if ( + field.type === 'select' && + Array.isArray(field.options) + ) { + const match = field.options.find( + (option) => String(option.value) === value + ); + value = match ? (match.label ?? match.value) : value; + } else if (field.type == 'boolean') { + value = rawValue === true ? queryText.yes() : commonText.no(); + } + return ( + + + + + ); + }; + return ( + + + + + {resource.fields.map((field) => fieldDisplay(field))} + + ); + } + return undefined; + })} + +
+ {field.label} +
+ {field.label} + + {value} +
+ {resource.label} +
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx new file mode 100644 index 00000000000..ec516027073 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/index.tsx @@ -0,0 +1,343 @@ +import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; + +import { useId } from '../../hooks/useId'; +import { commonText } from '../../localization/common'; +import { headerText } from '../../localization/header'; +import { setupToolText } from '../../localization/setupTool'; +import { ajax } from '../../utils/ajax'; +import { Http } from '../../utils/ajax/definitions'; +import { type RA, localized } from '../../utils/types'; +import { Container, H2, H3 } from '../Atoms'; +import { Progress } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { Form } from '../Atoms/Form'; +import { dialogIcons } from '../Atoms/Icons'; +import { Link } from '../Atoms/Link'; +import { Submit } from '../Atoms/Submit'; +import { LoadingContext } from '../Core/Contexts'; +import { loadingBar } from '../Molecules'; +import { checkFormCondition, renderFormFieldFactory } from './SetupForm'; +import { SetupOverview } from './SetupOverview'; +import type { FieldConfig, ResourceConfig } from './setupResources'; +import { resources } from './setupResources'; +import type { + ResourceFormData, + SetupProgress, + SetupResources, + SetupResponse, +} from './types'; +import { flattenAllResources } from './utils'; + +export const stepOrder: RA = [ + 'institution', + 'storageTreeDef', + 'division', + 'discipline', + 'geographyTreeDef', + 'taxonTreeDef', + 'collection', + 'specifyUser', +]; + +function findNextStep( + currentStep: number, + formData: ResourceFormData, + direction: number = 1 +): number { + /* + * Find the next *accessible* form. + * Handles conditional pages, like the global geography tree. + */ + let step = currentStep + direction; + while (step >= 0 && step < resources.length) { + const resource = resources[step]; + // Check condition + const pass = checkFormCondition(formData, resource); + if (pass) return step; + step += direction; + } + return currentStep; +} + +function useFormDefaults( + resource: ResourceConfig, + setFormData: (data: ResourceFormData) => void, + currentStep: number +): void { + const resourceName = resources[currentStep].resourceName; + const defaultFormData: ResourceFormData = {}; + const applyFieldDefaults = (field: FieldConfig, parentName?: string) => { + const fieldName = + parentName === undefined ? field.name : `${parentName}.${field.name}`; + if (field.type === 'object' && field.fields !== undefined) + field.fields.forEach((field) => applyFieldDefaults(field, fieldName)); + if (field.default !== undefined) defaultFormData[fieldName] = field.default; + }; + resource.fields.forEach((field) => applyFieldDefaults(field)); + setFormData((previous: any) => ({ + ...previous, + [resourceName]: { + ...defaultFormData, + ...previous[resourceName], + }, + })); +} + +export function SetupTool({ + setupProgress, + setSetupProgress, +}: { + readonly setupProgress: SetupProgress; + readonly setSetupProgress: ( + value: + | SetupProgress + | ((oldValue: SetupProgress | undefined) => SetupProgress | undefined) + | undefined + ) => void; +}): JSX.Element { + const formRef = React.useRef(null); + const [formData, setFormData] = React.useState( + Object.fromEntries(stepOrder.map((key) => [key, {}])) + ); + const [temporaryFormData, setTemporaryFormData] = + React.useState({}); // For front-end only. + + const [currentStep, setCurrentStep] = React.useState(0); + React.useEffect(() => { + useFormDefaults(resources[currentStep], setFormData, currentStep); + }, [currentStep]); + + const [saveBlocked, setSaveBlocked] = React.useState(false); + + React.useEffect(() => { + const formValid = formRef.current?.checkValidity(); + setSaveBlocked(!formValid); + }, [formData, temporaryFormData, currentStep]); + const SubmitComponent = saveBlocked ? Submit.Danger : Submit.Save; + + // Keep track of the last backend error. + const [setupError, setSetupError] = React.useState( + undefined + ); + + // Is the database currrently being created? + const [inProgress, setInProgress] = React.useState(false); + const nextIncompleteStep = stepOrder.findIndex( + (resourceName) => !setupProgress.resources[resourceName] + ); + React.useEffect(() => { + if (setupProgress.busy) setInProgress(true); + if (setupProgress.last_error) setSetupError(setupProgress.last_error); + }, [setupProgress]); + React.useEffect(() => { + // Poll for the latest setup progress. + if (!inProgress) return; + + const interval = setInterval( + async () => + ajax(`/setup_tool/setup_progress/`, { + method: 'GET', + headers: { Accept: 'application/json' }, + errorMode: 'dismissible', + }) + .then(({ data }) => { + setSetupProgress(data); + if (data.last_error !== undefined) { + setInProgress(false); + setSetupError(data.last_error); + } + }) + .catch((error) => { + console.error('Failed to fetch setup progress:', error); + return undefined; + }), + 3000 + ); + + return () => clearInterval(interval); + }, [inProgress, setSetupProgress]); + + const loading = React.useContext(LoadingContext); + + const startSetup = async (data: ResourceFormData): Promise => + ajax('/setup_tool/setup_database/create/', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(flattenAllResources(data)), + errorMode: 'visible', + expectedErrors: [Http.CONFLICT, Http.UNAVAILABLE], + }) + .then(({ data, status }) => { + if (status === Http.OK) { + console.log(`Setup completed successfully:`, data); + return data; + } else { + const dataParsed = JSON.parse(data as unknown as string); // Data is a string on errors + const errorMessage = String(dataParsed.error ?? data); + throw new Error(errorMessage); + } + }) + .catch((error) => { + setSetupError(String(error)); + throw error; + }); + + const handleChange = ( + name: string, + newValue: LocalizedString | boolean + ): void => { + setFormData((previous) => { + const resourceName = resources[currentStep].resourceName; + return { + ...previous, + [resourceName]: { + ...previous[resourceName], + [name]: newValue, + }, + }; + }); + }; + + const handleSubmit = (event: React.FormEvent): void => { + event.preventDefault(); + + if (currentStep === resources.length - 1) { + /* + * Send resources to backend to start the setup + * const { endpoint, resourceName } = resources[currentStep]; + */ + loading( + startSetup(formData) + .then((data) => { + console.log(data); + setSetupProgress(data.setup_progress as SetupProgress); + setInProgress(true); + }) + .catch((error) => { + console.error('Form submission failed:', error); + setInProgress(false); + }) + ); + } else { + // Continue onto the next resource/form + setCurrentStep(findNextStep(currentStep, formData, 1)); + } + }; + + const handleBack = (): void => { + setCurrentStep(findNextStep(currentStep, formData, -1)); + }; + + const { renderFormFields } = renderFormFieldFactory({ + formData, + currentStep, + handleChange, + temporaryFormData, + setTemporaryFormData, + formRef, + }); + + const id = useId('setup-tool'); + + return ( +
+
+
+ +

+ {setupToolText.specifyConfigurationSetup()} +

+
+
+ + {inProgress ? ( + +

+ {setupToolText.settingUp()} +

+

+ {nextIncompleteStep === -1 + ? setupToolText.settingUp() + : resources[nextIncompleteStep].label} +

+ {loadingBar} +
+ ) : ( +
+
+ +

+ {setupToolText.overview()} +

+
+ +
+
+
+
+ +
+
+

+ {resources[currentStep].label} +

+ {resources[currentStep].documentationUrl !== undefined && ( + + {headerText.documentation()} + + )} +
+ {resources[currentStep].description === + undefined ? undefined : ( +

+ {resources[currentStep].description} +

+ )} + {renderFormFields(resources[currentStep].fields)} +
+
+ + {commonText.back()} + + + {(currentStep === resources.length - 1) ? commonText.create() : commonText.next()} + +
+
+ +

{setupToolText.setupProgress()}

+ +
+ {setupError === undefined ? undefined : ( + +
+ {dialogIcons.warning} +

+ {setupToolText.setupError()} +

+
+

{localized(setupError)}

+
+ )} +
+
+ )} +
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts new file mode 100644 index 00000000000..df4b4aa4ef0 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SetupTool/setupResources.ts @@ -0,0 +1,391 @@ +import type { LocalizedString } from 'typesafe-i18n'; + +import { formsText } from '../../localization/forms'; +import { setupToolText } from '../../localization/setupTool'; +import type { RA } from '../../utils/types'; + +// Default for max field length. +export const FIELD_MAX_LENGTH = 64; + +export type ResourceConfig = { + readonly resourceName: string; + readonly label: LocalizedString; + readonly endpoint: string; + readonly description?: LocalizedString; + readonly condition?: Record< + string, + Record + >; + readonly documentationUrl?: string; + readonly fields: RA; +}; + +type Option = { + readonly value: number | string; + readonly label?: string; +}; + +export type FieldConfig = { + readonly name: string; + readonly label: string; + readonly type?: 'boolean' | 'object' | 'password' | 'select' | 'text'; + readonly required?: boolean; + readonly default?: boolean | number | string; + readonly description?: string; + readonly options?: RA