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..c7f76bb8b2e 100644 --- a/specifyweb/backend/cache/redis/connect.py +++ b/specifyweb/backend/cache/redis/connect.py @@ -1,6 +1,18 @@ +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): + db_name = getattr(settings, "DATABASE_NAME") + key_parts = ["specify", db_name, key] + return ":".join(key_parts) + class RedisConnection: def __init__(self, host=getattr(settings, "REDIS_HOST", None), @@ -21,9 +33,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 +52,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 +114,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, 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, 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..dec10fe129b --- /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) + """ + ... + + def get_queryset_filters(self, obj: Model) -> dict[str, Any]: + """ + REFACTOR: Might be better to make this a Q object + """ + return {self.field.name: obj.pk} + + +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..c5845b4f61d --- /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.relationship.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.relationship.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.lower() 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/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/permissions/initialize.py b/specifyweb/backend/permissions/initialize.py index 70182afa58b..ef0746da69c 100644 --- a/specifyweb/backend/permissions/initialize.py +++ b/specifyweb/backend/permissions/initialize.py @@ -393,6 +393,10 @@ class DefaultRole(TypedDict): } } +class DefaultRole(TypedDict): + description: str + policies: dict[str, tuple[str, ...]] + def _create_role_and_policies(role_model, role_policy_model, role_name: str, role_filters: dict = dict()): resolved_role: DefaultRole = LIBRARY_ROLES[role_name] role = role_model.objects.filter( 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/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/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 2e61fc63d87..da8101e35d8 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'; @@ -81,7 +81,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/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..7d5a11f6247 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/DeleteBlockersAside.tsx @@ -0,0 +1,241 @@ +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, strictGetTable } from '../DataModel/tables'; +import { localized } from '../../utils/types'; +import { + DeleteBlockerResource, + DeleteBlockerTable, + DeleteBlockerRelationship, +} from './store'; +import { Relationship } from '../DataModel/specifyField'; + +export function DeleteBlockersAside({ + records, + activeRelationshipKey, + useSmallLayout = false, + onRelationshipActive: handleRelationshipActive, +}: { + 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 && ( +
+ {record.tables.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/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..8189694e896 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/index.tsx @@ -0,0 +1,85 @@ +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, + useSmallLayout: initialUseSmallLayout = false, +}: { + readonly blockers: RA | undefined; + 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..17aed8d2cda --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/state.ts @@ -0,0 +1,680 @@ +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): BlockerNode { + const resourceKey = resourceToStringIdentifier(resource); + const existingNode = this.getNode(resourceKey); + if (existingNode !== undefined) { + return existingNode; + } + const newNode: BlockerNode = { + key: resourceKey, + resource, + relationshipMetaData: new Map(), + cascadeChildren: new Set(), + version: 0, + }; + this.nodes.set(resourceKey, newNode); + return 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); + const promiseInQueue = this.pagePromiseQueue.get(queueKey); + if (promiseInQueue === false || promiseInQueue !== undefined) { + return promiseInQueue; + } + 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 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 childNode = this.getOrCreateNode(childResource); + const marked = this.addCascadeRelationship(parentNode, childNode); + if (marked) { + this.updateAncestors(parentNode.key); + } + 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; + } + + public graphIterator( + rootKey: ResourceIdentifier, + direction: 'children' | 'ancestors', + nodeFunction: (node: BlockerNode) => void + ) { + 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; + } + nodeFunction(node); + 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)) { + 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..a193dc205fe --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DeleteBlockers/store.ts @@ -0,0 +1,251 @@ +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; + } + const blockerGraph = this.getBlockerGraph(resource); + if (blockerGraph === undefined) { + return undefined; + } + if (blockerGraph.some((resource) => resource.count === undefined)) { + return undefined; + } + return blockerGraph.reduce( + (previousSum, blockerRecord) => previousSum + (blockerRecord.count ?? 0), + 0 + ); + } + + 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 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 baf13a48768..5af6e3c50e1 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx @@ -24,7 +24,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..567b1e5e2c5 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 +207,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..02d0e12dbff 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..3a7ac137833 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,35 @@ 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'; 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 { 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 +52,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 +91,7 @@ export function LinkedRecords({ header={mergingText.linkedRecords()} onClose={handleClose} > - fetchBlockers(true)}> + {mergingText.linkedRecords()} @@ -105,7 +106,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 @@ -131,10 +132,7 @@ export function LinkedRecords({ onClose={handleClose} > {formsText.recordUsedDescription()} - + ) ) : undefined} 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/Security/User.tsx b/specifyweb/frontend/js_src/lib/components/Security/User.tsx index 457463e1468..9402c570228 100644 --- a/specifyweb/frontend/js_src/lib/components/Security/User.tsx +++ b/specifyweb/frontend/js_src/lib/components/Security/User.tsx @@ -39,7 +39,7 @@ import { tables } from '../DataModel/tables'; import type { SpecifyUser } from '../DataModel/types'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { useResourceView } from '../Forms/BaseResourceView'; -import { DeleteButton } from '../Forms/DeleteButton'; +import { DeleteButtonWrapped } from '../Forms/DeleteButton'; import { useAvailableCollections } from '../Forms/OtherCollectionView'; import { augmentMode } from '../Forms/ResourceView'; import { SaveButton } from '../Forms/Save'; @@ -452,7 +452,10 @@ function UserView({ {!userResource.isNew() && hasTablePermission('SpecifyUser', 'delete') && userResource.id !== userInformation.id ? ( - + ) : undefined} {changesMade ? ( diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx index 892a9a193e8..71687130257 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx @@ -144,7 +144,7 @@ function Map({ results: [results], canFetchMore, onFetchMore: handleFetchMore, - } = usePaginatedCollection({ + } = usePaginatedCollection({ fetchMore: props.fetchResults, fetchSize: props.fetchSize, totalCount: props.totalCount, diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx index 28c3c8c7faf..fd26991a465 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Actions.tsx @@ -16,7 +16,7 @@ import type { AnySchema, AnyTree } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { genericTables } from '../DataModel/tables'; -import { DeleteButton } from '../Forms/DeleteButton'; +import { DeleteButtonWrapped } from '../Forms/DeleteButton'; import { Dialog } from '../Molecules/Dialog'; import { ResourceLink } from '../Molecules/ResourceLink'; import { hasPermission, hasTablePermission } from '../Permissions/helpers'; @@ -507,7 +507,7 @@ function NodeDeleteButton({ return disabled || resource === undefined ? ( ) : ( - [ - collectorId, - collectingEventIds[index], - ]), - }, - { - method: 'POST', - body: { - _tablename: 'SpQuery', - contextname: 'CollectingEvent', - contexttableid: 10, - countonly: false, - createdbyagent: null, - fields: [ - { - _tablename: 'SpQueryField', - allownulls: null, - alwaysfilter: null, - columnalias: null, - contexttableident: null, - createdbyagent: null, - endvalue: null, - fieldname: 'collectingEventId', - 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: '10.collectingevent.collectingEventId', - tablelist: '10', - 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: agentId.toString(), - stringid: '10,30-collectors,5.agent.agentId', - tablelist: '10,30-collectors,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, - }, - } -); - -/** - * Jest's serializers don't like the circular structure of the Table type - * (fields and relationships have a reference to the table), so we - * serialize the tables and relationships to make it easier to compare results - */ -type SerializedBlocker = { - readonly table: keyof Tables; - readonly blockers: RA<{ - readonly parentRelationship: string | undefined; - readonly directRelationship: string; - readonly ids: RA<{ - readonly direct: number; - readonly parent: number | undefined; - }>; - }>; -}; - -const serializeBlocker = (blocker: DeleteBlocker): SerializedBlocker => ({ - ...blocker, - table: blocker.table.name, - blockers: blocker.blockers.map((innerBlocker) => ({ - ...innerBlocker, - directRelationship: innerBlocker.directRelationship.name, - parentRelationship: innerBlocker.parentRelationship?.name, - ids: innerBlocker.ids, - })), -}); - -const serializeBlockers = ( - blockers: RA | false | undefined -): RA | false | undefined => - Array.isArray(blockers) ? blockers.map(serializeBlocker) : blockers; - -const deleteBlockers: RA = [ - { - table: 'CollectingEvent', - blockers: [ - { - parentRelationship: 'collectingEvent', - directRelationship: 'agent', - ids: collectorIds.map((collectorId, index) => ({ - direct: collectorId, - parent: collectingEventIds[index], - })), - }, - ], - }, - { - table: 'CollectionObject', - blockers: [ - { - directRelationship: 'createdByAgent', - parentRelationship: undefined, - ids: collectionObjectIds.map((collectionObjectId) => ({ - direct: collectionObjectId, - parent: undefined, - })), - }, - ], - }, -]; - -describe('useDeleteBlockers', () => { - test('fetches blockers', async () => { - const agent = new tables.Agent.Resource({ id: agentId }); - const { result } = renderHook(() => useDeleteBlockers(agent)); - await waitFor(() => { - expect(serializeBlockers(result.current.blockers)).toStrictEqual( - deleteBlockers - ); - }); - }); - test('Blockers can be deferred and later fetched', async () => { - const agent = new tables.Agent.Resource({ id: agentId }); - const { result } = renderHook(() => useDeleteBlockers(agent, true)); - await waitFor(() => { - expect(serializeBlockers(result.current.blockers)).toBe(false); - }); - await act(() => result.current.fetchBlockers()); - await waitFor(() => { - expect(serializeBlockers(result.current.blockers)).toStrictEqual( - deleteBlockers - ); - }); - }); -}); diff --git a/specifyweb/frontend/js_src/lib/hooks/useDeleteBlockers.tsx b/specifyweb/frontend/js_src/lib/hooks/useDeleteBlockers.tsx deleted file mode 100644 index b8818dbe046..00000000000 --- a/specifyweb/frontend/js_src/lib/hooks/useDeleteBlockers.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; - -import type { AnySchema } from '../components/DataModel/helperTypes'; -import type { SpecifyResource } from '../components/DataModel/legacyTypes'; -import { resourceOn } from '../components/DataModel/resource'; -import type { DeleteBlocker } from '../components/Forms/DeleteBlocked'; -import { fetchDeleteBlockers } from '../components/Forms/DeleteButton'; -import type { GetOrSet } from '../utils/types'; -import type { RA } from '../utils/types'; -import type { SET } from '../utils/utils'; -import { useAsyncState } from './useAsyncState'; -import { useLiveState } from './useLiveState'; - -/** - * REFACTOR (Performance): Store DeleteBlockers in a WeakMap with Resources as - * keys. - * - * See components/DataModel/saveBlockers.tsx for inspiration - */ - -/** - * False if the blockers have not been fetched, undefined if the blockers are - * being fetched - */ -export type MaybeBlocker = RA | false | undefined; - -/** - * Fetch the delete blockers for a given resource. - * If initialDeferred is true, the delete blockers will not be fetched until - * the fetchBlockers function is called. Fetching blockers which are already - * fetched will have no effect unless the forceFetch option is passed to - * fetchBlockers, i.e., `fetchBlockers(true)` is called. - * - * - * Example: - * ```js - * const {blockers, setBlockers, fetchBlockers} = useDeleteBlockers(resource); - * // blockers will be undefined if being fetched, and false if fetching was - * // deferred. - * - * blockers === false ? - * // blockers were deferred - * // ... prefetch logic - * fetchBlockers(); - * : blockers === undefined ? - * // loading... logic - * : blockers.map... // we have the delete blocker array - * ``` - * - * When the resource is saved, the delete blockers will automatically be - * refetched - */ -export function useDeleteBlockers( - resource: SpecifyResource, - initialDeferred: boolean = false -): { - readonly blockers: MaybeBlocker; - readonly setBlockers: GetOrSet[typeof SET]; - readonly fetchBlockers: (forceRefetch?: boolean) => void; -} { - const [deferred, setDeferred] = useLiveState( - React.useCallback(() => initialDeferred, [initialDeferred, resource]) - ); - const fetchingBlockers = React.useRef(!deferred); - - const rawFetchBlockers = React.useCallback(async () => { - fetchingBlockers.current = true; - return fetchDeleteBlockers(resource).then((blockers) => { - fetchingBlockers.current = false; - return blockers; - }); - }, [resource]); - - const [blockers, setBlockers] = useAsyncState( - React.useCallback( - async () => (deferred ? false : rawFetchBlockers()), - [rawFetchBlockers, deferred] - ), - false - ); - - const fetchBlockers = React.useCallback( - (forceRefetch: boolean = false): void => { - if (fetchingBlockers.current) { - return undefined; - } - if (deferred) { - setBlockers(undefined); - setDeferred(false); - } else if (forceRefetch) { - setBlockers(undefined); - rawFetchBlockers().then(setBlockers); - } - }, - [blockers, deferred, setBlockers, setDeferred] - ); - - React.useEffect( - () => - deferred && blockers === false - ? undefined - : resourceOn(resource, 'saved', () => fetchBlockers(true), false), - [resource, blockers, deferred, fetchBlockers] - ); - - return { - blockers, - setBlockers, - fetchBlockers, - }; -} diff --git a/specifyweb/frontend/js_src/lib/hooks/useElementSize.tsx b/specifyweb/frontend/js_src/lib/hooks/useElementSize.tsx new file mode 100644 index 00000000000..01963b98716 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/hooks/useElementSize.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { listen } from '../utils/events'; + +/** + * A React hook to track the size of an HTML element, most commonly a container + * of some sort. + * This returns sizes in pixels. + * + * Example usage: + * ```ts + * const {elementWidth, elementHeight, elementRef} = useElementSize(); + * return ( + *
    + *

    My parent's width is {elementWidth}

    + *

    My parent's hieght is {elementHeight}

    + *
    + * ); + * ``` + */ +export function useElementSize() { + const [element, setElement] = React.useState(null); + const elementRef = React.useCallback(setElement, [setElement]); + const [elementWidth, setElementWidth] = React.useState(0); + const [elementHeight, setElementHieght] = React.useState(0); + + React.useEffect(() => { + if (element === null) return undefined; + + const updateSize = (): void => { + setElementWidth(element.clientWidth); + setElementHieght(element.clientHeight); + }; + updateSize(); + const observer = new ResizeObserver(updateSize); + observer.observe(element); + const removeResizeListener = listen(window, 'resize', updateSize); + return (): void => { + observer.disconnect(); + removeResizeListener(); + }; + }, [element]); + + return { + elementWidth, + elementHeight, + elementRef, + }; +} diff --git a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx index 58267d0b2f5..8016ff6942f 100644 --- a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx @@ -5,6 +5,8 @@ import { removeKey, SET } from '../utils/utils'; import { DEFAULT_FETCH_LIMIT } from '../components/DataModel/collection'; import { raise, softFail } from '../components/Errors/Crash'; +// REFACTOR: clean this up. See the changes in +// 3c6eea450a0d64acde19574d66f0bdd5ed4731c1 for an exmaple export function usePaginatedCollection({ initialRecords, totalCount: initialTotalCount, @@ -18,10 +20,18 @@ export function usePaginatedCollection({ | ((offset: number) => Promise>) | undefined; }) { + /* + * Warning: + * "results" can be a sparse array. Using sparse array to allow + * efficiently retrieving the last query result in a query that returns + * hundreds of thousands of results. + */ const [results, setResults] = useTriggerState< RA | undefined >(initialRecords); + const resultsRef = React.useRef(results); + const handleSetResults: GetOrSet< RA | undefined >[typeof SET] = React.useCallback( @@ -44,6 +54,7 @@ export function usePaginatedCollection({ initialTotalCount ); const previousInitialRecords = React.useRef(initialRecords); + React.useLayoutEffect(() => { if (previousInitialRecords.current === initialRecords) { setTotalCount(initialTotalCount); @@ -55,6 +66,7 @@ export function usePaginatedCollection({ handleSetResults(initialRecords); setTotalCount(initialTotalCount); }, [initialRecords, initialTotalCount, handleSetResults, setTotalCount]); + const canFetchMore = !Array.isArray(results) || totalCount === undefined || diff --git a/specifyweb/frontend/js_src/lib/hooks/useParentResource.tsx b/specifyweb/frontend/js_src/lib/hooks/useParentResource.tsx new file mode 100644 index 00000000000..49c35a081d2 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/hooks/useParentResource.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { AnySchema } from '../components/DataModel/helperTypes'; +import { SpecifyResource } from '../components/DataModel/legacyTypes'; +import { parentTableRelationship } from '../components/Forms/parentTables'; +import { useAsyncState } from './useAsyncState'; +import { Relationship } from '../components/DataModel/specifyField'; +import { fetchCollection } from '../components/DataModel/collection'; +import { backendFilter } from '../components/DataModel/helpers'; +import { resourceEvents } from '../components/DataModel/resource'; + +/** + * React hook to get basic parent information about a particular resource. + * Returns the relationship on the provided resource which "points" the parent, + * and returns the parent resoure as: false if no parent relationship is found, + * undefined if the parent resource is being fetching, and the resource + * otherwise + */ +export function useParentResource( + resource: SpecifyResource | undefined +): { + readonly parentRelationship: undefined | Relationship; + readonly parentResource: false | undefined | SpecifyResource; + readonly onCheckChildExists: () => Promise; +} { + const directParentRelationship = React.useMemo( + () => + resource === undefined + ? undefined + : parentTableRelationship()[resource.specifyTable.name], + [resource?.specifyTable.name] + ); + + const parentRelationship = React.useMemo(() => { + if (directParentRelationship !== undefined) return directParentRelationship; + const dependentRelationships = resource?.specifyTable.relationships.filter( + (relationship) => + relationship.isRequired && + relationship.otherSideName !== undefined && + relationship.relatedTable + .getRelationship(relationship.otherSideName) + ?.isDependent() + ); + // If there's exactly one relationship that's required and the otherside is + // dependent, we should be able to safely assume that relationship is the + // parent. + // If needed, we could broaden the filtering criteria to either required or + // otherside is dependent, as they should mean the same thing but might + // have some discrepancies (e.g., #8583) + // FEAT: Handle returning all dependent relationships + if (dependentRelationships?.length === 1) { + return dependentRelationships.at(0); + } + return directParentRelationship; + }, [resource, directParentRelationship]); + + const [parentResource, setParentResource] = useAsyncState< + false | SpecifyResource + >( + React.useCallback( + () => + parentRelationship === undefined || + resource === undefined || + // REFACTOR: We can probably use resourceEvents here to check for + // deletion instead? + resource.deleted + ? false + : resource.fetch({ strict: false }).then(async (newResource) => { + const returned = await newResource.rgetPromise( + parentRelationship.name, + true, + false + ); + return returned; + }), + [resource, resource?.deleted, parentRelationship] + ), + false + ); + + /** + * If the parent was deleted, the child should be deleted as well. + * We don't currently handle this case cleanly elsewhere in the frontend + * (such as ResourceView), so for now we just mark the record as having no + * parent if the parent is deleted. + */ + React.useEffect( + () => + resourceEvents.on('deleted', (deletedResource) => { + if (typeof parentResource !== 'object') return; + if ( + deletedResource.specifyTable.name === + parentResource.specifyTable.name && + deletedResource.id === parentResource.id + ) { + setParentResource(false); + } + }), + [parentResource] + ); + + const handleCheckChildExists = React.useCallback( + async () => + resource === undefined + ? true + : fetchCollection(resource.specifyTable.name, { + ...backendFilter('id').equals(resource.id), + limit: 1, + }).then(({ totalCount }) => totalCount > 0), + + [resource] + ); + + return { + parentRelationship, + parentResource, + onCheckChildExists: handleCheckChildExists, + }; +} diff --git a/specifyweb/frontend/js_src/lib/hooks/useResponsiveSplitView.ts b/specifyweb/frontend/js_src/lib/hooks/useResponsiveSplitView.ts index a5fe60a3273..752c95a1b29 100644 --- a/specifyweb/frontend/js_src/lib/hooks/useResponsiveSplitView.ts +++ b/specifyweb/frontend/js_src/lib/hooks/useResponsiveSplitView.ts @@ -1,6 +1,6 @@ import React from 'react'; -import { listen } from '../utils/events'; +import { useElementSize } from './useElementSize'; export const minimumSplitPaneWidth = 768; export const splitViewHandleWidth = 10; @@ -15,32 +15,16 @@ export function useResponsiveSplitView(preferredHorizontal: boolean): { readonly isHorizontal: boolean; readonly maximumPrimaryPaneWidth: number; } { - const [container, setContainer] = React.useState(null); - const containerRef = React.useCallback(setContainer, [setContainer]); - const [containerWidth, setContainerWidth] = React.useState(0); + const { elementWidth, elementRef } = useElementSize(); - React.useEffect(() => { - if (container === null) return undefined; - - const updateWidth = (): void => setContainerWidth(container.clientWidth); - updateWidth(); - const observer = new ResizeObserver(updateWidth); - observer.observe(container); - const removeResizeListener = listen(window, 'resize', updateWidth); - return (): void => { - observer.disconnect(); - removeResizeListener(); - }; - }, [container]); - - const canUseHorizontalSplit = containerWidth >= minimumHorizontalSplitWidth; + const canUseHorizontalSplit = elementWidth >= minimumHorizontalSplitWidth; return { canUseHorizontalSplit, - containerRef, + containerRef: elementRef, isHorizontal: preferredHorizontal && canUseHorizontalSplit, maximumPrimaryPaneWidth: Math.max( 0, - containerWidth - minimumSplitPaneWidth - splitViewHandleWidth + elementWidth - minimumSplitPaneWidth - splitViewHandleWidth ), }; } diff --git a/specifyweb/frontend/js_src/lib/hooks/useScreenSize.tsx b/specifyweb/frontend/js_src/lib/hooks/useScreenSize.tsx new file mode 100644 index 00000000000..4cc8d043bba --- /dev/null +++ b/specifyweb/frontend/js_src/lib/hooks/useScreenSize.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import resolveConfig from 'tailwindcss/resolveConfig'; +import tailwindConfig from '../../tailwind.config'; + +const resolvedConfig = resolveConfig(tailwindConfig); + +const breakPoints = resolvedConfig.theme.screens; + +/** + * React Hook that can be used for conditional rendering based on screen size + * Example usage: + * ```ts + * const isSmallScreen = useScreenSize('sm'); + * return isSmallScreen ? : + * ``` + * The screen size breakpoints will be equivalent to the tailwind breakpoints + * for scteen size. + * For more information, see the tailwind docs on responsive design and + * breakkpoints: + * https://tailwindcss.com/docs/responsive-design + */ +export function useScreenSize(screenSize: keyof typeof breakPoints) { + const breakPointSize = breakPoints[screenSize]; + + const mediaQuery = `(min-width: ${breakPointSize})`; + + const windowMatchesBreakpoint = React.useCallback(() => { + if (window === undefined) return false; + return window.matchMedia(mediaQuery).matches; + }, [mediaQuery]); + + const [isMatch, setIsMatch] = React.useState(windowMatchesBreakpoint); + + React.useEffect(() => { + function handleWindowResize() { + setIsMatch(() => windowMatchesBreakpoint()); + } + window.addEventListener('resize', handleWindowResize); + return () => window.removeEventListener('resize', handleWindowResize); + }, []); + + return isMatch; +} diff --git a/specifyweb/frontend/js_src/lib/localization/forms.ts b/specifyweb/frontend/js_src/lib/localization/forms.ts index 58bf6206d3f..cb8757e448b 100644 --- a/specifyweb/frontend/js_src/lib/localization/forms.ts +++ b/specifyweb/frontend/js_src/lib/localization/forms.ts @@ -1596,4 +1596,10 @@ export const formsText = createDictionary({ 'hr-hr': 'Dodajte djecu COG-a', nb: 'Legg til COG-underelementer', }, + viewParentResource: { + 'en-us': 'View Parent Resource', + }, + noParentFound: { + 'en-us': 'Could not find parent resource', + }, } as const); diff --git a/specifyweb/frontend/js_src/lib/utils/lruCache.ts b/specifyweb/frontend/js_src/lib/utils/lruCache.ts new file mode 100644 index 00000000000..0bbc2526ae6 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/utils/lruCache.ts @@ -0,0 +1,176 @@ +import { softError } from '../components/Errors/assert'; + +const MISSING = {}; + +// FEATURE: This can be fairly easily extended to support a TTL for each entry +// Or we can import some external library +// eslint-disable-next-line functional/no-class +export class LRUCache { + private readonly cache: Map; + // eslint-disable-next-line functional/prefer-readonly-type + private maxSize: number; + // eslint-disable-next-line functional/prefer-readonly-type + private size: number; + private readonly getItemSize: ((item: V) => number) | undefined; + private readonly onEvict?: (key: K, item: V) => void; + + public constructor({ + maxSize, + getItemSize = undefined, + onEvict = undefined, + }: { + readonly maxSize: number; + readonly getItemSize?: (item: V) => number; + readonly onEvict?: (key: K, item: V) => void; + }) { + this.cache = new Map(); + this.maxSize = maxSize; + this.size = 0; + this.getItemSize = getItemSize; + this.onEvict = onEvict; + } + + /** + * Retrieve an item from the cache, marking it as recenetly used if present. + * Returns undefined if the item is not present in the cache. + */ + public get(key: K): V | undefined { + const item = this._get(key, true); + if (item === MISSING) { + return undefined; + } + return item as V; + } + + /** + * Sets an item into the cache. Will remove old entries to make space for + * this if over maxSize. + * If the key already exists in the cache, this overwrites the entry and + * marks it recently used + */ + public set(key: K, value: V): void { + this.delete(key); + const itemSize = this.freeSpaceForItem(value); + this.size += itemSize; + this.cache.set(key, value); + } + + /** + * Deletes a key from the cache. Returns true if an entry at the key was + * deleted, and false if the key was not in the cache. + */ + public delete(key: K): boolean { + // We're going to be removing this item anyways, so no need to change its + // recency + const item = this._get(key, false); + if (item === MISSING) { + // This should always return false here, but just in case + return this.cache.delete(key); + } + const itemSize = this.getCanonicalItemSize(item as V); + this.size = Math.min(0, this.size - itemSize); + // BUG: should we call onEvict here? + return this.cache.delete(key); + } + + /** + * Removes all entries from the cache + */ + public clear() { + this.cache.clear(); + this.size = 0; + } + + /** + * Similar to get, but does not mark the entry at the key as the most + * recently accessed + */ + public peek(key: K): V | undefined { + const item = this._get(key, false); + if (item === MISSING) { + return undefined; + } + return item as V; + } + + public isFull(): boolean { + return this.getSize() >= this.maxSize; + } + + /** + * Gets the size of the cache. + * This is not necessarily the number of entries in the cache if a custom + * getItemSize function was provided at cache initialization + */ + public getSize(): number { + return this.size; + } + + /** + * Sets the new max size for the LRU cache. + * Will be enforced in the next set call + */ + public setMaxSize(newSize: number) { + this.maxSize = newSize; + } + + public entries() { + return this.cache.entries(); + } + + public keys() { + return this.cache.keys(); + } + + public values() { + return this.cache.values(); + } + + private _get(key: K, updateRecency: boolean = true) { + if (!this.cache.has(key)) { + return MISSING; + } + const item = this.cache.get(key) as V; + if (updateRecency) { + this.cache.delete(key); + this.cache.set(key, item); + } + return item; + } + + private getCanonicalItemSize(item: V): number { + const itemSize = + this.getItemSize === undefined ? 1 : this.getItemSize(item); + return itemSize; + } + + private freeSpaceForItem(item: V): number { + const itemSize = this.getCanonicalItemSize(item); + + if (itemSize >= this.maxSize) { + softError('Item exceeded maximum size for LRU Cache: ', item); + this.clear(); + return itemSize; + } + + if (itemSize + this.getSize() <= this.maxSize) { + // The requested size can already fit into the cache, so do nothing + return itemSize; + } + + // The new item can not fit into the cache, so we keep removing least + // accessed objects until we have enough space for the new item + const cacheIterator = this.cache.entries(); + while (this.getSize() + itemSize > this.maxSize) { + const nextEntry = cacheIterator.next().value; + // Stop if we've hit the end of all keys in the map + if (nextEntry === undefined) break; + // Otherwise remove the oldest key + const [nextKey, nextItem] = nextEntry; + this.delete(nextKey); + this.onEvict?.(nextKey, nextItem); + } + + return itemSize; + } +} diff --git a/specifyweb/frontend/js_src/lib/utils/promiseQueue.ts b/specifyweb/frontend/js_src/lib/utils/promiseQueue.ts new file mode 100644 index 00000000000..9144eba4a69 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/utils/promiseQueue.ts @@ -0,0 +1,82 @@ +type PromiseGenerator = () => Promise; + +// eslint-disable-next-line functional/no-class +export class PromiseQueue { + private readonly maxConcurrentPromises: number; + private readonly queue: Map>; + private readonly activePromises: Map>; + + public constructor(maxConcurrentPromises: number = 5) { + this.queue = new Map(); + this.activePromises = new Map(); + this.maxConcurrentPromises = maxConcurrentPromises; + } + + /** + * Add the promise to the Queue. If the queue has capacity to run the + * promise, it starts executing immediately. + * Otherwise the promise is held in a queue and will be executed when + * available. + * + * Returns the promise if made active and false if the promise was held in + * the queue. + */ + public enqueue(key: K, promiseGen: PromiseGenerator): Promise | false { + if (this.activePromises.size >= this.maxConcurrentPromises) { + // Add the promise to the queue + this.queue.set(key, promiseGen); + return false; + } else { + // The promise can be handled directly + return this.activatePromise(key, promiseGen); + } + } + + /** + * Returns the promise if active, false if the promise is in the queue, and + * undefined if the promise is not active or in the queue + */ + public get(key: K): Promise | false | undefined { + const activePromise = this.activePromises.get(key); + if (activePromise !== undefined) { + return activePromise; + } + const queuedPromise = this.queue.get(key); + if (queuedPromise !== undefined) { + return false; + } + return undefined; + } + + /** + * Removes all promises from the queue + */ + public clear() { + this.queue.clear(); + } + + private activatePromise(key: K, promiseGen: PromiseGenerator): Promise { + const activePromise = promiseGen(); + const wrapped = this.wrapPromise(key, activePromise); + this.activePromises.set(key, wrapped); + return wrapped; + } + + private wrapPromise(key: K, promise: Promise): Promise { + return promise.finally(() => { + this.activePromises.delete(key); + this.queueNext(); + }); + } + + private queueNext() { + const firstItem = this.queue.entries().next().value; + // If there's no items in the iterator, we don't need to do anything + if (firstItem === undefined) { + return; + } + const [key, promiseGen] = firstItem; + this.queue.delete(key); + this.activatePromise(key, promiseGen); + } +} diff --git a/specifyweb/frontend/js_src/lib/utils/utils.ts b/specifyweb/frontend/js_src/lib/utils/utils.ts index 39f1f86830a..437d3a6d924 100644 --- a/specifyweb/frontend/js_src/lib/utils/utils.ts +++ b/specifyweb/frontend/js_src/lib/utils/utils.ts @@ -217,7 +217,7 @@ export const split = ( * Convert an array of [key,value] tuples to a RA<[key, RA]> * * @remarks - * KEY doesn't have to be a string. It can be of any time + * KEY doesn't have to be a string. It can be of any type */ export const group = ( entries: RA diff --git a/specifyweb/specify/api/crud.py b/specifyweb/specify/api/crud.py index aba2a0eaed5..07e76a6f2b7 100644 --- a/specifyweb/specify/api/crud.py +++ b/specifyweb/specify/api/crud.py @@ -1,6 +1,3 @@ - - - import logging from typing import Any, Dict from collections.abc import Callable @@ -462,6 +459,8 @@ def strict_get_model(name: str, apps = apps): for model in app.get_models(): if model._meta.model_name == name: return model + # REFACTOR: consider raising a Table/Model DoesNotExist Error here + # instead? raise e def get_model(name: str, apps=apps): diff --git a/specifyweb/specify/auth/support_login.py b/specifyweb/specify/auth/support_login.py index f6175d699e7..aa05bc93392 100644 --- a/specifyweb/specify/auth/support_login.py +++ b/specifyweb/specify/auth/support_login.py @@ -17,7 +17,7 @@ from specifyweb.specify.models import Specifyuser from specifyweb.backend.workbench.upload.auditlog import str_to_bytes -from specifyweb.backend.cache.redis import set_bytes, get_bytes +from specifyweb.backend.cache.redis.connect import RedisConnection, RedisString logger = logging.getLogger(__name__) @@ -91,7 +91,8 @@ def make_token(user, key: bytes): "exp": issue_time + TTL } token: str = jwt.encode(payload, signing_key, algorithm="HS256") - set_bytes(key, salt, time_to_live=TTL) + redis_bytes = RedisString(RedisConnection(decode_responses=False)) + redis_bytes.set(key, salt, time_to_live=TTL) return token @@ -146,9 +147,10 @@ def authenticate(self, request, token=None, key=None): if token is None or key is None: return None + redis_bytes = RedisString(RedisConnection(decode_responses=False)) # A key should always be "consumed" and deleted from Redis once used: # all suport login tokens are a one-time use - salt = get_bytes(key, delete_key=True) + salt = redis_bytes.get(key, delete_key=True) if salt is None: return None diff --git a/specifyweb/specify/migrations/0043_normalize_many_to_many.py b/specifyweb/specify/migrations/0043_normalize_many_to_many.py index 2939a9032df..0390b3368b4 100644 --- a/specifyweb/specify/migrations/0043_normalize_many_to_many.py +++ b/specifyweb/specify/migrations/0043_normalize_many_to_many.py @@ -5,7 +5,7 @@ from django.db import migrations, models import specifyweb.specify.models -from specifyweb.backend.cache.redis import delete_key, redis_type, add_to_set, set_members +from specifyweb.backend.cache.redis.connect import RedisConnection, RedisSet """ WARNING: Data loss may occur if the Redis container is stopped once this @@ -213,7 +213,7 @@ } def redis_table_key(table: str): - return "specify:{database}:migration:0043:" + table + return "migration:0043:" + table results = dict() @@ -264,18 +264,20 @@ def get_existing_records(connection, table: str, table_schema) -> tuple[str, ... def store_existing_records(connection): existing_tables = tables_exist( connection, *LEGACY_MANY_TO_MANY_JOIN_TABLES.keys()) + redis_set = RedisSet(RedisConnection()) for existing_table in existing_tables: schema = LEGACY_MANY_TO_MANY_JOIN_TABLES[existing_table] - if redis_type(redis_table_key(existing_table)) != "set": - delete_key(redis_table_key(existing_table)) + if redis_set.key_type(redis_table_key(existing_table)) != 'set': + redis_set.delete(redis_table_key(existing_table)) existing_records = get_existing_records( connection, existing_table, schema) - add_to_set(redis_table_key(existing_table), *existing_records) + redis_set.add(redis_table_key(existing_table), *existing_records) def migrate_old_records(apps): + redis_set = RedisSet(RedisConnection()) for table in LEGACY_MANY_TO_MANY_JOIN_TABLES.keys(): - raw_existing_records = set_members(redis_table_key(table)) + raw_existing_records = redis_set.members(redis_table_key(table)) many_to_many_migration_schema = LEGACY_MANY_TO_MANY_JOIN_TABLES[table] app_label, model_label = many_to_many_migration_schema["to"] Model = apps.get_model(app_label, model_label) @@ -286,8 +288,9 @@ def split_iterable(iterable, chunk_size=999): yield iterable[i:i+chunk_size] def migrate_to_legacy(connection): + redis_set = RedisSet(RedisConnection()) for table, table_schema in LEGACY_MANY_TO_MANY_JOIN_TABLES.items(): - raw_existing_records = tuple(set_members(redis_table_key(table))) + raw_existing_records = tuple(redis_set.members(redis_table_key(table))) if len(raw_existing_records) <= 0: continue columns = { @@ -315,14 +318,16 @@ def migrate_to_legacy(connection): def wrapped_migrate_to_legacy(apps, schema_editor): connection = schema_editor.connection + redis_connection = RedisConnection() migrate_to_legacy(connection) for table_name in LEGACY_MANY_TO_MANY_JOIN_TABLES.keys(): - delete_key(redis_table_key(table_name)) + redis_connection.delete(redis_table_key(table_name)) def wrapped_migrate_old_records(apps, schema_editor): migrate_old_records(apps) + redis_connection = RedisConnection() for table_name in LEGACY_MANY_TO_MANY_JOIN_TABLES.keys(): - delete_key(redis_table_key(table_name)) + redis_connection.delete(redis_table_key(table_name)) def wrapped_store_records(apps, schema_editor): diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index 0c791d2f070..becc60fd9fd 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -1,11 +1,10 @@ from functools import partialmethod from django.db import models -from django.db.models import Q, CheckConstraint from django.utils import timezone from specifyweb.backend.businessrules.exceptions import AbortSave from specifyweb.specify.models_utils.model_timestamp import save_auto_timestamp_field_with_override -from specifyweb.specify.models_utils import model_extras -from .datamodel import datamodel, Table +from specifyweb.specify.models_utils import model_extras, tree_model_extras +from .datamodel import datamodel import logging logger = logging.getLogger(__name__) @@ -60,12 +59,6 @@ def custom_save(self, *args, **kwargs): logger.error("Save operation aborted: %s", e) return -# TODO: Use this everywhere -class ModelWithTable(models.Model): - specify_model: Table - objects: models.Manager["ModelWithTable"] - class Meta: - abstract = True # These Django model classes were generated by the specifyweb.specify.sp7_build_models.py script. # The original models in this file are based on the Specify 6.8.03 datamodel schema. @@ -3707,7 +3700,7 @@ class Meta: save = partialmethod(custom_save) -class Geography(model_extras.Geography): +class Geography(tree_model_extras.Geography): specify_model = datamodel.get_table_strict('geography') # ID Field @@ -3790,7 +3783,7 @@ class Meta: save = partialmethod(custom_save) -class Geographytreedefitem(model_extras.Geographytreedefitem): +class Geographytreedefitem(tree_model_extras.Geographytreedefitem): specify_model = datamodel.get_table_strict('geographytreedefitem') # ID Field @@ -3826,7 +3819,7 @@ class Meta: save = partialmethod(custom_save) -class Geologictimeperiod(model_extras.Geologictimeperiod): +class Geologictimeperiod(tree_model_extras.Geologictimeperiod): specify_model = datamodel.get_table_strict('geologictimeperiod') # aka. Chronostratigraphy # ID Field @@ -3904,7 +3897,7 @@ class Meta: save = partialmethod(custom_save) -class Geologictimeperiodtreedefitem(model_extras.Geologictimeperiodtreedefitem): +class Geologictimeperiodtreedefitem(tree_model_extras.Geologictimeperiodtreedefitem): specify_model = datamodel.get_table_strict('geologictimeperiodtreedefitem') # ID Field @@ -4339,7 +4332,7 @@ class Meta: save = partialmethod(custom_save) -class Lithostrat(model_extras.Lithostrat): +class Lithostrat(tree_model_extras.Lithostrat): specify_model = datamodel.get_table_strict('lithostrat') # ID Field @@ -4415,7 +4408,7 @@ class Meta: save = partialmethod(custom_save) -class Lithostrattreedefitem(model_extras.Lithostrattreedefitem): +class Lithostrattreedefitem(tree_model_extras.Lithostrattreedefitem): specify_model = datamodel.get_table_strict('lithostrattreedefitem') # ID Field @@ -6810,7 +6803,7 @@ class Meta: # save = partialmethod(custom_save) -class Storage(model_extras.Storage): +class Storage(tree_model_extras.Storage): specify_model = datamodel.get_table_strict('storage') # ID Field @@ -6911,7 +6904,7 @@ class Meta: save = partialmethod(custom_save) -class Storagetreedefitem(model_extras.Storagetreedefitem): +class Storagetreedefitem(tree_model_extras.Storagetreedefitem): specify_model = datamodel.get_table_strict('storagetreedefitem') # ID Field @@ -6947,7 +6940,7 @@ class Meta: save = partialmethod(custom_save) -class Taxon(model_extras.Taxon): +class Taxon(tree_model_extras.Taxon): specify_model = datamodel.get_table_strict('taxon') # ID Field @@ -7349,7 +7342,7 @@ class Meta: save = partialmethod(custom_save) -class Taxontreedefitem(model_extras.Taxontreedefitem): +class Taxontreedefitem(tree_model_extras.Taxontreedefitem): specify_model = datamodel.get_table_strict('taxontreedefitem') # ID Field @@ -8147,7 +8140,7 @@ class Meta: save = partialmethod(custom_save) -class Tectonicunittreedefitem(model_extras.Tectonicunittreedefitem): +class Tectonicunittreedefitem(tree_model_extras.Tectonicunittreedefitem): specify_model = datamodel.get_table_strict('tectonicUnittreedefitem') # ID Field @@ -8182,7 +8175,7 @@ class Meta: save = partialmethod(custom_save) -class Tectonicunit(model_extras.Tectonicunit): +class Tectonicunit(tree_model_extras.Tectonicunit): specify_model = datamodel.get_table_strict('tectonicunit') # ID Field diff --git a/specifyweb/specify/models_utils/load_datamodel.py b/specifyweb/specify/models_utils/load_datamodel.py index 8e62e0ba5b7..d9dd84b42c4 100644 --- a/specifyweb/specify/models_utils/load_datamodel.py +++ b/specifyweb/specify/models_utils/load_datamodel.py @@ -47,7 +47,7 @@ def get_table(self, tablename: str, strict: bool = False) -> Optional["Table"]: def get_table_strict(self, tablename: str) -> "Table": tablename = tablename.lower() for table in self.tables: - if table.name.lower() == tablename: + if table.name.lower() == tablename or table.table.lower() == tablename: return table raise TableDoesNotExistError( _("No table with name: %(table_name)r") % {"table_name": tablename} @@ -69,7 +69,7 @@ def reverse_relationship( ) -> Optional["Relationship"]: if relationship.relatedModelName is None or relationship.otherSideName is None: return None - return self.get_table_strict(relationship.relatedModelName).get_relationship( + return self.get_table_strict(relationship.relatedModelName).get_relationship_strict( cast(str, relationship.otherSideName) ) @@ -192,7 +192,10 @@ def get_field_strict(self, fieldname: str) -> Union["Field", "Relationship"]: raise FieldDoesNotExistError(_("Field %(field_name)s not in table %(table_name)s. ") % {'field_name':fieldname, 'table_name':self.name} + _("Fields: %(fields)s") % {'fields':[f.name for f in self.all_fields]}) - def get_relationship(self, name: str) -> "Relationship": + def get_relationship(self, name: str, strict = False) -> "Relationship | None": + return strict_to_optional(self.get_relationship_strict, name, strict) + + def get_relationship_strict(self, name: str) -> "Relationship": field = self.get_field_strict(name) if not isinstance(field, Relationship): raise FieldDoesNotExistError( @@ -217,10 +220,10 @@ def get_index(self, indexname: str, strict: bool = False) -> Optional["Index"]: @property def attachments_field(self) -> Optional["Relationship"]: try: - return self.get_relationship("attachments") + return self.get_relationship_strict("attachments") except FieldDoesNotExistError: try: - return self.get_relationship(self.name + "attachments") + return self.get_relationship_strict(self.name + "attachments") except FieldDoesNotExistError: return None @@ -414,7 +417,7 @@ def add_collectingevents_to_locality(datamodel: Datamodel) -> None: relatedModelName="collectingEvent", otherSideName="locality", ) - datamodel.get_table_strict("collectingevent").get_relationship( + datamodel.get_table_strict("collectingevent").get_relationship_strict( "locality" ).otherSideName = "collectingEvents" relationships = datamodel.get_table_strict("locality").relationships @@ -426,7 +429,7 @@ def flag_dependent_fields(datamodel: Datamodel) -> None: for name in sp6_dependent_fields: tablename, fieldname = name.split('.') try: - field = datamodel.get_table_strict(tablename).get_relationship(fieldname) + field = datamodel.get_table_strict(tablename).get_relationship_strict(fieldname) except DoesNotExistError as e: logger.warn( "missing table or relationship setting dependent field: %s", name @@ -437,7 +440,7 @@ def flag_dependent_fields(datamodel: Datamodel) -> None: for table in datamodel.tables: if table.is_attachment_jointable: - table.get_relationship("attachment").dependent = True + table.get_relationship_strict("attachment").dependent = True if table.attachments_field: table.attachments_field.dependent = True diff --git a/specifyweb/specify/models_utils/model_extras.py b/specifyweb/specify/models_utils/model_extras.py index 70c14bb72ea..677fc3dd115 100644 --- a/specifyweb/specify/models_utils/model_extras.py +++ b/specifyweb/specify/models_utils/model_extras.py @@ -3,16 +3,22 @@ from django.db import models from django.contrib.auth.base_user import BaseUserManager from django.conf import settings -from django.utils import timezone +from specifyweb.specify.datamodel import Table from .model_timestamp import save_auto_timestamp_field_with_override -from specifyweb.backend.trees.extras import Tree, TreeRank if settings.AUTH_LDAP_SERVER_URI is not None: from .auth import ldap_extras logger = logging.getLogger(__name__) +# TODO: Use this everywhere +class ModelWithTable(models.Model): + specify_model: Table + objects: models.Manager["ModelWithTable"] + class Meta: + abstract = True + class SpecifyUserManager(BaseUserManager): def create_user(self, name, password=None): raise NotImplementedError() @@ -228,51 +234,3 @@ def is_paleo_geo(self): class Meta: abstract = True - -class Taxon(Tree): - class Meta: - abstract = True - -class Storage(Tree): - class Meta: - abstract = True - -class Geography(Tree): - class Meta: - abstract = True - -class Geologictimeperiod(Tree): - class Meta: - abstract = True - -class Lithostrat(Tree): - class Meta: - abstract = True - -class Tectonicunit(Tree): - class Meta: - abstract = True - -class Geographytreedefitem(TreeRank): - class Meta: - abstract = True - -class Geologictimeperiodtreedefitem(TreeRank): - class Meta: - abstract = True - -class Lithostrattreedefitem(TreeRank): - class Meta: - abstract = True - -class Storagetreedefitem(TreeRank): - class Meta: - abstract = True - -class Taxontreedefitem(TreeRank): - class Meta: - abstract = True - -class Tectonicunittreedefitem(TreeRank): - class Meta: - abstract = True diff --git a/specifyweb/specify/models_utils/relationships.py b/specifyweb/specify/models_utils/relationships.py index a07ffff173f..836a360f615 100644 --- a/specifyweb/specify/models_utils/relationships.py +++ b/specifyweb/specify/models_utils/relationships.py @@ -171,7 +171,7 @@ def _handle_fk_field(collection, agent, obj, field, value, read_checker): elif hasattr(value, 'items'): # i.e. it's a dict of some sort # The related object is represented by a nested dict of data. rel_model = field.related_model - datamodel_field = obj.specify_model.get_relationship(field_name) + datamodel_field = obj.specify_model.get_relationship_strict(field_name) rel_obj = update_or_create_resource(collection, agent, rel_model, value, obj if dependent else None, datamodel_field) setattr(obj, field_name, rel_obj) diff --git a/specifyweb/specify/models_utils/tree_model_extras.py b/specifyweb/specify/models_utils/tree_model_extras.py new file mode 100644 index 00000000000..22a24a70cc6 --- /dev/null +++ b/specifyweb/specify/models_utils/tree_model_extras.py @@ -0,0 +1,51 @@ +from specifyweb.backend.trees.extras import Tree, TreeRank + +# REFACTOR: remove these in favor of just using Tree and TreeRank on the actual +# model definitions +class Taxon(Tree): + class Meta: + abstract = True + +class Storage(Tree): + class Meta: + abstract = True + +class Geography(Tree): + class Meta: + abstract = True + +class Geologictimeperiod(Tree): + class Meta: + abstract = True + +class Lithostrat(Tree): + class Meta: + abstract = True + +class Tectonicunit(Tree): + class Meta: + abstract = True + +class Geographytreedefitem(TreeRank): + class Meta: + abstract = True + +class Geologictimeperiodtreedefitem(TreeRank): + class Meta: + abstract = True + +class Lithostrattreedefitem(TreeRank): + class Meta: + abstract = True + +class Storagetreedefitem(TreeRank): + class Meta: + abstract = True + +class Taxontreedefitem(TreeRank): + class Meta: + abstract = True + +class Tectonicunittreedefitem(TreeRank): + class Meta: + abstract = True \ No newline at end of file diff --git a/specifyweb/specify/utils/autonumbering.py b/specifyweb/specify/utils/autonumbering.py index 7076e5a9196..43ccb98195d 100644 --- a/specifyweb/specify/utils/autonumbering.py +++ b/specifyweb/specify/utils/autonumbering.py @@ -232,7 +232,7 @@ def get_tables_from_field_path(model: str, field_path: str) -> list[str]: relationships = field_path.split('__') for relationship in relationships: - other_model = table.get_relationship( + other_model = table.get_relationship_strict( relationship).relatedModelName.lower() tables.append(other_model) table = datamodel.get_table_strict(other_model)