diff --git a/.env b/.env index f9cfe7040ab..1daacb33da3 100644 --- a/.env +++ b/.env @@ -31,7 +31,7 @@ APP_USER_PASSWORD=specify_user # https://discourse.specifysoftware.org/t/allow-support-login-documentation/2838 ALLOW_SUPPORT_LOGIN=false # The amount of time in seconds each token is valid for -SUPPORT_LOGIN_TTL = 180 +SUPPORT_LOGIN_TTL=180 # Make sure to set the `SECRET_KEY` to a unique value SECRET_KEY=change_this_to_some_unique_random_string @@ -60,7 +60,7 @@ CELERY_RESULT_BACKEND=redis://redis/1 # timezone as the operating system. # If running in a Windows environment this must be set to the same as your # system time zone. -TIME_ZONE = America/Chicago +TIME_ZONE=America/Chicago # This variable controls the Specify 7 logging level. Possible values # are: diff --git a/specifyweb/backend/cache/redis/__init__.py b/specifyweb/backend/cache/redis/__init__.py index a2642045fd8..1496d59c66c 100644 --- a/specifyweb/backend/cache/redis/__init__.py +++ b/specifyweb/backend/cache/redis/__init__.py @@ -1,11 +1 @@ -from .store import ( - set_bytes, - set_string, - get_bytes, - get_string, - add_to_set, - delete_key, - remove_from_set, - set_members, - redis_type -) \ No newline at end of file +from .connect import RedisConnection, RedisSet, RedisList, RedisString diff --git a/specifyweb/backend/cache/redis/connect.py b/specifyweb/backend/cache/redis/connect.py index 8c8f9d3862e..989abfb4d2f 100644 --- a/specifyweb/backend/cache/redis/connect.py +++ b/specifyweb/backend/cache/redis/connect.py @@ -1,6 +1,22 @@ +from typing import Literal + from redis import Redis from django.conf import settings +# https://redis.io/docs/latest/commands/type/ +Redis_Type = Literal["none", "string", "list", "set", "hash", "stream", "vectorset"] + +# REFACTOR: It might make more sense to move this to a setting on +# RedisConnection, rather than having it as a flag on each adapter +def _format_key(key: str | bytes): + db_name = getattr(settings, "DATABASE_NAME") + prefix = f"specify:{db_name}:" + if isinstance(key, bytes): + final_key = prefix.encode() + key + else: + final_key = prefix + key + return final_key + class RedisConnection: def __init__(self, host=getattr(settings, "REDIS_HOST", None), @@ -21,9 +37,16 @@ def __init__(self, decode_responses=self.decode_responses ) - def delete(self, key: str): + def delete(self, key: str, format_key=True): + if format_key: + key = _format_key(key) return self.connection.delete(key) + def key_type(self, key: str, format_key=True) -> Redis_Type: + if format_key: + key = _format_key(key) + return self.connection.type(key) + class RedisDataType: def __init__(self, established: RedisConnection) -> None: @@ -33,36 +56,55 @@ def __init__(self, established: RedisConnection) -> None: def connection(self): return self._established.connection - def delete(self, key: str): - return self._established.delete(key) + def delete(self, key: str, format_key=True): + return self._established.delete(key, format_key=format_key) + + def key_type(self, key: str, format_key=True) -> Redis_Type: + return self._established.key_type(key, format_key=format_key) class RedisList(RedisDataType): """ See https://redis.io/docs/latest/develop/data-types/lists/ """ - def left_push(self, key: str, value) -> int: + def left_push(self, key: str, value, format_key=True) -> int: + if format_key: + key = _format_key(key) return self.connection.lpush(key, value) - def right_push(self, key: str, value) -> int: + def right_push(self, key: str, value, format_key=True) -> int: + if format_key: + key = _format_key(key) return self.connection.rpush(key, value) - def right_pop(self, key: str) -> str | bytes | None: + def right_pop(self, key: str, format_key=True) -> str | bytes | None: + if format_key: + key = _format_key(key) return self.connection.rpop(key) - def left_pop(self, key: str) -> str | bytes | None: + def left_pop(self, key: str, format_key=True) -> str | bytes | None: + if format_key: + key = _format_key(key) return self.connection.lpop(key) - def length(self, key: str) -> int: + def length(self, key: str, format_key=True) -> int: + if format_key: + key = _format_key(key) return self.connection.llen(key) - def range(self, key: str, start_index: int, end_index: int) -> list[str] | list[bytes]: + def range(self, key: str, start_index: int, end_index: int, format_key=True) -> list[str] | list[bytes]: + if format_key: + key = _format_key(key) return self.connection.lrange(key, start_index, end_index) - def trim(self, key: str, start_index: int, end_index: int) -> list[str] | list[bytes]: + def trim(self, key: str, start_index: int, end_index: int, format_key=True) -> list[str] | list[bytes]: + if format_key: + key = _format_key(key) return self.connection.ltrim(key, start_index, end_index) - def blocking_left_pop(self, key: str, timeout: int) -> str | bytes | None: + def blocking_left_pop(self, key: str, timeout: int, format_key=True) -> str | bytes | None: + if format_key: + key = _format_key(key) # This will block the thread until either an item is pushed into the # List or timeout seconds have passed # https://redis.io/docs/latest/commands/blpop/ @@ -76,44 +118,68 @@ class RedisSet(RedisDataType): """ See https://redis.io/docs/latest/develop/data-types/sets/ """ - def add(self, key: str, *values: str) -> int: + def add(self, key: str, *values: str, format_key=True) -> int: + if format_key: + key = _format_key(key) + if len(values) == 0: + return 0 return self.connection.sadd(key, *values) - def is_member(self, key: str, value: str) -> bool: + def is_member(self, key: str, value: str, format_key=True) -> bool: + if format_key: + key = _format_key(key) is_member = int(self.connection.sismember(key, value)) return is_member == 1 - def remove(self, key: str, value: str): - return self.connection.srem(key, value) + def remove(self, key: str, *values: str, format_key=True): + if format_key: + key = _format_key(key) + return self.connection.srem(key, *values) - def size(self, key: str) -> int: + def size(self, key: str, format_key=True) -> int: + if format_key: + key = _format_key(key) return self.connection.scard(key) - def members(self, key: str) -> set[str]: + def members(self, key: str, format_key=True) -> set[str]: + if format_key: + key = _format_key(key) return self.connection.smembers(key) - def union(self, *keys: str) -> set[str]: + def union(self, *keys: str, format_keys=True) -> set[str]: + if format_keys: + keys = tuple((_format_key(key) for key in keys)) return self.connection.sunion(*keys) - def intersection(self, *keys: str) -> set[str]: + def intersection(self, *keys: str, format_keys=True) -> set[str]: + if format_keys: + keys = tuple((_format_key(key) for key in keys)) return self.connection.sinter(*keys) - def difference(self, *keys: str) -> set[str]: + def difference(self, *keys: str, format_keys=True) -> set[str]: + if format_keys: + keys = tuple((_format_key(key) for key in keys)) return self.connection.sdiff(*keys) +# REFACTOR: Make a dedicated RedisBytes class to make working with bytes more +# intuitive class RedisString(RedisDataType): """ See https://redis.io/docs/latest/develop/data-types/strings/ """ - def set(self, key, value, time_to_live=None, override_existing=True): + def set(self, key: str | bytes, value, time_to_live=None, override_existing=True, format_key=True): + if format_key: + key = _format_key(key) flags = { "ex": time_to_live, "nx": not override_existing } self.connection.set(key, value, **flags) - def get(self, key, delete_key=False) -> str | bytes | None: + def get(self, key: str | bytes, delete_key=False, format_key=True) -> str | bytes | None: + if format_key: + key = _format_key(key) if delete_key: return self.connection.getdel(key) return self.connection.get(key) diff --git a/specifyweb/backend/cache/redis/rqueue.py b/specifyweb/backend/cache/redis/rqueue.py deleted file mode 100644 index e7ab1bf1d11..00000000000 --- a/specifyweb/backend/cache/redis/rqueue.py +++ /dev/null @@ -1,61 +0,0 @@ -import json - -from typing import Callable, Generator, Iterable, cast - -from specifyweb.backend.cache.redis.connect import RedisConnection, RedisList - -type Serialized = str | bytes | bytearray - -type Serializer[T] = Callable[[T], str] -type Deserializer[T] = Callable[[Serialized], T] - -def default_serializer(obj) -> str: - return str(obj) - -def default_deserializer(serialized: Serialized): - return serialized - -class RedisQueue[T]: - def __init__(self, connection: RedisConnection, key: str, - serializer: Serializer[T] | None = None, - deserializer: Deserializer[T] | None = None): - self.connection = RedisList(connection) - self.key = key - self.serializer = serializer or cast(Serializer[T], default_serializer) - self.deserializer = deserializer or cast(Deserializer[T], default_deserializer) - - def key_name(self, *name_parts: str | None): - key_name = "_".join([self.key, *(part for part in name_parts if part is not None)]) - return key_name - - def push(self, *objs: T, sub_key: str | None = None) -> int: - key_name = self.key_name(sub_key) - return self.connection.right_push(key_name, *self._serialize_objs(*objs)) - - def pop(self, sub_key: str | None = None) -> T | None: - key_name = self.key_name(sub_key) - popped = self.connection.left_pop(key_name) - if popped is None: - return None - return self.deserializer(popped) - - def wait_and_pop(self, timeout: int = 0, sub_key: str | None = None) -> T: - key_name = self.key_name(sub_key) - popped = self.connection.blocking_left_pop(key_name, timeout) - if popped is None: - raise TimeoutError("No items in queue after timeout") - return self.deserializer(popped) - - def peek(self, sub_key: str | None = None) -> T | None: - key_name = self.key_name(sub_key) - top_value = self._deserialize_objs(*self.connection.range(key_name, 0, 0)) - if len(top_value) == 0: - return None - return top_value[0] - - def _serialize_objs(self, *objs: T) -> Generator[str, None, None]: - return (self.serializer(obj) for obj in objs) - - def _deserialize_objs(self, *serialized: Serialized): - return tuple(self.deserializer(obj) for obj in serialized) - diff --git a/specifyweb/backend/cache/redis/store.py b/specifyweb/backend/cache/redis/store.py deleted file mode 100644 index 1e9576b2303..00000000000 --- a/specifyweb/backend/cache/redis/store.py +++ /dev/null @@ -1,38 +0,0 @@ -from .utils import _set_string, _get_string, _delete_key, _add_to_set, _remove_from_set, _set_elements, _redis_type, format_key - -# REFACTOR: Replace these with RedisConnection adapters - -def set_string(key: str | bytes, value: str, time_to_live=None, override_existing=True): - return _set_string(format_key(key), value, time_to_live=time_to_live, override_existing=override_existing, decode_responses=True) - - -def set_bytes(key: str | bytes, value: bytes, time_to_live=None, override_existing=True): - return _set_string(format_key(key), value, time_to_live=time_to_live, override_existing=override_existing, decode_responses=False) - - -def get_string(key: str | bytes, delete_key=False) -> str: - return _get_string(format_key(key), delete_key=delete_key, decode_responses=True) - - -def get_bytes(key: str | bytes, delete_key=False) -> bytes: - return _get_string(format_key(key), delete_key=delete_key, decode_responses=False) - - -def add_to_set(key: str | bytes, *elements: str): - return _add_to_set(format_key(key), *elements) - - -def remove_from_set(key: str | bytes, *elements: str): - return _remove_from_set(format_key(key), *elements) - - -def set_members(key: str | bytes): - return _set_elements(format_key(key)) - - -def delete_key(key: str | bytes): - return _delete_key(format_key(key)) - - -def redis_type(key: str | bytes): - return _redis_type(format_key(key)) diff --git a/specifyweb/backend/cache/redis/utils.py b/specifyweb/backend/cache/redis/utils.py deleted file mode 100644 index 5682b559358..00000000000 --- a/specifyweb/backend/cache/redis/utils.py +++ /dev/null @@ -1,79 +0,0 @@ -from typing import overload, Literal - -from redis import Redis -from django.conf import settings - - -def redis_connection(decode_responses=True): - redis_host = getattr(settings, "REDIS_HOST", None) - redis_port = getattr(settings, "REDIS_PORT", None) - redis_db_index = getattr(settings, "REDIS_DB_INDEX", 0) - if None in (redis_host, redis_port, redis_db_index): - raise ValueError("Redis is not correctly configured", redis_host, redis_port) - return Redis(host=redis_host, port=redis_port, db=redis_db_index, decode_responses=decode_responses) - -@overload -def format_key(key: str) -> str: ... - -@overload -def format_key(key: bytes) -> bytes: ... - -def format_key(key: str | bytes) -> str | bytes: - """Formats key to avoid collisions when specify instances are sharing a cache. - Expected format: specify:{database}:app:name""" - if isinstance(key, bytes): - return key - db_name = getattr(settings, "DATABASE_NAME") - return key.format(database=db_name) - -def _delete_key(key: str): - host = redis_connection() - host.delete(key) - -def _set_string(key: str, value: str, time_to_live=None, override_existing=True, decode_responses=True): - host = redis_connection(decode_responses=decode_responses) - # See https://redis.readthedocs.io/en/stable/commands.html#redis.commands.core.CoreCommands.set - flags = { - "ex": time_to_live, - "nx": not override_existing - } - host.set(key, value, **flags) - - -@overload -def _get_string(key: str, delete_key: bool, decode_responses: True) -> str | None: ... - - -@overload -def _get_string(key: str, delete_key: bool, decode_responses: False) -> bytes | None: ... - - -def _get_string(key: str, delete_key: bool=False, decode_responses=True) -> str | bytes | None: - host = redis_connection(decode_responses=decode_responses) - if delete_key: - return host.getdel(key) - - return host.get(key) - -def _add_to_set(key: str, *elements: str): - if len(elements) <= 0: - return 0 - host = redis_connection(decode_responses=True) - return host.sadd(key, *elements) - -def _remove_from_set(key: str, *elements: str) -> int: - if len(elements) <= 0: - return 0 - host = redis_connection(decode_responses=True) - return host.srem(key, *elements) - -def _set_elements(key: str) -> set: - host = redis_connection(decode_responses=True) - return host.smembers(key) - -# https://redis.io/docs/latest/commands/type/ -Redis_Type = Literal["none", "string", "list", "set", "hash", "stream", "vectorset"] - -def _redis_type(key: str) -> Redis_Type: - host = redis_connection(decode_responses=True) - return host.type(key) \ No newline at end of file diff --git a/specifyweb/backend/delete_blockers/field.py b/specifyweb/backend/delete_blockers/field.py new file mode 100644 index 00000000000..ff9ad42ce74 --- /dev/null +++ b/specifyweb/backend/delete_blockers/field.py @@ -0,0 +1,241 @@ +from typing_extensions import TypeIs +from typing import Protocol, Any + +from django.db.models import Model, QuerySet, Field, ForeignObjectRel, ForeignKey, ManyToManyRel, ManyToManyField +from django.db.models.deletion import PROTECT, CASCADE + +from specifyweb.specify.datamodel import datamodel +from specifyweb.specify.models import protect_with_blockers + + +def valid_blocker_field(field: Field | ForeignObjectRel) -> TypeIs[ForeignObjectRel | ManyToManyField]: + if not field.is_relation: + return False + # The ManyToMany Join Tables do not exist on the Specify Datamodel, so + # currently this is a convient way to weed out fields on the through models + # Implementors of DeleteBlocker behavior should work with the + # ManyToManyField and ManyToManyRel objects directly instead and access + # these fields through ManyToManyRel.through + if not hasattr(field.related_model, "specify_model"): + return False + # At this point we're generally only concerned with remote fields that + # reference the model we're trying to get references for. + # e.g., all relationships B -> A, where A is the table we're getting + # blockers for. + # ManyToManyFields don't have non-concrete backreferences the way other + # fields do, and the FK referencing the table we care about was weeded out + # in the previous conditional. + # Because of this, we consider all remote fields AND many to + # many fields as valid. We'll worry about checking the ON_DELETE behavior + # later (see ManyToManyBlockerField) + many_to_many_or_remote = (field.many_to_many + or not getattr(field, "concrete", True)) + if not many_to_many_or_remote: + return False + + # Finally we check the ON_DELETE behavior of the FK. We're only concerned + # about ON_DELETE behavior of PROTECT or CASCADE: we can ignore fields + # with other delete behaviors + field_with_delete_behavior = field + if field.many_to_many: + field_with_delete_behavior = many_to_many_foreignkey(field) + return (relationship_cascades_delete(field_with_delete_behavior) + or relationship_blocks_deletion(field_with_delete_behavior)) + + +def relationship_blocks_deletion(relationship: ForeignObjectRel | ForeignKey): + remote = ensure_remote(relationship) + return remote.on_delete is protect_with_blockers or remote.on_delete is PROTECT + + +def relationship_cascades_delete(relationship: ForeignObjectRel | ForeignKey): + remote = ensure_remote(relationship) + return remote.on_delete is CASCADE + + +def ensure_remote(field: ForeignObjectRel | ForeignKey) -> ForeignObjectRel: + if isinstance(field, ForeignKey): + return field.remote_field + return field + + +def django_field_to_blocker_field(field: ForeignKey | ManyToManyField | ForeignObjectRel) -> "DeleteBlockerField": + if isinstance(field, (ManyToManyField, ManyToManyRel)): + return ManyToManyBlockerField(field) + elif isinstance(field, ForeignKey): + return ForeignKeyBlockerField(field) + elif isinstance(field, ForeignObjectRel): + return RemoteBlockerField(field) + raise TypeError( + f"Unexpected field type '{field.__class__}' of when constructing blocker field for {field}") + + +def many_to_many_foreignkey(relationship: ManyToManyRel | ManyToManyField) -> ForeignKey: + """ + Returns the ForeignKey on a JOIN/THROUGH table for a particular ManyToMany + field. + If a ManyToManyRel is supplied (i.e., the remote or non-concrete side of + the ManyToMany relationship), this returns the FK pointing to the base + model of the relationship. + e.g., if the ManyToManyField is defined on A related to some model B, and + the relationship passed in is B -> A from B then this function will return + the FK on the JOIN table pointing to B. + Otherwise a ManyToManyField is supplied which is the 'concrete' side of + the ManyToMany relationship, in this case A -> B. + This function will return the FK on the JOIN table pointing to A. + """ + # The through model can only be accessed from the remote side and the FK + # names can only be retreived from the concrete side of the relationship + if isinstance(relationship, ManyToManyField): + field_to_through_model: ManyToManyRel = relationship.remote_field + fk_name_on_through_model: str = relationship.m2m_field_name() + elif isinstance(relationship, ManyToManyRel): + field_to_through_model = relationship + field_on_concrete: ManyToManyField = relationship.remote_field + fk_name_on_through_model: str = field_on_concrete.m2m_reverse_field_name() + else: + raise TypeError( + "Trying to get ManyToMany FK on relationship that's not ManyToMany") + + # Now that we know we have the ManyToManyRel here, we can access its + # 'through' attribute for direct access to the through Model as defined in + # its models.py + through_model = field_to_through_model.through + # We got the name of the field on the through model earlier, so just look + # it up. This will return something like a ForwardManyToOneDescriptor + field_on_through_model = getattr(through_model, fk_name_on_through_model) + # Now we can finally resolve the field to the actual ForeignKey defined on + # the through model + fk_on_through_model = field_on_through_model.field + return fk_on_through_model + + +def _get_table_and_field_name(default_table_name: str, default_field_name: str) -> tuple[str, str]: + table = datamodel.get_table(default_table_name) + table_name = None + field_name = None + if table is not None: + table_name = table.name + relationship = table.get_relationship(default_field_name) + field_name = None if relationship is None else relationship.name + + resolved_table_name = (table_name or default_table_name).lower() + resolved_field_name = (field_name or default_field_name).lower() + return resolved_table_name, resolved_field_name + + +class DeleteBlockerField(Protocol): + """ + A class implementing this Protocol represents a field to be used with + DeleteBlockers. + Its purpose is to encapsulate common delete blocker functionality across + the many Django relationship types so DeleteBlocker consumers can have a + consistent interface/contract. + """ + model: type[Model] + field: Field | ForeignObjectRel + db_table_name: str + django_field_name: str + # Returns whether the field CASCADEs. + # If false, the field is assumed to PROTECT + # If needed, do validation that the field CASCADEs or PROTECTS during or + # before class initialization + cascades: bool + # Human friendly names (from the Specify datamodel) that are included in + # the API responses + table_name: str + field_name: str + + def get_queryset(self, obj: Model) -> QuerySet: + """ + A basic QuerySet to get all related objects of this field's model that + point to some given obj. + e.g., if fetching delete blockers for an Accession through the + CollectionObject -> accession reference, the QuerySet will be + equivalent to: + CollectionObject.objects.filter(accession_id=obj.pk) + """ + raise NotImplementedError + + def get_queryset_filters(self, obj: Model) -> dict[str, Any]: + """ + REFACTOR: Might be better to make this a Q object rather than dict + """ + raise NotImplementedError + + +class ForeignKeyBlockerField: + """ + This is the ForeignKey (FK) on the referencing table. + e.g., If we want to fetch references for some table A and there's a table B + with a field B -> A, this class should be passed the FK on B. + When constructing a QuerySet, this class accepts an object of type A to use + its ID. + """ + + def __init__(self, field: ForeignKey): + self.field = field + self.model = field.model + self.cascades = relationship_cascades_delete(field) + self.db_table_name = self.model._meta.db_table + self.django_field_name = field.name + self.table_name, self.field_name = _get_table_and_field_name( + self.db_table_name, self.django_field_name) + + def get_queryset_filters(self, obj: Model) -> dict[str, Any]: + return {self.field.name: obj.pk} + + def get_queryset(self, obj: Model) -> QuerySet: + return self.model.objects.filter( + **self.get_queryset_filters(obj) + ) + + +class RemoteBlockerField(ForeignKeyBlockerField): + """ + This field exists on the table we want to get blockers for and is a + "backreference" to the FK of the referencing table. + e.g., If we want to fetch references for some table A and there's a table B + with field B -> A, this class should be passed the remote field on A that's + the reverse of B -> A. + """ + + def __init__(self, field: ForeignObjectRel): + foreign_key = field.field + super().__init__(foreign_key) + + +class ManyToManyBlockerField: + """ + This is either the concrete (non-remote) ManyToMany field or the remote + side of the ManyToMany field on the table we want to get blockers for. + We treat this similarly to RemoteBlockerField, and prepare this class to + filter on the related table. + """ + + def __init__(self, field: ManyToManyField | ManyToManyRel): + through_table_fk = many_to_many_foreignkey(field) + # FIXME: We should invert this for to-many relationships. Or at least + # handle it better. Delete semantics are a little different than normal + # DeleteBlockers... + self.cascades = relationship_cascades_delete(through_table_fk) + remote_field = field.remote_field + self.field = remote_field + self.model = field.related_model + self.db_table_name = self.model._meta.db_table + self.django_field_name = self.field.name + # BUG: While the logic of this class handles cases where the + # related_name of the ManyToMany field is not set (i.e., '+') and still + # returns the correct objects from its QuerySet, the "human friendly" + # field name will be the autogenerated name for the field + # (something like _specify_maintable_fieldname_+) + self.table_name, self.field_name = _get_table_and_field_name( + self.db_table_name, self.django_field_name) + + def get_queryset_filters(self, obj: Model) -> dict[str, Any]: + return {self.field.name: obj.pk} + + def get_queryset(self, obj: Model) -> QuerySet: + return self.model.objects.filter( + **self.get_queryset_filters(obj) + ) diff --git a/specifyweb/backend/delete_blockers/filter.py b/specifyweb/backend/delete_blockers/filter.py new file mode 100644 index 00000000000..df20ffcc0b5 --- /dev/null +++ b/specifyweb/backend/delete_blockers/filter.py @@ -0,0 +1,177 @@ +from typing import TypedDict +from functools import reduce + +from django.db.models import Model + +from specifyweb.specify.api.crud import get_model +from specifyweb.specify.models_utils.load_datamodel import TableDoesNotExistError + +from specifyweb.backend.delete_blockers.validators import DELETE_BLOCKER_LIMIT, CleanedDeleteBlockerFilter, CleanedDeleteBlockerRequestForm +from specifyweb.backend.delete_blockers.utils import blocker_relationships_for_obj +from specifyweb.backend.delete_blockers.field import django_field_to_blocker_field +from specifyweb.backend.delete_blockers.paginate import resolve_reference_paginator, ReferencePaginator + + +class DeleteBlocker(TypedDict): + table: str + field: str + ids: list[int] + limit: int + complete: bool + anchor: int | None + backwards: bool + + +class DeleteBlockerCount(TypedDict): + table: str + field: str + count: int + + +class DeleteBlockerCounts(TypedDict): + results: list[DeleteBlockerCount] + total_count: int + + +class DeleteBlockerFilter: + def __init__(self, paginator: ReferencePaginator) -> None: + self.paginator = paginator + + @property + def obj(self): + return self.paginator.obj + + @property + def table_name(self): + return self.paginator.table_name + + @property + def field_name(self): + return self.paginator.field_name + + @classmethod + def from_json(cls, obj: Model, json: CleanedDeleteBlockerFilter): + table_name = json["table"] + model = get_model(table_name) + if model is None: + raise TableDoesNotExistError(f"Unable to find {table_name}") + field_name = json["field"] + field = model._meta.get_field(field_name) + blocker_field = django_field_to_blocker_field(field) + Paginator = resolve_reference_paginator(obj, blocker_field) + paginator_instance = Paginator( + obj=obj, + blocker_field=blocker_field, + limit=json["limit"], + anchor_id=json["anchor"], + backwards=json["backwards"] + ) + return cls(paginator=paginator_instance) + + def count(self) -> None | DeleteBlockerCount: + # We're only collecting counts for direct blockers + if self.paginator.blocker_field.cascades: + return None + + record_count = self.paginator.count() + return { + "table": self.table_name, + "field": self.field_name, + "count": record_count + } + + def fetch_page(self) -> tuple[bool, DeleteBlocker]: + fetched_ids = self.paginator.fetch_page() + complete = self.paginator.limit == 0 or len( + fetched_ids) < self.paginator.limit + cascades = self.paginator.blocker_field.cascades + return cascades, { + "table": self.table_name, + "field": self.field_name, + "ids": fetched_ids, + "limit": self.paginator.limit, + "complete": complete, + "anchor": self.paginator.anchor_id, + "backwards": self.paginator.backwards + } + + +def default_filters(obj: Model, limit=DELETE_BLOCKER_LIMIT, count_only=False) -> list[DeleteBlockerFilter]: + filters = [] + protect_fields, cascade_fields = blocker_relationships_for_obj(obj) + for blocker_field in protect_fields: + # FIXME: optimize this, shouldn't need new reference for every relaitonship + PaginatorClass = resolve_reference_paginator(obj, blocker_field) + paginator = PaginatorClass(obj, blocker_field=blocker_field, limit=limit) + delete_blocker_filter = DeleteBlockerFilter(paginator) + filters.append(delete_blocker_filter) + + # If we're only worrying about counts, we don't need to resolve any of the + # CASCADE relationships, so we can stop here + if count_only: + return filters + + for blocker_field in cascade_fields: + PaginatorClass = resolve_reference_paginator(obj, blocker_field) + paginator = PaginatorClass(obj, blocker_field=blocker_field, limit=limit) + delete_blocker_filter = DeleteBlockerFilter(paginator) + filters.append(delete_blocker_filter) + return filters + + +def merge_delete_blocker_counts(aggregated: DeleteBlockerCounts, current_count: DeleteBlockerCount | None) -> DeleteBlockerCounts: + if current_count is None or current_count["count"] == 0: + return aggregated + new_results = [*aggregated["results"], current_count] + new_count = aggregated["total_count"] + current_count["count"] + return { + "results": new_results, + "total_count": new_count + } + + +class DeleteBlockerFilters: + def __init__(self, + obj: Model, + limit=DELETE_BLOCKER_LIMIT, + filters: list[DeleteBlockerFilter] | None = None, + count_only=False + ) -> None: + self.obj = obj + if filters is None: + self.filters = default_filters(obj, limit, count_only) + else: + self.filters = filters + + @classmethod + def from_json(cls, obj: Model, json: CleanedDeleteBlockerRequestForm, count_only=False): + if json["filters"] is None: + filters = None + else: + filters = [DeleteBlockerFilter.from_json( + obj=obj, json=blocker_filter) for blocker_filter in json["filters"]] + limit = json["limit"] + return cls(obj=obj, limit=limit, filters=filters, count_only=count_only) + + def count(self) -> DeleteBlockerCounts: + starting_value: DeleteBlockerCounts = { + "results": [], + "total_count": 0 + } + return reduce( + merge_delete_blocker_counts, + (blocker.count() for blocker in self.filters), + starting_value + ) + + def fetch(self) -> tuple[list[DeleteBlocker], list[DeleteBlocker]]: + blocked, deferred = [], [] + for blocker_filter in self.filters: + blocker_deferred, page = blocker_filter.fetch_page() + if len(page["ids"]) == 0: + continue + if blocker_deferred: + deferred.append(page) + else: + blocked.append(page) + return blocked, deferred diff --git a/specifyweb/backend/delete_blockers/paginate.py b/specifyweb/backend/delete_blockers/paginate.py new file mode 100644 index 00000000000..0517306f1cd --- /dev/null +++ b/specifyweb/backend/delete_blockers/paginate.py @@ -0,0 +1,238 @@ +from django.db.models import Model, ForeignObjectRel, ForeignKey, QuerySet + +from specifyweb.specify.models import Spexportschemaitem +from specifyweb.backend.trees.extras import Tree, TreeRank +from specifyweb.backend.delete_blockers.validators import DELETE_BLOCKER_LIMIT +from specifyweb.backend.delete_blockers.utils import node_numbers_valid_for_tree +from specifyweb.backend.delete_blockers.field import relationship_cascades_delete, django_field_to_blocker_field, DeleteBlockerField + + +class ReferencePaginator: + def __init__(self, + obj: Model, + blocker_field: DeleteBlockerField, + limit: int = DELETE_BLOCKER_LIMIT, + anchor_id: int | None = None, + backwards: bool = False): + self.obj = obj + self.blocker_field = blocker_field + self.limit = limit + self.anchor_id = anchor_id + self.backwards = backwards + + def _get_base_queryset(self) -> QuerySet: + return self.blocker_field.get_queryset(self.obj) + + @property + def table_name(self) -> str: + return self.blocker_field.table_name + + @property + def field_name(self) -> str: + return self.blocker_field.field_name + + def count(self) -> int: + queryset = self._get_base_queryset() + return queryset.count() + + def fetch_page(self) -> list[int]: + queryset = self._get_page_queryset() + return list(queryset) + + def _get_page_queryset(self): + queryset = self._get_base_queryset() + + if self.anchor_id is not None: + direction = "lt" if self.backwards else "gt" + queryset = queryset.filter(**{f"pk__{direction}": self.anchor_id}) + + if self.backwards: + queryset = queryset.order_by("-pk") + else: + queryset = queryset.order_by("pk") + + queryset = queryset.values_list("pk", flat=True) + + if self.limit != 0: + queryset = queryset[:self.limit] + return queryset + + +class TreeTableReferencePaginator(ReferencePaginator): + """ + This paginator should be used for objects that are Tree Tables + (Taxon, Geography, Storage, Lithostrat, etc.). + WARNING: This strictly assumes: + + 1. All NodeNumbers for the tree are valid and up-to-date + 2. The ON_DELETE behavior for the 'parent' FK is set to CASCADE + 3. The NodeNumber column on the tree is indexed + + Use the base ReferencePaginator if you can not guarantee all of the above + properties. + """ + + def _get_base_queryset(self): + node_number = self.obj.nodenumber + highest_node_number = self.obj.highestchildnodenumber + model = self.blocker_field.model + # With the assumption that nodenumbers are up-to-date and correctly + # formed for the tree, we can use their properties to optimize fetching + # references. + # Particularly, we know the node and all of its descendants lie within + # the range >= NodeNumber and <= HighestChildNodeNumber. + # Essentially node numbers can "flatten" any subtree within the + # tree so we don't have to worry about expensive or complicated + # Parent self-joins to traverse the subtree structure + node_number_filters = { + f"{self.blocker_field.django_field_name}__nodenumber__range": (node_number, highest_node_number) + # We shouldn't need a filter on definition, as NodeNumbers should + # be unique even across trees of the same type + # TEST: Make sure we have tests for the above assumption in case + # the behavior is ever changed + } + return model.objects.filter(**node_number_filters) + + def fetch_page(self) -> list[int]: + # With the above node number optimization, we should be able to exclude + # pages for the parent relationship for delete blockers + # REFACTOR: This might not be the best place this for this. + # Consider moving this to DeleteBlockerFilter + if self.blocker_field.field is self.obj._meta.get_field("parent"): + return [] + return super().fetch_page() + + +class TreeNodeToDefinitionPaginator(ReferencePaginator): + def _get_base_queryset(self) -> QuerySet: + model = self.blocker_field.model + base_filters = self.blocker_field.get_queryset_filters(self.obj) + return model.objects.filter( + **base_filters, + parent__isnull=True + ) + + +class TreeRankToDefinitionPaginator(ReferencePaginator): + def _get_base_queryset(self) -> QuerySet: + model = self.blocker_field.model + base_filters = self.blocker_field.get_queryset_filters(self.obj) + return model.objects.filter( + **base_filters, + parent__isnull=True + ) + + +class SpLocaleContainerToDisciplinePaginator(ReferencePaginator): + """ + This optimized Paginator is only possible because the only relationship + that can block deletion in the chain of + SpLocaleContainer -> SpLocaleContainerItem -> SpLocaleItemStr, etc. is + SpExportSchemaItem to SpLocaleContainerItem on splocalecontaineritem. + REFACTOR: Make this more generic. When resolving a paginator by field, + recursively check the relationships CASCADEd relationships until a PROTECT + is found. We can even completely exclude the relationship from Delete + Blockers if there is no termimal PROTECT, i.e., all relationships CASCADE. + Reminder to be mindful and handle self JOINs for the above code + """ + + def __init__(self, obj, blocker_field, limit=DELETE_BLOCKER_LIMIT, anchor_id=None, backwards=False): + resolved_relationship = Spexportschemaitem._meta.get_field( + "splocalecontaineritem") + new_blocker_field = django_field_to_blocker_field(resolved_relationship) + super().__init__(obj, new_blocker_field, limit, anchor_id, backwards) + + def _get_base_queryset(self): + return Spexportschemaitem.objects.filter( + splocalecontaineritem__container__discipline_id=self.obj.pk + ) + + +def is_tree_definition(obj: Model): + db_table = obj._meta.db_table.lower() + return db_table.endswith("treedef") + + +def resolve_reference_paginator(obj: Model, field: DeleteBlockerField) -> type[ReferencePaginator]: + Paginator = None + if isinstance(obj, Tree): + Paginator = _resolve_tree_node_paginator(obj) + elif is_tree_definition(obj): + Paginator = _resolve_tree_definition_paginator(obj, field) + elif _is_discipline(obj): + Paginator = _resolve_paginator_for_discipline(field) + + if Paginator is None: + Paginator = ReferencePaginator + + if Paginator is not None: + return Paginator + raise TypeError(f"Not able to resolve paginator for field: {field}") + + +def _resolve_tree_node_paginator(obj: Tree) -> type[ReferencePaginator] | None: + # This is first because it is far cheaper than checking for NodeNumber validity + if not _tree_cascades_delete(obj.__class__): + return None + definition_id = obj.definition_id + node_numbers_valid = node_numbers_valid_for_tree( + obj._meta.db_table, definition_id) + if node_numbers_valid: + return TreeTableReferencePaginator + return None + + +def _resolve_tree_definition_paginator(obj: Model, blocker_field: DeleteBlockerField) -> type[ReferencePaginator] | None: + if not isinstance(blocker_field.field, ForeignKey): + return None + + # These Paginators should only apply when the field CASCADEs delete + if not blocker_field.cascades: + return None + + tree_name = obj._meta.db_table.lower().replace("treedef", "") + django_field_name = blocker_field.field.name + field_name = django_field_name.lower() + + if field_name not in ("definition", "treedef"): + return None + + tree_is_valid = node_numbers_valid_for_tree(tree_name, obj.pk) + + if not tree_is_valid: + return None + + if issubclass(blocker_field.model, Tree) and field_name == "definition": + return TreeNodeToDefinitionPaginator + elif issubclass(blocker_field.model, TreeRank) and field_name == "treedef": + return TreeRankToDefinitionPaginator + return None + + +def _tree_cascades_delete(obj: type[Tree]): + parent_field = obj._meta.get_field("parent") + if not isinstance(parent_field, ForeignKey): + return False + # The ON_DELETE behavior is on the other side of the FK + other_side = parent_field.remote_field + if not isinstance(other_side, ForeignObjectRel): + return False + + return relationship_cascades_delete(other_side) + + +def _is_discipline(obj: Model) -> bool: + table_name = obj._meta.db_table.lower() + return table_name == "discipline" + + +def _resolve_paginator_for_discipline(blocker_field: DeleteBlockerField): + """ + We're only interested in custom paginator for the + SpLocaleContainer -> discipline field + """ + if blocker_field.db_table_name.lower() != "splocalecontainer": + return None + if blocker_field.django_field_name.lower() != "discipline" or not blocker_field.cascades: + return None + return SpLocaleContainerToDisciplinePaginator diff --git a/specifyweb/backend/delete_blockers/tests/__init__.py b/specifyweb/backend/delete_blockers/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/specifyweb/backend/delete_blockers/tests/test_filter.py b/specifyweb/backend/delete_blockers/tests/test_filter.py new file mode 100644 index 00000000000..78eb6bc3241 --- /dev/null +++ b/specifyweb/backend/delete_blockers/tests/test_filter.py @@ -0,0 +1,177 @@ +from specifyweb.backend.delete_blockers.field import django_field_to_blocker_field +from specifyweb.backend.delete_blockers.filter import ( + DeleteBlockerFilter, + DeleteBlockerFilters, + merge_delete_blocker_counts, +) +from specifyweb.backend.delete_blockers.paginate import ReferencePaginator +from specifyweb.specify import models +from specifyweb.specify.tests.test_api import ApiTests + +class DeleteBlockerFilterTests(ApiTests): + def setUp(self): + super().setUp() + self.accession = models.Accession.objects.create( + accessionnumber="FILTER", + division=self.division, + ) + self.cos_to_test = len(self.collectionobjects) - 2 + co_ids = map(lambda co: co.pk, self.collectionobjects[:self.cos_to_test]) + models.Collectionobject.objects.filter(pk__in=co_ids).update( + accession=self.accession + ) + + django_field = models.Collectionobject._meta.get_field("accession") + blocker_field = django_field_to_blocker_field(django_field) + paginator = ReferencePaginator(self.accession, blocker_field, limit=2) + self.filter = DeleteBlockerFilter(paginator) + + def test_count_reports_protected_references(self): + self.assertEqual( + self.filter.count(), + { + "table": "collectionobject", + "field": "accession", + "count": 3, + }, + ) + + def test_fetch_page_reports_cursor_metadata_and_completion(self): + deferred, page = self.filter.fetch_page() + page_limit = self.filter.paginator.limit + self.assertFalse(deferred) + self.assertEqual(page["ids"], [obj.id for obj in self.collectionobjects[:page_limit]]) + self.assertEqual(page["limit"], page_limit) + self.assertFalse(page["complete"]) + self.assertIsNone(page["anchor"]) + self.assertFalse(page["backwards"]) + + def test_cascaded_relationship_is_not_counted_as_a_direct_blocker(self): + container = models.Splocalecontainer.objects.create( + discipline=self.discipline, + schematype=0, + name="Cascade container", + ) + django_field = models.Splocalecontainer._meta.get_field("discipline") + blocker_field = django_field_to_blocker_field(django_field) + paginator = ReferencePaginator(self.discipline, blocker_field) + filter_ = DeleteBlockerFilter(paginator) + self.assertIsNone(filter_.count()) + deferred, page = filter_.fetch_page() + self.assertTrue(deferred) + self.assertEqual(page["ids"], [container.id]) + + def test_from_json_resolves_model_field_and_pagination_options(self): + filter_json = { + "table": "collectionobject", + "field": "accession", + "anchor": self.collectionobjects[0].id, + "limit": 2, + "backwards": False + } + configured_filter = DeleteBlockerFilter.from_json(self.accession,filter_json) + self.assertEqual( + configured_filter.paginator.fetch_page(), + [obj.id for obj in self.collectionobjects[1:3]], + ) + + +class DeleteBlockerFiltersTests(ApiTests): + def setUp(self): + super().setUp() + self.accession = models.Accession.objects.create( + accessionnumber="FILTERS", + division=self.division, + ) + for collection_object in self.collectionobjects[:3]: + collection_object.accession = self.accession + collection_object.save() + + def test_fetch_separates_blocked_from_deferred(self): + container = models.Splocalecontainer.objects.create( + discipline=self.discipline, + schematype=0, + name="Deferred container", + ) + accession_field = django_field_to_blocker_field( + models.Collectionobject._meta.get_field("accession") + ) + discipline_field = django_field_to_blocker_field( + models.Splocalecontainer._meta.get_field("discipline") + ) + filters = DeleteBlockerFilters( + self.discipline, + filters=[ + DeleteBlockerFilter( + ReferencePaginator(self.accession, accession_field, limit=2) + ), + DeleteBlockerFilter( + ReferencePaginator(self.discipline, discipline_field, limit=2) + ), + ], + ) + + blocked, deferred = filters.fetch() + self.assertEqual(len(blocked), 1) + self.assertEqual(blocked[0]["ids"], [obj.id for obj in self.collectionobjects[:2]]) + self.assertEqual(len(deferred), 1) + self.assertEqual(deferred[0]["ids"], [container.id]) + + def test_count_aggregates_nonempty_direct_blocker_counts(self): + filters = DeleteBlockerFilters.from_json( + self.accession, + { + "limit": 2, + "filters": [ + { + "table": "collectionobject", + "field": "accession", + "anchor": None, + "limit": 2, + "backwards": False, + }, + ], + }, + ) + self.assertEqual( + filters.count(), + { + "results": [ + { + "table": "collectionobject", + "field": "accession", + "count": 3, + } + ], + "total_count": 3, + }, + ) + + def test_count_only_defaults_skip_cascaded_relationships(self): + filters = DeleteBlockerFilters(self.discipline, count_only=True) + fields_cascade = (item.paginator.blocker_field.cascades for item in filters.filters) + self.assertTrue(all(not cascades for cascades in fields_cascade)) + + +class MergeDeleteBlockerCountsTests(ApiTests): + def test_ignores_missing_and_zero_counts(self): + initial = {"results": [], "total_count": 0} + self.assertEqual(merge_delete_blocker_counts(initial, None), initial) + self.assertEqual( + merge_delete_blocker_counts( + initial, + {"table": "collectionobject", "field": "accession", "count": 0}, + ), + initial, + ) + + def test_appends_positive_count_and_updates_total(self): + initial = { + "results": [{"table": "accession", "field": "division", "count": 2}], + "total_count": 2, + } + current = {"table": "collectionobject", "field": "accession", "count": 3} + self.assertEqual( + merge_delete_blocker_counts(initial, current), + {"results": [*initial["results"], current], "total_count": 5}, + ) diff --git a/specifyweb/backend/delete_blockers/tests/test_paginate.py b/specifyweb/backend/delete_blockers/tests/test_paginate.py new file mode 100644 index 00000000000..355b47a29f9 --- /dev/null +++ b/specifyweb/backend/delete_blockers/tests/test_paginate.py @@ -0,0 +1,414 @@ +from functools import partial + +from specifyweb.backend.delete_blockers.validators import DELETE_BLOCKER_LIMIT +from specifyweb.backend.delete_blockers.field import django_field_to_blocker_field +from specifyweb.backend.delete_blockers.paginate import ( + ReferencePaginator, + TreeNodeToDefinitionPaginator, + TreeRankToDefinitionPaginator, + TreeTableReferencePaginator, +) +from specifyweb.specify import models +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.backend.trees.extras import renumber_tree +from specifyweb.backend.trees.tests.test_trees import TestTreeSetup + +class PaginatorTestMixin: + """ + This class is meant to be an abstract base class for tests on + ReferencePaginators. + It provides some simple tests for functionality that all paginators should + implement, and gets run by UnitTest automatically. + + Subclasses using this Mixin should: + - define a make_paginator method that returns the desired paginator + - define a record_ids attribute which is a sorted (ascending) list of all + record IDs that could be returned by the paginator with a limit of 0 + """ + """ + Other notes: + Looks like the common 'clean' way of having a class that's inherited but + NOT run/discovered by UnitTest is to not make it a unit test subclass in + the first place. Unfourtanetly this means that IDE support is limited + (there is no assertEqual, setUp, or other methods normally on UnitTest). + """ + record_ids: list[int] + + def test_default_paginator(self): + paginator = self.make_paginator() + count = paginator.count() + self.assertEqual(count, len(self.record_ids)) + fetched_page = paginator.fetch_page() + self.assertEqual(fetched_page, self.record_ids[:DELETE_BLOCKER_LIMIT]) + + def test_custom_limit(self): + custom_limit = 5 + paginator = self.make_paginator(limit=custom_limit) + count = paginator.count() + self.assertEqual(count, len(self.record_ids)) + fetched_page = paginator.fetch_page() + self.assertEqual(fetched_page, self.record_ids[:custom_limit]) + + def test_zero_limit_returns_all(self): + paginator = self.make_paginator(limit=0) + count = paginator.count() + self.assertEqual(count, len(self.record_ids)) + fetched_page = paginator.fetch_page() + self.assertEqual(fetched_page, self.record_ids) + + def test_custom_anchor(self): + fetch_limit = 10 + # The record we choose to anchor from is largely arbitrary + # Going to try and set the anchor ID to the 11 object + # If these tests or defaults have been changed and there's less than + # that number of records that were created for the test, instead try + # and take the record that's 3 from the end as the anchor + # This will cause an IndexError if there are no records, but that's + # probably a good thing (make sure there's records to test!) :) + anchor_index = min( + fetch_limit, + max(0, len(self.record_ids) - 3) + ) + record_to_anchor = self.record_ids[anchor_index] + paginator = self.make_paginator(anchor_id=record_to_anchor, limit=fetch_limit) + fetched_page = paginator.fetch_page() + self.assertEqual( + fetched_page, + self.record_ids[anchor_index + 1: anchor_index + 1 + fetch_limit] + ) + + def test_backwards_fetching(self): + paginator = self.make_paginator(backwards=True) + fetched_page = paginator.fetch_page() + expected_response = sorted(self.record_ids[-DELETE_BLOCKER_LIMIT:], reverse=True) + self.assertEqual(fetched_page, list(expected_response)) + + def make_paginator(self, limit: int = DELETE_BLOCKER_LIMIT, anchor_id: int | None = None, backwards: bool = False) -> ReferencePaginator: + raise NotImplementedError(f"{self.__class__} must implement make_paginator method") + +class ReferencePaginatorTests(PaginatorTestMixin, ApiTests): + def setUp(self): + super().setUp() + self.accession = models.Accession.objects.create( + accessionnumber="PAGINATOR", + division=self.division, + ) + self.make_co(DELETE_BLOCKER_LIMIT * 2) + models.Collectionobject.objects.update( + accession=self.accession + ) + co_to_accession_field = models.Collectionobject._meta.get_field("accession") + self.blocker_field = django_field_to_blocker_field(co_to_accession_field) + self.record_ids: list[int] = list(models.Collectionobject.objects.all().order_by("pk").values_list("pk", flat=True)) + + def make_paginator(self, limit = DELETE_BLOCKER_LIMIT, anchor_id = None, backwards = False): + return ReferencePaginator( + self.accession, + self.blocker_field, + limit=limit, + anchor_id=anchor_id, + backwards=backwards + ) + +class TreeTableReferencePaginatorTests(PaginatorTestMixin, TestTreeSetup): + def setUp(self): + super().setUp() + # Create the taxon tree and get a list of IDs for all descendents of a + # node we're testing: the class Actinopterygii + taxon_ids = self.populate_taxon_tree() + renumber_tree("taxon") + # The paginator will use the in memory version of Actinopterygii, so + # this will update the instance and fetch the nodeNumber and + # highestChildNodeNumber + self.initial_parent.refresh_from_db() + self.populate_determinations() + determination_ids = models.Determination.objects.filter( + taxon__id__in=taxon_ids + ).order_by("pk").values_list("pk", flat=True) + self.record_ids = list(determination_ids) + + def populate_taxon_tree(self): + treedef_items = self.taxontreedef.treedefitems.order_by("rankid").all() + get_rank = partial(TestTreeSetup.get_treedefitem_at_rankid, treedef_items) + # This will hold the nodes which are descendents of a particular + # subtree we're testing: everything under Actinopterygii + # The Paginator should only return these descendents + records = [] + root = models.Taxon.objects.create( + name="Life", + fullname="Life", + parent=None, + definition=self.taxontreedef, + definitionitem=get_rank(0) + ) + kingdom = models.Taxon.objects.create( + name="Animalia", + fullname="Animalia", + parent=root, + definition=self.taxontreedef, + definitionitem=get_rank(10) + ) + phylum = models.Taxon.objects.create( + name="Chordata", + fullname="Chordata", + parent=kingdom, + definition=self.taxontreedef, + definitionitem=get_rank(30) + ) + # The class Actinopterygii + self.initial_parent = actinopterygii = models.Taxon.objects.create( + name="Actinopterygii", + fullname="Actinopterygii", + parent=phylum, + definition=self.taxontreedef, + definitionitem=get_rank(60) + ) + records.append(actinopterygii.id) + + # order under Actinopterygii + amiiformes = models.Taxon.objects.create( + name="Amiiformes", + fullname="Amiiformes", + parent=actinopterygii, + definition=self.taxontreedef, + definitionitem=get_rank(100) + ) + records.append(amiiformes.id) + + # The class Chondrichthyes + chondrichthyes = models.Taxon.objects.create( + name="Chondrichthyes", + fullname="Chondrichthyes", + parent=phylum, + definition=self.taxontreedef, + definitionitem=get_rank(60) + ) + + # order under Actinopterygii + acipenseriformes = models.Taxon.objects.create( + name="Acipenseriformes", + fullname="Acipenseriformes", + parent=actinopterygii, + definition=self.taxontreedef, + definitionitem=get_rank(100) + ) + records.append(acipenseriformes.id) + + # order under chondrichthyes + models.Taxon.objects.create( + name="Chimaeriformes", + fullname="Chimaeriformes", + parent=chondrichthyes, + definition=self.taxontreedef, + definitionitem=get_rank(100) + ) + + # family under Acipenseriformes + polyodontidae = models.Taxon.objects.create( + name="Polyodontidae", + fullname="Polyodontidae", + parent=acipenseriformes, + definition=self.taxontreedef, + definitionitem=get_rank(140) + ) + + records.append(polyodontidae.id) + + # order under chondrichthyes + rajiformes = models.Taxon.objects.create( + name="Rajiformes", + fullname="Rajiformes", + parent=chondrichthyes, + definition=self.taxontreedef, + definitionitem=get_rank(100) + ) + + # order under Actinopterygii + lepisosteiformes = models.Taxon.objects.create( + name="Lepisosteiformes", + fullname="Lepisosteiformes", + parent=actinopterygii, + definition=self.taxontreedef, + definitionitem=get_rank(100) + ) + records.append(lepisosteiformes.id) + + # order under chondrichthyes + models.Taxon.objects.create( + name="Squaliformes", + fullname="Squaliformes", + parent=chondrichthyes, + definition=self.taxontreedef, + definitionitem=get_rank(100) + ) + + # family under Rajiformes + models.Taxon.objects.create( + name="Rajidae", + fullname="Rajidae", + parent=rajiformes, + definition=self.taxontreedef, + definitionitem=get_rank(140) + ) + return records + + def populate_determinations(self): + all_taxa = list(models.Taxon.objects.all().values_list("pk", flat=True)) + cos = self.make_co(len(all_taxa)) + models.Determination.objects.bulk_create( + [ + models.Determination( + collectionmemberid=self.collection.pk, + iscurrent=True, + collectionobject=co, + taxon_id=all_taxa[co_idx], + preferredtaxon_id=all_taxa[co_idx] + ) + for co_idx, co in enumerate(cos) + ] + ) + + + + def _get_blocker_field(self): + # This can be any relationship to Taxon. We're going to arbitrarily pick + # one that blocks deletion: Determination -> taxon. + # REFACTOR: We can make this really generic using + # blocker_relationships_for_obj to arbitrarily pick a PROTECT + # relationship, though we would have to modify populate_determinations + # to add records of that table instead. Do-able, but a little more work. + # And hopefully Determination -> taxon is pretty stable... + django_field = models.Determination._meta.get_field("taxon") + blocker_field = django_field_to_blocker_field(django_field) + assert not blocker_field.cascades, "Determination -> taxon is set to CASCADE! Please change the field being used in this test" + return blocker_field + + + def make_paginator(self, limit = DELETE_BLOCKER_LIMIT, anchor_id = None, backwards = False): + blocker_field = self._get_blocker_field() + return TreeTableReferencePaginator( + self.initial_parent, + blocker_field, + limit=limit, + anchor_id=anchor_id, + backwards=backwards + ) + + +class TreeNodeToDefinitionPaginatorTests(PaginatorTestMixin, TestTreeSetup): + def setUp(self): + super().setUp() + other_definition = models.Taxontreedef.objects.create( + discipline=self.discipline, + name="Other Taxon Tree" + ) + self.make_taxon_ranks(other_definition) + self.populate_tree(other_definition) + self.record_ids = self.populate_tree(self.taxontreedef) + renumber_tree("Taxon") + + def populate_tree(self, treedef): + treedef_items = treedef.treedefitems.order_by("rankid").all() + get_rank = partial(TestTreeSetup.get_treedefitem_at_rankid, treedef_items) + root = models.Taxon.objects.create( + name="Life", + fullname="Life", + parent=None, + definition=treedef, + definitionitem=get_rank(0) + ) + kingdom = models.Taxon.objects.create( + name="Animalia", + fullname="Animalia", + parent=root, + definition=treedef, + definitionitem=get_rank(10) + ) + models.Taxon.objects.create( + name="Chordata", + fullname="Chordata", + parent=kingdom, + definition=treedef, + definitionitem=get_rank(30) + ) + return [root.pk] + + def _get_blocker_field(self): + django_field = models.Taxon._meta.get_field("definition") + blocker_field = django_field_to_blocker_field(django_field) + return blocker_field + + def make_paginator(self, limit = DELETE_BLOCKER_LIMIT, anchor_id = None, backwards = False): + blocker_field = self._get_blocker_field() + return TreeNodeToDefinitionPaginator( + self.taxontreedef, + blocker_field, + limit=limit, + anchor_id=anchor_id, + backwards=backwards + ) + + +class TreeRankToDefinitionPaginatorTests(PaginatorTestMixin, TestTreeSetup): + def setUp(self): + super().setUp() + other_definition = models.Taxontreedef.objects.create( + discipline=self.discipline, + name="Other Taxon Tree" + ) + self.make_taxon_ranks(other_definition) + self.populate_tree(other_definition) + self.populate_tree(self.taxontreedef) + renumber_tree("Taxon") + first_rank = models.Taxontreedefitem.objects.get( + treedef=self.taxontreedef, + rankid=0, + parent__isnull=True + ) + self.record_ids = [first_rank.pk] + + def populate_tree(self, treedef): + # This isn't strictly needed for this test, but keeping it here in case + # there's ever some side-effects with having tree records + treedef_items = treedef.treedefitems.order_by("rankid").all() + get_rank = partial(TestTreeSetup.get_treedefitem_at_rankid, treedef_items) + root = models.Taxon.objects.create( + name="Life", + fullname="Life", + parent=None, + definition=self.taxontreedef, + definitionitem=get_rank(0) + ) + kingdom = models.Taxon.objects.create( + name="Animalia", + fullname="Animalia", + parent=root, + definition=self.taxontreedef, + definitionitem=get_rank(10) + ) + models.Taxon.objects.create( + name="Chordata", + fullname="Chordata", + parent=kingdom, + definition=self.taxontreedef, + definitionitem=get_rank(30) + ) + return [root.pk] + + def _get_blocker_field(self): + django_field = models.Taxontreedefitem._meta.get_field("treedef") + blocker_field = django_field_to_blocker_field(django_field) + return blocker_field + + def make_paginator(self, limit = DELETE_BLOCKER_LIMIT, anchor_id = None, backwards = False): + blocker_field = self._get_blocker_field() + return TreeRankToDefinitionPaginator( + self.taxontreedef, + blocker_field, + limit=limit, + anchor_id=anchor_id, + backwards=backwards + ) + + +# TODO: ReferencePaginator resolution and SpLocaleContainer -> Discipline optimized paginaotr \ No newline at end of file diff --git a/specifyweb/backend/delete_blockers/urls.py b/specifyweb/backend/delete_blockers/urls.py index 9945728ca5d..da4d564d2ef 100644 --- a/specifyweb/backend/delete_blockers/urls.py +++ b/specifyweb/backend/delete_blockers/urls.py @@ -3,5 +3,7 @@ from . import views urlpatterns = [ re_path(r'^delete_blockers/(?P\w+)/(?P\d+)/$', views.delete_blockers), + re_path(r'^count/(?P\w+)/(?P\d+)/$', views.count_blockers), + re_path(r'^old_delete_blockers/(?P\w+)/(?P\d+)/$', views.old_delete_blockers), ] diff --git a/specifyweb/backend/delete_blockers/utils.py b/specifyweb/backend/delete_blockers/utils.py new file mode 100644 index 00000000000..cbe1f4551ef --- /dev/null +++ b/specifyweb/backend/delete_blockers/utils.py @@ -0,0 +1,147 @@ +from sqlalchemy import select, and_, or_, not_, union_all +from sqlalchemy.orm import aliased + +from specifyweb.specify.models_utils.model_extras import ModelWithTable +from specifyweb.backend.delete_blockers.field import valid_blocker_field, django_field_to_blocker_field, DeleteBlockerField +from specifyweb.specify.api.crud import strict_get_model +from specifyweb.backend.stored_queries import models as sqlmodels +from specifyweb.backend.cache.redis.connect import RedisConnection, RedisString + + +def blocker_relationships_for_obj(obj: ModelWithTable | type[ModelWithTable]) -> tuple[list[DeleteBlockerField], list[DeleteBlockerField]]: + protect, cascade = [], [] + all_fields = obj._meta.get_fields(include_hidden=True) + blocker_relationships = filter( + valid_blocker_field, + all_fields + ) + for relationship in blocker_relationships: + blocker_field = django_field_to_blocker_field(relationship) + target_list = cascade if blocker_field.cascades else protect + target_list.append(blocker_field) + + return protect, cascade + + +def node_numbers_valid_for_tree(tree_name: str, definition_id: int) -> bool: + # REFACTOR: If we need to speed this up even more, consider using the DB cursor + # directly and skip SQLAlchemy constructs (like validate_tree_numbering) + # REFACTOR: Maybe we can try to renumber the tree before this check. + # That might need to be cached as well to prevent renumbering the tree for + # every request + cached_value = _cached_node_numbers_valid(tree_name, definition_id) + if cached_value is not None: + return cached_value + + tree_model = strict_get_model(tree_name) + tree_table = tree_model.specify_model + canonical_tree_name = tree_table.name + tree_node = sqlmodels.models_by_tableid[tree_table.tableId] + with sqlmodels.session_context() as session: + invalid_nodes_exist = _individual_nodes_invalid( + tree_node, canonical_tree_name, definition_id + ) + nodes_in_acceptable_ranges = _node_numbers_within_parent_ranges( + tree_node, canonical_tree_name, definition_id + ) + final_query = ( + union_all(invalid_nodes_exist, nodes_in_acceptable_ranges) + .limit(1) + ) + result = session.execute(final_query).scalar_one_or_none() + tree_is_valid = result is None + _set_cached_node_numbers_valid(tree_name, definition_id, tree_is_valid) + return tree_is_valid + + +def _node_number_cache_key(tree_name: str, definition_id: int) -> str: + return f"{tree_name}:{definition_id}:nodenumbers:valid" + + +# The node number validation queries can push a lot of pages into the InnoDB +# Buffer Pool, potentially evicting other hot entries which would need to be +# read from disk. +# This cache helps alleviate the problem by caching the result of the node +# number validation queries to give the buffer pool some breathing room between +# validations +def _cached_node_numbers_valid(tree_name: str, definition_id: int) -> bool | None: + redis_string = RedisString(RedisConnection()) + key = _node_number_cache_key(tree_name, definition_id) + cached_value = redis_string.get(key) + if cached_value is None: + return None + return cached_value == "1" + + +# REFACTOR: We can be smarter here and update the cache when the tree is +# renumbered. This would let us set a much longer TTL. +def _set_cached_node_numbers_valid(tree_name: str, definition_id: int, is_valid: bool, time_to_live: int | None = None): + redis_string = RedisString(RedisConnection()) + key = _node_number_cache_key(tree_name, definition_id) + cache_value = "1" if is_valid else "0" + if time_to_live is None: + # If the node numbers are already valid, it's very unlikely for them to + # become invalid. + # If the node numbers are invalid, then they won't become valid until + # the tree is renumbered (through a tree operation in Specify). + # However, running the validation queries on an invalid tree can be far + # more performant than on a valid tree. + ttl = 900 if is_valid else 600 + else: + ttl = time_to_live + redis_string.set(key, cache_value, time_to_live=ttl) + + +# REFACTOR: It might be better here to validate that all NodeNumbers and +# HighestChildNodeNumbers are distinct here instead. +# e.g., COUNT(*) == COUNT(DISTINCT NodeNumber) == COUNT(DISTINCT HighestChildNodeNumber) +def _individual_nodes_invalid(tree_model, tree_name: str, definition_id: int): + tree_def = getattr(tree_model, tree_name + "TreeDefID") + invalid_nodes_exist_query = ( + select(1) + .select_from(tree_model) + .where( + and_( + tree_def == definition_id, + or_( + tree_model.nodeNumber.is_(None), + tree_model.highestChildNodeNumber.is_(None), + tree_model.highestChildNodeNumber < tree_model.nodeNumber + ) + ) + ) + .limit(1) + ) + return invalid_nodes_exist_query + + +def _node_numbers_within_parent_ranges(tree_model, tree_name: str, definition_id: int): + # This is one of two generally efficient ways i've found to validate nodes + # are within their correct ranges. + # The other method is to build a derived table that groups tree nodes by + # parent and gets the MIN and MAX of all of the children's NodeNumbers. + # Then the tree table is joined on the derived table by ParentID and + # validates the MIN and MAX nodenumber across all children lie within the + # parent's expected range. + # That method uses significantly less page accesses (~10x on some DBs) than + # this direct Parent JOIN, but these accesses are usually not for distinct + # pages, so not a big deal as long as they're in the buffer pool. + # The other method does use some space in the buffer pool for filesort + # operations, so keeping with this approach for now. + tree_def = getattr(tree_model, tree_name + "TreeDefID") + parent = aliased(tree_model) + children_within_node_ranges = ( + select(1) + .select_from(tree_model) + .join(parent, tree_model.ParentID == parent._id) + .where( + tree_def == definition_id, + not_(tree_model.nodeNumber.between( + parent.nodeNumber, + parent.highestChildNodeNumber + ) + ) + ) + .limit(1) + ) + return children_within_node_ranges diff --git a/specifyweb/backend/delete_blockers/validators.py b/specifyweb/backend/delete_blockers/validators.py new file mode 100644 index 00000000000..2d274b197a3 --- /dev/null +++ b/specifyweb/backend/delete_blockers/validators.py @@ -0,0 +1,120 @@ +from typing import TypedDict, NotRequired + +from django import forms +from django.core.exceptions import ValidationError + +DELETE_BLOCKER_LIMIT = 40 + + +class DeleteBlockerFilterJSON(TypedDict): + table: str + field: str + # The direction of the fetch. If true then results will be returned in + # descending order starting at the anchor + backwards: NotRequired[bool] + # The ID that will be used to anchor and base the results from to achieve + # keysey/cursor pagination. + # The anchor is exclusive and will not be included in the results + # If the anchor is not provided, the default value is based on the + # direction of the request. If fetching records forwards, the anchor will be + # 0 + # If fetching records backwards, the anchor will be the last record + anchor: NotRequired[int | None] + limit: NotRequired[int] + + +class CleanedDeleteBlockerFilter(TypedDict): + table: str + field: str + backwards: bool + anchor: int | None + limit: int + + +class CleanedDeleteBlockerRequestForm(TypedDict): + limit: int + filters: None | list[CleanedDeleteBlockerFilter] + + +class DeleteBlockerRequestForm(forms.Form): + limit = forms.IntegerField(required=False) + + # Whether the format the results based on the default table formatter for + # the resulting table + # format = forms.BooleanField(required=False) + + filters = forms.JSONField(required=False) + + def clean(self): + super().clean() + if self.cleaned_data.get('limit') is None: + self.cleaned_data["limit"] = DELETE_BLOCKER_LIMIT + + return self.cleaned_data + + # BUG: Currently a table + field can be defined more than once within the + # list of filters. The backend will run each filter independently. + # Might be desirable to error or arbitarily choose the last defined filter. + # I guess the current behavior could be considered a feature if you wanted + # to use different anchors, directions, limits, etc. at the same time? + def clean_filters(self): + cleaned_filters = self.cleaned_data.get('filters') + if cleaned_filters is None: + return cleaned_filters + if not isinstance(cleaned_filters, list): + raise ValidationError("filters must be a list") + new_filters: list[CleanedDeleteBlockerFilter] = [] + for filter_idx, filter in enumerate(cleaned_filters): + if not isinstance(filter, dict): + raise ValidationError( + "Filter at index '%(filter_idx)d' is not a dict", + params={"filter_idx": filter_idx} + ) + required_keys = {'table', 'field'} + if not required_keys.issubset(filter.keys()): + missing_keys = list(required_keys - filter.keys()) + raise ValidationError( + "Filter at index '%(filter_idx)d' missing required keys '%(missing_keys)s'", + params={"filter_idx": filter_idx, + "missing_keys": str(missing_keys)} + ) + if not all(isinstance(key, str) for key in required_keys): + formatted_keys = " or ".join(required_keys) + raise ValidationError( + "Filter at index '%(filter_idx)d' did not get str for '%(required_keys)s'", + params={ + "filter_idx": filter_idx, + "required_keys": formatted_keys, + } + ) + new_filter: CleanedDeleteBlockerFilter = { + "table": filter['table'], + "field": filter['field'], + "anchor": filter.get('anchor'), + "limit": filter.get('limit', DELETE_BLOCKER_LIMIT), + "backwards": filter.get('backwards', False) + } + expected_types = { + "table": str, + "field": str, + "backwards": bool, + "anchor": (int, type(None)), + "limit": int + } + for key, types in expected_types.items(): + if not isinstance(new_filter[key], types): + formatted_types = ( + " or ".join(map(lambda cls: cls.__name__, types)) + if isinstance(types, tuple) + else str(types) + ) + raise ValidationError( + "Filter at index '%(filter_idx)d' did not get %(types)s for '%(key)s'", + params={ + "filter_idx": filter_idx, + "types": formatted_types, + "key": key + } + ) + new_filters.append(new_filter) + return new_filters diff --git a/specifyweb/backend/delete_blockers/views.py b/specifyweb/backend/delete_blockers/views.py index e562390aa76..961e255d158 100644 --- a/specifyweb/backend/delete_blockers/views.py +++ b/specifyweb/backend/delete_blockers/views.py @@ -1,3 +1,5 @@ +import json + from django import http from django.db import router, transaction from django.db.models.deletion import Collector @@ -12,17 +14,24 @@ from specifyweb.specify.api.serializers import toJson from specifyweb.specify.views import login_maybe_required +from specifyweb.backend.delete_blockers.validators import DeleteBlockerRequestForm +from specifyweb.backend.delete_blockers.filter import DeleteBlockerFilters + + @login_maybe_required @require_http_methods(['GET', 'HEAD']) -def delete_blockers(request, model, id): +def old_delete_blockers(request, model, id): """Returns a JSON list of fields on that point to related resources which prevent the resource of that model from being deleted. """ + # limit = request.GET["limit"] + # depth_limit = request.GET["depthLimit"] + obj = get_object_or_404(model, id=int(id)) using = router.db_for_write(obj.__class__, instance=obj) - if obj._meta.model_name == 'discipline': # Special case for discipline + if obj._meta.model_name == 'discipline': # Special case for discipline guard_blockers = get_discipline_delete_guard_blockers(obj) if guard_blockers: result = guard_blockers @@ -38,6 +47,41 @@ def delete_blockers(request, model, id): return http.HttpResponse(toJson(result), content_type='application/json') + +@login_maybe_required +@require_http_methods(['GET', 'POST']) +def delete_blockers(request: http.HttpRequest, model: str, id: int): + obj = get_object_or_404(model, id=int(id)) + + if request.method == "POST": + request_data = json.loads(request.body) + else: + request_data = request.GET + + + request_filters = DeleteBlockerRequestForm(request_data) + if not request_filters.is_valid(): + return http.HttpResponseBadRequest( + toJson(request_filters.errors), content_type='application/json' + ) + + blocker_fetcher = DeleteBlockerFilters.from_json(obj, json=request_filters.cleaned_data) + blocked, deferred = blocker_fetcher.fetch() + result = { + "results": blocked, + "next": deferred + } + return http.JsonResponse(result) + +@login_maybe_required +@require_http_methods(['GET', 'POST']) +def count_blockers(request: http.HttpRequest, model: str, id: int): + obj = get_object_or_404(model, id=int(id)) + + delete_block_fetcher = DeleteBlockerFilters(obj, count_only=True) + result = delete_block_fetcher.count() + return http.JsonResponse(result) + def _collect_delete_blockers(obj, using) -> list[dict]: collector = Collector(using=using) collector.delete_blockers = [] @@ -48,6 +92,7 @@ def _collect_delete_blockers(obj, using) -> list[dict]: ] for field, sub_objs in collector.delete_blockers ]) + def _serialize_delete_blocker(field, sub_objs) -> dict: normalized = _normalize_many_to_many_blocker(field, sub_objs) if normalized is not None: @@ -59,6 +104,7 @@ def _serialize_delete_blocker(field, sub_objs) -> dict: 'ids': [sub_obj.id for sub_obj in sub_objs] } + def _normalize_many_to_many_blocker(field, sub_objs) -> dict | None: through_model = sub_objs[0].__class__ if hasattr(through_model, 'specify_model'): @@ -105,5 +151,6 @@ def _normalize_many_to_many_blocker(field, sub_objs) -> dict | None: 'ids': [getattr(sub_obj, other_field.attname) for sub_obj in sub_objs], } + def flatten(l): return [item for sublist in l for item in sublist] diff --git a/specifyweb/backend/merge/record_merging.py b/specifyweb/backend/merge/record_merging.py index 2b501cfbb7f..3640c086d8f 100644 --- a/specifyweb/backend/merge/record_merging.py +++ b/specifyweb/backend/merge/record_merging.py @@ -106,7 +106,7 @@ def clean_fields_pre_delete(obj_instance): def fix_orderings(base_model: Table, new_record_data): for field_name, records in list(new_record_data.items()): try: - relationship = base_model.get_relationship(field_name) + relationship = base_model.get_relationship_strict(field_name) except FieldDoesNotExistError: continue ordering_fields = ordering_tables.get(relationship.relatedModelName.lower(), None) diff --git a/specifyweb/backend/setup_tool/apps.py b/specifyweb/backend/setup_tool/apps.py index 728cb672e71..b8cad285d20 100644 --- a/specifyweb/backend/setup_tool/apps.py +++ b/specifyweb/backend/setup_tool/apps.py @@ -1,5 +1,5 @@ from django.apps import AppConfig -from specifyweb.backend.cache.redis import get_string +from specifyweb.backend.cache.redis.connect import RedisConnection, RedisString import logging logger = logging.getLogger(__name__) @@ -10,8 +10,9 @@ class SetupToolConfig(AppConfig): def ready(self): from specifyweb.backend.setup_tool.redis import ACTIVE_TASK_REDIS_KEY try: + redis_string = RedisString(RedisConnection()) # Clear potential setup task id leftover from a crash - task_id = get_string(ACTIVE_TASK_REDIS_KEY, delete_key=True) + task_id = redis_string.get(ACTIVE_TASK_REDIS_KEY, delete_key=True) if task_id is not None: logger.debug(f'Clearing last active setup task: {task_id}') except Exception: diff --git a/specifyweb/backend/setup_tool/redis.py b/specifyweb/backend/setup_tool/redis.py index df96acab946..52b177b17c8 100644 --- a/specifyweb/backend/setup_tool/redis.py +++ b/specifyweb/backend/setup_tool/redis.py @@ -1,10 +1,10 @@ # Keep track of the currently running setup task. There should only ever be one. # Also defined separately in setup_tool/apps.py -ACTIVE_TASK_REDIS_KEY = "specify:{database}:setup:active_task_id" +ACTIVE_TASK_REDIS_KEY = "setup:active_task_id" ACTIVE_TASK_TTL = 60*60*2 # setup should be less than 2 hours # Keep track of last error. -LAST_ERROR_REDIS_KEY = "specify:{database}:setup:last_error" +LAST_ERROR_REDIS_KEY = "setup:last_error" # Track async setup/config tasks by resource scope. -DISCIPLINE_TASKS_REDIS_KEY = "specify:{database}:setup:discipline:{discipline_id}:tasks" +DISCIPLINE_TASKS_REDIS_KEY = "setup:discipline:{discipline_id}:tasks" diff --git a/specifyweb/backend/setup_tool/setup_tasks.py b/specifyweb/backend/setup_tool/setup_tasks.py index ad8af24f07a..db243492162 100644 --- a/specifyweb/backend/setup_tool/setup_tasks.py +++ b/specifyweb/backend/setup_tool/setup_tasks.py @@ -11,7 +11,7 @@ from specifyweb.backend.setup_tool.tree_defaults import start_preload_default_tree from specifyweb.specify.models_utils.model_extras import PALEO_DISCIPLINES, GEOLOGY_DISCIPLINES from specifyweb.celery_tasks import is_worker_alive, MissingWorkerError -from specifyweb.backend.cache.redis import set_string, get_string +from specifyweb.backend.cache.redis.connect import RedisString, RedisConnection from specifyweb.backend.setup_tool.redis import ACTIVE_TASK_REDIS_KEY, ACTIVE_TASK_TTL, LAST_ERROR_REDIS_KEY from uuid import uuid4 @@ -33,13 +33,15 @@ def setup_database_background(data: dict) -> str: task = setup_database_task.apply_async(args, task_id=task_id) - set_string(ACTIVE_TASK_REDIS_KEY, task.id, time_to_live=ACTIVE_TASK_TTL) + redis_string = RedisString(RedisConnection()) + redis_string.set(ACTIVE_TASK_REDIS_KEY, task.id, time_to_live=ACTIVE_TASK_TTL) 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.""" - task_id = get_string(ACTIVE_TASK_REDIS_KEY) + redis_string = RedisString(RedisConnection()) + task_id = redis_string.get(ACTIVE_TASK_REDIS_KEY) if not task_id: return None, False @@ -183,13 +185,15 @@ def update_progress(): raise def get_last_setup_error() -> Optional[str]: - err = get_string(LAST_ERROR_REDIS_KEY) + redis_string = RedisString(RedisConnection()) + err = redis_string.get(LAST_ERROR_REDIS_KEY) if err == '': return None return err def set_last_setup_error(error_text: Optional[str]): - set_string(LAST_ERROR_REDIS_KEY, error_text or '', time_to_live=60*60*24) + redis_string = RedisString(RedisConnection()) + redis_string.set(LAST_ERROR_REDIS_KEY, error_text or '', time_to_live=60*60*24) def create_discipline_and_trees_task(data: dict): from specifyweb.specify.models import Discipline diff --git a/specifyweb/backend/setup_tool/task_tracking.py b/specifyweb/backend/setup_tool/task_tracking.py index 31a174622fb..ae18ba94354 100644 --- a/specifyweb/backend/setup_tool/task_tracking.py +++ b/specifyweb/backend/setup_tool/task_tracking.py @@ -1,7 +1,7 @@ from typing import Optional import logging -from specifyweb.backend.cache.redis import add_to_set, delete_key, remove_from_set, set_members +from specifyweb.backend.cache.redis.connect import RedisConnection, RedisSet from specifyweb.celery_tasks import CELERY_TASK_STATE, app from specifyweb.backend.setup_tool.redis import ( DISCIPLINE_TASKS_REDIS_KEY, @@ -32,13 +32,16 @@ def _discipline_tasks_key(discipline_id: int) -> str: return DISCIPLINE_TASKS_REDIS_KEY.replace("{discipline_id}", str(discipline_id)) def _remove_task_ids_and_delete_empty_key(key: str, *task_ids: str) -> None: - remove_from_set(key, *task_ids) - if len(set_members(key)) == 0: - delete_key(key) + redis_set = RedisSet(RedisConnection()) + redis_set.remove(key, *task_ids) + if redis_set.size(key) == 0: + redis_set.delete(key) def queue_discipline_background_task(discipline_id: int, task_id: str) -> None: try: - add_to_set(_discipline_tasks_key(discipline_id), task_id) + redis_set = RedisSet(RedisConnection()) + key = _discipline_tasks_key(discipline_id) + redis_set.add(key, task_id) except Exception: logger.warning( "Failed to track discipline task %s for discipline %s.", @@ -58,7 +61,8 @@ def finish_discipline_background_task(discipline_id: int, task_id: str) -> None: def _active_task_ids_from_redis_key(key: str) -> set[str]: try: - task_ids = set_members(key) + redis_set = RedisSet(RedisConnection()) + task_ids = redis_set.members(key) if not task_ids: return set() diff --git a/specifyweb/backend/stored_queries/batch_edit.py b/specifyweb/backend/stored_queries/batch_edit.py index ba136c59b70..7aad9cadbde 100644 --- a/specifyweb/backend/stored_queries/batch_edit.py +++ b/specifyweb/backend/stored_queries/batch_edit.py @@ -777,7 +777,7 @@ def _to_upload_plan(rel_name: str, _self: "RowPlanCanonical"): base_table if intermediary_to_tree else datamodel.get_table_strict( - base_table.get_relationship(rel_name).relatedModelName + base_table.get_relationship_strict(rel_name).relatedModelName ) ) return _self.to_upload_plan( diff --git a/specifyweb/backend/stored_queries/tests/test_format/test_format_query.py b/specifyweb/backend/stored_queries/tests/test_format/test_format_query.py index 2dd0883c277..0637a293e4c 100644 --- a/specifyweb/backend/stored_queries/tests/test_format/test_format_query.py +++ b/specifyweb/backend/stored_queries/tests/test_format/test_format_query.py @@ -65,7 +65,7 @@ def test_basic_formatters(self): 'WHEN :param_2 THEN concat(IFNULL(agent."LastName", \'\'), IFNULL(concat(:concat_1, agent."FirstName"), \'\'), IFNULL(concat(:concat_2, agent."MiddleInitial"), \'\')) ' 'WHEN :param_3 THEN IFNULL(agent."LastName", \'\') ' 'WHEN :param_4 THEN IFNULL(agent."LastName", \'\') END, \'\')') - orm_field = object_formatter.aggregate(query, spmodels.datamodel.get_table('Accession').get_relationship('accessionagents'), models.Accession, None, []) + orm_field = object_formatter.aggregate(query, spmodels.datamodel.get_table('Accession').get_relationship_strict('accessionagents'), models.Accession, None, []) self.assertEqual(sqlparse.format(str(orm_field), reindent=True), '\n ' '(SELECT IFNULL(GROUP_CONCAT(IFNULL(concat(IFNULL(CASE IFNULL(agent_1."AgentType", \'\')' diff --git a/specifyweb/backend/table_rows/forms.py b/specifyweb/backend/table_rows/forms.py deleted file mode 100644 index 4814525940a..00000000000 --- a/specifyweb/backend/table_rows/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -from django import forms - -from specifyweb.specify.api.validators import GetCollectionForm - -class RowsForm(GetCollectionForm): - fields = forms.CharField(required=True) # type: ignore - distinct = forms.CharField(required=False) - defaults = dict( - domainfilter=None, - limit=0, - offset=0, - orderby=None, - distinct=False, - fields=None, - filterchronostrat=False, - ) \ No newline at end of file diff --git a/specifyweb/backend/trees/apps.py b/specifyweb/backend/trees/apps.py index a6dd26a5f4c..43c52c7f7c8 100644 --- a/specifyweb/backend/trees/apps.py +++ b/specifyweb/backend/trees/apps.py @@ -1,5 +1,5 @@ from django.apps import AppConfig -from specifyweb.backend.cache.redis import get_string +from specifyweb.backend.cache.redis.connect import RedisConnection, RedisString import logging logger = logging.getLogger(__name__) @@ -11,8 +11,9 @@ class TreesConfig(AppConfig): def ready(self): from specifyweb.backend.trees.redis import ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY try: + redis_string = RedisString(RedisConnection()) # Clear potential leftover tree creation tracking information - data = get_string(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY, delete_key=True) + data = redis_string.get(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY, delete_key=True) if data is not None: logger.debug(f'Clearing last active default tree creation tasks: {data}') except Exception: diff --git a/specifyweb/backend/trees/defaults.py b/specifyweb/backend/trees/defaults.py index 1a137ce59d6..590fc549df2 100644 --- a/specifyweb/backend/trees/defaults.py +++ b/specifyweb/backend/trees/defaults.py @@ -9,7 +9,7 @@ from specifyweb.backend.trees.default_tree_files import stream_default_tree_csv from specifyweb.backend.trees.utils import get_models, TREE_ROOT_NODES from specifyweb.backend.trees.extras import renumber_tree, set_fullnames -from specifyweb.backend.cache.redis import add_to_set, remove_from_set, set_members +from specifyweb.backend.cache.redis.connect import RedisConnection, RedisSet from specifyweb.backend.trees.redis import ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY import logging @@ -324,8 +324,6 @@ def add_default_tree_record(context: DefaultTreeContext, row: dict, tree_cfg: Tr if v: defaults[model_field] = v - rank_title = rank_mapping.get('title', rank_name.capitalize()) - # Get the rank by the rank id, or column name as a fallback. # Skip creating on this rank if it doesn't exist if rank_mapping['rank']: @@ -393,18 +391,21 @@ def add_default_tree_record(context: DefaultTreeContext, row: dict, tree_cfg: Tr def queue_create_default_tree_task(task_id): """Store queued (and active) default tree creation tasks so they can be reliably tracked later.""" - add_to_set(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY, task_id) - logger.debug(f"Queued task {task_id}. Current tasks: {set_members(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY)}") + redis_set = RedisSet(RedisConnection()) + redis_set.add(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY, task_id) + logger.debug(f"Queued task {task_id}. Current tasks: {redis_set.members(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY)}") def get_active_create_default_tree_tasks() -> list[str]: - tasks = set_members(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY) + redis_set = RedisSet(RedisConnection()) + tasks = redis_set.members(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY) logger.debug(f"Active tree creation tasks: {tasks}") return list(tasks) def finish_create_default_tree_task(task_id): """Clear a finished tree creation task from redis cache.""" - remove_from_set(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY, task_id) - logger.debug(f"Finished task {task_id}. Current tasks: {set_members(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY)}") + redis_set = RedisSet(RedisConnection()) + redis_set.remove(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY, task_id) + logger.debug(f"Finished task {task_id}. Current tasks: {redis_set.members(ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY)}") @app.task(base=LogErrorsTask, bind=True) def create_default_tree_task(self, url: str, discipline_id: int, tree_type: str, specify_collection_id: Optional[int], diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index ba13869707d..97bea8bc5e0 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -12,15 +12,14 @@ from django.db import models, connection from django.db.models import F, Q, ProtectedError -from django.conf import settings from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException -import specifyweb.specify.models as spmodels +from specifyweb.specify.models_utils.model_extras import ModelWithTable from specifyweb.backend.workbench.upload.auditcodes import TREE_BULK_MOVE, TREE_MERGE, TREE_SYNONYMIZE, TREE_DESYNONYMIZE @contextmanager -def validate_node_numbers(table, revalidate_after=True): +def validate_node_numbers(table: str, revalidate_after=True): try: validate_tree_numbering(table) except AssertionError: @@ -29,8 +28,9 @@ def validate_node_numbers(table, revalidate_after=True): if revalidate_after: validate_tree_numbering(table) -class Tree(models.Model): +class Tree(ModelWithTable): _requires_collection_user = True + class Meta: abstract = True diff --git a/specifyweb/backend/trees/redis.py b/specifyweb/backend/trees/redis.py index 1b9b023ec0b..0caae40a8b6 100644 --- a/specifyweb/backend/trees/redis.py +++ b/specifyweb/backend/trees/redis.py @@ -1,2 +1,2 @@ # Used to track active default tree creation tasks -ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY = "specify:{database}:trees:active_tree_creation" \ No newline at end of file +ACTIVE_DEFAULT_TREE_TASK_REDIS_KEY = "trees:active_tree_creation" \ No newline at end of file diff --git a/specifyweb/backend/trees/tests/test_trees.py b/specifyweb/backend/trees/tests/test_trees.py index 139c20a452e..709dc5a0a7e 100644 --- a/specifyweb/backend/trees/tests/test_trees.py +++ b/specifyweb/backend/trees/tests/test_trees.py @@ -19,18 +19,28 @@ class TestTreeSetup(ApiTests): def make_taxon_ranks(self, treedef): self.taxon_root = treedef.treedefitems.create(name="Taxonomy Root", rankid=0) - self.taxon_kingdom = treedef.treedefitems.create(name="Kingdom", rankid=10) - treedef.treedefitems.create(name="Phylum", rankid=30) - treedef.treedefitems.create(name="Class", rankid=60) - treedef.treedefitems.create(name="Order", rankid=100) - treedef.treedefitems.create(name="Superfamily", rankid=130) - treedef.treedefitems.create(name="Family", rankid=140) - treedef.treedefitems.create(name="Genus", rankid=180) - treedef.treedefitems.create(name="Subgenus", rankid=190) - treedef.treedefitems.create(name="Species", rankid=220) - treedef.treedefitems.create(name="Subspecies", rankid=230) + self.taxon_kingdom = treedef.treedefitems.create(name="Kingdom", rankid=10, parent=self.taxon_root) + phylum = treedef.treedefitems.create(name="Phylum", rankid=30, parent=self.taxon_kingdom) + clazz = treedef.treedefitems.create(name="Class", rankid=60, parent=phylum) + order = treedef.treedefitems.create(name="Order", rankid=100, parent=clazz) + superfamily = treedef.treedefitems.create(name="Superfamily", rankid=130, parent=order) + family = treedef.treedefitems.create(name="Family", rankid=140, parent=superfamily) + genus = treedef.treedefitems.create(name="Genus", rankid=180, parent=family) + subgenus = treedef.treedefitems.create(name="Subgenus", rankid=190, parent=genus) + species = treedef.treedefitems.create(name="Species", rankid=220, parent=subgenus) + treedef.treedefitems.create(name="Subspecies", rankid=230, parent=species) + + # REFACTOR: Move this to something like tree_extras? Or ApiTests? + @staticmethod + def get_treedefitem_at_rankid(treedefitems: list, rankid: int): + for treedefitem in treedefitems: + if treedefitem.rankid == rankid: + return treedefitem + + raise ValueError(f"Unable to find treedefitem with rank {rankid} in {treedefitems}") def make_geography_ranks(self, treedef): + # BUG: make sure these ranks have parents if not treedef.treedefitems.all().filter(rankid=0).exists(): treedef.treedefitems.create(name="Planet", rankid=0) treedef.treedefitems.create(name="Continent", rankid=100) @@ -89,7 +99,6 @@ def setUp(self) -> None: super().setUp() self.make_geography_ranks(self.geographytreedef) - self.taxontreedef = models.Taxontreedef.objects.create(name="Test Taxonomy") self.make_taxon_ranks(self.taxontreedef) self.collectionobjecttype.taxontreedef = self.taxontreedef diff --git a/specifyweb/backend/workbench/upload/clone.py b/specifyweb/backend/workbench/upload/clone.py index 3ba75db28e0..7c6137d207c 100644 --- a/specifyweb/backend/workbench/upload/clone.py +++ b/specifyweb/backend/workbench/upload/clone.py @@ -11,7 +11,7 @@ from specifyweb.specify.utils.func import Func from specifyweb.specify.models_utils.load_datamodel import Table -from specifyweb.specify.models import ModelWithTable +from specifyweb.specify.models_utils.model_extras import ModelWithTable FIELDS_TO_NOT_CLONE: dict[str, list[str]] = json.load( open("specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json") @@ -66,7 +66,7 @@ def clone_record( field, ( field.is_relation - and specify_model.get_relationship(field.name).dependent + and specify_model.get_relationship_strict(field.name).dependent or field.name.lower() in one_to_ones.get(model_name.lower(), []) and field.name is not None ), diff --git a/specifyweb/backend/workbench/upload/scoping.py b/specifyweb/backend/workbench/upload/scoping.py index 1e7332b8611..c6dcd6c06bc 100644 --- a/specifyweb/backend/workbench/upload/scoping.py +++ b/specifyweb/backend/workbench/upload/scoping.py @@ -66,25 +66,25 @@ def scoping_relationships(collection, table: Table) -> dict[str, int]: pass try: - table.get_relationship("collection") + table.get_relationship_strict("collection") extra_static["collection_id"] = collection.id except DoesNotExistError: pass try: - table.get_relationship("discipline") + table.get_relationship_strict("discipline") extra_static["discipline_id"] = collection.discipline.id except DoesNotExistError: pass try: - table.get_relationship("division") + table.get_relationship_strict("division") extra_static["division_id"] = collection.discipline.division.id except DoesNotExistError: pass try: - table.get_relationship("institution") + table.get_relationship_strict("institution") extra_static["institution_id"] = collection.discipline.division.institution.id except DoesNotExistError: pass diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py index 282beec9085..2e5a7029b71 100644 --- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py +++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py @@ -418,7 +418,7 @@ def _make_taxon_records(self): Taxon = get_table("Taxon") treedef_items = self.taxontreedef.treedefitems.order_by("rankid").all() - get_rank = partial(TreeUpdateTests.get_treedefitem_at_rankid, treedef_items) + get_rank = partial(UploadTestsBase.get_treedefitem_at_rankid, treedef_items) root = Taxon.objects.create( name="Life", fullname="Life", @@ -500,14 +500,6 @@ def _create_genus_and_species(self, genus_name: str, species_name: str): ) return genus, species - @staticmethod - def get_treedefitem_at_rankid(treedefitems: list, rankid: int): - for treedefitem in treedefitems: - if treedefitem.rankid == rankid: - return treedefitem - - raise ValueError(f"Unable to find treedefitem with rank {rankid} in {treedefitems}") - def _upload_collection_objects(self, datas: list[dict]): Collectionobject = get_table("Collectionobject") result = [] diff --git a/specifyweb/backend/workbench/upload/upload_plan_schema.py b/specifyweb/backend/workbench/upload/upload_plan_schema.py index 5da7eefdaf5..b6241e50e2b 100644 --- a/specifyweb/backend/workbench/upload/upload_plan_schema.py +++ b/specifyweb/backend/workbench/upload/upload_plan_schema.py @@ -361,7 +361,7 @@ def parse_uploadable(table: Table, to_parse: dict) -> Uploadable: def parse_upload_table(table: Table, to_parse: dict) -> UploadTable: def rel_table(key: str) -> Table: - related_model_name = table.get_relationship(key).relatedModelName + related_model_name = table.get_relationship_strict(key).relatedModelName if related_model_name is None: raise ValueError(f"Related model name for key '{key}' is None.") return datamodel.get_table_strict(related_model_name) diff --git a/specifyweb/backend/workbench/upload/upload_table.py b/specifyweb/backend/workbench/upload/upload_table.py index d253ba6e259..7dcd72f55d8 100644 --- a/specifyweb/backend/workbench/upload/upload_table.py +++ b/specifyweb/backend/workbench/upload/upload_table.py @@ -6,6 +6,7 @@ from specifyweb.backend.businessrules.exceptions import BusinessRuleException from specifyweb.specify import models +from specifyweb.specify.models_utils.model_extras import ModelWithTable from specifyweb.specify.utils.func import Func from specifyweb.specify.utils.field_change_info import FieldChangeInfo from specifyweb.specify.utils.autonumbering import AutonumberingLockDispatcher @@ -366,7 +367,7 @@ def can_save(self) -> bool: return isinstance(self.current_id, int) @property - def django_model(self) -> models.ModelWithTable: + def django_model(self) -> ModelWithTable: return getattr(models, self.name.capitalize()) @property @@ -509,8 +510,8 @@ def save_row(self, force=False) -> UploadResult: else update_table.process_row_with_null() ) - def _get_reference(self, should_cache=True) -> models.ModelWithTable | None: - model: models.ModelWithTable = self.django_model + def _get_reference(self, should_cache=True) -> ModelWithTable | None: + model: ModelWithTable = self.django_model current_id = self.current_id if current_id is None: @@ -712,7 +713,7 @@ def _check_missing_required(self) -> ParseFailures | None: def _do_upload( self, - model: models.ModelWithTable, + model: ModelWithTable, to_one_results: dict[str, UploadResult], info: ReportInfo, ) -> UploadResult: @@ -786,7 +787,7 @@ def _do_upload( return UploadResult(record, to_one_results, to_many_results) def _handle_to_many( - self, update: bool, parent_id: int, model: models.ModelWithTable + self, update: bool, parent_id: int, model: ModelWithTable ): return { fieldname: _upload_to_manys( @@ -897,7 +898,7 @@ def _relationship_is_dependent(self, field_name) -> bool: if field_name in self.toOne and self.toOne[field_name].is_one_to_one(): return True - return django_model.specify_model.get_relationship(field_name).dependent + return django_model.specify_model.get_relationship_strict(field_name).dependent class BoundOneToOneTable(BoundUploadTable): diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/EditorComponents.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/EditorComponents.tsx index a026de01745..29e26423921 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/EditorComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/EditorComponents.tsx @@ -25,7 +25,7 @@ import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { getFieldBlockerKey, useSaveBlockers } from '../DataModel/saveBlockers'; import type { SpAppResource, SpViewSetObj } from '../DataModel/types'; -import { DeleteButton } from '../Forms/DeleteButton'; +import { DeleteButtonWrapped } from '../Forms/DeleteButton'; import { Dialog } from '../Molecules/Dialog'; import { downloadFile, FilePicker, fileToText } from '../Molecules/FilePicker'; import { hasToolPermission } from '../Permissions/helpers'; @@ -83,7 +83,7 @@ export function AppResourceEditButton({ {!appResource.isNew() && hasToolPermission('resources', 'delete') && typeof handleDeleted === 'function' ? ( - diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourceEditButton.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourceEditButton.test.tsx index 8a3280ee38c..ca3ee5cc42f 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourceEditButton.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourceEditButton.test.tsx @@ -86,7 +86,14 @@ describe('AppResourceEditButton', () => { expect(handleDeleted).not.toHaveBeenCalled(); }); - overrideAjax(`/delete_blockers/delete_blockers/spappresource/3/`, []); + overrideAjax(`/delete_blockers/delete_blockers/spappresource/3/?limit=40`, { + results: [], + next: [], + }); + overrideAjax(`/delete_blockers/count/spappresource/3/`, { + results: [], + total_count: 0, + }); overrideAjax(`/api/specify/spappresource/3/`, { id: 3, name: 'TestName', diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.ts b/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.ts index 06eceb435cf..c2d3dce17bd 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.ts @@ -10,6 +10,7 @@ import { Backbone } from './backbone'; import { DEFAULT_FETCH_LIMIT } from './collection'; import type { AnySchema } from './helperTypes'; import type { SpecifyResource } from './legacyTypes'; +import { Collection } from './specifyTable'; // REFACTOR: remove @ts-nocheck @@ -20,7 +21,9 @@ const Base = Backbone.Collection.extend({ }, }); -export const isRelationshipCollection = (value: unknown): boolean => +export const isRelationshipCollection = ( + value: unknown +): value is Collection => value instanceof DependentCollection || value instanceof IndependentCollection; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts b/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts index aaebb4478a8..bc4870ea16c 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts @@ -3,6 +3,7 @@ import type { RA, ValueOf } from '../../utils/types'; import { caseInsensitiveHash } from '../../utils/utils'; import { isTreeResource } from '../InitialContext/treeRanks'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; +import { isRelationshipCollection } from './collectionApi'; import type { AnySchema, AnyTree, @@ -250,6 +251,22 @@ export async function fetchDistantRelated( }; } +export function gatherAllDependents( + resource: SpecifyResource +): RA> { + const directDependents = Object.values(resource.dependentResources) + .filter((related) => related !== null && related !== undefined) + .flatMap((related) => + isRelationshipCollection(related) + ? related.models + : // Typescript still thinks related can be undefined or null here, so + // using a cast + (related as SpecifyResource) + ); + const cascadedDependents = directDependents.flatMap(gatherAllDependents); + return [...directDependents, ...cascadedDependents]; +} + // Cog types: Discrete, Consolidated, Drill Core export const cogTypes = { DISCRETE: 'Discrete', diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index 35f9770a810..4e94a64f8c2 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -183,7 +183,9 @@ export type SpecifyResource = { readonly errorOnAlreadySaving?: boolean; }): Promise>; destroy(): Promise; - fetch(): Promise>; + fetch(options?: { + readonly strict?: boolean; + }): Promise>; viewUrl(): string; isNew(): boolean; isBeingInitialized(): boolean; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.ts b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.ts index 8213992f986..307e0c60587 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.ts @@ -16,7 +16,7 @@ import { IndependentCollection, isRelationshipCollection, } from './collectionApi'; -import { backboneFieldSeparator } from './helpers'; +import { backboneFieldSeparator, gatherAllDependents } from './helpers'; import type { AnySchema, SerializedRecord, @@ -976,6 +976,15 @@ export const ResourceBase = Backbone.Model.extend({ const promise = await Backbone.Model.prototype.destroy.apply(this, ...args); this.deleted = true; resourceEvents.trigger('deleted', this); + // BUG: Is there ever a case where a dependent is not deleted when the + // parent is deleted? + // We could be smarter about this and ask the DB whether the dependents + // were deleted or not after this resource was deleted + gatherAllDependents(this).forEach((dependent) => { + resourceEvents.trigger('deleted', dependent); + }); + // BUG:? Do we need to reset the dependent resources here? + // They could be used by some component, so keeping them as-is for now return promise; }, toJSON() { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/tables.ts b/specifyweb/frontend/js_src/lib/components/DataModel/tables.ts index 1c85b563a2d..da35afb63e8 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/tables.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/tables.ts @@ -211,6 +211,28 @@ export function getTable(name: string): SpecifyTable | undefined { export const strictGetTable = (name: string): SpecifyTable => defined(getTable(name), `Trying to get unknown table: ${name}`); +/** Sort a table list by its caption or internal name. */ +export function sortTablesByCaption( + values: RA, + getName: (value: T) => string, + useCaption = true +): RA { + return [...values].sort((first, second) => { + const firstName = getName(first); + const secondName = getName(second); + const firstLabel = useCaption + ? (getTable(firstName)?.label ?? firstName) + : firstName; + const secondLabel = useCaption + ? (getTable(secondName)?.label ?? secondName) + : secondName; + return ( + firstLabel.localeCompare(secondLabel) || + firstName.localeCompare(secondName) + ); + }); +} + export function getTreeTable(name: string): SpecifyTable | undefined { const table = getTable(name); if (typeof table === 'object' && !isTreeTable(table.name)) @@ -227,8 +249,8 @@ export const getTableById = ( tableId: number ): SpecifyTable => (Object.values(genericTables).find((table) => table.tableId === tableId) as - | SpecifyTable - | undefined) ?? error(`Table with id ${tableId} does not exist`); + SpecifyTable | undefined) ?? + error(`Table with id ${tableId} does not exist`); // If this is true, then you can use {domainfilter:true} when fetching that table export const hasHierarchyField = (table: SpecifyTable): boolean => diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/Context.tsx b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/Context.tsx new file mode 100644 index 00000000000..7fd93252606 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/Context.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { DeleteBlockerState } from './state'; +import { error } from '../Errors/assert'; +import { DeleteBlockerStore } from './store'; +import { resourceEvents } from '../DataModel/resource'; + +const DeleteBlockerContext = React.createContext( + null +); + +export function DeleteBlockerProvider({ + blockerStoreParams, + children, +}: { + readonly blockerStoreParams?: ConstructorParameters< + typeof DeleteBlockerState + >[0]; + readonly children: JSX.Element; +}) { + const existingStore = React.useContext(DeleteBlockerContext); + if (existingStore !== null) { + return children; + } + const deleteBlockerStore = React.useRef( + new DeleteBlockerStore(blockerStoreParams) + ); + + const store = deleteBlockerStore.current; + React.useEffect(() => { + return () => { + if (store === null) { + return; + } + store.clearState(); + }; + }, [store]); + + return ( + + {children} + + ); +} + +export function useDeleteBlockerStore(): DeleteBlockerStore { + const store = React.useContext(DeleteBlockerContext); + if (store === null) { + error('Attempting to access a delete blocker store that is not created'); + } + React.useEffect( + () => + resourceEvents.on('deleted', (resource) => + store.removeResource(resource.specifyTable.name, resource.id) + ), + [store] + ); + return store; +} diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/DeleteBlockersAside.tsx b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/DeleteBlockersAside.tsx new file mode 100644 index 00000000000..31489ac0b67 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/DeleteBlockersAside.tsx @@ -0,0 +1,253 @@ +import React from 'react'; +import { Button } from '../Atoms/Button'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { AnySchema } from '../DataModel/helperTypes'; +import { resourceToStringIdentifier } from './state'; +import { RA } from '../../utils/types'; +import { format } from '../Formatters/formatters'; +import { useAsyncState } from '../../hooks/useAsyncState'; +import { commonText } from '../../localization/common'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { TableIcon } from '../Molecules/TableIcon'; +import { + getTable, + sortTablesByCaption, + strictGetTable, +} from '../DataModel/tables'; +import { localized } from '../../utils/types'; +import { + DeleteBlockerResource, + DeleteBlockerTable, + DeleteBlockerRelationship, +} from './store'; +import { Relationship } from '../DataModel/specifyField'; + +export function DeleteBlockersAside({ + resource, + records, + activeRelationshipKey, + useSmallLayout = false, + onRelationshipActive: handleRelationshipActive, +}: { + readonly resource: SpecifyResource; + readonly records: RA; + readonly activeRelationshipKey: string | undefined; + readonly useSmallLayout?: boolean; + readonly onRelationshipActive: ( + resource: SpecifyResource, + relationship: Relationship, + relationshipKey: string + ) => void; +}): JSX.Element { + return ( + + ); +} + +function BlockerResource({ + record, + activeRelationshipKey, + onRelationshipActive: handleRelationshipActive, +}: { + readonly record: DeleteBlockerResource; + readonly activeRelationshipKey: string | undefined; + readonly onRelationshipActive: ( + resource: SpecifyResource, + relationship: Relationship, + relationshipKey: string + ) => void; +}): JSX.Element | null { + const [formatted] = useAsyncState( + React.useCallback(() => format(record.resource), [record.resource]), + false + ); + + const [isOpen, _, __, handleToggleOpen] = useBooleanState(false); + + const resourceLabel = + formatted === undefined ? commonText.loading() : formatted; + + return record.tables.length === 0 ? null : ( +
+ + + + {record.count !== undefined + ? commonText.countLine({ + resource: resourceLabel, + count: record.count, + }) + : // FIXME: Localize this? + `${resourceLabel} (${commonText.loading()})`} + + + {isOpen && ( +
+ {sortTablesByCaption(record.tables, ({ tableName }) => tableName).map( + (blockerTable, index) => ( + + handleRelationshipActive( + record.resource, + relationship, + relationshipKey + ) + } + /> + ) + )} +
+ )} +
+ ); +} + +function DeleteBlockersTable({ + blockerTable, + activeRelationshipKey, + onRelationshipActive: handleRelationshipActive, +}: { + readonly blockerTable: DeleteBlockerTable; + readonly activeRelationshipKey: string | undefined; + readonly onRelationshipActive: ( + relationship: Relationship, + relationshipKey: string + ) => void; +}) { + const [tableName, tableLabel] = React.useMemo(() => { + const table = getTable(blockerTable.tableName); + const tableName = table?.name ?? blockerTable.tableName; + const tableLabel = table?.label ?? blockerTable.tableName; + return [tableName, tableLabel]; + }, [blockerTable]); + + const [isOpen, _, __, handleToggleOpen] = useBooleanState(false); + return blockerTable.count === 0 ? null : ( + <> + + + + {blockerTable.count !== undefined + ? commonText.countLine({ + resource: localized(tableLabel), + count: blockerTable.count, + }) + : // FIXME: Localize this? + `${localized(tableLabel)} (${commonText.loading()})`} + + + {isOpen && ( +
+ {blockerTable.relationships.map((blockerRel, index) => ( + + ))} +
+ )} + + ); +} + +function DeleteBlockersRelationship({ + tableName, + blockerRelationship, + isActive, + onRelationshipActive: handleRelationshipActive, +}: { + readonly tableName: string; + readonly blockerRelationship: DeleteBlockerRelationship; + readonly isActive: boolean; + readonly onRelationshipActive: ( + relationship: Relationship, + relationshipKey: string + ) => void; +}) { + const relationship = React.useMemo(() => { + const table = strictGetTable(tableName); + const field = table.strictGetRelationship( + blockerRelationship.relationshipName + ); + return field; + }, [tableName, blockerRelationship]); + return ( + { + handleRelationshipActive(relationship, blockerRelationship.key); + }} + > + + {blockerRelationship.count !== undefined + ? commonText.countLine({ + resource: relationship.label, + count: blockerRelationship.count, + }) + : // FIXME: Localize this? + `${relationship.label} (${commonText.loading()})`} + + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/DeleteBlockersPaginator.tsx b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/DeleteBlockersPaginator.tsx new file mode 100644 index 00000000000..053490494c6 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/DeleteBlockersPaginator.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { AnySchema } from '../DataModel/helperTypes'; +import { RecordSelectorFromPage } from '../FormSliders/RecordSelectorFromPage'; +import { f } from '../../utils/functools'; +import { Relationship } from '../DataModel/specifyField'; +import { useDeleteBlockerPages } from './useDeleteBlockerPages'; +import { buildPageCacheKey } from './pageCache'; + +export function DeleteBlockersPaginator({ + parentResource, + relationship, + dialog, + onClose: handleClose, + onAllBlockersDeleted: handleAllBlockersDeleted, +}: { + readonly parentResource: SpecifyResource; + readonly relationship: Relationship; + readonly dialog: false | 'modal' | 'nonModal'; + readonly onClose: () => void; + readonly onAllBlockersDeleted: () => void; +}): JSX.Element { + const { + page, + pageMetaData, + pageSize, + onNextPageFetch: handleNextPageFetch, + } = useDeleteBlockerPages( + parentResource, + relationship, + handleAllBlockersDeleted + ); + + const pageKey = React.useMemo( + () => + buildPageCacheKey( + parentResource, + relationship.table.name, + relationship.name + ), + [parentResource, relationship] + ); + + // FEAT: use the Relationships reference count in the paginator? + // const referenceCount = useRelationshipReferenceCount( + // parentResource, + // relationship + // ); + + return ( + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/__tests__/state.test.ts b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/__tests__/state.test.ts new file mode 100644 index 00000000000..f5dda75076f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/__tests__/state.test.ts @@ -0,0 +1,341 @@ +import { waitFor } from '@testing-library/react'; + +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { PromiseQueue } from '../../../utils/promiseQueue'; +import type { RA } from '../../../utils/types'; +import { tables } from '../../DataModel/tables'; +import { DELETE_BLOCKER_LIMIT } from '../deleteBlockers'; +import { DeleteBlockerLRUPage, makeBlockerKey } from '../pageCache'; +import { DeleteBlockerState, resourceToStringIdentifier } from '../state'; + +requireContext(); + +const accessionId = 1; +const coBlockerIds = [11, 12]; +const initialPage = { + table: 'collectionobject', + field: 'accession', + ids: coBlockerIds, + limit: DELETE_BLOCKER_LIMIT, + complete: true, + anchor: null, + backwards: false, +}; + +const initialBlockers = jest.fn(() => ({ + results: [initialPage], + next: [], +})); + +const makeAccessionCountsResponse = (count: number) => ({ + results: [{ table: 'collectionobject', field: 'accession', count }], + total_count: count, +}); +const fetchCounts = jest.fn(() => makeAccessionCountsResponse(2)); + +function firstPage( + result: DeleteBlockerLRUPage | RA | false | undefined +): DeleteBlockerLRUPage | undefined { + if (result === false || result === undefined) return undefined; + return Array.isArray(result) ? result[0] : result; +} + +overrideAjax( + `/delete_blockers/delete_blockers/accession/${accessionId}/?limit=${DELETE_BLOCKER_LIMIT}`, + initialBlockers +); + +const newCoId = 13; +overrideAjax( + `/delete_blockers/delete_blockers/accession/${accessionId}/`, + { + results: [ + { + ...initialPage, + ids: [newCoId], + anchor: coBlockerIds.at(-1), + }, + ], + next: [], + }, + { + method: 'POST', + body: { + filters: [ + { + table: 'collectionobject', + field: 'accession', + anchor: coBlockerIds.at(-1), + backwards: false, + limit: DELETE_BLOCKER_LIMIT, + }, + ], + }, + } +); +overrideAjax(`/delete_blockers/count/accession/${accessionId}/`, fetchCounts); + +type DeleteBlockerStateQueues = { + readonly countPromiseQueue: PromiseQueue; + readonly pagePromiseQueue: PromiseQueue< + string, + RA | undefined + >; +}; + +const getStateQueues = (state: DeleteBlockerState): DeleteBlockerStateQueues => + state as unknown as DeleteBlockerStateQueues; + +describe('DeleteBlockerState: Accession -> CollectionObject blockers', () => { + beforeEach(() => { + initialBlockers.mockClear(); + fetchCounts.mockClear(); + fetchCounts.mockImplementation(() => makeAccessionCountsResponse(2)); + }); + + test('seeds blocker pages and loads resource counts', async () => { + const state = new DeleteBlockerState(); + const resource = new tables.Accession.Resource({ id: accessionId }); + + await state.seedBlockers(resource); + await state.fetchCountsForResource(resource); + + const page = firstPage( + await state.filterBlockers(resource, 'CollectionObject', 'accession') + ); + const node = state.getNode(resourceToStringIdentifier(resource)); + + expect(page?.ids).toEqual(coBlockerIds); + expect(node?.count).toBe(coBlockerIds.length); + expect( + [...(node?.relationshipMetaData.values() ?? [])].map( + (relationship) => relationship.count + ) + ).toEqual([coBlockerIds.length]); + expect(initialBlockers).toHaveBeenCalledTimes(1); + expect(fetchCounts).toHaveBeenCalledTimes(1); + + state.destroy(); + }); + + test('fetches and returns a filtered blocker page', async () => { + const state = new DeleteBlockerState(); + const resource = new tables.Accession.Resource({ id: accessionId }); + + const pages = await state.filterBlockers( + resource, + 'CollectionObject', + 'accession', + coBlockerIds.at(-1) + ); + if (!Array.isArray(pages)) { + throw new Error('Expected the filtered blocker request to return pages'); + } + fetchCounts.mockImplementationOnce(() => makeAccessionCountsResponse(1)); + await state.fetchCountsForResource(resource); + + expect(pages.map((page) => page.ids)).toEqual([[newCoId]]); + expect(state.getNode(resourceToStringIdentifier(resource))?.count).toBe(2); + expect(fetchCounts).toHaveBeenCalledTimes(1); + + state.destroy(); + }); + + test('removes a deleted blocker from cached pages and refreshes counts', async () => { + const state = new DeleteBlockerState(); + const resource = new tables.Accession.Resource({ id: accessionId }); + await state.seedBlockers(resource); + await state.fetchCountsForResource(resource); + + const accessionKey = resourceToStringIdentifier(resource); + + fetchCounts.mockImplementationOnce(() => makeAccessionCountsResponse(1)); + const coToRemove = makeBlockerKey( + tables.CollectionObject.name.toLowerCase(), + coBlockerIds.at(0) + ); + state.removeResource(coToRemove); + await waitFor(() => expect(state.getNode(accessionKey)?.count).toBe(1)); + + const page = firstPage( + await state.filterBlockers(resource, 'CollectionObject', 'accession') + ); + const node = state.getNode(accessionKey); + + expect(page?.ids).toEqual([coBlockerIds.at(1)]); + expect(node?.count).toBe(1); + expect( + [...(node?.relationshipMetaData.values() ?? [])].map( + (relationship) => relationship.count + ) + ).toEqual([1]); + expect(fetchCounts).toHaveBeenCalledTimes(2); + + state.destroy(); + }); + + test('destroy clears queued count and page requests and cached nodes', async () => { + const state = new DeleteBlockerState(); + const resource = new tables.Accession.Resource({ id: accessionId }); + await state.seedBlockers(resource); + await waitFor(() => + expect(state.getNode(resourceToStringIdentifier(resource))?.count).toBe(2) + ); + + const queues = getStateQueues(state); + const activeResolvers: Array<() => void> = []; + const activeRequest = jest.fn( + () => + new Promise | undefined>((resolve) => { + activeResolvers.push(() => resolve(undefined)); + }) + ); + const queuedCount = jest.fn(() => Promise.resolve()); + const queuedPage = jest.fn(() => + Promise.resolve>([]) + ); + const activeCountPromise = queues.countPromiseQueue.enqueue( + 'active-count', + activeRequest + ); + const activePagePromises = [ + queues.pagePromiseQueue.enqueue('active-page-1', activeRequest), + queues.pagePromiseQueue.enqueue('active-page-2', activeRequest), + queues.pagePromiseQueue.enqueue('active-page-3', activeRequest), + ]; + queues.countPromiseQueue.enqueue('queued-count', queuedCount); + queues.pagePromiseQueue.enqueue('queued-page', queuedPage); + + expect(queues.countPromiseQueue.get('queued-count')).toBe(false); + expect(queues.pagePromiseQueue.get('queued-page')).toBe(false); + expect(state.getNode(resourceToStringIdentifier(resource))).toBeDefined(); + expect(activeCountPromise).not.toBe(false); + expect(activePagePromises.every((promise) => promise !== false)).toBe(true); + + state.destroy(); + + expect(queues.countPromiseQueue.get('queued-count')).toBeUndefined(); + expect(queues.pagePromiseQueue.get('queued-page')).toBeUndefined(); + expect(state.getNode(resourceToStringIdentifier(resource))).toBeUndefined(); + + activeResolvers.forEach((resolve) => resolve()); + await Promise.all([ + activeCountPromise === false ? Promise.resolve() : activeCountPromise, + ...activePagePromises.map((promise) => + promise === false ? Promise.resolve() : promise + ), + ]); + + expect(queuedCount).not.toHaveBeenCalled(); + expect(queuedPage).not.toHaveBeenCalled(); + }); +}); + +const collectionObjectId = 2; +const determinationIds = [21, 22]; +const nextDeterminations = jest.fn(() => ({ + results: [], + next: [ + { + table: 'determination', + field: 'collectionobject', + ids: determinationIds, + limit: DELETE_BLOCKER_LIMIT, + complete: true, + anchor: null, + backwards: false, + }, + ], +})); +const determinationBlockers = determinationIds.map((id) => + jest.fn(() => ({ + results: [ + { + table: 'determination', + field: 'taxon', + ids: [id], + limit: DELETE_BLOCKER_LIMIT, + complete: true, + anchor: null, + backwards: false, + }, + ], + next: [], + })) +); +const cascadeCounts = jest.fn(() => ({ results: [], total_count: 0 })); +const determinationCounts = determinationIds.map(() => + jest.fn(() => ({ + results: [{ table: 'determination', field: 'taxon', count: 1 }], + total_count: 1, + })) +); + +overrideAjax( + `/delete_blockers/delete_blockers/collectionobject/${collectionObjectId}/?limit=40`, + nextDeterminations +); +overrideAjax( + `/delete_blockers/count/collectionobject/${collectionObjectId}/`, + cascadeCounts +); +determinationIds.forEach((id, index) => { + overrideAjax( + `/delete_blockers/delete_blockers/determination/${id}/?limit=40`, + determinationBlockers[index] + ); + overrideAjax( + `/delete_blockers/count/determination/${id}/`, + determinationCounts[index] + ); +}); + +describe('DeleteBlockerState: Next/Cascaded blockers', () => { + beforeEach(() => { + nextDeterminations.mockClear(); + cascadeCounts.mockClear(); + determinationBlockers.forEach((request) => request.mockClear()); + determinationCounts.forEach((request) => request.mockClear()); + }); + + test('fetches blockers for every record in a cascade next page', async () => { + const state = new DeleteBlockerState(); + const resource = new tables.CollectionObject.Resource({ + id: collectionObjectId, + }); + await state.seedBlockers(resource); + + await waitFor(() => { + determinationBlockers.forEach((request) => + expect(request).toHaveBeenCalledTimes(1) + ); + determinationCounts.forEach((request) => + expect(request).toHaveBeenCalledTimes(1) + ); + }); + + expect( + state.getNode(resourceToStringIdentifier(resource))?.cascadeChildren + ).toEqual( + new Set( + determinationIds.map((id) => + makeBlockerKey(tables.Determination.name.toLowerCase(), id) + ) + ) + ); + + for (const [index, id] of determinationIds.entries()) { + const determination = new tables.Determination.Resource({ id }); + const page = firstPage( + await state.filterBlockers(determination, 'Determination', 'taxon') + ); + expect(page?.ids).toEqual([determinationIds[index]]); + expect( + state.getNode(resourceToStringIdentifier(determination))?.count + ).toBe(1); + } + + state.destroy(); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/deleteBlockers.ts b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/deleteBlockers.ts new file mode 100644 index 00000000000..56b79785180 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/deleteBlockers.ts @@ -0,0 +1,104 @@ +import { ajax } from '../../utils/ajax'; +import { Http } from '../../utils/ajax/definitions'; +import { RA } from '../../utils/types'; +import { Tables } from '../DataModel/types'; +import { formatUrl } from '../Router/queryString'; + +export const DELETE_BLOCKER_LIMIT = 40; + +export type APIDeleteBlockerPage = { + readonly table: Lowercase; + readonly field: string; + readonly ids: RA; + readonly limit: number; + readonly complete: boolean; + readonly anchor: null | number; + readonly backwards: boolean; +}; + +export type APIDeleteBlockers = { + readonly results: RA; + readonly next: RA; +}; + +export type APIDeleteBlockerCount = { + readonly table: Lowercase; + readonly field: string; + readonly count: number; +}; + +export type APIDeleteBlockerCounts = { + readonly results: RA; + readonly total_count: number; +}; + +type DeleteBlockerFilter = { + readonly table: keyof Tables | Lowercase; + readonly field: string; + readonly anchor?: number | null; + readonly limit?: number; + readonly backwards?: boolean; +}; + +export async function fetchInitialBlockers( + table: keyof Tables, + recordId: number, + limit: number = DELETE_BLOCKER_LIMIT, + expectFailure = false +): Promise { + return ajax( + formatUrl( + `/delete_blockers/delete_blockers/${table.toLowerCase()}/${recordId}/`, + { + limit, + } + ), + { + headers: { Accept: 'application/json' }, + expectedErrors: expectFailure ? [Http.NOT_FOUND] : [], + } + ).then((results) => + results.status === Http.NOT_FOUND ? undefined : results.data + ); +} + +export async function filterDeleteBlockers( + table: keyof Tables, + recordId: number, + filters: RA, + expectFailure = false +): Promise { + return ajax( + `/delete_blockers/delete_blockers/${table.toLowerCase()}/${recordId}/`, + { + method: 'POST', + headers: { Accept: 'application/json' }, + expectedErrors: expectFailure ? [Http.NOT_FOUND] : [], + body: { + filters: filters.map((filter) => ({ + ...filter, + table: filter.table.toLowerCase(), + field: filter.field.toLowerCase(), + })), + }, + } + ).then((result) => + result.status === Http.NOT_FOUND ? undefined : result.data + ); +} + +export async function fetchReferenceCounts( + table: keyof Tables | Lowercase, + recordId: number, + expectFailure = false +): Promise { + return ajax( + `/delete_blockers/count/${table.toLowerCase()}/${recordId}/`, + { + headers: { Accept: 'application/json' }, + expectedErrors: expectFailure ? [Http.NOT_FOUND] : [], + } + ).then((result) => { + return result.status === Http.NOT_FOUND ? undefined : result.data; + }); +} diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/index.tsx b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/index.tsx new file mode 100644 index 00000000000..207aec4070a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/index.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { AnySchema } from '../DataModel/helperTypes'; +import { Relationship } from '../DataModel/specifyField'; +import { useScreenSize } from '../../hooks/useScreenSize'; +import { DeleteBlockersAside } from './DeleteBlockersAside'; +import { DeleteBlockersPaginator } from './DeleteBlockersPaginator'; +import { DeleteBlockerResource } from './store'; +import { RA } from '../../utils/types'; +import { useLiveState } from '../../hooks/useLiveState'; + +export const MIN_DELETEBLOCKER_CONTAINER_WIDTH = 768; + +export function DeleteBlockers({ + blockers, + resource, + useSmallLayout: initialUseSmallLayout = false, +}: { + readonly blockers: RA | undefined; + readonly resource: SpecifyResource; + readonly useSmallLayout?: boolean; +}) { + const [paginatorKey, setPaginatorKey] = React.useState< + | undefined + | { + readonly resource: SpecifyResource; + readonly relationship: Relationship; + readonly relationshipKey: string; + } + >(undefined); + + const isLargeScreen = useScreenSize('lg'); + + const useSmallLayout = initialUseSmallLayout || !isLargeScreen; + + const [isPaginatorDialogOpen, setPaginatorDialogOpen] = useLiveState( + React.useCallback(() => useSmallLayout, [useSmallLayout]) + ); + + const handleSetPaginatorKey = React.useCallback( + ( + resource: SpecifyResource, + relationship: Relationship, + relationshipKey: string + ) => { + setPaginatorKey(() => ({ + resource, + relationship, + relationshipKey, + })); + }, + [useSmallLayout] + ); + + return blockers === undefined ? null : ( + <> +
+ + {!useSmallLayout && paginatorKey !== undefined && ( + setPaginatorDialogOpen(false)} + onAllBlockersDeleted={() => setPaginatorKey(undefined)} + parentResource={paginatorKey.resource} + relationship={paginatorKey.relationship} + /> + )} +
+ {useSmallLayout && + paginatorKey !== undefined && + isPaginatorDialogOpen && ( + setPaginatorKey(undefined)} + onAllBlockersDeleted={() => setPaginatorKey(undefined)} + parentResource={paginatorKey.resource} + relationship={paginatorKey.relationship} + /> + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/pageCache.ts b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/pageCache.ts new file mode 100644 index 00000000000..940e02233eb --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/pageCache.ts @@ -0,0 +1,144 @@ +import { LRUCache } from '../../utils/lruCache'; +import { RA } from '../../utils/types'; +import { AnySchema } from '../DataModel/helperTypes'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { Tables } from '../DataModel/types'; +import { softFail } from '../Errors/Crash'; +import { APIDeleteBlockerPage } from './deleteBlockers'; +import { ResourceIdentifier } from './state'; + +export type BlockerPageCacheKey = string; + +export type DeleteBlockerLRUPage = { + readonly ownerTable: string; + readonly ownerId: number; + readonly table: Lowercase; + readonly ids: RA; + readonly anchor: 'last' | number | null; + readonly backwards: boolean; + readonly complete: boolean; +}; + +export function pageAnchorToFilter(anchor: number | null | 'last') { + return anchor === undefined || anchor === 'last' ? null : anchor; +} + +export function makeBlockerKey(...components: RA) { + return components.join('_'); +} + +export function buildPageCacheKey( + owner: SpecifyResource, + relatedTable?: string, + relationshipName?: string, + anchor: number | null | 'last' = null +) { + const ownerTable = owner.specifyTable.name.toLowerCase(); + const ownerId = owner.id; + if (ownerId === undefined) { + softFail('Attempting to get DeleteBlockers for record without ID'); + } + const keyParts = [ + ownerTable, + ownerId, + relatedTable?.toLowerCase(), + relationshipName?.toLowerCase(), + anchor, + ]; + return makeBlockerKey(...keyParts); +} + +export function blockerPageToCacheKey( + owner: SpecifyResource, + page: APIDeleteBlockerPage +): BlockerPageCacheKey { + const tableName = page.table; + const relationshipName = page.field; + const backwards = page.backwards; + const anchorId = page.anchor; + const resolvedAnchor = anchorId === null && backwards ? 'last' : anchorId; + return buildPageCacheKey(owner, tableName, relationshipName, resolvedAnchor); +} + +// eslint-disable-next-line functional/no-class +export class DeleteBlockerLRU { + private readonly pageCache: LRUCache< + BlockerPageCacheKey, + DeleteBlockerLRUPage + >; + public constructor({ + maxPages = 200, + onEvict = undefined, + }: { + readonly maxPages?: number; + readonly onEvict?: ( + key: BlockerPageCacheKey, + page: DeleteBlockerLRUPage + ) => void; + }) { + this.pageCache = new LRUCache({ maxSize: maxPages, onEvict }); + } + + public cachePages( + resource: SpecifyResource, + blockerPages: RA + ) { + blockerPages.forEach((page) => this.cachePage(resource, page)); + } + + public cachePage( + resource: SpecifyResource, + blockerPage: APIDeleteBlockerPage + ) { + const cacheKey = blockerPageToCacheKey(resource, blockerPage); + const cachedPage: DeleteBlockerLRUPage = { + ownerTable: resource.specifyTable.name.toLowerCase(), + ownerId: resource.id, + ids: blockerPage.ids, + table: blockerPage.table, + anchor: blockerPage.anchor, + backwards: blockerPage.backwards, + complete: blockerPage.complete, + }; + this.pageCache.set(cacheKey, cachedPage); + return cachedPage; + } + + // BUG:? This marks the page as recently used + public replacePage( + cacheKey: BlockerPageCacheKey, + newPage: DeleteBlockerLRUPage + ) { + this.pageCache.set(cacheKey, newPage); + } + + public deletePage(cacheKey: BlockerPageCacheKey) { + this.pageCache.delete(cacheKey); + } + + public getPage( + cacheKey: BlockerPageCacheKey + ): DeleteBlockerLRUPage | undefined { + return this.pageCache.get(cacheKey); + } + + public peekPage( + cacheKey: BlockerPageCacheKey + ): DeleteBlockerLRUPage | undefined { + return this.pageCache.peek(cacheKey); + } + + // Given a specific resource identifier (table + id), this removes all pages + // owned by that resource + public removePagesOwnedBy(identifer: ResourceIdentifier) { + for (const cacheKey of this.pageCache.keys()) { + if (cacheKey.startsWith(identifer)) { + this.deletePage(cacheKey); + } + } + } + + public clear() { + return this.pageCache.clear(); + } +} diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/state.ts b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/state.ts new file mode 100644 index 00000000000..84ef21ff978 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/state.ts @@ -0,0 +1,687 @@ +import { PromiseQueue } from '../../utils/promiseQueue'; +import { RA, WritableArray } from '../../utils/types'; +import { AnySchema } from '../DataModel/helperTypes'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { getTable } from '../DataModel/tables'; +import { Tables } from '../DataModel/types'; +import { + APIDeleteBlockerCount, + APIDeleteBlockerCounts, + DELETE_BLOCKER_LIMIT, + fetchInitialBlockers, + fetchReferenceCounts, + filterDeleteBlockers, +} from './deleteBlockers'; +import { + BlockerPageCacheKey, + blockerPageToCacheKey, + buildPageCacheKey, + DeleteBlockerLRU, + DeleteBlockerLRUPage, + makeBlockerKey, + pageAnchorToFilter, +} from './pageCache'; +import { APIDeleteBlockerPage, APIDeleteBlockers } from './deleteBlockers'; +import { f } from '../../utils/functools'; +import { softFail } from '../Errors/Crash'; + +export type ResourceIdentifier = string; + +export type BlockerRelationship = { + readonly key: BlockerPageCacheKey; + readonly table: Lowercase; + readonly field: string; + // eslint-disable-next-line functional/prefer-readonly-type + count?: number; +}; + +export type BlockerNode = { + readonly key: ResourceIdentifier; + readonly resource: SpecifyResource; + readonly relationshipMetaData: Map; + readonly cascadeChildren: Set; + // eslint-disable-next-line functional/prefer-readonly-type + count?: number; + // eslint-disable-next-line functional/prefer-readonly-type + version: number; +}; + +export function resourceToStringIdentifier( + resource: SpecifyResource +): ResourceIdentifier { + return makeBlockerKey(resource.specifyTable.name.toLowerCase(), resource.id); +} + +function identifierToParts( + resourceKey: ResourceIdentifier +): readonly [Lowercase, number] { + const [tableName, recordId] = resourceKey.split('_', 2); + return [ + tableName.toLowerCase() as Lowercase, + f.fastParseInt(recordId), + ]; +} + +// eslint-disable-next-line functional/no-class +export class DeleteBlockerState { + private readonly pageCache: DeleteBlockerLRU; + private readonly countPromiseQueue: PromiseQueue; + private readonly pagePromiseQueue: PromiseQueue< + BlockerPageCacheKey, + RA | undefined + >; + private readonly nodes: Map; + private readonly cascadeParents: Map>; + private readonly recordLocations: Map< + ResourceIdentifier, + Set + >; + private readonly recordsPerPage: number; + private readonly onChange?: () => void; + + public constructor({ + maxPages = 200, + recordsPerPage = DELETE_BLOCKER_LIMIT, + onChange = undefined, + }: { + readonly recordsPerPage?: number; + readonly maxPages?: number; + readonly onChange?: () => void; + } = {}) { + this.recordsPerPage = recordsPerPage; + this.pageCache = new DeleteBlockerLRU({ + maxPages, + onEvict: (key, page) => this.unindexBlockerPage(key, page), + }); + // We only allow one promise to be resolving at a time for counts, and + // three for pages. + // This should still let the browser handle other requests while fetching + // blocker information for records with a lot of references + this.countPromiseQueue = new PromiseQueue(1); + this.pagePromiseQueue = new PromiseQueue(3); + this.nodes = new Map(); + this.cascadeParents = new Map(); + this.recordLocations = new Map(); + this.onChange = onChange; + } + + public getNode(nodeKey: ResourceIdentifier) { + return this.nodes.get(nodeKey); + } + + public pageSize(): number { + return this.recordsPerPage; + } + + public destroy() { + this.countPromiseQueue.clear(); + this.pagePromiseQueue.clear(); + this.pageCache.clear(); + this.nodes.clear(); + this.recordLocations.clear(); + this.cascadeParents.clear(); + } + + private getOrCreateNode( + resource: SpecifyResource + ): readonly [boolean, BlockerNode] { + const resourceKey = resourceToStringIdentifier(resource); + const existingNode = this.getNode(resourceKey); + if (existingNode !== undefined) { + return [true, existingNode]; + } + const newNode: BlockerNode = { + key: resourceKey, + resource, + relationshipMetaData: new Map(), + cascadeChildren: new Set(), + version: 0, + }; + this.nodes.set(resourceKey, newNode); + return [false, newNode]; + } + + private getOrCreateRelationship( + node: BlockerNode, + table: Lowercase, + field: string + ): BlockerRelationship { + const relationshipKey = this.nodeRelationshipKey(node, table, field); + const existingRelationship = node.relationshipMetaData.get(relationshipKey); + + if (existingRelationship !== undefined) { + return existingRelationship; + } + + const newRelationship: BlockerRelationship = { + key: relationshipKey, + table, + field, + }; + node.relationshipMetaData.set(relationshipKey, newRelationship); + return newRelationship; + } + + public nodeRelationshipKey( + node: BlockerNode, + table: keyof Tables | Lowercase, + field: string + ) { + return makeBlockerKey(node.key, table.toLowerCase(), field.toLowerCase()); + } + + public async seedBlockers(resource: SpecifyResource) { + const queueKey = buildPageCacheKey(resource); + if (this.pageIsCached(queueKey) || this.pageIsFetching(queueKey)) { + return; + } + return this.pagePromiseQueue.enqueue(queueKey, () => + this.handleMainBlockers(resource) + ); + } + + private async handleMainBlockers(resource: SpecifyResource) { + return fetchInitialBlockers( + resource.specifyTable.name, + resource.id, + this.recordsPerPage, + true + ).then((blockers) => + // This branch should only mean that the resource was deleted while the + // request was in-flight. + // If that happens, just clean it up as if it were deleted + blockers === undefined + ? this.removeResource( + makeBlockerKey( + resource.specifyTable.name.toLowerCase(), + resource.id + ) + ) + : // Otherwise we can treat the response as normal + this.handleDeleteBlockerPage(resource, blockers) + ); + } + + private handleDeleteBlockerPage( + resource: SpecifyResource, + blockers: APIDeleteBlockers + ): RA { + const [_, node] = this.getOrCreateNode(resource); + const cachedPages = this.applyBlockerPages(node, blockers.results); + this.updateAncestors(node.key); + this.queueNextBlockers(resource, blockers.next); + this.fetchCountsForResource(resource); + this.onChange?.(); + return cachedPages; + } + + private applyBlockerPages( + node: BlockerNode, + blockerPages: RA + ): RA { + return blockerPages.map((page) => this.applyBlockerPage(node, page)); + } + + private applyBlockerPage( + node: BlockerNode, + blockerPage: APIDeleteBlockerPage + ) { + this.getOrCreateRelationship(node, blockerPage.table, blockerPage.field); + const blockerKey = blockerPageToCacheKey(node.resource, blockerPage); + const existingPage = this.pageCache.peekPage(blockerKey); + if (existingPage !== undefined) { + this.unindexBlockerPage(blockerKey, existingPage); + } + const cachedPage = this.pageCache.cachePage(node.resource, blockerPage); + this.indexBlockerPage(blockerKey, blockerPage); + return cachedPage; + } + + private indexBlockerPage( + blockerPageCacheKey: BlockerPageCacheKey, + blockerPage: APIDeleteBlockerPage | DeleteBlockerLRUPage + ) { + for (const id of blockerPage.ids) { + const resourceKey = makeBlockerKey(blockerPage.table.toLowerCase(), id); + const pageKeys = this.recordLocations.get(resourceKey); + + if (pageKeys === undefined) { + const newSet = new Set([blockerPageCacheKey]); + this.recordLocations.set(resourceKey, newSet); + } else { + pageKeys.add(blockerPageCacheKey); + } + } + } + + private unindexBlockerPage( + pageKey: BlockerPageCacheKey, + blockerPage: DeleteBlockerLRUPage + ) { + for (const id of blockerPage.ids) { + const resourceKey = makeBlockerKey(blockerPage.table.toLowerCase(), id); + this.removeRecordLocation(resourceKey, pageKey); + } + } + + public removeResource(resourceKey: ResourceIdentifier) { + const otherRecordsWithPage = this.removeRecordFromPages(resourceKey); + + this.pageCache.removePagesOwnedBy(resourceKey); + + const affectedParents = this.removeRelationshipIndexes(resourceKey); + this.nodes.delete(resourceKey); + + const allAffectedRecords = new Set([ + ...otherRecordsWithPage, + ...affectedParents, + ]); + + for (const key of allAffectedRecords) { + if (this.nodes.has(key)) this.updateAncestors(key); + this.queueBlockerCount(key); + } + this.onChange?.(); + return undefined; + } + + private removeRecordFromPages(resourceKey: ResourceIdentifier) { + const otherRecordsWithPage = new Set(); + const blockerCacheKeys = [...(this.recordLocations.get(resourceKey) ?? [])]; + for (const cacheKey of blockerCacheKeys) { + const blockerPage = this.pageCache.peekPage(cacheKey); + + if (blockerPage === undefined) { + this.removeRecordLocation(resourceKey, cacheKey); + continue; + } + + const table = blockerPage.table.toLowerCase(); + const remainingRecordIds = blockerPage.ids.filter( + (id) => makeBlockerKey(table, id) !== resourceKey + ); + if (remainingRecordIds.length === blockerPage.ids.length) { + this.removeRecordLocation(resourceKey, cacheKey); + continue; + } + const ownerIdentifier: ResourceIdentifier = makeBlockerKey( + blockerPage.ownerTable.toLowerCase(), + blockerPage.ownerId + ); + otherRecordsWithPage.add(ownerIdentifier); + const updatedPage: DeleteBlockerLRUPage = { + ...blockerPage, + ids: remainingRecordIds, + }; + // We probably don't need to re-index the page here, but just to be safe + this.unindexBlockerPage(cacheKey, blockerPage); + // REFACTOR:? This keeps the page in the cache even if all IDs are now + // removed. Do we want to explicitly remove the cached page if that's the + // case? + this.pageCache.replacePage(cacheKey, updatedPage); + this.indexBlockerPage(cacheKey, updatedPage); + this.invalidateCounts(ownerIdentifier); + } + return otherRecordsWithPage; + } + private removeRecordLocation( + resourceKey: ResourceIdentifier, + blockerCacheKey: BlockerPageCacheKey + ) { + const pageKeys = this.recordLocations.get(resourceKey); + if (pageKeys === undefined) { + return; + } + pageKeys.delete(blockerCacheKey); + if (pageKeys.size === 0) { + this.recordLocations.delete(resourceKey); + } + } + private removeRelationshipIndexes(resourceKey: ResourceIdentifier) { + const affectedParents = this.removeRecordFromParents(resourceKey); + this.removeChildrenFrom(resourceKey); + return affectedParents; + } + + private removeRecordFromParents(resourceKey: ResourceIdentifier) { + const affectedParents = new Set(); + const parentKeys = this.cascadeParents.get(resourceKey); + + if (parentKeys === undefined) { + return affectedParents; + } + + for (const parentKey of parentKeys) { + const parent = this.getNode(parentKey); + if (parent === undefined) continue; + parent.cascadeChildren.delete(resourceKey); + affectedParents.add(parentKey); + } + + this.cascadeParents.delete(resourceKey); + return affectedParents; + } + + private removeChildrenFrom(resourceKey: ResourceIdentifier) { + const node = this.getNode(resourceKey); + if (node === undefined) { + return; + } + for (const childKey of node.cascadeChildren) { + const childParents = this.cascadeParents.get(childKey); + if (childParents === undefined) continue; + + childParents.delete(resourceKey); + + if (childParents.size === 0) { + this.cascadeParents.delete(childKey); + } + } + node.cascadeChildren.clear(); + } + + public async fetchCountsForResource(resource: SpecifyResource) { + const countKey = resourceToStringIdentifier(resource); + return this.queueBlockerCount(countKey); + } + + private queueBlockerCount(resourceKey: ResourceIdentifier) { + const alreadyQueued = this.countPromiseQueue.get(resourceKey); + const [tableName, resourceId] = identifierToParts(resourceKey); + if (alreadyQueued === false) { + return false; + } + if (alreadyQueued !== undefined) { + return undefined; + } + + const fetchCounts = () => + fetchReferenceCounts(tableName, resourceId, true).then((counts) => + counts === undefined + ? // This branch should only mean that the resource was deleted while + // the request was in-flight. + // If that happens, just clean it up as if it were deleted + this.removeResource(resourceKey) + : // Otherwise we can treat the counts as normal + this.handleDeleteBlockerCounts(resourceKey, counts) + ); + + return this.countPromiseQueue.enqueue(resourceKey, fetchCounts); + } + + private invalidateCounts(resourceKey: ResourceIdentifier) { + const node = this.getNode(resourceKey); + if (node === undefined) return; + node.count = undefined; + node.relationshipMetaData.forEach((blockerRelationship) => { + blockerRelationship.count = undefined; + }); + } + private handleDeleteBlockerCounts( + resourceKey: ResourceIdentifier, + counts: APIDeleteBlockerCounts + ) { + const node = this.getNode(resourceKey); + if (node === undefined) { + // We're expecting the node to exist at this point, but maybe the + // resource was deleted between the time the count request was made and + // this is executing? + console.warn( + 'Trying to handle counts for record not in DeleteBlocker state', + { resource: resourceKey } + ); + return; + } + node.count = counts.total_count; + node.relationshipMetaData.forEach((blockerRelationship) => { + blockerRelationship.count = 0; + }); + counts.results.forEach((relationshipCount) => + this.addCountsToRelationship(node, relationshipCount) + ); + this.updateAncestors(node.key); + this.onChange?.(); + } + + private addCountsToRelationship( + node: BlockerNode, + referenceCounts: APIDeleteBlockerCount + ) { + const { table, field, count } = referenceCounts; + const relationshipKey = this.nodeRelationshipKey(node, table, field); + const relationship = node.relationshipMetaData.get(relationshipKey); + if (relationship === undefined) { + // We're expecting this relationship to already exist on the node. + // Maybe all records in the relationship were deleted and it was removed? + console.warn('Trying to handle counts for missing relationship', { + resource: resourceToStringIdentifier(node.resource), + table, + field, + }); + return; + } + relationship.count = count; + } + + private async queueNextBlockers( + resource: SpecifyResource, + nextBlockers: RA + ) { + nextBlockers.forEach((nextBlocker) => { + this.queueNextBlocker(resource, nextBlocker); + }); + } + + private pageIsFetching(key: BlockerPageCacheKey): boolean { + const isCurrentlyFetching = this.pagePromiseQueue.get(key); + return isCurrentlyFetching !== false && isCurrentlyFetching !== undefined; + } + + private pageIsCached(key: BlockerPageCacheKey): boolean { + const isCached = this.pageCache.peekPage(key); + return isCached !== undefined; + } + + private async queueNextBlocker( + resource: SpecifyResource, + nextBlocker: APIDeleteBlockerPage + ) { + const [parentCreated, parentNode] = this.getOrCreateNode(resource); + + const table = getTable(nextBlocker.table); + if (table === undefined) { + softFail(new Error('Unknown table when fetching delete blocker'), { + tableName: nextBlocker.table, + }); + return; + } + for (const id of nextBlocker.ids) { + const childResource = new table.Resource( + { id }, + { noBusinessRules: true } + ); + const [childCreated, childNode] = this.getOrCreateNode(childResource); + const marked = this.addCascadeRelationship(parentNode, childNode); + if (marked) { + this.updateAncestors(parentNode.key); + } + // This is to try and prevent cycles when fetching CASCADE relationships + // when both tables have relationships set to CASCADE: + // e.g., Collection -> autoNumberingSchemes and + // AutoNumberingScheme -> collections + if (marked && (parentCreated || childCreated)) + this.seedBlockers(childResource); + } + // We need to handle queuing up the rest of the next blockers + if (nextBlocker.complete === false) { + this.handleNextPage(resource, nextBlocker); + } + } + + private addCascadeRelationship( + parentNode: BlockerNode, + childNode: BlockerNode + ): boolean { + const hasChild = parentNode.cascadeChildren.has(childNode.key); + + if (hasChild) { + return false; + } + + parentNode.cascadeChildren.add(childNode.key); + + const childParents = this.cascadeParents.get(childNode.key); + if (childParents === undefined) { + const newChildParents = new Set([parentNode.key]); + this.cascadeParents.set(childNode.key, newChildParents); + } else { + childParents.add(parentNode.key); + } + + return true; + } + + private updateNode(node: BlockerNode) { + node.version += 1; + return undefined; + } + + public graphIterator( + rootKey: ResourceIdentifier, + direction: 'children' | 'ancestors', + nodeFunction: (node: BlockerNode) => boolean | undefined + ) { + const visitedRecords = new Set(); + const stack: WritableArray = [rootKey]; + + while (stack.length > 0) { + const currentKey = stack.pop()!; + if (visitedRecords.has(currentKey)) { + continue; + } + visitedRecords.add(currentKey); + const node = this.getNode(currentKey); + if (node === undefined) { + continue; + } + if (nodeFunction(node) === true) break; + const collection = + direction === 'ancestors' + ? this.cascadeParents.get(currentKey) + : node.cascadeChildren; + for (const nextKey of collection ?? []) { + stack.push(nextKey); + } + } + } + + private updateAncestors(changedKey: ResourceIdentifier) { + this.graphIterator(changedKey, 'ancestors', this.updateNode); + } + + public getBlockerPage(cacheKey: string) { + return this.pageCache.getPage(cacheKey); + } + + public async filterBlockers( + resource: SpecifyResource, + relatedTable: keyof Tables | Lowercase, + relationshipName: string, + anchor: number | null | 'last' = null, + backwards: boolean = false + ) { + const cacheKey = buildPageCacheKey( + resource, + relatedTable, + relationshipName, + anchor + ); + if (this.pageIsCached(cacheKey)) { + return this.getBlockerPage(cacheKey); + } + if (this.pageIsFetching(cacheKey)) { + return undefined; + } + const fetchBlockers = () => + filterDeleteBlockers( + resource.specifyTable.name, + resource.id, + [ + { + table: relatedTable, + field: relationshipName, + anchor: pageAnchorToFilter(anchor), + // This is needed to fetch the last page + // A null anchor depends on the direction of the request + backwards: anchor === 'last' ? true : backwards, + limit: this.recordsPerPage, + }, + ], + true + ).then((blockers) => { + // This branch should only mean that the resource was deleted while the + // request was in-flight. + // If that happens, just clean it up as if it were deleted + if (blockers === undefined) { + const resourceKey = makeBlockerKey( + resource.specifyTable.name.toLowerCase(), + resource.id + ); + this.removeResource(resourceKey); + return; + } + // Othwerwise we treat the response as normal + const formattedBlockers = + anchor === 'last' || backwards === true + ? { + ...blockers, + results: blockers.results.map((page) => ({ + ...page, + ids: [...page.ids].reverse(), + // We normally treat backwards requests the same as forwards + // and transform them accordingly. + // However when the last page is requested then it's + // important to include indication of backwards as that's the + // only way to know the last page was requested. + // See blockerPageToCacheKey + // REFACTOR: Maybe we can define a modified type of the API + // response and pass those to the cache that allows anchor to + // be 'false'. + // That would decouple the anchor + backwards pairing to make + // working with cached pages easier + backwards: anchor === 'last', + })), + } + : blockers; + return this.handleDeleteBlockerPage(resource, formattedBlockers); + }); + return this.pagePromiseQueue.enqueue(cacheKey, fetchBlockers); + } + + private async handleNextPage( + resource: SpecifyResource, + blockerPage: APIDeleteBlockerPage + ) { + if (blockerPage.complete) { + // The page we're requesting the next page from is already complete. + // Skip the request + return; + } + const newAnchor = blockerPage.ids.at(-1); + + if (newAnchor === undefined) { + // A blocker page with no IDs should be considered complete + console.warn('Empty blocker with no IDs not marked complete'); + return; + } + const newPage = { + ...blockerPage, + anchor: newAnchor, + }; + const cacheKey = blockerPageToCacheKey(resource, newPage); + if (this.pageIsFetching(cacheKey) || this.pageIsCached(cacheKey)) { + return; + } + this.filterBlockers(resource, newPage.table, newPage.field, newAnchor); + } +} diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/store.ts b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/store.ts new file mode 100644 index 00000000000..cb984b0cb81 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/store.ts @@ -0,0 +1,252 @@ +import { RA, Writable, WritableArray } from '../../utils/types'; +import { AnySchema } from '../DataModel/helperTypes'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { Relationship } from '../DataModel/specifyField'; +import { Tables } from '../DataModel/types'; +import { + BlockerPageCacheKey, + buildPageCacheKey, + DeleteBlockerLRUPage, + makeBlockerKey, +} from './pageCache'; +import { + BlockerNode, + DeleteBlockerState, + ResourceIdentifier, + resourceToStringIdentifier, +} from './state'; + +export type DeleteBlockerRelationship = { + readonly key: BlockerPageCacheKey; + readonly relationshipName: string; + readonly count?: number; +}; + +export type DeleteBlockerTable = { + readonly tableName: string; + readonly count?: number; + readonly relationships: RA; +}; + +export type DeleteBlockerResource = { + readonly key: ResourceIdentifier; + readonly resource: SpecifyResource; + readonly count?: number; + readonly tables: RA; +}; + +type MutableDeleteBlockerTable = Writable< + Omit +> & { + relationships: Map; +}; + +export class DeleteBlockerStore { + private readonly state: DeleteBlockerState; + + private readonly listeners = new Set<() => void>(); + + public constructor( + blockerStateParams: ConstructorParameters< + typeof DeleteBlockerState + >[0] = undefined + ) { + const params: ConstructorParameters[0] = { + ...blockerStateParams, + onChange: () => { + blockerStateParams?.onChange?.(); + this.notify(); + }, + }; + this.state = new DeleteBlockerState(params); + } + + public readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + }; + + public notify(): void { + this.listeners.forEach((listener) => listener()); + } + + public pageSize(): number { + return this.state.pageSize(); + } + + public async seedBlockers(resource: SpecifyResource) { + return this.state.seedBlockers(resource); + } + + public getAllBlockerCounts(resource: SpecifyResource) { + const blockerKey = resourceToStringIdentifier(resource); + const node = this.getNode(blockerKey); + if (node === undefined) { + return undefined; + } + let countsPending = false; + let totalCount = 0; + this.state.graphIterator(blockerKey, 'children', (node) => { + if (node.version === 0 || node.count === undefined) { + countsPending = true; + return true; + } + totalCount += node.count; + return false; + }); + return countsPending ? undefined : totalCount; + } + + public getReferenceCountForRelationship( + resource: SpecifyResource, + relationship: Relationship + ) { + const blockerKey = resourceToStringIdentifier(resource); + const node = this.getNode(blockerKey); + if (node === undefined) { + return undefined; + } + const relationshipKey = this.state.nodeRelationshipKey( + node, + relationship.table.name, + relationship.name + ); + const blockerRelationship = node.relationshipMetaData.get(relationshipKey); + return blockerRelationship?.count; + } + + public filterBlockers( + resource: SpecifyResource, + relationship: Relationship, + anchor: number | null | 'last' = null, + backwards: boolean = false + ) { + return this.state.filterBlockers( + resource, + relationship.table.name, + relationship.name, + anchor, + backwards + ); + } + + public getCachedPage( + resource: SpecifyResource, + relationship: Relationship, + anchor: number | null | 'last' = null + ): DeleteBlockerLRUPage | undefined { + const cacheKey = buildPageCacheKey( + resource, + relationship.table.name, + relationship.name, + anchor + ); + return this.state.getBlockerPage(cacheKey); + } + + public removeResource( + table: keyof Tables | Lowercase, + recordId: number + ) { + const resourceKey = makeBlockerKey(table.toLowerCase(), recordId); + this.state.removeResource(resourceKey); + } + + public getDirectBlockers( + resource: SpecifyResource + ): DeleteBlockerResource | undefined { + const identifier = resourceToStringIdentifier(resource); + const node = this.getNode(identifier); + if (node === undefined) { + return undefined; + } + return this.blockerNodeToResource(node); + } + + public getBlockerGraph( + resource: SpecifyResource + ): RA | undefined { + const identifier = resourceToStringIdentifier(resource); + const node = this.getNode(identifier); + if (node === undefined) { + return undefined; + } + const results: WritableArray = []; + this.state.graphIterator(identifier, 'children', (node) => { + const blockerResource = this.blockerNodeToResource(node); + if ( + blockerResource !== undefined && + (blockerResource.count === undefined || blockerResource.count > 0) + ) { + results.push(blockerResource); + } + return false; + }); + return results; + } + + public getNode(nodeKey: ResourceIdentifier) { + return this.state.getNode(nodeKey); + } + + public clearState() { + this.state.destroy(); + } + + private blockerNodeToResource( + node: BlockerNode + ): DeleteBlockerResource | undefined { + // The node is still being initialized + if (node.version === 0) { + return undefined; + } + const tables = new Map(); + let resourceCount = 0; + let hasUndefinedCounts = false; + + for (const relationship of node.relationshipMetaData.values()) { + const defaultTable: MutableDeleteBlockerTable = { + tableName: relationship.table, + relationships: new Map(), + }; + const foundTable = tables.get(relationship.table); + if (foundTable === undefined) { + tables.set(relationship.table, defaultTable); + } + const table = tables.get(relationship.table)!; + + if (relationship.count === undefined) { + hasUndefinedCounts = true; + } else if (relationship.count > 0) { + table.count = table.count ?? 0 + relationship.count; + resourceCount += relationship.count; + } else { + table.count = 0; + continue; + } + + const relationshipKey = makeBlockerKey( + relationship.table, + relationship.field + ); + table.relationships.set(relationshipKey, { + key: relationship.key, + relationshipName: relationship.field, + count: relationship.count, + }); + } + return { + resource: node.resource, + key: node.key, + count: hasUndefinedCounts ? undefined : resourceCount, + tables: Array.from(tables.values(), (table) => ({ + tableName: table.tableName, + count: table.count, + relationships: Array.from(table.relationships.values()), + })), + }; + } +} diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/useDeleteBlockerPages.tsx b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/useDeleteBlockerPages.tsx new file mode 100644 index 00000000000..cb9a8cc6693 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/useDeleteBlockerPages.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { AnySchema } from '../DataModel/helperTypes'; +import { Relationship } from '../DataModel/specifyField'; +import { DeleteBlockerLRUPage } from './pageCache'; +import { PageMetaData } from '../FormSliders/RecordSelectorFromPage'; +import { useDeleteBlockerStore } from './Context'; +import { useLiveState } from '../../hooks/useLiveState'; +import { useRelationshipReferenceCount } from './useReferenceCount'; + +export function useDeleteBlockerPages( + resource: SpecifyResource, + relationship: Relationship, + handleAllBlockersDeleted: () => void +): { + readonly page: DeleteBlockerLRUPage | undefined; + readonly pageMetaData: PageMetaData; + readonly pageSize: number; + readonly onNextPageFetch: ( + previous: DeleteBlockerLRUPage, + direction: 'next' | 'previous' | 'first' | 'last' + ) => void; +} { + const store = useDeleteBlockerStore(); + + const referenceCount = useRelationshipReferenceCount(resource, relationship); + + const [pageCursor, setPageCursor] = useLiveState<{ + readonly anchor: number | null | 'last'; + readonly backwards: boolean; + }>( + React.useCallback( + () => ({ + anchor: null, + backwards: false, + }), + [resource, relationship] + ) + ); + + const deleteBlockerPage = React.useSyncExternalStore(store.subscribe, () => + store.getCachedPage(resource, relationship, pageCursor.anchor) + ); + + // When the cursor changes, this useEffect tells the DeleteBlockerStore to + // fetch the page + // The useSyncExternalStore will update deleteBlockerPage when the promise + // resolves and the blockers are cached + React.useEffect(() => { + store.filterBlockers( + resource, + relationship, + pageCursor.anchor, + pageCursor.backwards + ); + }, [store, resource, relationship, pageCursor]); + + React.useEffect(() => { + if (referenceCount === 0) handleAllBlockersDeleted(); + }, [referenceCount]); + + const handleNextPageFetch = React.useCallback( + ( + previousPage: DeleteBlockerLRUPage, + direction: 'next' | 'previous' | 'first' | 'last' + ) => { + const backwards = direction === 'previous' || direction === 'last'; + + if (direction === 'last') { + setPageCursor(() => ({ + anchor: 'last', + backwards: true, + })); + return; + } + if (direction === 'first') { + setPageCursor(() => ({ + anchor: null, + backwards: false, + })); + return; + } + if (previousPage.ids.length === 0) { + setPageCursor(() => ({ + anchor: previousPage.anchor, + backwards, + })); + return; + } + setPageCursor(() => ({ + anchor: previousPage.ids.at(-1) ?? null, + backwards, + })); + }, + [setPageCursor] + ); + + const isFirstPage = + deleteBlockerPage?.anchor === null && !pageCursor.backwards; + + const isLastPage = + deleteBlockerPage?.complete === true || + (deleteBlockerPage?.anchor === null && pageCursor.backwards); + + return { + page: deleteBlockerPage, + pageMetaData: + isFirstPage && isLastPage + ? 'single' + : isFirstPage + ? 'first' + : isLastPage + ? 'last' + : undefined, + pageSize: store.pageSize(), + onNextPageFetch: handleNextPageFetch, + }; +} diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/useDeleteBlockersForResource.tsx b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/useDeleteBlockersForResource.tsx new file mode 100644 index 00000000000..33ff683f615 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/useDeleteBlockersForResource.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { resourceToStringIdentifier } from './state'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { AnySchema } from '../DataModel/helperTypes'; +import { DeleteBlockerResource } from './store'; +import { RA } from '../../utils/types'; +import { useDeleteBlockerStore } from './Context'; +import { useLiveState } from '../../hooks/useLiveState'; + +export function useDeleteBlockersForResource( + resource: SpecifyResource, + initialDeferred: boolean = false +): { + readonly blockers: RA | undefined | false; + readonly onBlockersRequested: () => void; +} { + const store = useDeleteBlockerStore(); + const blockerKey = resourceToStringIdentifier(resource); + + const [deferred, setDeferred] = useLiveState( + React.useCallback(() => initialDeferred, [initialDeferred, resource]) + ); + + const handleBlockersRequested = React.useCallback(() => { + setDeferred(false); + }, []); + + React.useEffect(() => { + if (!deferred) { + void store.seedBlockers(resource); + } + }, [store, blockerKey, deferred]); + + const nodeVersion = React.useSyncExternalStore( + store.subscribe, + () => store.getNode(blockerKey)?.version ?? 0 + ); + + const records = React.useMemo( + () => store.getBlockerGraph(resource), + [store, nodeVersion, blockerKey] + ); + + return { + blockers: deferred && records === undefined ? false : records, + onBlockersRequested: handleBlockersRequested, + }; +} + +export function useDirectDeleteBlockersForResource( + resource: SpecifyResource, + initialDeferred: boolean = false +): { + readonly blockers: DeleteBlockerResource | undefined | false; + readonly onBlockersRequested: () => void; +} { + const store = useDeleteBlockerStore(); + const blockerKey = resourceToStringIdentifier(resource); + + const [deferred, setDeferred] = useLiveState( + React.useCallback(() => initialDeferred, [initialDeferred, resource]) + ); + + const handleBlockersRequested = React.useCallback(() => { + setDeferred(false); + }, []); + + React.useEffect(() => { + if (!deferred) { + void store.seedBlockers(resource); + } + }, [store, blockerKey, deferred]); + + const nodeVersion = React.useSyncExternalStore( + store.subscribe, + () => store.getNode(blockerKey)?.version ?? 0 + ); + + const records = React.useMemo( + () => store.getDirectBlockers(resource), + [store, nodeVersion, blockerKey] + ); + + return { + blockers: records === undefined && deferred ? false : records, + onBlockersRequested: handleBlockersRequested, + }; +} diff --git a/specifyweb/frontend/js_src/lib/components/DeleteBlockers/useReferenceCount.tsx b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/useReferenceCount.tsx new file mode 100644 index 00000000000..0cfb64e5763 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/useReferenceCount.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { AnySchema } from '../DataModel/helperTypes'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { Relationship } from '../DataModel/specifyField'; +import { useDeleteBlockerStore } from './Context'; + +export function useDeleteBlockerCount(resource: SpecifyResource) { + const store = useDeleteBlockerStore(); + + const blockerCount = React.useSyncExternalStore(store.subscribe, () => + store.getAllBlockerCounts(resource) + ); + return blockerCount; +} + +export function useRelationshipReferenceCount( + resource: SpecifyResource, + relationship: Relationship +): number | undefined { + const store = useDeleteBlockerStore(); + + const referenceCount = React.useSyncExternalStore(store.subscribe, () => + store.getReferenceCountForRelationship(resource, relationship) + ); + + return referenceCount; +} diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx index b50fdf1479d..3d0e1985f3d 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx @@ -23,7 +23,7 @@ import type { Collection, SpecifyTable } from '../DataModel/specifyTable'; import type { CollectionObjectGroup } from '../DataModel/types'; import { FormMeta } from '../FormMeta'; import type { FormCellDefinition, SubViewSortField } from '../FormParse/cells'; -import { DeleteButton } from '../Forms/DeleteButton'; +import { DeleteButtonWrapped } from '../Forms/DeleteButton'; import { SpecifyForm } from '../Forms/SpecifyForm'; import { SubViewContext } from '../Forms/SubView'; import { propsToFormMode, useViewDefinition } from '../Forms/useViewDefinition'; @@ -515,7 +515,7 @@ export function FormTable({ resource.id !== undefined && resource.id !== null && isTreeTable ? ( - ({ {icons.trash} )} - + ) : ( void) + | undefined; +}) { + const { + navigateToFirst, + navigateToPrevious, + navigateToNext, + navigateToLast, + } = useSliderButtons({ buttonsDisabled, onChange: handleChange }); + + return ( + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx index 8b6da91d914..934f706b75f 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx @@ -24,7 +24,7 @@ import { COJODialog } from '../FormCells/COJODialog'; import { FormTableCollection } from '../FormCells/FormTableCollection'; import type { FormType } from '../FormParse'; import type { SubViewSortField } from '../FormParse/cells'; -import { DeleteButton } from '../Forms/DeleteButton'; +import { DeleteButtonWrapped } from '../Forms/DeleteButton'; import { augmentMode, ResourceView } from '../Forms/ResourceView'; import { useFirstFocus } from '../Forms/SpecifyForm'; import { SubViewContext } from '../Forms/SubView'; @@ -243,7 +243,10 @@ export function IntegratedRecordSelector({ ( + headerButtons={( + specifyNetworkBadge, + showParentResource + ): JSX.Element => ( <> )} -
+ ) : ( ) ) : undefined} + {showParentResource} ({ <> ( + headerButtons={( + specifyNetworkBadge, + showParentResource + ): JSX.Element => (
{headerButtons} @@ -221,6 +224,7 @@ export function RecordSelectorFromIds({ onClick={(): void => handleRemove('minusButton')} /> ) : undefined} + {newResource === undefined ? showParentResource : undefined} {typeof newResource === 'object' && handleAdd !== undefined ? (

{formsText.creatingNewRecord()}

) : ( diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromPage.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromPage.tsx new file mode 100644 index 00000000000..5242a3dea23 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromPage.tsx @@ -0,0 +1,262 @@ +import React from 'react'; +import { RA } from '../../utils/types'; +import { ResourceView } from '../Forms/ResourceView'; +import { SpecifyTable } from '../DataModel/specifyTable'; +import { useLiveState } from '../../hooks/useLiveState'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { AnySchema } from '../DataModel/helperTypes'; +import { LocalizedString } from 'typesafe-i18n'; +import { hasTablePermission } from '../Permissions/helpers'; +import { BasicSlider } from './BasicSlider'; +import { DataEntry } from '../Atoms/DataEntry'; +import { commonText } from '../../localization/common'; +import { clamp } from '../../utils/utils'; +import { SetUnloadProtectsContext } from '../Router/UnloadProtect'; +import { Dialog } from '../Molecules/Dialog'; +import { Button } from '../Atoms/Button'; +import { unsetUnloadProtect } from '../../hooks/navigation'; +import { saveFormUnloadProtect } from '../Forms/Save'; +import { formsText } from '../../localization/forms'; +import { useTriggerState } from '../../hooks/useTriggerState'; + +type Page = { + readonly ids: RA; + readonly complete: boolean; +}; + +export type PageMetaData = 'single' | 'first' | 'last' | undefined; + +function usePagedRecordSelector({ + pageMetaData, + index, + records, + onSlide: handleSlide, + onDelete: handleDelete, +}: { + readonly pageMetaData: PageMetaData; + readonly index: number; + readonly records: RA> | undefined; + readonly onSlide: (direction: 'next' | 'previous' | 'first' | 'last') => void; + readonly onDelete?: (index: number) => void; +}) { + const disableNextButtons = + records !== undefined && + index === records.length - 1 && + (pageMetaData === 'last' || pageMetaData === 'single'); + + const disablePreviousButtons = + (pageMetaData === 'single' || pageMetaData === 'first') && index === 0; + + return { + slider: ( + + ), + isLoading: records == undefined || records[index] === undefined, + resource: records?.[index], + onRemove: () => { + handleDelete?.(index); + handleSlide('previous'); + }, + }; +} + +export function RecordSelectorFromPage< + SCHEMA extends AnySchema, + PAGE_TYPE extends Page, +>({ + page: initialPage, + pageMetaData, + pageSize, + pageKey, + dialog, + headerButtons, + isDependent = false, + isLoading: isExternalLoading = false, + table, + title, + viewName, + onNextPageFetch: handleNextPageFetch, + onClose: handleClose, + onDelete: handleDelete, + onSaved: handleSaved, + onParentDeleted: handleParentDeleted, +}: { + readonly page: PAGE_TYPE | undefined; + readonly pageKey: string | undefined; + readonly pageSize: number; + readonly pageMetaData: PageMetaData; + readonly dialog: false | 'modal' | 'nonModal'; + readonly headerButtons?: JSX.Element; + readonly isLoading?: boolean; + readonly isDependent?: boolean; + readonly table: SpecifyTable; + readonly title: LocalizedString | undefined; + readonly viewName?: string; + readonly onNextPageFetch: ( + previousPage: PAGE_TYPE, + direction: 'next' | 'previous' | 'first' | 'last' + ) => void; + readonly onClose: () => void; + readonly onDelete?: (page: PAGE_TYPE, index: number) => void; + readonly onSaved: (resource: SpecifyResource) => void; + readonly onParentDeleted?: () => void; +}): JSX.Element | null { + const [page] = useTriggerState(initialPage); + const [indexInPage, setIndexInPage] = React.useState(0); + + const [records] = useLiveState> | undefined>( + React.useCallback( + () => page?.ids.map((id) => new table.Resource({ id })), + [page, indexInPage, page?.ids.length] + ) + ); + + React.useEffect(() => { + setIndexInPage(0); + }, [table, pageKey]); + + const currentResource = records?.[indexInPage]; + + // Show a warning dialog if navigating away before saving the record + const [unloadProtect, setUnloadProtect] = React.useState< + (() => void) | undefined + >(undefined); + const setUnloadProtects = React.useContext(SetUnloadProtectsContext)!; + + function handleSlide(direction: 'next' | 'previous' | 'first' | 'last') { + if (page === undefined || records === undefined) { + return; + } + if ( + direction === 'first' && + (pageMetaData === 'single' || pageMetaData === 'first') + ) { + setIndexInPage(0); + return; + } + if ( + direction === 'last' && + (pageMetaData === 'single' || pageMetaData === 'last') + ) { + setIndexInPage(records.length - 1); + return; + } + if (direction === 'first') { + setIndexInPage(0); + return handleNextPageFetch(page, 'first'); + } + if (direction === 'last') { + setIndexInPage(pageSize - 1); + return handleNextPageFetch(page, 'last'); + } + if ( + indexInPage === 0 && + direction === 'previous' && + pageMetaData !== 'single' && + pageMetaData !== 'first' + ) { + setIndexInPage(pageSize - 1); + return handleNextPageFetch(page, 'previous'); + } + + if (indexInPage === records.length - 1 && direction === 'next') { + setIndexInPage(0); + return handleNextPageFetch(page, 'next'); + } + setIndexInPage((oldIndex) => + clamp(0, direction === 'next' ? oldIndex + 1 : oldIndex - 1, pageSize - 1) + ); + return; + } + + const { + resource, + slider, + isLoading, + onRemove: handleRemove, + } = usePagedRecordSelector({ + index: indexInPage, + pageMetaData, + records, + onDelete: + page === undefined || handleDelete === undefined + ? undefined + : (index) => handleDelete(page, index), + onSlide: (direction) => { + const doSlide = () => handleSlide(direction); + + if (currentResource?.needsSaved === true) { + setUnloadProtect(() => doSlide); + } else doSlide(); + }, + }); + + return ( + <> + ( +
+
+ {headerButtons} + + {showParentResource} + + {specifyNetworkBadge} +
+ {slider} +
+ )} + isDependent={isDependent} + isLoading={isLoading || isExternalLoading} + isSubForm={false} + resource={resource} + title={title} + viewName={viewName} + onClose={handleClose} + onDeleted={ + hasTablePermission(table.name, 'delete') ? handleRemove : undefined + } + onSaved={() => handleSaved(currentResource!)} + onParentDeleted={handleParentDeleted} + onAdd={undefined} + /> + {typeof unloadProtect === 'function' && ( + + {commonText.cancel()} + { + unsetUnloadProtect(setUnloadProtects, saveFormUnloadProtect); + setUnloadProtects([]); + unloadProtect(); + setUnloadProtect(undefined); + }} + > + {commonText.proceed()} + + + } + header={formsText.recordSelectorUnloadProtect()} + onClose={(): void => setUnloadProtect(undefined)} + > + {formsText.recordSelectorUnloadProtectDescription()} + + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/Slider.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/Slider.tsx index 6b5d9d90705..b4845691f0b 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/Slider.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/Slider.tsx @@ -2,9 +2,8 @@ import React from 'react'; import { formsText } from '../../localization/forms'; import { clamp } from '../../utils/utils'; -import { Button } from '../Atoms/Button'; import { Input } from '../Atoms/Form'; -import { icons } from '../Atoms/Icons'; +import { SlideDirection, useSliderButtons } from './useSliderButtons'; export function Slider({ value, @@ -26,25 +25,36 @@ export function Slider({ ); const max = Math.max(1, count); const resolvedValue = Number.isNaN(pendingValue) ? '' : pendingValue + 1; + + const handleSlide = React.useCallback( + (direction: SlideDirection) => { + if (direction === 'first') handleChange?.(0); + if (direction === 'previous') handleChange?.(value - 1); + if (direction === 'next') handleChange?.(value + 1); + if (direction === 'last') handleChange?.(count - 1); + }, + [value, handleChange] + ); + + const { + navigateToFirst, + navigateToPrevious, + navigateToNext, + navigateToLast, + } = useSliderButtons({ + buttonsDisabled: { + first: value === 0, + previous: value === 0, + next: value + 1 === count, + last: value + 1 == count, + }, + onChange: handleSlide, + }); + return count > 0 ? ( ) : null; } diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/useSliderButtons.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/useSliderButtons.tsx new file mode 100644 index 00000000000..6bb5824afba --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/useSliderButtons.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { formsText } from '../../localization/forms'; +import { Button } from '../Atoms/Button'; +import { icons } from '../Atoms/Icons'; + +type DisabledProps = { + readonly first?: boolean; + readonly next?: boolean; + readonly previous?: boolean; + readonly last?: boolean; +}; + +export type SlideDirection = 'first' | 'last' | 'next' | 'previous'; + +export function useSliderButtons({ + buttonsDisabled = { + first: false, + next: false, + previous: false, + last: false, + }, + onChange: handleChange, +}: { + readonly buttonsDisabled?: DisabledProps; + readonly onChange: ((direction: SlideDirection) => void) | undefined; +}): { + readonly navigateToFirst: JSX.Element; + readonly navigateToPrevious: JSX.Element; + readonly navigateToNext: JSX.Element; + readonly navigateToLast: JSX.Element; +} { + const navigateToFirst = ( + handleChange?.('first')} + > + {icons.chevronDoubleLeft} + + ); + + const navigateToPrevious = ( + handleChange?.('previous')} + > + {icons.chevronLeft} + + ); + + const navigateToNext = ( + handleChange?.('next')} + > + {icons.chevronRight} + + ); + + const navigateToLast = ( + handleChange?.('last')} + > + {icons.chevronDoubleRight} + + ); + + return { + navigateToFirst, + navigateToPrevious, + navigateToNext, + navigateToLast, + }; +} diff --git a/specifyweb/frontend/js_src/lib/components/Forms/DeleteBlocked.tsx b/specifyweb/frontend/js_src/lib/components/Forms/DeleteBlocked.tsx deleted file mode 100644 index 52bd7434bbb..00000000000 --- a/specifyweb/frontend/js_src/lib/components/Forms/DeleteBlocked.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import React from 'react'; - -import { useBooleanState } from '../../hooks/useBooleanState'; -import { commonText } from '../../localization/common'; -import type { GetSet, RA } from '../../utils/types'; -import { localized } from '../../utils/types'; -import { removeItem, replaceItem } from '../../utils/utils'; -import { Ul } from '../Atoms'; -import { Button } from '../Atoms/Button'; -import type { AnySchema } from '../DataModel/helperTypes'; -import type { SpecifyResource } from '../DataModel/legacyTypes'; -import type { Relationship } from '../DataModel/specifyField'; -import type { SpecifyTable } from '../DataModel/specifyTable'; -import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; -import { TableIcon } from '../Molecules/TableIcon'; -import { queryFieldFilterSpecs } from '../QueryBuilder/FieldFilterSpec'; -import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; -import { DateRange } from './DateRange'; - -export type DeleteBlocker = { - readonly table: SpecifyTable; - readonly blockers: RA<{ - readonly directRelationship: Relationship; - readonly parentRelationship: Relationship | undefined; - readonly ids: RA<{ - /* - * Blocker might be for "Determiner", but we would resolve it to - * "Determination" as that is a more interesting table. - */ - readonly direct: number; - readonly parent: number | undefined; - }>; - }>; -}; - -export function DeleteBlockers({ - resource: parentResource, - blockers: [blockers, setBlockers], -}: { - readonly resource: SpecifyResource; - readonly blockers: GetSet>; -}): JSX.Element { - return ( -
    - {blockers.map((blocker, blockerIndex) => ( - - setBlockers( - /* - * TODO: Make this smarter. The same resource can occur across relationships. If deleted - * from one relationship, automatically delete from the other. - */ - replaceItem(blockers, blockerIndex, { - ...blockers[blockerIndex], - blockers: replaceItem( - blockers[blockerIndex].blockers, - relationshipIndex, - { - ...blockers[blockerIndex].blockers[relationshipIndex], - ids: removeItem( - blockers[blockerIndex].blockers[relationshipIndex].ids, - resourceIndex - ), - } - ).filter(({ ids }) => ids.length > 0), - }).filter(({ blockers }) => blockers.length > 0) - ) - } - /> - ))} -
- ); -} - -function TableBlockersPreview({ - blocker: { table, blockers }, - parentResource, - onDeleted: handleDeleted, -}: { - readonly blocker: DeleteBlocker; - readonly parentResource: SpecifyResource; - readonly onDeleted: (blockerIndex: number, resourceIndex: number) => void; -}): JSX.Element { - const label = ( - <> - - {commonText.countLine({ - resource: table.label, - count: blockers.flatMap(({ ids }) => ids).length, - })} - - ); - - return ( -
  • - {blockers.length === 1 ? ( - handleDeleted(0, resourceIndex)} - /> - ) : ( -
    - -
    {label}
    -
    -
      - {blockers.map((blocker, blockerIndex) => ( - - handleDeleted(blockerIndex, resourceIndex) - } - /> - ))} -
    -
    - )} -
  • - ); -} - -function BlockerPreview({ - blocker: { directRelationship, parentRelationship, ids }, - parentResource, - includeTableName, - nested = false, - onDeleted: handleDeleted, -}: { - readonly blocker: DeleteBlocker['blockers'][number]; - readonly parentResource: SpecifyResource; - readonly includeTableName: boolean; - readonly nested?: boolean; - readonly onDeleted: (resourceIndex: number) => void; -}): JSX.Element { - const [isOpen, handleOpen, handleClose] = useBooleanState(); - const resolvedIds = React.useMemo( - () => ids.map(({ direct, parent = direct }) => parent), - [ids] - ); - - const table = parentRelationship?.relatedTable ?? directRelationship.table; - const resolvedOthersideQuery = React.useMemo(() => { - /* - * Check if parent relationship exists. If not, optimize via direct relationship query. - * If exists, and otherSideName is valid, optimize via other side. - * Otherwise, default to in query - * - */ - const rawQueryField = - parentRelationship === undefined - ? QueryFieldSpec.fromPath(table.name, [ - directRelationship.name, - directRelationship.relatedTable.idField.name, - ]) - : parentRelationship.otherSideName === undefined - ? undefined - : QueryFieldSpec.fromPath(table.name, [ - parentRelationship.otherSideName, - directRelationship.name, - directRelationship.relatedTable.idField.name, - ]); - - return ( - rawQueryField - ?.toSpQueryField() - .set('isDisplay', false) - .set('operStart', queryFieldFilterSpecs.equal.id) - .set('startValue', parentResource.id.toString()) ?? - QueryFieldSpec.fromPath(table.name, ['id']) - .toSpQueryField() - .set('isDisplay', false) - .set('startValue', resolvedIds.join(',')) - .set('operStart', queryFieldFilterSpecs.in.id) - ); - }, [resolvedIds]); - return ( - <> - - {includeTableName && ( - - )} - {commonText.countLine({ - resource: - includeTableName && !nested - ? directRelationship.table.name - : directRelationship.label, - count: ids.length, - })} - {localized(' ')} - - - {isOpen && ( - { - if ( - parentRelationship === undefined && - resource.get(directRelationship.name) !== - parentResource.get('resource_uri') - ) - handleDeleted(resolvedIds.indexOf(resource.id)); - handleClose(); - }} - onSlide={undefined} - /> - )} - - ); -} diff --git a/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx b/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx index 9d5e89f6b3f..8e9f36736a1 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx @@ -1,37 +1,29 @@ import React from 'react'; import { useBooleanState } from '../../hooks/useBooleanState'; -import { useDeleteBlockers } from '../../hooks/useDeleteBlockers'; import { commonText } from '../../localization/common'; import { formsText } from '../../localization/forms'; -import { mergingText } from '../../localization/merging'; import { treeText } from '../../localization/tree'; import { StringToJsx } from '../../localization/utils'; -import { ajax } from '../../utils/ajax'; -import { Http } from '../../utils/ajax/definitions'; -import type { RA } from '../../utils/types'; import { overwriteReadOnly } from '../../utils/types'; -import { group } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { icons } from '../Atoms/Icons'; import { LoadingContext } from '../Core/Contexts'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; -import { serializeResource } from '../DataModel/serializers'; -import type { Relationship } from '../DataModel/specifyField'; -import { strictGetTable } from '../DataModel/tables'; -import type { Tables } from '../DataModel/types'; -import { loadingBar } from '../Molecules'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { FormattedResource } from '../Molecules/FormattedResource'; import { TableIcon } from '../Molecules/TableIcon'; -import { createQuery } from '../QueryBuilder'; -import { queryFieldFilterSpecs } from '../QueryBuilder/FieldFilterSpec'; -import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; -import { runQuery } from '../QueryBuilder/ResultsWrapper'; -import type { DeleteBlocker } from './DeleteBlocked'; -import { DeleteBlockers } from './DeleteBlocked'; -import { parentTableRelationship } from './parentTables'; +import { DeleteBlockerProvider } from '../DeleteBlockers/Context'; +import { + DeleteBlockers, + MIN_DELETEBLOCKER_CONTAINER_WIDTH, +} from '../DeleteBlockers'; +import { useDeleteBlockersForResource } from '../DeleteBlockers/useDeleteBlockersForResource'; +import { mergingText } from '../../localization/merging'; +import { loadingBar } from '../Molecules'; +import { useDeleteBlockerCount } from '../DeleteBlockers/useReferenceCount'; +import { useElementSize } from '../../hooks/useElementSize'; export type DeleteButtonProps = { readonly resource: SpecifyResource; @@ -43,13 +35,23 @@ export type DeleteButtonProps = { readonly deferred?: boolean; }; +export function DeleteButtonWrapped( + deleteBlockerProps: Parameters[0] +): JSX.Element { + return ( + + {...deleteBlockerProps} /> + + ); +} + /** * A button to delele a resorce * Prompts before deletion * Checks for delete blockers (other resources depending on this one) before * deletion */ -export function DeleteButton({ +function DeleteButton({ resource, deletionMessage = formsText.deleteConfirmationDescription(), deferred = false, @@ -65,22 +67,25 @@ export function DeleteButton({ // A render prop to render custom children inside the delete dialog readonly children?: (onClick: () => void, disabled: boolean) => JSX.Element; }): JSX.Element { - const { blockers, setBlockers, fetchBlockers } = useDeleteBlockers( - resource, - deferred - ); - const [isOpen, handleOpen, handleClose] = useBooleanState(); const loading = React.useContext(LoadingContext); - const isBlocked = Array.isArray(blockers) && blockers.length > 0; + const { blockers, onBlockersRequested: handleBlockersRequested } = + useDeleteBlockersForResource(resource, deferred); + + const { elementRef: deleteBlockedRef, elementWidth: deleteBlockedWidth } = + useElementSize(); + + const blockerCount = useDeleteBlockerCount(resource); + + const isBlocked = blockerCount !== undefined && blockerCount > 0; const iconName = resource.specifyTable.name; // Callback for button click const handleClick = (): void => { handleOpen(); - fetchBlockers(); + handleBlockersRequested(); }; const isDisabled = blockers === undefined || isBlocked; @@ -94,37 +99,31 @@ export function DeleteButton({ { - handleOpen(); - fetchBlockers(); - }} + onClick={handleClick} /> ) : ( { - handleOpen(); - fetchBlockers(); - }} + onClick={handleClick} > {isBlocked ? icons.exclamation : undefined} {commonText.delete()} )} {isOpen ? ( - /** - * This would be shown if the blockers aren't being fetched and aren't - * fetched. - * This branch should never be accessed, but just in case - */ blockers === false ? ( + /** + * This would be shown if the blockers aren't being fetched and aren't + * fetched. i.e., the dialog is open but blockers are still deferred. + * This branch should never be accessed, but just in case + */ - fetchBlockers(true)}> + {mergingText.linkedRecords()} @@ -139,8 +138,8 @@ export function DeleteButton({ {formsText.checkingIfResourceCanBeDeleted()} {loadingBar} - ) : // Blockers have finished fetching, and there are no blockers - blockers.length === 0 ? ( + ) : // Blockers have finished fetching and there are no blockers + blockerCount !== undefined && blockerCount === 0 ? ( @@ -188,6 +187,7 @@ export function DeleteButton({ // There's one or more DeleteBlockers for the resource ({ > {formsText.deleteBlockedDescription()} ) @@ -205,110 +208,3 @@ export function DeleteButton({ ); } - -function resolveParentViaOtherside( - parentRelationship: Relationship, - directRelationship: Relationship, - id: number -) { - const baseTable = parentRelationship.relatedTable; - return createQuery('Delete blockers', baseTable).set('fields', [ - QueryFieldSpec.fromPath(baseTable.name, [ - baseTable.idField.name, - ]).toSpQueryField(), - QueryFieldSpec.fromPath(baseTable.name, [ - parentRelationship.otherSideName!, - directRelationship.name, - directRelationship.relatedTable.idField.name, - ]) - .toSpQueryField() - .set('isDisplay', false) - .set('operStart', queryFieldFilterSpecs.equal.id) - .set('startValue', id.toString()), - ]); -} - -export async function fetchDeleteBlockers( - resource: SpecifyResource, - expectFailure: boolean = false -): Promise> { - const { data, status } = await ajax< - RA<{ - readonly table: keyof Tables; - readonly field: string; - readonly ids: RA; - }> - >( - `/delete_blockers/delete_blockers/${resource.specifyTable.name.toLowerCase()}/${ - resource.id - }/`, - { - headers: { Accept: 'application/json' }, - expectedErrors: expectFailure ? [Http.NOT_FOUND] : [], - } - ); - if (status === Http.NOT_FOUND) return []; - - const blockersPromise = data.map(async ({ ids, field, table: tableName }) => { - const table = strictGetTable(tableName); - const directRelationship = table.strictGetRelationship(field); - const parentRelationship = - parentTableRelationship()[directRelationship.table.name]; - return [ - parentRelationship?.relatedTable ?? directRelationship.table, - { - directRelationship, - parentRelationship, - ids: - parentRelationship === undefined - ? ids.map((id) => ({ - direct: id, - parent: undefined, - })) - : await runQuery( - serializeResource( - /* - * TODO: Check if this is possible. - */ - parentRelationship.otherSideName === undefined - ? createQuery( - 'Delete blockers', - directRelationship.table - ).set('fields', [ - QueryFieldSpec.fromPath(directRelationship.table.name, [ - directRelationship.table.idField.name, - ]) - .toSpQueryField() - .set('isDisplay', false) - .set('operStart', queryFieldFilterSpecs.in.id) - .set('startValue', ids.join(',')), - /* - * TODO: ParentRelationship.table.name should always be directRelationship.model.name. - * Check if that can never be the case - */ - QueryFieldSpec.fromPath(parentRelationship.table.name, [ - parentRelationship.name, - parentRelationship.relatedTable.idField.name, - ]).toSpQueryField(), - ]) - : resolveParentViaOtherside( - parentRelationship, - directRelationship, - resource.id - ) - ), - { - limit: 0, - } - ).then((rows) => - rows.map(([direct, parent]) => ({ - direct, - parent, - })) - ), - }, - ] as const; - }); - const blockers = await Promise.all(blockersPromise); - return group(blockers).map(([table, blockers]) => ({ table, blockers })); -} diff --git a/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx b/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx index d6e482ef971..14d3e03689d 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx @@ -21,16 +21,18 @@ import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { InFormEditorContext } from '../FormEditor/Context'; import { AppTitle } from '../Molecules/AppTitle'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; -import { LinkedRecords } from '../Molecules/LinkedRecords'; +import { LinkedRecordsWrapped } from '../Molecules/LinkedRecords'; import { IsNotReadOnly } from '../Molecules/ResourceLink'; import { hasTablePermission } from '../Permissions/helpers'; import { userPreferences } from '../Preferences/userPreferences'; import { reportEvents } from '../Reports/events'; import { UnloadProtectDialog } from '../Router/UnloadProtect'; import { useResourceView } from './BaseResourceView'; -import { DeleteButton } from './DeleteButton'; +import { DeleteButtonWrapped } from './DeleteButton'; import { SaveButton } from './Save'; import { propsToFormMode } from './useViewDefinition'; +import { DeleteBlockerProvider } from '../DeleteBlockers/Context'; +import { useShowParentButton } from './useShowParentButton'; /** * There is special behavior required when creating one of these resources, @@ -94,7 +96,7 @@ export const augmentMode = ( // REFACTOR: split this into smaller components export function ResourceView({ - isLoading, + isLoading: initialLoading, resource, extraButtons, headerButtons, @@ -108,6 +110,8 @@ export function ResourceView({ children, viewName, title: titleOverride, + onParentDeleted: handleParentDeleted, + onParentSaved: handleParentSaved, /* * The presence of these attributes kind of breaks the abstraction, but they * are required to change the behaviour in certain ways: @@ -123,7 +127,8 @@ export function ResourceView({ readonly resource: SpecifyResource | undefined; readonly viewName?: string; readonly headerButtons?: ( - specifyNetworkBadge: JSX.Element | undefined + specifyNetworkBadge: JSX.Element | undefined, + showParentResourceButton: JSX.Element | undefined ) => JSX.Element; readonly extraButtons?: JSX.Element | undefined; readonly deletionMessage?: string | undefined; @@ -135,6 +140,8 @@ export function ResourceView({ | undefined; readonly onDeleted: (() => void) | undefined; readonly onClose: () => void; + readonly onParentDeleted?: () => void; + readonly onParentSaved?: (parentResource: SpecifyResource) => void; readonly children?: JSX.Element; readonly isSubForm: boolean; readonly isDependent: boolean; @@ -150,6 +157,8 @@ export function ResourceView({ // Remove isDeleted status when resource changes React.useEffect(setNotDeleted, [resource, setNotDeleted]); + const [isLoading, setIsLoading] = useTriggerState(initialLoading); + function handleDelete(): void { setDeleted(); handleDeleted(); @@ -217,6 +226,14 @@ export function ResourceView({ !isNotReadOnlyContext ); + const { showParentResourceButton, parentResourceForm } = useShowParentButton({ + resource: isSubForm ? undefined : resource, + onLoading: setIsLoading, + onDelete: handleDelete, + onParentSaved: handleParentSaved, + onParentDeleted: handleParentDeleted, + }); + const navigate = useNavigate(); if (isDeleted) return ( @@ -271,7 +288,7 @@ export function ResourceView({ !isReadOnly && hasTablePermission(resource.specifyTable.name, 'delete') ? ( - ({ showResourceReferenceButtons && hasTablePermission(resource.specifyTable.name, 'read') ? ( - + ) : undefined; @@ -315,8 +332,12 @@ export function ResourceView({ typeof saveButtonElement === 'object' || typeof extraButtons === 'object' ? ( - {deleteButton} - {referencingRecordsButton} + + <> + {deleteButton} + {referencingRecordsButton} + + {extraButtons ?? } {saveButtonElement} @@ -324,9 +345,13 @@ export function ResourceView({ ); - const headerComponents = headerButtons?.(headerContent) ?? ( + const headerComponents = headerButtons?.( + headerContent, + showParentResourceButton + ) ?? ( <> + {showParentResourceButton} {headerContent} ); @@ -355,16 +380,19 @@ export function ResourceView({
    ) : ( - - - - - {customTitle ?? jsxFormatted} - {headerComponents} - - {formattedChildren} - - + <> + + + + + {customTitle ?? jsxFormatted} + {headerComponents} + + {formattedChildren} + + + {parentResourceForm} + ); } @@ -381,8 +409,12 @@ export function ResourceView({ buttons={ isSubForm ? undefined : ( <> - {deleteButton} - {referencingRecordsButton} + + <> + {deleteButton} + {referencingRecordsButton} + + {extraButtons ?? } {isModified && !isDependent ? ( {commonText.cancel()} @@ -405,9 +437,10 @@ export function ResourceView({ header={customTitle ?? title} headerButtons={ <> - {headerButtons?.(headerContent) ?? ( + {headerButtons?.(headerContent, showParentResourceButton) ?? ( <> + {showParentResourceButton} {headerContent} @@ -431,6 +464,7 @@ export function ResourceView({ {formsText.unsavedFormUnloadProtect()} )} + {parentResourceForm} ); } diff --git a/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx b/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx deleted file mode 100644 index eda37d3d8cc..00000000000 --- a/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { overrideAjax } from '../../../tests/ajax'; -import { mockTime, requireContext } from '../../../tests/helpers'; -import { tables } from '../../DataModel/tables'; -import { fetchDeleteBlockers } from '../DeleteButton'; - -requireContext(); -mockTime(); - -const agentId = 2; -const loanAgentId = 1; -const loanId = 3; -overrideAjax(`/delete_blockers/delete_blockers/agent/${agentId}/`, [ - { - table: 'LoanAgent', - field: 'agent', - ids: [loanAgentId], - }, - { - table: 'CollectionObject', - field: 'createdByAgent', - ids: [2, 3, 4], - }, -]); - -overrideAjax( - '/stored_query/ephemeral/', - { - results: [[loanAgentId, loanId]], - }, - { - method: 'POST', - body: { - _tablename: 'SpQuery', - contextname: 'Loan', - contexttableid: 52, - countonly: false, - createdbyagent: null, - fields: [ - { - _tablename: 'SpQueryField', - allownulls: null, - alwaysfilter: null, - columnalias: null, - contexttableident: null, - createdbyagent: null, - endvalue: null, - fieldname: 'loanId', - formatname: null, - isdisplay: true, - isnot: false, - isprompt: null, - isrelfld: false, - isstrict: false, - modifiedbyagent: null, - operend: null, - operstart: 8, - position: 0, - query: null, - resource_uri: undefined, - sorttype: 0, - startvalue: '', - stringid: '52.loan.loanId', - tablelist: '52', - timestampcreated: '2022-08-31', - timestampmodified: null, - version: 1, - }, - { - _tablename: 'SpQueryField', - allownulls: null, - alwaysfilter: null, - columnalias: null, - contexttableident: null, - createdbyagent: null, - endvalue: null, - fieldname: 'agentId', - formatname: null, - isdisplay: false, - isnot: false, - isprompt: null, - isrelfld: false, - isstrict: false, - modifiedbyagent: null, - operend: null, - operstart: 1, - position: 0, - query: null, - resource_uri: undefined, - sorttype: 0, - startvalue: '2', - stringid: '52,53-loanAgents,5.agent.agentId', - tablelist: '52,53-loanAgents,5', - timestampcreated: '2022-08-31', - timestampmodified: null, - version: 1, - }, - ], - formatauditrecids: false, - isfavorite: true, - limit: 0, - modifiedbyagent: null, - name: 'Delete blockers', - ordinal: 32_767, - remarks: null, - resource_uri: undefined, - searchsynonymy: false, - selectdistinct: false, - smushed: false, - specifyuser: '/api/specify/specifyuser/2/', - sqlstr: null, - timestampcreated: '2022-08-31', - timestampmodified: null, - version: 1, - }, - } -); - -test('fetchBlockers', async () => { - const resource = new tables.Agent.Resource({ id: agentId }); - const resources = await fetchDeleteBlockers(resource); - - expect(JSON.parse(JSON.stringify(resources))).toEqual([ - { - blockers: [ - { - directRelationship: '[relationship LoanAgent.agent]', - ids: [ - { - direct: 1, - parent: 3, - }, - ], - parentRelationship: '[relationship LoanAgent.loan]', - }, - ], - table: '[table Loan]', - }, - { - blockers: [ - { - directRelationship: '[relationship CollectionObject.createdByAgent]', - ids: [ - { - direct: 2, - }, - { - direct: 3, - }, - { - direct: 4, - }, - ], - }, - ], - table: '[table CollectionObject]', - }, - ]); -}); diff --git a/specifyweb/frontend/js_src/lib/components/Forms/useShowParentButton.tsx b/specifyweb/frontend/js_src/lib/components/Forms/useShowParentButton.tsx new file mode 100644 index 00000000000..3c73283f336 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Forms/useShowParentButton.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { useParentResource } from '../../hooks/useParentResource'; +import { AnySchema } from '../DataModel/helperTypes'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { Button } from '../Atoms/Button'; +import { formsText } from '../../localization/forms'; +import { ResourceView } from './ResourceView'; +import { Dialog } from '../Molecules/Dialog'; +import { commonText } from '../../localization/common'; +import { GetOrSet } from '../../utils/types'; +import { SET } from '../../utils/utils'; + +export function useShowParentButton({ + resource, + onLoading: handleLoading, + onDelete: handleDelete, + onParentDeleted: handleParentDeleted, + onParentSaved: handleParentSaved, +}: { + readonly resource: SpecifyResource | undefined; + readonly onLoading: GetOrSet[typeof SET]; + readonly onDelete: () => void; + readonly onParentDeleted?: () => void; + readonly onParentSaved?: (parentResource: SpecifyResource) => void; +}) { + const { + parentRelationship, + parentResource, + onCheckChildExists: handleCheckChildExists, + } = useParentResource(resource); + + const [ + isParentResourceOpen, + handleOpenParentResource, + handleCloseParentResource, + ] = useBooleanState(); + + return { + showParentResourceButton: ( + <> + {parentRelationship === undefined ? null : ( + + {formsText.viewParentResource()} + + )} + + ), + parentResourceForm: ( + <> + {isParentResourceOpen ? ( + parentResource !== false ? ( + { + handleLoading(true); + handleParentDeleted?.(); + handleCheckChildExists().then((childExists) => { + if (!childExists) { + handleDelete(); + } + handleLoading(false); + handleCloseParentResource(); + }); + }} + isSubForm={false} + onSaved={() => { + handleLoading(true); + if (parentResource !== undefined) + handleParentSaved?.(parentResource); + handleCheckChildExists().then((childExists) => { + if (!childExists) { + handleDelete(); + } + handleLoading(false); + handleCloseParentResource(); + }); + }} + /> + ) : ( + {commonText.close()} + } + header={formsText.noParentFound()} + onClose={handleCloseParentResource} + > + {formsText.noParentFound()} + + ) + ) : null} + + ), + }; +} diff --git a/specifyweb/frontend/js_src/lib/components/Merging/Usages.tsx b/specifyweb/frontend/js_src/lib/components/Merging/Usages.tsx index fc3257100f7..7b7bf618e20 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/Usages.tsx +++ b/specifyweb/frontend/js_src/lib/components/Merging/Usages.tsx @@ -1,14 +1,15 @@ import React from 'react'; -import { useDeleteBlockers } from '../../hooks/useDeleteBlockers'; import { commonText } from '../../localization/common'; import { mergingText } from '../../localization/merging'; import type { RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; -import { DeleteBlockers } from '../Forms/DeleteBlocked'; import { MergeRow } from './Header'; +import { useDeleteBlockersForResource } from '../DeleteBlockers/useDeleteBlockersForResource'; +import { DeleteBlockers } from '../DeleteBlockers'; +import { DeleteBlockerProvider } from '../DeleteBlockers/Context'; export function UsagesSection({ resources, @@ -16,12 +17,14 @@ export function UsagesSection({ readonly resources: RA>; }): JSX.Element { return ( - - {commonText.notApplicable()} - {resources.map((resource, index) => ( - - ))} - + + + {commonText.notApplicable()} + {resources.map((resource, index) => ( + + ))} + + ); } @@ -31,10 +34,8 @@ function Usages({ }: { readonly resource: SpecifyResource; }): JSX.Element { - const { blockers, setBlockers, fetchBlockers } = useDeleteBlockers( - resource, - true - ); + const { blockers, onBlockersRequested: handleBlockersRequested } = + useDeleteBlockersForResource(resource); const hasBlockers = Array.isArray(blockers) && blockers.length > 0; @@ -48,14 +49,11 @@ function Usages({ {blockers === undefined ? ( commonText.loading() ) : blockers === false ? ( - fetchBlockers()}> + {mergingText.linkedRecords()} ) : ( - + )} ); diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/LinkedRecords.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/LinkedRecords.tsx index 537b7e9557a..a97b7bf75d8 100644 --- a/specifyweb/frontend/js_src/lib/components/Molecules/LinkedRecords.tsx +++ b/specifyweb/frontend/js_src/lib/components/Molecules/LinkedRecords.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { useBooleanState } from '../../hooks/useBooleanState'; import { useDelay } from '../../hooks/useDelay'; -import { useDeleteBlockers } from '../../hooks/useDeleteBlockers'; import { commonText } from '../../localization/common'; import { formsText } from '../../localization/forms'; import { mergingText } from '../../localization/merging'; @@ -11,25 +10,38 @@ import { Button } from '../Atoms/Button'; import { icons } from '../Atoms/Icons'; import { SECOND } from '../Atoms/timeUnits'; import type { AnySchema } from '../DataModel/helperTypes'; -import { DeleteBlockers } from '../Forms/DeleteBlocked'; import type { DeleteButtonProps } from '../Forms/DeleteButton'; import { loadingBar } from '.'; import { Dialog, dialogClassNames } from './Dialog'; +import { DeleteBlockerProvider } from '../DeleteBlockers/Context'; +import { useDirectDeleteBlockersForResource } from '../DeleteBlockers/useDeleteBlockersForResource'; +import { DeleteBlockers } from '../DeleteBlockers'; +import { MIN_DELETEBLOCKER_CONTAINER_WIDTH } from '../DeleteBlockers'; +import { useElementSize } from '../../hooks/useElementSize'; const LOADING_TIMEOUT = 2 * SECOND; +export function LinkedRecordsWrapped( + linkedRecordProps: Parameters[0] +) { + return ( + + {...linkedRecordProps} /> + + ); +} + // REFACTOR: consider merging this with Merging/Usages -export function LinkedRecords({ +function LinkedRecords({ resource, // Whether to defer fetching the Linked Records until the Button is clicked deferred = false, }: DeleteButtonProps): JSX.Element { const [isOpen, handleOpen, handleClose] = useBooleanState(); + const { elementRef, elementWidth } = useElementSize(); - const { blockers, setBlockers, fetchBlockers } = useDeleteBlockers( - resource, - deferred - ); + const { blockers, onBlockersRequested: handleBlockersRequested } = + useDirectDeleteBlockersForResource(resource, deferred); /* * To reduce sudden shifts in the button, only display the Loading... text on @@ -43,38 +55,30 @@ export function LinkedRecords({ 0 + ? mergingText.linkedRecords() + : formsText.noLinkedRecords() } onClick={() => { handleOpen(); - fetchBlockers(); + handleBlockersRequested(); }} > {icons.documentSearch} {blockers === false ? undefined - : blockers === undefined + : blockers?.count === undefined ? showLoadingText ? commonText.loading() : undefined : localized( - blockers - .reduce( - (sum, blocker) => - sum + - blocker.blockers.reduce( - (innerSum, { ids }) => innerSum + ids.length, - 0 - ), - 0 - ) - .toLocaleString() // This formats the count nicely. + blockers.count.toLocaleString() // This formats the count nicely. )} {isOpen ? ( @@ -90,7 +94,7 @@ export function LinkedRecords({ header={mergingText.linkedRecords()} onClose={handleClose} > - fetchBlockers(true)}> + {mergingText.linkedRecords()} @@ -105,7 +109,7 @@ export function LinkedRecords({ {formsText.checkingIfResourceIsUsed()} {loadingBar} - ) : blockers.length === 0 ? ( + ) : blockers.count === 0 ? ( /* * This dialog is shown when there are no linked records. * In most cases, the user will not see this, but if it takes some @@ -123,6 +127,7 @@ export function LinkedRecords({ ) : ( ({ > {formsText.recordUsedDescription()} ) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/definitions.ts b/specifyweb/frontend/js_src/lib/components/PickLists/definitions.ts index e9023ca2321..528ca4144ef 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/definitions.ts +++ b/specifyweb/frontend/js_src/lib/components/PickLists/definitions.ts @@ -16,7 +16,11 @@ import { agentTypes, getField } from '../DataModel/helpers'; import type { SerializedResource, TableFields } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { deserializeResource } from '../DataModel/serializers'; -import { genericTables, tables } from '../DataModel/tables'; +import { + genericTables, + sortTablesByCaption, + tables, +} from '../DataModel/tables'; import type { PickList, PickListItem, Tables } from '../DataModel/types'; import { getUiFormatters } from '../FieldFormatters'; import { hasToolPermission } from '../Permissions/helpers'; @@ -110,8 +114,8 @@ export function definePicklist( export const pickListTablesPickList = f.store(() => definePicklist( '_TablesByName', - Object.values(genericTables).map(({ name, label }) => - createPickListItem(name.toLowerCase(), label) + sortTablesByCaption(Object.values(genericTables), ({ name }) => name).map( + ({ name, label }) => createPickListItem(name.toLowerCase(), label) ) ) ); @@ -157,8 +161,8 @@ export const getFrontEndPickLists = f.store<{ // Like pickListTablesPickList, but indexed by tableId const tablesPickList = definePicklist( '_Tables', - Object.values(genericTables).map(({ tableId, label }) => - createPickListItem(tableId.toString(), label) + sortTablesByCaption(Object.values(genericTables), ({ name }) => name).map( + ({ tableId, label }) => createPickListItem(tableId.toString(), label) ) ); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index 6eac838f511..eaf960cf9d3 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -157,7 +157,7 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { onFetchMore: handleFetchMore, totalCount: [totalCount, setTotalCount], canFetchMore, - } = usePaginatedCollection({ + } = usePaginatedCollection({ initialRecords: initialData, fetchMore: fetchResults, fetchSize: props.fetchSize, diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx index b898b1df421..dbd45fa5229 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx @@ -29,10 +29,11 @@ export function SchemaConfigSidebar({ } cacheKey="schemaConfig" currentTableName={tableName} + showAllTables + showTableCaptionToggle getAction={(table): string => `/specify/schema-config/${language}/${table.name}/` } - localizeTableNames={false} /> ); } diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx index 806741d6529..d6f6d11f96b 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx @@ -7,13 +7,12 @@ import { wbPlanText } from '../../localization/wbPlan'; import type { CacheDefinitions } from '../../utils/cache/definitions'; import type { RA } from '../../utils/types'; import { localized } from '../../utils/types'; -import { sortFunction } from '../../utils/utils'; import { H3, Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Input, Label } from '../Atoms/Form'; import { Link } from '../Atoms/Link'; import type { SpecifyTable } from '../DataModel/specifyTable'; -import { genericTables } from '../DataModel/tables'; +import { genericTables, sortTablesByCaption } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; import { userInformation } from '../InitialContext/userInformation'; import { TableIcon } from '../Molecules/TableIcon'; @@ -69,6 +68,8 @@ export function TableList({ filter = defaultFilter, children, localizeTableNames = true, + showAllTables = false, + showTableCaptionToggle = false, currentTableName, badge, }: { @@ -77,6 +78,8 @@ export function TableList({ readonly filter?: (showHiddenTables: boolean, table: SpecifyTable) => boolean; readonly children?: (table: SpecifyTable) => React.ReactNode; readonly localizeTableNames?: boolean; + readonly showAllTables?: boolean; + readonly showTableCaptionToggle?: boolean; readonly currentTableName?: string; readonly badge?: (table: SpecifyTable) => React.ReactNode; }): JSX.Element { @@ -84,13 +87,20 @@ export function TableList({ cacheKey, 'showHiddenTables' ); + const [useTableCaptions, setuseTableCaptions] = + React.useState(localizeTableNames); + const shouldShowHiddenTables = showAllTables || showHiddenTables; const sortedTables = React.useMemo( () => - Object.values(genericTables) - .filter((table) => filter(showHiddenTables, table)) - .sort(sortFunction(({ name }) => name)), - [filter, showHiddenTables] + sortTablesByCaption( + Object.values(genericTables).filter((table) => + filter(shouldShowHiddenTables, table) + ), + ({ name }) => name, + useTableCaptions + ), + [filter, shouldShowHiddenTables, useTableCaptions] ); const listRef = React.useRef(null); @@ -123,13 +133,13 @@ export function TableList({ currentTableName !== undefined && table.name.toLowerCase() === currentTableName.toLowerCase(); const isVisible = - showHiddenTables || + shouldShowHiddenTables || children === undefined || extraContent !== undefined; const content = ( <> - {localizeTableNames ? table.label : localized(table.name)}{' '} + {useTableCaptions ? table.label : localized(table.name)}{' '} {extraContent !== undefined && extraContent} {badgeContent} @@ -158,13 +168,23 @@ export function TableList({ ) : undefined; })} - - - {wbPlanText.showAllTables()} - + {showTableCaptionToggle ? ( + + + {schemaText.useTableCaptions()} + + ) : showAllTables ? undefined : ( + + + {wbPlanText.showAllTables()} + + )}
    ); } @@ -178,6 +198,8 @@ export function CollapsibleTableList({ getAction, filter: extraFilter, localizeTableNames = true, + showAllTables = false, + showTableCaptionToggle = false, currentTableName, badge, asAside = false, @@ -186,6 +208,8 @@ export function CollapsibleTableList({ readonly getAction: (table: SpecifyTable) => string | (() => void); readonly filter?: (table: SpecifyTable) => boolean; readonly localizeTableNames?: boolean; + readonly showAllTables?: boolean; + readonly showTableCaptionToggle?: boolean; readonly currentTableName?: string; readonly badge?: (table: SpecifyTable) => React.ReactNode; // Use