diff --git a/specifyweb/businessrules/__init__.py b/specifyweb/businessrules/__init__.py index e69de29bb2d..de606e9cc1a 100644 --- a/specifyweb/businessrules/__init__.py +++ b/specifyweb/businessrules/__init__.py @@ -0,0 +1 @@ +default_app_config = 'specifyweb.businessrules.apps.BussinessRuleConfig' diff --git a/specifyweb/businessrules/apps.py b/specifyweb/businessrules/apps.py new file mode 100644 index 00000000000..66c3ccf04ec --- /dev/null +++ b/specifyweb/businessrules/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class BussinessRuleConfig(AppConfig): + name = "specifyweb.businessrules" + + def ready(self) -> None: + import specifyweb.businessrules.rules diff --git a/specifyweb/businessrules/migrations/0001_initial.py b/specifyweb/businessrules/migrations/0001_initial.py new file mode 100644 index 00000000000..f170eacdcdb --- /dev/null +++ b/specifyweb/businessrules/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2.15 on 2023-12-28 22:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('specify', '__first__'), + ] + + operations = [ + migrations.CreateModel( + name='UniquenessRule', + fields=[ + ('id', models.AutoField(db_column='uniquenessruleid', + primary_key=True, serialize=False, verbose_name='uniquenessruleid')), + ('isDatabaseConstraint', models.BooleanField( + db_column='isDatabaseConstraint', default=False)), + ('modelName', models.CharField(max_length=256)), + ('discipline', models.ForeignKey(db_column='DisciplineID', blank=True, null=True, + on_delete=django.db.models.deletion.PROTECT, to='specify.discipline')), + ], + options={ + 'db_table': 'uniquenessrule', + }, + ), + migrations.CreateModel( + name='UniquenessRule_Field', + fields=[ + ('uniquenessrule_fieldid', models.AutoField(primary_key=True, + serialize=False, verbose_name='uniquenessrule_fieldsid')), + ('fieldPath', models.TextField(blank=True, null=True)), + ('isScope', models.BooleanField(default=False)), + ('uniquenessrule', models.ForeignKey(db_column='uniquenessruleid', + on_delete=django.db.models.deletion.CASCADE, to='businessrules.uniquenessrule')), + ], + options={ + 'db_table': 'uniquenessrule_fields', + }, + ), + ] diff --git a/specifyweb/businessrules/migrations/0002_default_unique_rules.py b/specifyweb/businessrules/migrations/0002_default_unique_rules.py new file mode 100644 index 00000000000..161d1b2f39a --- /dev/null +++ b/specifyweb/businessrules/migrations/0002_default_unique_rules.py @@ -0,0 +1,22 @@ +from django.db import migrations + +from specifyweb.specify import models as spmodels +from specifyweb.businessrules.uniqueness_rules import apply_default_uniqueness_rules + + +def apply_rules_to_discipline(apps, schema_editor): + for disp in spmodels.Discipline.objects.all(): + apply_default_uniqueness_rules(disp) + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ('specify', '__first__'), + ('businessrules', '0001_initial'), + ] + + operations = [ + migrations.RunPython(apply_rules_to_discipline), + ] diff --git a/specifyweb/businessrules/migrations/__init__.py b/specifyweb/businessrules/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/specifyweb/businessrules/models.py b/specifyweb/businessrules/models.py index de08fff77da..8dbadf97186 100644 --- a/specifyweb/businessrules/models.py +++ b/specifyweb/businessrules/models.py @@ -1,24 +1,67 @@ -from . import ( - uniqueness_rules, - recordset_rules, - collector_rules, - author_rules, - collectionobject_rules, - determination_rules, - locality_rules, - tree_rules, - address_rules, - discipline_rules, - agent_rules, - agentspecialty_rules, - groupperson_rules, - attachment_rules, - guid_rules, - interaction_rules, - workbench_rules, - user_rules, - accessionagent_rules, - fundingagent_rules, - determiner_rules, - extractor_rules, -) +from django.db import models + +from specifyweb.specify import models as spmodels + +Discipline = getattr(spmodels, 'Discipline') + + +class PsuedoManyToManyManager(models.Manager): + def __init__(self, base_instance, through_model, through_field) -> None: + self.base_instance = base_instance + self.through_model = through_model + self.through_field = through_field.field.name + + self._related_field_name = [ + field.name for field in through_model._meta.fields if field.related_model is not None][0] + + def all(self) -> models.QuerySet: + return self.through_model.objects.filter(**{self._related_field_name: self.base_instance}) + + def filter(self, *args, **kwargs) -> models.QuerySet: + return self.all().filter(*args, **kwargs) + + def clear(self): + return self.all().delete() + + def add(self, *args, through_defaults={}): + for item in args: + to_create = { + self._related_field_name: self.base_instance, + self.through_field: item} + + for field, value in through_defaults.items(): + to_create[field] = value + self.through_model.objects.create(**to_create) + + def set(self, iterable, through_defaults={}): + self.clear() + self.add(*iterable, through_defaults=through_defaults) + + +class UniquenessRule(models.Model): + id = models.AutoField('uniquenessruleid', + primary_key=True, db_column='uniquenessruleid') + isDatabaseConstraint = models.BooleanField( + default=False, db_column='isDatabaseConstraint') + modelName = models.CharField(max_length=256) + discipline = models.ForeignKey( + Discipline, null=True, blank=True, on_delete=models.PROTECT, db_column="DisciplineID") + + @property + def fields(self): + return PsuedoManyToManyManager(self, UniquenessRule_Field, UniquenessRule_Field.fieldPath) + + class Meta: + db_table = 'uniquenessrule' + + +class UniquenessRule_Field(models.Model): + uniquenessrule_fieldid = models.AutoField( + 'uniquenessrule_fieldsid', primary_key=True) + uniquenessrule = models.ForeignKey( + UniquenessRule, on_delete=models.CASCADE, db_column='uniquenessruleid') + fieldPath = models.TextField(null=True, blank=True) + isScope = models.BooleanField(default=False) + + class Meta: + db_table = "uniquenessrule_fields" diff --git a/specifyweb/businessrules/orm_signal_handler.py b/specifyweb/businessrules/orm_signal_handler.py index a9e34052aa5..040da44a816 100644 --- a/specifyweb/businessrules/orm_signal_handler.py +++ b/specifyweb/businessrules/orm_signal_handler.py @@ -1,22 +1,44 @@ +from typing import Callable, Literal, Optional, Hashable + from django.db.models import signals from django.dispatch import receiver from specifyweb.specify import models -def orm_signal_handler(signal, model=None): +# See https://docs.djangoproject.com/en/3.2/ref/signals/#module-django.db.models.signals +MODEL_SIGNAL = Literal["pre_init", "post_init", "pre_save", + "post_save", "pre_delete", "post_delete", "m2m_changed"] + + +def orm_signal_handler(signal: MODEL_SIGNAL, model: Optional[str] = None, **kwargs): def _dec(rule): - receiver_kwargs = {} + receiver_kwargs = kwargs if model is not None: receiver_kwargs['sender'] = getattr(models, model) + def handler(sender, **kwargs): - if kwargs.get('raw', False): return + if kwargs.get('raw', False): + return # since the rule knows what model the signal comes from # the sender value is redundant. rule(kwargs['instance']) else: def handler(sender, **kwargs): - if kwargs.get('raw', False): return + if kwargs.get('raw', False): + return rule(sender, kwargs['instance']) return receiver(getattr(signals, signal), **receiver_kwargs)(handler) return _dec + + +def disconnect_signal(signal: MODEL_SIGNAL, model_name: Optional[str] = None, dispatch_uid: Optional[Hashable] = None) -> bool: + fetched_signal = getattr(signals, signal) + django_model = None if model_name is None else getattr(models, model_name) + return fetched_signal.disconnect( + sender=django_model, dispatch_uid=dispatch_uid) + +def connect_signal(signal: MODEL_SIGNAL, callback: Callable, model_name: Optional[str] = None, dispatch_uid: Optional[Hashable] = None): + fetched_signal = getattr(signals, signal) + django_model = None if model_name is None else getattr(models, model_name) + return fetched_signal.connect(callback, sender=django_model, dispatch_uid=dispatch_uid) \ No newline at end of file diff --git a/specifyweb/businessrules/rules/__init__.py b/specifyweb/businessrules/rules/__init__.py new file mode 100644 index 00000000000..18073c4f676 --- /dev/null +++ b/specifyweb/businessrules/rules/__init__.py @@ -0,0 +1,26 @@ +from . import ( + accessionagent_rules, + address_rules, + agent_rules, + agentspecialty_rules, + attachment_rules, + author_rules, + collectionobject_rules, + collector_rules, + determination_rules, + determiner_rules, + discipline_rules, + extractor_rules, + fieldnotebook_rules, + fundingagent_rules, + guid_rules, + groupperson_rules, + interaction_rules, + locality_rules, + pcrperson_rules, + preparation_rules, + recordset_rules, + tree_rules, + user_rules, + workbench_rules +) diff --git a/specifyweb/businessrules/accessionagent_rules.py b/specifyweb/businessrules/rules/accessionagent_rules.py similarity index 57% rename from specifyweb/businessrules/accessionagent_rules.py rename to specifyweb/businessrules/rules/accessionagent_rules.py index 49b96bb5508..d6296843541 100644 --- a/specifyweb/businessrules/accessionagent_rules.py +++ b/specifyweb/businessrules/rules/accessionagent_rules.py @@ -1,11 +1,12 @@ -from .orm_signal_handler import orm_signal_handler -from .exceptions import BusinessRuleException +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.exceptions import BusinessRuleException from django.utils.translation import gettext as _ + @orm_signal_handler('pre_save', 'Accessionagent') def agent_division_must_not_be_null(accessionagent): if accessionagent.agent_id is None: raise BusinessRuleException( _("AccessionAgent -> Agent relationship is required."), - {"table" : "Accessionagent", - "fieldName" : "agent" }) + {"table": "Accessionagent", + "fieldName": "agent"}) diff --git a/specifyweb/businessrules/address_rules.py b/specifyweb/businessrules/rules/address_rules.py similarity index 74% rename from specifyweb/businessrules/address_rules.py rename to specifyweb/businessrules/rules/address_rules.py index 86169275507..ff0bfda76d2 100644 --- a/specifyweb/businessrules/address_rules.py +++ b/specifyweb/businessrules/rules/address_rules.py @@ -1,7 +1,7 @@ -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler + @orm_signal_handler('pre_save', 'Address') def at_most_one_primary_address_per_agent(address): if address.isprimary and address.agent is not None: address.agent.addresses.all().update(isprimary=False) - diff --git a/specifyweb/businessrules/agent_rules.py b/specifyweb/businessrules/rules/agent_rules.py similarity index 75% rename from specifyweb/businessrules/agent_rules.py rename to specifyweb/businessrules/rules/agent_rules.py index 4e13f429a2c..380cd11d7a9 100644 --- a/specifyweb/businessrules/agent_rules.py +++ b/specifyweb/businessrules/rules/agent_rules.py @@ -1,6 +1,7 @@ -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Specifyuser -from .exceptions import BusinessRuleException +from specifyweb.businessrules.exceptions import BusinessRuleException + @orm_signal_handler('pre_delete', 'Agent') def agent_delete_blocked_by_related_specifyuser(agent): @@ -9,10 +10,10 @@ def agent_delete_blocked_by_related_specifyuser(agent): except Specifyuser.DoesNotExist: return raise BusinessRuleException( - "agent cannot be deleted while associated with a specifyuser", - {"table" : "Agent", - "fieldName" : "specifyuser", - "agentid" : agent.id, + "agent cannot be deleted while associated with a specifyuser", + {"table": "Agent", + "fieldName": "specifyuser", + "agentid": agent.id, "specifyuserid": user.id}) # Disabling this rule because system agents must be created separate from divisions @@ -25,17 +26,18 @@ def agent_delete_blocked_by_related_specifyuser(agent): # "fieldName" : "division", # "agentid" : agent.id}) + @orm_signal_handler('pre_save', 'Agent') def agent_types_other_and_group_do_not_have_addresses(agent): from specifyweb.specify.agent_types import agent_types if agent.agenttype is None: raise BusinessRuleException( - "agenttype cannot be null", - {"table" : "Agent", - "fieldName" : "agenttype", - "agentid" : agent.id}) - - # This Business Rule (Agents of type Other/Group can not have Addresses) was removed + "agenttype cannot be null", + {"table": "Agent", + "fieldName": "agenttype", + "agentid": agent.id}) + + # This Business Rule (Agents of type Other/Group can not have Addresses) was removed # See https://github.com/specify/specify7/issues/2518 for more information # if agent_types[agent.agenttype] in ('Other', 'Group'): # agent.addresses.all().delete() diff --git a/specifyweb/businessrules/agentspecialty_rules.py b/specifyweb/businessrules/rules/agentspecialty_rules.py similarity index 84% rename from specifyweb/businessrules/agentspecialty_rules.py rename to specifyweb/businessrules/rules/agentspecialty_rules.py index eb6cc8995dc..43bc3f98238 100644 --- a/specifyweb/businessrules/agentspecialty_rules.py +++ b/specifyweb/businessrules/rules/agentspecialty_rules.py @@ -1,9 +1,9 @@ -from .orm_signal_handler import orm_signal_handler -from .exceptions import BusinessRuleException +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Agentspecialty from django.db.models import Max + @orm_signal_handler('pre_save', 'Agentspecialty') def agentspecialty_pre_save(agentspecialty): if agentspecialty.id is None: @@ -12,4 +12,3 @@ def agentspecialty_pre_save(agentspecialty): others = Agentspecialty.objects.filter(agent=agentspecialty.agent) top = others.aggregate(Max('ordernumber'))['ordernumber__max'] agentspecialty.ordernumber = 0 if top is None else top + 1 - diff --git a/specifyweb/businessrules/attachment_rules.py b/specifyweb/businessrules/rules/attachment_rules.py similarity index 76% rename from specifyweb/businessrules/attachment_rules.py rename to specifyweb/businessrules/rules/attachment_rules.py index 3cbc01e76fe..5a43dec3a81 100644 --- a/specifyweb/businessrules/attachment_rules.py +++ b/specifyweb/businessrules/rules/attachment_rules.py @@ -1,12 +1,10 @@ import re -from django.db.models import Max - -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.scoping import Scoping from specifyweb.specify import models -from .exceptions import AbortSave +from specifyweb.businessrules.exceptions import AbortSave JOINTABLE_NAME_RE = re.compile('(.*)attachment') @@ -16,22 +14,28 @@ tables_with_attachments = {getattr(models, model.__name__.replace('attachment', '')) for model in attachment_tables} + @orm_signal_handler('pre_save') def attachment_jointable_save(sender, obj): - if sender not in attachment_tables: return + if sender not in attachment_tables: + return - if obj.attachment_id is None: raise AbortSave() + if obj.attachment_id is None: + raise AbortSave() attachee = get_attachee(obj) obj.attachment.tableid = attachee.specify_model.tableId - obj.attachment.scopetype, obj.attachment.scopeid = Scoping(attachee)() + scopetype, scope = Scoping(attachee)() + obj.attachment.scopetype, obj.attachment.scopeid = scopetype, scope.id obj.attachment.save() + @orm_signal_handler('post_delete') def attachment_jointable_deletion(sender, obj): if sender in attachment_tables: obj.attachment.delete() + @orm_signal_handler('pre_save', 'Attachment') def attachment_save(attachment): if attachment.id is None and attachment.tableid is None: @@ -39,13 +43,15 @@ def attachment_save(attachment): # the actual table id will be set when the join row is saved. (see above) attachment.tableid = models.Attachment.specify_model.tableId + @orm_signal_handler('post_delete', 'Attachment') def attachment_deletion(attachment): from specifyweb.attachment_gw.views import delete_attachment_file if attachment.attachmentlocation is not None: delete_attachment_file(attachment.attachmentlocation) + def get_attachee(jointable_inst): - main_table_name = JOINTABLE_NAME_RE.match(jointable_inst.__class__.__name__).group(1) + main_table_name = JOINTABLE_NAME_RE.match( + jointable_inst.__class__.__name__).group(1) return getattr(jointable_inst, main_table_name.lower()) - diff --git a/specifyweb/businessrules/author_rules.py b/specifyweb/businessrules/rules/author_rules.py similarity index 85% rename from specifyweb/businessrules/author_rules.py rename to specifyweb/businessrules/rules/author_rules.py index 534cf6b2954..dc534aa283f 100644 --- a/specifyweb/businessrules/author_rules.py +++ b/specifyweb/businessrules/rules/author_rules.py @@ -1,7 +1,8 @@ from django.db.models import Max -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Author + @orm_signal_handler('pre_save', 'Author') def author_pre_save(author): if author.id is None: diff --git a/specifyweb/businessrules/collectionobject_rules.py b/specifyweb/businessrules/rules/collectionobject_rules.py similarity index 70% rename from specifyweb/businessrules/collectionobject_rules.py rename to specifyweb/businessrules/rules/collectionobject_rules.py index 7bde9c3870c..035b3463f81 100644 --- a/specifyweb/businessrules/collectionobject_rules.py +++ b/specifyweb/businessrules/rules/collectionobject_rules.py @@ -1,5 +1,5 @@ +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler -from .orm_signal_handler import orm_signal_handler @orm_signal_handler('pre_save', 'Collectionobject') def collectionobject_pre_save(co): diff --git a/specifyweb/businessrules/collector_rules.py b/specifyweb/businessrules/rules/collector_rules.py similarity index 69% rename from specifyweb/businessrules/collector_rules.py rename to specifyweb/businessrules/rules/collector_rules.py index dd1c8ebac3f..208571bdc93 100644 --- a/specifyweb/businessrules/collector_rules.py +++ b/specifyweb/businessrules/rules/collector_rules.py @@ -1,12 +1,14 @@ from django.db.models import Max -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Collector + @orm_signal_handler('pre_save', 'Collector') def collector_pre_save(collector): if collector.id is None: if collector.ordernumber is None: # this should be atomic, but whatever - others = Collector.objects.filter(collectingevent=collector.collectingevent) + others = Collector.objects.filter( + collectingevent=collector.collectingevent) top = others.aggregate(Max('ordernumber'))['ordernumber__max'] collector.ordernumber = 0 if top is None else top + 1 diff --git a/specifyweb/businessrules/determination_rules.py b/specifyweb/businessrules/rules/determination_rules.py similarity index 53% rename from specifyweb/businessrules/determination_rules.py rename to specifyweb/businessrules/rules/determination_rules.py index 4fa6fcd3e55..b1794de0c5e 100644 --- a/specifyweb/businessrules/determination_rules.py +++ b/specifyweb/businessrules/rules/determination_rules.py @@ -1,7 +1,8 @@ -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Determination, Taxon + @orm_signal_handler('pre_save', 'Determination') def determination_pre_save(det): if det.collectionmemberid is None: @@ -11,14 +12,19 @@ def determination_pre_save(det): if taxon_id is None: det.preferredtaxon = None else: - acceptedtaxon_id = Taxon.objects.select_for_update().values_list('acceptedtaxon_id', flat=True).get(id=taxon_id) + acceptedtaxon_id = Taxon.objects.select_for_update().values_list( + 'acceptedtaxon_id', flat=True).get(id=taxon_id) limit = 100 while acceptedtaxon_id is not None: - if acceptedtaxon_id == taxon_id: break + if acceptedtaxon_id == taxon_id: + break limit -= 1 - if not limit > 0: raise AssertionError(f"Could not find accepted taxon for synonymized taxon (id ='{taxon_id}')", {"taxonId" : taxon_id, "localizationKey" : "limitReachedDeterminingAccepted"}) + if not limit > 0: + raise AssertionError(f"Could not find accepted taxon for synonymized taxon (id ='{taxon_id}')", { + "taxonId": taxon_id, "localizationKey": "limitReachedDeterminingAccepted"}) taxon_id = acceptedtaxon_id - acceptedtaxon_id = Taxon.objects.select_for_update().values_list('acceptedtaxon_id', flat=True).get(id=taxon_id) + acceptedtaxon_id = Taxon.objects.select_for_update().values_list( + 'acceptedtaxon_id', flat=True).get(id=taxon_id) det.preferredtaxon_id = taxon_id @@ -26,5 +32,5 @@ def determination_pre_save(det): @orm_signal_handler('pre_save', 'Determination') def only_one_determination_iscurrent(determination): if determination.iscurrent: - Determination.objects.filter(collectionobject=determination.collectionobject_id).update(iscurrent=False) - + Determination.objects.filter( + collectionobject=determination.collectionobject_id).update(iscurrent=False) diff --git a/specifyweb/businessrules/determiner_rules.py b/specifyweb/businessrules/rules/determiner_rules.py similarity index 76% rename from specifyweb/businessrules/determiner_rules.py rename to specifyweb/businessrules/rules/determiner_rules.py index a6787e3fefa..17a4fabe7a8 100644 --- a/specifyweb/businessrules/determiner_rules.py +++ b/specifyweb/businessrules/rules/determiner_rules.py @@ -1,5 +1,5 @@ from django.db.models import Max -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify import models # This check is provided to support the Specify 6.8.01 @@ -12,6 +12,7 @@ def determiner_pre_save(determiner): if determiner.id is None: if determiner.ordernumber is None: # this should be atomic, but whatever - others = models.Determiner.objects.filter(determination=determiner.determination) + others = models.Determiner.objects.filter( + determination=determiner.determination) top = others.aggregate(Max('ordernumber'))['ordernumber__max'] determiner.ordernumber = 0 if top is None else top + 1 diff --git a/specifyweb/businessrules/discipline_rules.py b/specifyweb/businessrules/rules/discipline_rules.py similarity index 82% rename from specifyweb/businessrules/discipline_rules.py rename to specifyweb/businessrules/rules/discipline_rules.py index 4dd8c4a2f78..340a41fa2b4 100644 --- a/specifyweb/businessrules/discipline_rules.py +++ b/specifyweb/businessrules/rules/discipline_rules.py @@ -1,6 +1,7 @@ -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Taxontreedef + @orm_signal_handler('pre_save', 'Discipline') def create_taxontreedef_if_null(discipline): if discipline.id is not None: @@ -9,4 +10,3 @@ def create_taxontreedef_if_null(discipline): if discipline.taxontreedef is None: discipline.taxontreedef = Taxontreedef.objects.create( name='Sample') - diff --git a/specifyweb/businessrules/extractor_rules.py b/specifyweb/businessrules/rules/extractor_rules.py similarity index 70% rename from specifyweb/businessrules/extractor_rules.py rename to specifyweb/businessrules/rules/extractor_rules.py index 336e80000e0..57cad79623b 100644 --- a/specifyweb/businessrules/extractor_rules.py +++ b/specifyweb/businessrules/rules/extractor_rules.py @@ -1,12 +1,14 @@ from django.db.models import Max -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Extractor + @orm_signal_handler('pre_save', 'Extractor') def collector_pre_save(extractor): if extractor.id is None: if extractor.ordernumber is None: # this should be atomic, but whatever - others = Extractor.objects.filter(dnasequence=extractor.dnasequence) + others = Extractor.objects.filter( + dnasequence=extractor.dnasequence) top = others.aggregate(Max('ordernumber'))['ordernumber__max'] - extractor.ordernumber = 0 if top is None else top + 1 \ No newline at end of file + extractor.ordernumber = 0 if top is None else top + 1 diff --git a/specifyweb/businessrules/fieldnotebook_rules.py b/specifyweb/businessrules/rules/fieldnotebook_rules.py similarity index 59% rename from specifyweb/businessrules/fieldnotebook_rules.py rename to specifyweb/businessrules/rules/fieldnotebook_rules.py index b267c460898..b55629f9f22 100644 --- a/specifyweb/businessrules/fieldnotebook_rules.py +++ b/specifyweb/businessrules/rules/fieldnotebook_rules.py @@ -1,12 +1,14 @@ from django.db.models import Max -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Fieldnotebookpageset + @orm_signal_handler('pre_save', 'Fieldnotebookpageset') def collector_pre_save(pageset): if pageset.id is None: if pageset.ordernumber is None: # this should be atomic, but whatever - others = Fieldnotebookpageset.objects.filter(fieldnotebook=pageset.fieldnotebook) + others = Fieldnotebookpageset.objects.filter( + fieldnotebook=pageset.fieldnotebook) top = others.aggregate(Max('ordernumber'))['ordernumber__max'] - pageset.ordernumber = 0 if top is None else top + 1 \ No newline at end of file + pageset.ordernumber = 0 if top is None else top + 1 diff --git a/specifyweb/businessrules/fundingagent_rules.py b/specifyweb/businessrules/rules/fundingagent_rules.py similarity index 70% rename from specifyweb/businessrules/fundingagent_rules.py rename to specifyweb/businessrules/rules/fundingagent_rules.py index 63625ec1459..8857819f832 100644 --- a/specifyweb/businessrules/fundingagent_rules.py +++ b/specifyweb/businessrules/rules/fundingagent_rules.py @@ -1,13 +1,14 @@ -from .exceptions import BusinessRuleException from django.db.models import Max -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Fundingagent + @orm_signal_handler('pre_save', 'Fundingagent') def fundingagent_pre_save(fundingagent): if fundingagent.id is None: if fundingagent.ordernumber is None: # this should be atomic, but whatever - others = Fundingagent.objects.filter(collectingtrip=fundingagent.collectingtrip) + others = Fundingagent.objects.filter( + collectingtrip=fundingagent.collectingtrip) top = others.aggregate(Max('ordernumber'))['ordernumber__max'] fundingagent.ordernumber = 0 if top is None else top + 1 diff --git a/specifyweb/businessrules/groupperson_rules.py b/specifyweb/businessrules/rules/groupperson_rules.py similarity index 67% rename from specifyweb/businessrules/groupperson_rules.py rename to specifyweb/businessrules/rules/groupperson_rules.py index f8d3cd7dc52..4e57586029c 100644 --- a/specifyweb/businessrules/groupperson_rules.py +++ b/specifyweb/businessrules/rules/groupperson_rules.py @@ -1,16 +1,18 @@ -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from django.db.models import Max from specifyweb.specify.models import Groupperson -from .exceptions import BusinessRuleException +from specifyweb.businessrules.exceptions import BusinessRuleException + @orm_signal_handler('pre_save', 'Groupperson') def agent_cannot_be_in_self(groupperson): if groupperson.member_id == groupperson.group_id: raise BusinessRuleException( - 'a group cannot be made a member of itself', - {"table" : "GroupPerson", - "fieldName" : "member", - "groupid" : groupperson.group_id}) + 'a group cannot be made a member of itself', + {"table": "GroupPerson", + "fieldName": "member", + "groupid": groupperson.group_id}) + @orm_signal_handler('pre_save', 'Groupperson') def grouppersion_pre_save(groupperson): diff --git a/specifyweb/businessrules/guid_rules.py b/specifyweb/businessrules/rules/guid_rules.py similarity index 84% rename from specifyweb/businessrules/guid_rules.py rename to specifyweb/businessrules/rules/guid_rules.py index 93335a551b1..730a14fdbc3 100644 --- a/specifyweb/businessrules/guid_rules.py +++ b/specifyweb/businessrules/rules/guid_rules.py @@ -1,5 +1,5 @@ from uuid import uuid4 -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Taxon, Geography diff --git a/specifyweb/businessrules/interaction_rules.py b/specifyweb/businessrules/rules/interaction_rules.py similarity index 57% rename from specifyweb/businessrules/interaction_rules.py rename to specifyweb/businessrules/rules/interaction_rules.py index b89ec7f8fb4..b17b78449cd 100644 --- a/specifyweb/businessrules/interaction_rules.py +++ b/specifyweb/businessrules/rules/interaction_rules.py @@ -1,8 +1,7 @@ -from .orm_signal_handler import orm_signal_handler -from specifyweb.specify import models -from .exceptions import BusinessRuleException +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.exceptions import BusinessRuleException from django.db import connection -from specifyweb.specify.api import parse_uri + def get_availability(prep, iprepid, iprepid_fld): args = [prep.id] @@ -14,9 +13,9 @@ def get_availability(prep, iprepid, iprepid_fld): left join exchangeoutprep ep on ep.PreparationID = p.PreparationID where p.preparationid = %s """ if iprepid is not None: - sql += "and " + iprepid_fld + " != %s " + sql += "and " + iprepid_fld + " != %s " args.append(iprepid) - + sql += "group by p.preparationid" cursor = connection.cursor() @@ -25,48 +24,54 @@ def get_availability(prep, iprepid, iprepid_fld): if row is None: return prep.countamt else: - return row[0]; - + return row[0] + + @orm_signal_handler('pre_save', 'Loanpreparation') def loanprep_quantity_must_be_lte_availability(ipreparation): if ipreparation.preparation is not None: - available = get_availability(ipreparation.preparation, ipreparation.id, "loanpreparationid") or 0 + available = get_availability( + ipreparation.preparation, ipreparation.id, "loanpreparationid") or 0 quantity = ipreparation.quantity or 0 quantityresolved = ipreparation.quantityresolved or 0 if available < (quantity - quantityresolved): raise BusinessRuleException( - f"loan preparation quantity exceeds availability ({ipreparation.id}: {quantity - quantityresolved} {available})", - {"table" : "LoanPreparation", - "fieldName" : "quantity", - "preparationid" : ipreparation.id, - "quantity" : quantity, - "quantityresolved" : quantityresolved, - "available" : available}) + f"loan preparation quantity exceeds availability ({ipreparation.id}: {quantity - quantityresolved} {available})", + {"table": "LoanPreparation", + "fieldName": "quantity", + "preparationid": ipreparation.id, + "quantity": quantity, + "quantityresolved": quantityresolved, + "available": available}) + @orm_signal_handler('pre_save', 'Giftpreparation') def giftprep_quantity_must_be_lte_availability(ipreparation): if ipreparation.preparation is not None: - available = get_availability(ipreparation.preparation, ipreparation.id, "giftpreparationid") or 0 + available = get_availability( + ipreparation.preparation, ipreparation.id, "giftpreparationid") or 0 quantity = ipreparation.quantity or 0 if available < quantity: raise BusinessRuleException( - f"gift preparation quantity exceeds availability ({ipreparation.id}: {quantity} {available})", - {"table" : "GiftPreparation", - "fieldName" : "quantity", - "preparationid" : ipreparation.id, - "quantity" : quantity, - "available" : available}) + f"gift preparation quantity exceeds availability ({ipreparation.id}: {quantity} {available})", + {"table": "GiftPreparation", + "fieldName": "quantity", + "preparationid": ipreparation.id, + "quantity": quantity, + "available": available}) + @orm_signal_handler('pre_save', 'Exchangeoutprep') def exchangeoutprep_quantity_must_be_lte_availability(ipreparation): if ipreparation.preparation is not None: - available = get_availability(ipreparation.preparation, ipreparation.id, "exchangeoutprepid") or 0 + available = get_availability( + ipreparation.preparation, ipreparation.id, "exchangeoutprepid") or 0 quantity = ipreparation.quantity or 0 if available < quantity: raise BusinessRuleException( - "exchangeout preparation quantity exceeds availability ({ipreparation.id}: {quantity} {available})", - {"table" : "ExchangeOutPrep", - "fieldName" : "quantity", - "preparationid" : ipreparation.id, - "quantity" : quantity, - "available" : available}) + "exchangeout preparation quantity exceeds availability ({ipreparation.id}: {quantity} {available})", + {"table": "ExchangeOutPrep", + "fieldName": "quantity", + "preparationid": ipreparation.id, + "quantity": quantity, + "available": available}) diff --git a/specifyweb/businessrules/locality_rules.py b/specifyweb/businessrules/rules/locality_rules.py similarity index 83% rename from specifyweb/businessrules/locality_rules.py rename to specifyweb/businessrules/rules/locality_rules.py index e8a46039361..6c8b165afec 100644 --- a/specifyweb/businessrules/locality_rules.py +++ b/specifyweb/businessrules/rules/locality_rules.py @@ -1,4 +1,4 @@ -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler @orm_signal_handler('pre_save', 'Locality') diff --git a/specifyweb/businessrules/pcrperson_rules.py b/specifyweb/businessrules/rules/pcrperson_rules.py similarity index 70% rename from specifyweb/businessrules/pcrperson_rules.py rename to specifyweb/businessrules/rules/pcrperson_rules.py index 673b448355d..b0f5c9d1ab1 100644 --- a/specifyweb/businessrules/pcrperson_rules.py +++ b/specifyweb/businessrules/rules/pcrperson_rules.py @@ -1,12 +1,14 @@ from django.db.models import Max -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.specify.models import Pcrperson + @orm_signal_handler('pre_save', 'Pcrperson') def collector_pre_save(pcr_person): if pcr_person.id is None: if pcr_person.ordernumber is None: # this should be atomic, but whatever - others = Pcrperson.objects.filter(dnasequence=pcr_person.dnasequence) + others = Pcrperson.objects.filter( + dnasequence=pcr_person.dnasequence) top = others.aggregate(Max('ordernumber'))['ordernumber__max'] - pcr_person.ordernumber = 0 if top is None else top + 1 \ No newline at end of file + pcr_person.ordernumber = 0 if top is None else top + 1 diff --git a/specifyweb/businessrules/rules/preparation_rules.py b/specifyweb/businessrules/rules/preparation_rules.py new file mode 100644 index 00000000000..b80f3e2c2d6 --- /dev/null +++ b/specifyweb/businessrules/rules/preparation_rules.py @@ -0,0 +1,7 @@ +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler + + +@orm_signal_handler('pre_save', 'Preparation') +def preparation_pre_save(preparation): + if preparation.collectionmemberid is None: + preparation.collectionmemberid = preparation.collectionobject.collectionmemberid diff --git a/specifyweb/businessrules/recordset_rules.py b/specifyweb/businessrules/rules/recordset_rules.py similarity index 75% rename from specifyweb/businessrules/recordset_rules.py rename to specifyweb/businessrules/rules/recordset_rules.py index 68327c5ded3..dfd243a206f 100644 --- a/specifyweb/businessrules/recordset_rules.py +++ b/specifyweb/businessrules/rules/recordset_rules.py @@ -1,26 +1,32 @@ -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from django.db import connection from specifyweb.specify import models from specifyweb.specify.models import Recordsetitem + @orm_signal_handler('post_delete') def remove_from_recordsets(sender, obj): - if not hasattr(sender, 'specify_model'): return + if not hasattr(sender, 'specify_model'): + return if sender in (models.Workbenchtemplate, models.Workbenchrow, models.Workbenchdataitem, - models.Workbenchtemplatemappingitem, models.Workbenchrowimage): return + models.Workbenchtemplatemappingitem, models.Workbenchrowimage): + return rsis = Recordsetitem.objects.filter( recordset__dbtableid=sender.specify_model.tableId, recordid=obj.id) rsis.delete() + @orm_signal_handler('pre_save', 'Recordset') def recordset_pre_save(recordset): if recordset.specifyuser_id is None: recordset.specifyuser = recordset.createdbyagent.specifyuser + @orm_signal_handler('pre_delete', 'Recordset') def recordset_pre_delete(recordset): cursor = connection.cursor() - cursor.execute("delete from recordsetitem where recordsetid = %s", [recordset.id]) + cursor.execute( + "delete from recordsetitem where recordsetid = %s", [recordset.id]) diff --git a/specifyweb/businessrules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py similarity index 56% rename from specifyweb/businessrules/tree_rules.py rename to specifyweb/businessrules/rules/tree_rules.py index 417e9c8a3f2..38be2d13784 100644 --- a/specifyweb/businessrules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -1,22 +1,24 @@ import logging -from .orm_signal_handler import orm_signal_handler -from .exceptions import TreeBusinessRuleException +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.exceptions import TreeBusinessRuleException logger = logging.getLogger(__name__) + @orm_signal_handler('pre_delete') def cannot_delete_root_treedefitem(sender, obj): - if hasattr(obj, 'treedef'): # is it a treedefitem? + if hasattr(obj, 'treedef'): # is it a treedefitem? if sender.objects.get(id=obj.id).parent is None: raise TreeBusinessRuleException( - "cannot delete root level tree definition item", - {"tree" : obj.__class__.__name__, - "localizationKey" : 'deletingTreeRoot', - "node" : { - "id" : obj.id + "cannot delete root level tree definition item", + {"tree": obj.__class__.__name__, + "localizationKey": 'deletingTreeRoot', + "node": { + "id": obj.id }}) + @orm_signal_handler('pre_save') def set_is_accepted_if_prefereed(sender, obj): if hasattr(obj, 'isaccepted'): diff --git a/specifyweb/businessrules/user_rules.py b/specifyweb/businessrules/rules/user_rules.py similarity index 82% rename from specifyweb/businessrules/user_rules.py rename to specifyweb/businessrules/rules/user_rules.py index c762b90806a..5d5c387c81b 100644 --- a/specifyweb/businessrules/user_rules.py +++ b/specifyweb/businessrules/rules/user_rules.py @@ -3,12 +3,13 @@ from django.db import connection from specifyweb.specify.models import Specifyuser, Spprincipal, Collection -from .exceptions import BusinessRuleException +from specifyweb.businessrules.exceptions import BusinessRuleException @receiver(signals.post_save, sender=Specifyuser) def added_user(sender, instance, created, raw, **kwargs): - if raw or not created: return + if raw or not created: + return user = instance cursor = connection.cursor() @@ -24,7 +25,6 @@ def added_user(sender, instance, created, raw, **kwargs): # cursor.execute('insert into specifyuser_spprincipal(SpecifyUserID, SpPrincipalID) values (%s, %s)', # (user.id, principal.id)) - group_principals = Spprincipal.objects.filter( groupsubclass='edu.ku.brc.af.auth.specify.principal.GroupPrincipal', grouptype=user.usertype, @@ -36,20 +36,23 @@ def added_user(sender, instance, created, raw, **kwargs): [user.id, gp.id] ) + @receiver(signals.pre_delete, sender=Specifyuser) def deleting_user(sender, instance, **kwargs): user = instance - nonpersonal_appresources = user.spappresources.filter(spappresourcedir__ispersonal=False) + nonpersonal_appresources = user.spappresources.filter( + spappresourcedir__ispersonal=False) if nonpersonal_appresources.exists(): raise BusinessRuleException( f"user {user.name} owns nonpersonal appresources {[r.name for r in nonpersonal_appresources]}", - {"table" : user.__class__.__name__, - "userid" : user.id} + {"table": user.__class__.__name__, + "userid": user.id} ) cursor = connection.cursor() - cursor.execute('delete from specifyuser_spprincipal where SpecifyUserID = %s', [user.id]) + cursor.execute( + 'delete from specifyuser_spprincipal where SpecifyUserID = %s', [user.id]) # Clean up unused user principal rows. cursor.execute('delete from spprincipal where grouptype is null and spprincipalid not in (' 'select spprincipalid from specifyuser_spprincipal)') diff --git a/specifyweb/businessrules/workbench_rules.py b/specifyweb/businessrules/rules/workbench_rules.py similarity index 93% rename from specifyweb/businessrules/workbench_rules.py rename to specifyweb/businessrules/rules/workbench_rules.py index 80ce48f8b76..f0ecbdc8151 100644 --- a/specifyweb/businessrules/workbench_rules.py +++ b/specifyweb/businessrules/rules/workbench_rules.py @@ -1,12 +1,14 @@ from django.db import connection -from .orm_signal_handler import orm_signal_handler +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler + @orm_signal_handler('pre_save', 'Workbench') def fix_workbenchtemplate_name(workbench): workbench.workbenchtemplate.name = workbench.name workbench.workbenchtemplate.save(update_fields=['name']) + @orm_signal_handler('pre_delete', 'Workbench') def optimize_workbench_delete(workbench): cursor = connection.cursor() diff --git a/specifyweb/businessrules/tests/__init__.py b/specifyweb/businessrules/tests/__init__.py index 507b00b5d08..76abaab110e 100644 --- a/specifyweb/businessrules/tests/__init__.py +++ b/specifyweb/businessrules/tests/__init__.py @@ -34,4 +34,5 @@ from .storagetreedefitem import * from .taxon import * from .taxontreedefitem import * - +from .preparation import * +from .uniqueness_rules import * diff --git a/specifyweb/businessrules/tests/accessionagent.py b/specifyweb/businessrules/tests/accessionagent.py index 8a60b3db21c..70d92f49ab6 100644 --- a/specifyweb/businessrules/tests/accessionagent.py +++ b/specifyweb/businessrules/tests/accessionagent.py @@ -4,6 +4,7 @@ from specifyweb.specify.api_tests import ApiTests from ..exceptions import BusinessRuleException + class AccessionAgentTests(ApiTests): @skip("rule was removed in 17e82c6157") def test_no_duped_agents_in_accession(self): diff --git a/specifyweb/businessrules/tests/permit.py b/specifyweb/businessrules/tests/permit.py index e7d386903b1..951c3ffe74f 100644 --- a/specifyweb/businessrules/tests/permit.py +++ b/specifyweb/businessrules/tests/permit.py @@ -3,6 +3,7 @@ from specifyweb.specify.api_tests import ApiTests from ..exceptions import BusinessRuleException + class PermitTests(ApiTests): def test_number_is_unique(self): models.Permit.objects.create( @@ -11,6 +12,7 @@ def test_number_is_unique(self): with self.assertRaises(BusinessRuleException): models.Permit.objects.create( + institution=self.institution, permitnumber='1') models.Permit.objects.create( diff --git a/specifyweb/businessrules/tests/preparation.py b/specifyweb/businessrules/tests/preparation.py new file mode 100644 index 00000000000..277ef067f51 --- /dev/null +++ b/specifyweb/businessrules/tests/preparation.py @@ -0,0 +1,57 @@ +from specifyweb.specify import models +from specifyweb.specify.api_tests import ApiTests +from ..exceptions import BusinessRuleException + + +class PreparationTests(ApiTests): + def test_barcode_unique_to_collection(self): + prep_type = models.Preptype.objects.create( + name='testPrepType', + isloanable=False, + collection=self.collection, + ) + + models.Preparation.objects.create( + collectionobject=self.collectionobjects[0], + barcode='1', + preptype=prep_type + ) + with self.assertRaises(BusinessRuleException): + models.Preparation.objects.create( + collectionobject=self.collectionobjects[0], + barcode='1', + preptype=prep_type + ) + with self.assertRaises(BusinessRuleException): + models.Preparation.objects.create( + collectionobject=self.collectionobjects[1], + barcode='1', + preptype=prep_type + ) + models.Preparation.objects.create( + collectionobject=self.collectionobjects[0], + barcode='2', + preptype=prep_type + ) + + other_collection = models.Collection.objects.create( + catalognumformatname='test', + collectionname='OtherCollection', + isembeddedcollectingevent=False, + discipline=self.discipline) + + other_co = models.Collectionobject.objects.create( + catalognumber='num-1', + collection=other_collection, + ) + other_preptype = models.Preptype.objects.create( + name='otherPrepType', + isloanable=False, + collection=other_collection, + ) + + models.Preparation.objects.create( + collectionobject=other_co, + barcode='1', + preptype=other_preptype + ) diff --git a/specifyweb/businessrules/tests/uniqueness_rules.py b/specifyweb/businessrules/tests/uniqueness_rules.py new file mode 100644 index 00000000000..2f85d21cc06 --- /dev/null +++ b/specifyweb/businessrules/tests/uniqueness_rules.py @@ -0,0 +1,126 @@ +import json + +from django.test import Client + +from specifyweb.specify import models +from specifyweb.specify.api_tests import ApiTests +from specifyweb.businessrules.models import UniquenessRule +from specifyweb.businessrules.exceptions import BusinessRuleException + + +class UniquenessTests(ApiTests): + def test_simple_validation(self): + c = Client() + c.force_login(self.specifyuser) + + models.Collectionobject.objects.all().update(text1='test') + + response = c.post( + '/businessrules/uniqueness_rules/validate/', + data=json.dumps({ + "table": "Collectionobject", + "rule": { + "fields": ["text1"], + "scopes": [] + } + }), + content_type='application/json' + ) + + expected_response = {"totalDuplicates": 5, "fields": [ + {"duplicates": 5, "fields": {"text1": "test"}}]} + + response_content = json.loads(response.content.decode()) + + self.assertEquals(response_content, expected_response) + + def test_pathed_scope_validation(self): + c = Client() + c.force_login(self.specifyuser) + + event1 = models.Collectingevent.objects.create( + discipline=self.discipline + ) + event1.collectionobjects.add( + self.collectionobjects[0], self.collectionobjects[1]) + + event2 = models.Collectingevent.objects.create( + discipline=self.discipline + ) + + event2.collectionobjects.add( + self.collectionobjects[2]) + + models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + text2='test', + yesno1=1, + number1=10, + ) + + models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + text2='test', + yesno1=1, + number1=10, + ) + + models.Determination.objects.create( + collectionobject=self.collectionobjects[2], + text2='test', + yesno1=1, + number1=10, + ) + + response = c.post( + '/businessrules/uniqueness_rules/validate/', + data=json.dumps({ + "table": "Determination", + "rule": { + "fields": ["text2", "yesNo1", "number1"], + "scopes": ["collectionObject__collectingEvent"] + } + }), + content_type='application/json' + ) + + expected_response = {'totalDuplicates': 2, 'fields': [{'duplicates': 2, 'fields': { + 'text2': 'test', 'yesno1': True, 'number1': '10.0000000000', 'collectionobject__collectingevent': event1.id}}]} + + response_content = json.loads(response.content.decode()) + + self.assertEquals(response_content, expected_response) + + def test_creating_uniqueness_rule(self): + c = Client() + c.force_login(self.specifyuser) + + new_accession_rule = {"id": None, "fields": ["text1"], "scopes": [ + ], "isDatabaseConstraint": False, "modelName": "accession"} + + # Also deletes the default accession rule stating accessionNumber must be unique to division + c.put( + f'/businessrules/uniqueness_rules/{self.discipline.id}/', + data=json.dumps({ + "model": "Accession", + "rules": [new_accession_rule] + }), + content_type='application/json' + ) + + models.Accession.objects.create( + division=self.division, + accessionnumber="accession1" + ) + + models.Accession.objects.create( + division=self.division, + text1="test", + accessionnumber="accession1" + ) + + with self.assertRaises(BusinessRuleException): + models.Accession.objects.create( + division=self.division, + text1="test", + ) diff --git a/specifyweb/businessrules/uniqueness_rules.json b/specifyweb/businessrules/uniqueness_rules.json new file mode 100644 index 00000000000..2c2d3d94bb2 --- /dev/null +++ b/specifyweb/businessrules/uniqueness_rules.json @@ -0,0 +1,224 @@ +{ + "Accession": [ + { + "rule": [["accessionNumber"], ["division"]], + "isDatabaseConstraint": false + } + ], + "Accessionagent": [ + { + "rule": [["role", "agent"], ["accession"]], + "isDatabaseConstraint": true + }, + { + "rule": [["role", "agent"], ["repositoryagreement"]], + "isDatabaseConstraint": true + } + ], + "Appraisal": [ + { + "rule": [["appraisalNumber"], ["accession"]], + "isDatabaseConstraint": true + } + ], + "Author": [ + { + "rule": [["agent"], ["referenceWork"]], + "isDatabaseConstraint": true + }, + { + "rule": [["orderNumber"], ["referenceWork"]], + "isDatabaseConstraint": false + } + ], + "Borrowagent": [ + { + "rule": [["role", "agent"], ["borrow"]], + "isDatabaseConstraint": true + } + ], + "Collection": [ + { + "rule": [["collectionName"], ["discipline"]], + "isDatabaseConstraint": false + }, + { + "rule": [["code"], ["discipline"]], + "isDatabaseConstraint": false + } + ], + "Collectingevent": [ + { + "rule": [["uniqueIdentifier"], []], + "isDatabaseConstraint": true + } + ], + "Collectionobject": [ + { + "rule": [["catalogNumber"], ["collection"]], + "isDatabaseConstraint": true + }, + { + "rule": [["uniqueIdentifier"], []], + "isDatabaseConstraint": true + }, + { + "rule": [["guid"], []], + "isDatabaseConstraint": false + } + ], + "Collector": [ + { + "rule": [["agent"], ["collectingEvent"]], + "isDatabaseConstraint": true + } + ], + "Determiner": [ + { + "rule": [["agent"], ["determination"]], + "isDatabaseConstraint": true + } + ], + "Discipline": [ + { + "rule": [["name"], ["division"]], + "isDatabaseConstraint": false + } + ], + "Disposalagent": [ + { + "rule": [["role", "agent"], ["disposal"]], + "isDatabaseConstraint": true + } + ], + "Division": [ + { + "rule": [["name"], ["institution"]], + "isDatabaseConstraint": false + } + ], + "Extractor": [ + { + "rule": [["agent"], ["dnaSequence"]], + "isDatabaseConstraint": true + } + ], + "Fundingagent": [ + { + "rule": [["agent"], ["collectingTrip"]], + "isDatabaseConstraint": true + } + ], + "Gift": [ + { + "rule": [["giftNumber"], ["discipline"]], + "isDatabaseConstraint": false + } + ], + "Giftagent": [ + { + "rule": [["role", "agent"], ["gift"]], + "isDatabaseConstraint": true + } + ], + "Groupperson": [ + { + "rule": [["member"], ["group"]], + "isDatabaseConstraint": true + } + ], + "Institution": [ + { + "rule": [["name"], []], + "isDatabaseConstraint": false + } + ], + "Loan": [ + { + "rule": [["loanNumber"], ["discipline"]], + "isDatabaseConstraint": false + } + ], + "Loanagent": [ + { + "rule": [["role", "agent"], ["loan"]], + "isDatabaseConstraint": true + } + ], + "Locality": [ + { + "rule": [["uniqueIdentifier"], []], + "isDatabaseConstraint": true + } + ], + "Localitycitation": [ + { + "rule": [["referenceWork"], ["locality"]], + "isDatabaseConstraint": true + } + ], + "Pcrperson": [ + { + "rule": [["agent"], ["dnaSequence"]], + "isDatabaseConstraint": true + } + ], + "Permit": [ + { + "rule": [["permitNumber"], []], + "isDatabaseConstraint": false + } + ], + "Picklist": [ + { + "rule": [["name"], ["collection"]], + "isDatabaseConstraint": false + } + ], + "Preparation": [ + { + "rule": [["barCode"], ["collectionobject__collection"]], + "isDatabaseConstraint": true + } + ], + "Preptype": [ + { + "rule": [["name"], ["collection"]], + "isDatabaseConstraint": false + } + ], + "Repositoryagreement": [ + { + "rule": [["repositoryAgreementNumber"], ["division"]], + "isDatabaseConstraint": false + } + ], + "Spappresourcedata": [ + { + "rule": [["spAppResource"], []], + "isDatabaseConstraint": false + } + ], + "Specifyuser": [ + { + "rule": [["name"], []], + "isDatabaseConstraint": true + } + ], + "Taxontreedef": [ + { + "rule": [["name"], ["discipline"]], + "isDatabaseConstraint": false + } + ], + "Taxontreedefitem": [ + { + "rule": [["name"], ["treeDef"]], + "isDatabaseConstraint": false + }, + { + "rule": [["title"], ["treeDef"]], + "isDatabaseConstraint": false + } + ] +} diff --git a/specifyweb/businessrules/uniqueness_rules.py b/specifyweb/businessrules/uniqueness_rules.py index f2dc501450c..b11c74d13ed 100644 --- a/specifyweb/businessrules/uniqueness_rules.py +++ b/specifyweb/businessrules/uniqueness_rules.py @@ -1,179 +1,178 @@ +from functools import reduce +import logging import json +from typing import Dict, List, Union, Iterable + +from django.db import connections +from django.db.migrations.recorder import MigrationRecorder from django.core.exceptions import ObjectDoesNotExist -from typing import Dict, List, Union, Tuple from specifyweb.specify import models +from specifyweb.specify.datamodel import datamodel +from specifyweb.middleware.general import serialize_django_obj +from specifyweb.specify.scoping import in_same_scope from .orm_signal_handler import orm_signal_handler from .exceptions import BusinessRuleException -from specifyweb.middleware.general import serialize_django_obj +from .models import UniquenessRule +DEFAULT_UNIQUENESS_RULES: Dict[str, List[Dict[str, Union[List[List[str]], bool]]]] = json.load( + open('specifyweb/businessrules/uniqueness_rules.json')) -def make_uniqueness_rule(model_name, - rule_fields: Tuple[Tuple[str], Tuple[str]]): - model = getattr(models, model_name) - table_name = models.datamodel.get_table(model_name).name - base_fields = rule_fields[0] - parent_fields = rule_fields[1] - all_fields = [field for partition in rule_fields for field in partition] - - def get_matchable(instance): - def best_match_or_none(field_name): - try: - object_or_field = getattr(instance, field_name, None) - if object_or_field is None: - return None - if not hasattr(object_or_field, 'id'): - return field_name, object_or_field - if hasattr(instance, field_name+'_id'): - return field_name+'_id', object_or_field.id - - except ObjectDoesNotExist: - pass - return None +UNIQUENESS_DISPATCH_UID = 'uniqueness-rules' - matchable = {} - field_mapping = {} - for field in all_fields: - matched_or_none = best_match_or_none(field) - if matched_or_none is not None: - field_mapping[field] = matched_or_none[0] - matchable[matched_or_none[0]] = matched_or_none[1] - if len(matchable) != len(all_fields): - # if any field is missing, pass - return None +NO_FIELD_VALUE = {} + + +logger = logging.getLogger(__name__) + + +@orm_signal_handler('pre_save', None, dispatch_uid=UNIQUENESS_DISPATCH_UID) +def check_unique(model, instance): + model_name = instance.__class__.__name__ + rules = UniquenessRule.objects.filter(modelName=model_name) + applied_migrations = MigrationRecorder( + connections['default']).applied_migrations() + + for migration in applied_migrations: + app, migration_name = migration + if app == 'businessrules' and migration_name == '0001_initial': + break + else: + return + + for rule in rules: + if not rule_is_global(tuple(field.fieldPath for field in rule.fields.filter(isScope=True))) and not in_same_scope(rule, instance): + continue + + field_names = [ + field.fieldPath.lower() for field in rule.fields.filter(isScope=False)] + + _scope = rule.fields.filter(isScope=True) + scope = None if len(_scope) == 0 else _scope[0] + + all_fields = [*field_names] + + if scope is not None: + all_fields.append(scope.fieldPath.lower()) + + def get_matchable(instance): + def best_match_or_none(field_name: str): + try: + return field_path_with_value(instance, model_name, field_name, NO_FIELD_VALUE) + except ObjectDoesNotExist: + pass + return None + + matchable = {} + field_mapping = {} + for field in all_fields: + matched_or_none = best_match_or_none(field) + if matched_or_none is not None: + field_mapping[field] = matched_or_none[0] + matchable[matched_or_none[0]] = matched_or_none[1] + + return field_mapping, matchable + + def get_exception(conflicts, matchable, field_map): + error_message = '{} must have unique {}'.format(model_name, + join_with_and(field_names)) + + response = {"table": model_name, + "localizationKey": "fieldNotUnique" + if scope is None + else "childFieldNotUnique", + "fieldName": ','.join(field_names), + "fieldData": serialize_multiple_django(matchable, field_map, field_names), + } + + if scope is not None: + error_message += ' in {}'.format(scope.fieldPath.lower()) + response.update({ + "parentField": scope.fieldPath, + "parentData": serialize_multiple_django(matchable, field_map, [scope.fieldPath.lower()]) + }) + response['conflicting'] = list( + conflicts.values_list('id', flat=True)[:100]) + return BusinessRuleException(error_message, response) - return field_mapping, matchable - - def get_exception(conflicts, matchable, field_map): - error_message = '{} must have unique {}'.format(table_name, - join_with_and(base_fields)) - - response = {"table": table_name, - "localizationKey": "fieldNotUnique" - if len(parent_fields) == 0 - else "childFieldNotUnique", - "fieldName": ','.join(base_fields), - "fieldData": serialize_multiple_django(matchable, field_map, base_fields), - } - - if len(parent_fields) > 0: - error_message += ' in {}'.format(join_with_and(parent_fields)) - response.update({ - "parentField": ','.join(parent_fields), - "parentData": serialize_multiple_django(matchable, field_map, parent_fields) - }) - response['conflicting'] = list( - conflicts.values_list('id', flat=True)[:100]) - return BusinessRuleException(error_message, response) - - @orm_signal_handler('pre_save', model_name) - def check_unique(instance): match_result = get_matchable(instance) if match_result is None: return + field_map, matchable = match_result + if len(matchable.keys()) == 0 or set(all_fields) != set(field_map.keys()): + return + conflicts = model.objects.only('id').filter(**matchable) if instance.id is not None: conflicts = conflicts.exclude(id=instance.id) if conflicts: raise get_exception(conflicts, matchable, field_map) - return check_unique -def join_with_and(fields): - return ' and '.join(fields) +def field_path_with_value(instance, model_name, field_path, default): + object_or_field = reduce(lambda obj, field: getattr( + obj, field, default), field_path.split('__'), instance) + if object_or_field is default: + return None -RAW_UNIQUENESS_RULES: Dict[ - str, Dict[str, List[Union[Dict[str, Union[str, list]], str, None]]]] = \ - json.load(open( - 'specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json')) - -''' -The current definition of uniqueness rules are rather inconvenient. -For example, a definition like - "AccessionAgent":{ - "role":[ - { - "field":"accession", - "otherFields":[ - "agent" - ] - }, - { - "field":"repositoryagreement", - "otherFields":[ - "agent" - ] - } - ], - "agent":[ - { - "field":"accession", - "otherFields":[ - "role" - ] - }, - { - "field":"repositoryagreement", - "otherFields":[ - "role" - ] - } - ] - } -can simply be - "AccessionAgent": [ - (("role", "agent"), ("accession")), - (("role", "agent"), ("repositoryagreement")) - ] -The second format also makes it much easier to construct django queries. -So, parse_uniqueness_rules() automatically converts the current representation -TODO: Refactor front-end to use this instead -''' - - -def parse_uniqueness_rules(): - PARSED_UNIQUENESS_RULES = {} - for table, rules in RAW_UNIQUENESS_RULES.items(): - table = table.lower().capitalize() - if hasattr(models, table): - PARSED_UNIQUENESS_RULES[table] = [] - for field_name, rule in rules.items(): - # The Specify Model field names are always in lowercase - field_name = field_name.lower() - for rule_fields in rule: - child, parent = resolve_child_parent(field_name, - rule_fields) - matching_rule = [matched_rule - for matched_rule in - PARSED_UNIQUENESS_RULES[table] - if matched_rule == (child, parent)] - if len(matching_rule) == 0: - PARSED_UNIQUENESS_RULES[table].append((child, parent)) - return PARSED_UNIQUENESS_RULES - - -def resolve_child_parent(field, rule_instance): - child = [field] - parent = [] - if isinstance(rule_instance, dict): - parent.append(rule_instance['field']) - child.extend(rule_instance['otherFields']) - else: - if rule_instance is not None: - parent.append(rule_instance) - child.sort() - return tuple(child), tuple(parent) + if object_or_field is None: + if '__' in field_path or hasattr(object_or_field, 'id'): + return None + + table = datamodel.get_table_strict(model_name) + field = table.get_field_strict(field_path) + field_required = field.required if field is not None else False + if not field_required: + return None + + return field_path, object_or_field def serialize_multiple_django(matchable, field_map, fields): return {field: serialize_django_obj(matchable[field_map[field]]) for field in fields} -UNIQUENESS_RULES = parse_uniqueness_rules() -uniqueness_rules = [make_uniqueness_rule(model, rule_field) - for model, rules in list(UNIQUENESS_RULES.items()) - for rule_field in rules - ] +def join_with_and(fields): + return ' and '.join(fields) + + +def apply_default_uniqueness_rules(discipline: models.Discipline): + has_set_global_rules = len( + UniquenessRule.objects.filter(discipline=None)) > 0 + + for table, rules in DEFAULT_UNIQUENESS_RULES.items(): + model_name = datamodel.get_table_strict(table).django_name + for rule in rules: + fields, scopes = rule["rule"] + isDatabaseConstraint = rule["isDatabaseConstraint"] + + if rule_is_global(scopes): + if has_set_global_rules: + continue + else: + discipline = None + + create_uniqueness_rule( + model_name, discipline, isDatabaseConstraint, fields, scopes) + + +def create_uniqueness_rule(model_name, discipline, is_database_constraint, fields, scopes) -> UniquenessRule: + created_rule = UniquenessRule.objects.create(discipline=discipline, + modelName=model_name, isDatabaseConstraint=is_database_constraint) + created_rule.fields.set(fields) + created_rule.fields.add( + *scopes, through_defaults={"isScope": True}) + + +"""If a uniqueness rule has a scope which traverses through a hiearchy +relationship scoped above the discipline level, that rule should not be +scoped to discipline and instead be global +""" +GLOBAL_RULE_FIELDS = ["division", 'institution'] + + +def rule_is_global(scopes: Iterable[str]) -> bool: + return len(scopes) == 0 or any(any(scope_field.lower() in GLOBAL_RULE_FIELDS for scope_field in scope.split('__')) for scope in scopes) diff --git a/specifyweb/businessrules/urls.py b/specifyweb/businessrules/urls.py new file mode 100644 index 00000000000..d7273fa0640 --- /dev/null +++ b/specifyweb/businessrules/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls import include, url + +from . import views + +urlpatterns = [ + url(r'^uniqueness_rules/(?P\d+)/$', views.uniqueness_rule), + url(r'^uniqueness_rules/validate/$', views.validate_uniqueness), +] diff --git a/specifyweb/businessrules/views.py b/specifyweb/businessrules/views.py index 60f00ef0ef3..3f3d3b3f64b 100644 --- a/specifyweb/businessrules/views.py +++ b/specifyweb/businessrules/views.py @@ -1 +1,296 @@ # Create your views here. +import json + +from django import http +from django.db import transaction +from django.db.models import Q, Count +from django.views.decorators.http import require_http_methods, require_POST + +from specifyweb.businessrules.models import UniquenessRule +from specifyweb.businessrules.uniqueness_rules import rule_is_global +from specifyweb.specify.views import login_maybe_required, openapi +from specifyweb.specify import models +from specifyweb.specify.models import datamodel +from specifyweb.permissions.permissions import PermissionTarget, PermissionTargetAction, check_permission_targets + + +UniquenessRuleSchema = { + "type": "object", + "properties": { + "rule": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "fields": { + "type": "array", + "description": "The unique fields of the rule, which is an array of field names for a rule's model", + "items": { + "type": "string", + } + }, + "scopes": { + "type": "array", + "items": { + "description": "The 'scope' of the uniqueness rule. The rule is unique to database if scope is null and otherwise is a field name or path to a field", + "type": "string", + } + }, + "modelName": { + "type": "string" + }, + "isDatabaseConstraint": { + "type": "boolean" + } + } + } + }, + "required": ["id", "fields", "scopes", "modelName", "isDatabaseConstraint"], + "additionalProperties": False, +} + + +@openapi(schema={ + "get": { + "parameters": [ + {'in': 'query', 'name': 'model', 'required': False, + 'schema': {'type': 'string', 'description': 'The table name to fetch the uniqueness rules for'}} + ], + "responses": { + "200": { + "description": "Uniqueness Rules fetched successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "An object with keys corresponding to table names and values are an array of uniqueness rules", + "additionalProperties": { + "type": "array", + "description": "The array of uniqueness rules for a given table", + "items": UniquenessRuleSchema + } + } + } + } + } + } + }, + "put": { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "description": "The array of uniqueness rules for a given table", + "items": UniquenessRuleSchema + }, + "model": { + "type": "string" + } + }, + "required": ["rules"] + } + } + } + }, + "responses": { + "201": { + "description": "Uniqueness rules properly updated and/or created", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } +}) +@login_maybe_required +@require_http_methods(['GET', 'PUT']) +@transaction.atomic +def uniqueness_rule(request, discipline_id): + data = {} + + if request.method == 'GET': + + try: + model = request.GET["model"] + except: + model = None + + rules = UniquenessRule.objects.filter( + Q(discipline=discipline_id) | Q(discipline=None)) + for rule in rules: + rule_fields = rule.fields.filter(isScope=0) + scopes = rule.fields.filter(isScope=1) + + table = rule.modelName + if model is not None and table.lower() != model.lower(): + continue + if table not in data.keys(): + data[table] = [] + data[table].append({"rule": {"id": rule.id, "fields": [field.fieldPath for field in rule_fields], "scopes": [ + _scope.fieldPath for _scope in scopes], + "modelName": rule.modelName, "isDatabaseConstraint": rule.isDatabaseConstraint}}) + + else: + ids = set() + tables = set() + rules = json.loads(request.body)['rules'] + model = datamodel.get_table(json.loads(request.body)["model"]) + discipline = models.Discipline.objects.get(id=discipline_id) + for rule in rules: + scopes = rule["scopes"] + if rule["id"] is None: + fetched_rule = UniquenessRule.objects.create( + isDatabaseConstraint=rule["isDatabaseConstraint"], modelName=datamodel.get_table_strict(rule['modelName']).django_name, discipline=None if rule_is_global(scopes) else discipline) + ids.add(fetched_rule.id) + else: + ids.add(rule["id"]) + fetched_rule = UniquenessRule.objects.filter( + id=rule["id"]).first() + tables.add(fetched_rule.modelName) + fetched_rule.discipline = None if rule_is_global( + scopes) else discipline + fetched_rule.isDatabaseConstraint = rule["isDatabaseConstraint"] + fetched_rule.save() + + fetched_rule.fields.clear() + fetched_rule.fields.set(rule["fields"]) + if len(scopes) > 0: + fetched_rule.fields.add( + *scopes, through_defaults={"isScope": True}) + + rules_to_remove = UniquenessRule.objects.filter( + Q(discipline=discipline) | Q(discipline=None), Q(modelName__in=[*tables, model.django_name])).exclude(id__in=ids) + + rules_to_remove.delete() + + return http.JsonResponse(data, safe=False, status=201 if request.method == "PUT" else 200) + + +@openapi(schema={ + "post": { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "table": { + "type": "string", + "description": "The name of the table from which to validate uniqueness from", + }, + "rule": { + "type": "object", + "properties": { + "fields": { + "description": "An array containing field names from which represent the unique fields", + "type": "array", + "items": { + "type": "string" + } + }, + "scopes": { + "type": "array", + "items": { + "description": "The given scope of the uniqueness rule, as a field name", + "type": "string" + } + } + }, + "required": ["fields", "scopes"] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Uniqueness rules checked for validation", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "totalDuplicates": { + "type": "number", + "minimum": 0 + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "duplicates": { + "type": "number", + "minimum": 1 + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "description": "An object with keys of field names and values corresponding to the value of the field. \ + For example: `{catalognumber : 012345678}`", + } + } + }, + "additionalProperties": False + } + } + } + } + } + } + } + } + }}) +@require_POST +def validate_uniqueness(request): + data = json.loads(request.body) + table = datamodel.get_table_strict(data['table']) + django_model = getattr(models, table.django_name, None) + + if table is None or django_model is None: + return http.HttpResponseBadRequest('Invalid table name in request') + + uniqueness_rule = data['rule'] + fields = [field.lower() for field in uniqueness_rule['fields']] + scopes = [rule.lower() for rule in uniqueness_rule['scopes']] + + required_fields = {field: table.get_field( + field).required for field in fields} + + strict_filters = Q() + for field, is_required in required_fields.items(): + if not is_required: + strict_filters &= (~Q(**{f"{field}": None})) + + for scope in scopes: + strict_filters &= (~Q(**{f"{scope}": None})) + + all_fields = [*fields, *scopes] + + duplicates_field = '__duplicates' + + duplicates = django_model.objects.values( + *all_fields).annotate(**{duplicates_field: Count('id')}).filter(strict_filters).filter(**{f"{duplicates_field}__gt": 1}).order_by(f'-{duplicates_field}') + + total_duplicates = sum(duplicate[duplicates_field] + for duplicate in duplicates) + + final = { + "totalDuplicates": total_duplicates, + "fields": [{"duplicates": duplicate[duplicates_field], "fields": {field: value for field, value in duplicate.items() if field != duplicates_field}} + for duplicate in duplicates]} + + return http.JsonResponse(final, safe=False) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index 6a5cc9e9c79..cb5c9920038 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -19,6 +19,7 @@ import { Button, DialogContext } from '../Atoms/Button'; import { className } from '../Atoms/className'; import { Input, Label, Select } from '../Atoms/Form'; import { DEFAULT_FETCH_LIMIT, fetchCollection } from '../DataModel/collection'; +import { backendFilter } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import { getModel, schema } from '../DataModel/schema'; import type { Attachment, Tables } from '../DataModel/types'; @@ -111,16 +112,14 @@ function Attachments({ }, allTablesWithAttachments().length === tablesWithAttachments().length ? {} - : { - tableId__in: tablesWithAttachments() - .map(({ tableId }) => tableId) - .join(','), - } + : backendFilter('tableId').isIn( + tablesWithAttachments().map(({ tableId }) => tableId) + ) ).then(({ totalCount }) => totalCount), unused: fetchCollection( 'Attachment', { limit: 1 }, - { tableId__isNull: 'true' } + backendFilter('tableId').isNull() ).then(({ totalCount }) => totalCount), byTable: f.all( Object.fromEntries( @@ -156,7 +155,7 @@ function Attachments({ limit: DEFAULT_FETCH_LIMIT, }, filter.type === 'unused' - ? { tableId__isNull: 'true' } + ? backendFilter('tableId').isNull() : filter.type === 'byTable' ? { tableId: schema.models[filter.tableName].tableId, @@ -164,11 +163,9 @@ function Attachments({ : allTablesWithAttachments().length === tablesWithAttachments().length ? {} - : { - tableId__in: tablesWithAttachments() - .map(({ tableId }) => tableId) - .join(','), - } + : backendFilter('tableId').isIn( + tablesWithAttachments().map(({ tableId }) => tableId) + ) ), [order, filter] ) diff --git a/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx b/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx index c326a30816e..c9d37267f98 100644 --- a/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx @@ -17,6 +17,7 @@ import { Form, Input, Label } from '../Atoms/Form'; import { Submit } from '../Atoms/Submit'; import { LoadingContext } from '../Core/Contexts'; import { SplashScreen } from '../Core/SplashScreen'; +import { backboneFieldSeparator } from '../DataModel/helpers'; import type { SerializedModel } from '../DataModel/helperTypes'; import type { Collection } from '../DataModel/types'; import { toLargeSortConfig } from '../Molecules/Sorting'; @@ -77,7 +78,11 @@ function Wrapped({ // FEATURE: support sorting by related model (collection) => collection[ - toLowerCase(fieldNames.join('.') as keyof Collection['fields']) + toLowerCase( + fieldNames.join( + backboneFieldSeparator + ) as keyof Collection['fields'] + ) ], direction === 'desc' ) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index ae69815a794..cf30134ca63 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -1,75 +1,11 @@ import { mockTime, requireContext } from '../../../tests/helpers'; import { overwriteReadOnly } from '../../../utils/types'; -import { businessRuleDefs } from '../businessRuleDefs'; import { getResourceApiUrl } from '../resource'; import { schema } from '../schema'; mockTime(); requireContext(); -describe('uniqueness rules assigned correctly', () => { - test('otherField uniqueness rule assigned', async () => { - expect(businessRuleDefs.AccessionAgent?.uniqueIn).toMatchInlineSnapshot(` - { - "agent": [ - { - "field": "accession", - "otherFields": [ - "role", - ], - }, - { - "field": "repositoryagreement", - "otherFields": [ - "role", - ], - }, - ], - "role": [ - { - "field": "accession", - "otherFields": [ - "agent", - ], - }, - { - "field": "repositoryagreement", - "otherFields": [ - "agent", - ], - }, - ], - } - `); - }); - - test('Standard rules assigned correctly', async () => { - expect(businessRuleDefs.CollectionObject?.uniqueIn).toMatchInlineSnapshot(` - { - "catalogNumber": [ - "collection", - ], - "guid": [ - undefined, - ], - "uniqueIdentifier": [ - undefined, - ], - } - `); - }); - - test('JSON nulls are converted to undefined', async () => { - expect(businessRuleDefs.Permit?.uniqueIn).toMatchInlineSnapshot(` - { - "permitNumber": [ - undefined, - ], - } - `); - }); -}); - describe('Borrow Material business rules', () => { const borrowMaterialId = 1; const borrowMaterialUrl = getResourceApiUrl( diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/collection.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/collection.test.ts index 36b2c84f30a..115ae9791e8 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/collection.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/collection.test.ts @@ -2,6 +2,7 @@ import { overrideAjax } from '../../../tests/ajax'; import { requireContext } from '../../../tests/helpers'; import { addMissingFields } from '../addMissingFields'; import { fetchCollection, fetchRelated } from '../collection'; +import { backendFilter } from '../helpers'; import { getResourceApiUrl } from '../resource'; requireContext(); @@ -51,7 +52,7 @@ describe('fetchCollection', () => { })); overrideAjax( - '/api/specify/locality/?limit=1&localityname__istarswith=Test&id__in=1%2C2', + '/api/specify/locality/?limit=1&localityname__istartswith=Test&id__in=1%2C2', { meta: { total_count: 2, @@ -66,8 +67,8 @@ describe('fetchCollection', () => { 'Locality', { limit: 1 }, { - localityName__iStarsWith: 'Test', - id__in: '1,2', + ...backendFilter('localityName').caseInsensitiveStartsWith('Test'), + ...backendFilter('id').isIn([1, 2]), } ) ).resolves.toEqual({ diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/domain.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/domain.test.ts index 4251b39e06b..96cec8da0a2 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/domain.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/domain.test.ts @@ -7,6 +7,7 @@ import { fetchCollectionsForResource, getCollectionForResource, } from '../domain'; +import { formatRelationshipPath } from '../helpers'; import { getResourceApiUrl } from '../resource'; import { schema } from '../schema'; @@ -71,7 +72,7 @@ describe('fetchCollectionsForResource', () => { overrideAjax( formatUrl('/api/specify/collection/', { limit: '0', - discipline__division: divisionId.toString(), + [formatRelationshipPath('discipline', 'division')]: divisionId.toString(), }), { meta: { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/schemaBase.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/schemaBase.test.ts index 28a6892297e..0c86c629422 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/schemaBase.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/schemaBase.test.ts @@ -25,7 +25,6 @@ test('domain data is fetched and parsed correctly', async () => { 'Institution', ], paleoContextChildTable: 'collectionobject', - pathJoinSymbol: '.', referenceSymbol: '#', treeSymbol: '$', }); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 79c23e83b35..391941aaffe 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -1,6 +1,5 @@ -import type { RA } from '../../utils/types'; -import type { BusinessRuleResult } from './businessRules'; -import type { AnySchema, TableFields } from './helperTypes'; +import { BusinessRuleResult } from './businessRules'; +import { AnySchema, TableFields } from './helperTypes'; import { checkPrepAvailability, getTotalLoaned, @@ -24,7 +23,6 @@ import type { Tables, Taxon, } from './types'; -import uniquenessRules from './uniquness_rules.json'; export type BusinessRuleDefs = { readonly onAdded?: ( @@ -35,7 +33,6 @@ export type BusinessRuleDefs = { resource: SpecifyResource, collection: Collection ) => void; - readonly uniqueIn?: UniquenessRule; readonly customInit?: (resource: SpecifyResource) => void; readonly fieldChecks?: { readonly [FIELD_NAME in TableFields]?: ( @@ -44,31 +41,11 @@ export type BusinessRuleDefs = { }; }; -const uniqueRules: JSONUniquenessRules = uniquenessRules; - -type JSONUniquenessRules = { - readonly [TABLE in keyof Tables]?: JSONUniquenessRule; -}; - -type JSONUniquenessRule = { - readonly [FIELD_NAME in TableFields]?: - | RA<{ readonly field: string; readonly otherFields: readonly string[] }> - | RA - | RA; -}; - -export type UniquenessRule = { - readonly [FIELD_NAME in TableFields]?: - | RA<{ readonly field: string; readonly otherFields: readonly string[] }> - | RA - | RA; -}; - type MappedBusinessRuleDefs = { readonly [TABLE in keyof Tables]?: BusinessRuleDefs; }; -export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { +export const businessRuleDefs: MappedBusinessRuleDefs = { BorrowMaterial: { fieldChecks: { quantityReturned: ( @@ -365,41 +342,3 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }, }, }; - -/* - * From this code, Typescript believes that a businessRuleDefs uniqueIn can be from any table - * For example, it believes the following is possible: - * BusinessRuleDefs & {uniqueIn: UniquenessRule | UniquenessRule | ...} - */ -// @ts-expect-error -export const businessRuleDefs: MappedBusinessRuleDefs = Object.fromEntries( - ( - Object.keys({ ...uniqueRules, ...nonUniqueBusinessRuleDefs }) as RA< - keyof Tables - > - ).map((table) => { - /* - * To ensure compatibility and consistency with other areas of the frontend, - * the undefined type is preferable over the null type. - * In the JSON uniqueness rules, if a field should be unique at a global (institution) - * level, then it is unique in 'null'. - * Thus we need to replace null with undefined - */ - const uniquenessRules: UniquenessRule | undefined = - uniqueRules[table] === undefined - ? undefined - : Object.fromEntries( - Object.entries(uniqueRules[table]!).map(([fieldName, rule]) => [ - fieldName, - rule[0] === null ? [undefined] : rule, - ]) - ); - const ruleDefs = - nonUniqueBusinessRuleDefs[table] === undefined - ? uniquenessRules === undefined - ? undefined - : { uniqueIn: uniquenessRules } - : { ...nonUniqueBusinessRuleDefs[table], uniqueIn: uniquenessRules }; - return [table, ruleDefs]; - }) -); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index eef5e55485c..1adc11356f8 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -1,35 +1,35 @@ -import { formsText } from '../../localization/forms'; import type { ResolvablePromise } from '../../utils/promise'; import { flippedPromise } from '../../utils/promise'; import type { IR, RA } from '../../utils/types'; import { filterArray, overwriteReadOnly } from '../../utils/types'; +import { removeKey } from '../../utils/utils'; import { formatConjunction } from '../Atoms/Internationalization'; import { isTreeResource } from '../InitialContext/treeRanks'; import type { BusinessRuleDefs } from './businessRuleDefs'; import { businessRuleDefs } from './businessRuleDefs'; -import type { - AnySchema, - AnyTree, - CommonFields, - TableFields, -} from './helperTypes'; +import { backboneFieldSeparator, djangoLookupSeparator } from './helpers'; +import type { AnySchema, AnyTree, CommonFields } from './helperTypes'; import type { SpecifyResource } from './legacyTypes'; -import { idFromUrl } from './resource'; +import { getResourceApiUrl } from './resource'; import { SaveBlockers } from './saveBlockers'; import type { LiteralField, Relationship } from './specifyField'; -import type { Collection } from './specifyModel'; +import type { Collection, SpecifyModel } from './specifyModel'; import { initializeTreeRecord, treeBusinessRules } from './treeBusinessRules'; import type { CollectionObjectAttachment } from './types'; +import type { UniquenessRule } from './uniquenessRules'; +import { getUniqueInvalidReason, getUniquenessRules } from './uniquenessRules'; +/* eslint-disable functional/no-this-expression */ +// eslint-disable-next-line functional/no-class export class BusinessRuleManager { - private readonly resource: SpecifyResource; - - private readonly rules: BusinessRuleDefs | undefined; - // eslint-disable-next-line functional/prefer-readonly-type public pendingPromise: Promise = Promise.resolve(undefined); + private readonly resource: SpecifyResource; + + private readonly rules: BusinessRuleDefs | undefined; + // eslint-disable-next-line functional/prefer-readonly-type private fieldChangePromises: Record> = {}; @@ -41,6 +41,53 @@ export class BusinessRuleManager { this.rules = businessRuleDefs[this.resource.specifyModel.name]; } + public setUpManager(): void { + this.addPromise(this.invokeRule('customInit', undefined, [this.resource])); + if (isTreeResource(this.resource as SpecifyResource)) + initializeTreeRecord(this.resource as SpecifyResource); + + this.resource.on('change', this.changed, this); + this.resource.on('add', this.added, this); + this.resource.on('remove', this.removed, this); + } + + public async checkField( + fieldName: keyof SCHEMA['fields'] + ): Promise>> { + const processedFieldName = fieldName.toString().toLowerCase(); + const thisCheck: ResolvablePromise = flippedPromise(); + this.addPromise(thisCheck); + + if (this.fieldChangePromises[processedFieldName] !== undefined) + this.fieldChangePromises[processedFieldName].resolve('superseded'); + this.fieldChangePromises[processedFieldName] = thisCheck; + + const checks: RA | undefined>> = [ + this.invokeRule('fieldChecks', processedFieldName, [this.resource]), + this.checkUnique(processedFieldName), + isTreeResource(this.resource as SpecifyResource) + ? treeBusinessRules( + this.resource as SpecifyResource, + processedFieldName + ) + : Promise.resolve({ valid: true }), + ]; + + return Promise.all(checks).then((results) => { + /* + * TEST: Check if the variable is necessary. The legacy js code called processCheckFieldResults first before resolving. + * Using the variable to maintain same functionality, as processCheckFieldResults might have side-effects, + * especially since pendingPromise is public. Assuming that legacy code had no related bugs to this. + */ + const resolvedResult: RA> = + thisCheck === this.fieldChangePromises[processedFieldName] + ? this.processCheckFieldResults(processedFieldName, results) + : [{ valid: true }]; + thisCheck.resolve('finished'); + return resolvedResult; + }); + } + private addPromise( promise: Promise ): void { @@ -51,17 +98,24 @@ export class BusinessRuleManager { } private changed(resource: SpecifyResource): void { - if (resource.isBeingInitialized && typeof resource.changed === 'object') { - Object.keys(resource.changed).forEach((field) => { - this.checkField(field); - }); + if ( + !resource.isBeingInitialized() && + typeof resource.changed === 'object' + ) { + this.addPromise( + Promise.all( + Object.keys(resource.changed).map(async (field) => + this.checkField(field) + ) + ).then(() => undefined) + ); } } private added( resource: SpecifyResource, collection: Collection - ) { + ): void { /** * REFACTOR: remove the need for this and the orderNumber check by * implementing a general solution on the backend @@ -86,54 +140,6 @@ export class BusinessRuleManager { ); } - public setUpManager(): void { - this.addPromise(this.invokeRule('customInit', undefined, [this.resource])); - if (isTreeResource(this.resource as SpecifyResource)) - initializeTreeRecord(this.resource as SpecifyResource); - - this.resource.on('change', this.changed, this); - this.resource.on('add', this.added, this); - this.resource.on('remove', this.removed, this); - } - - public async checkField( - fieldName: keyof SCHEMA['fields'] - ): Promise>> { - fieldName = - typeof fieldName === 'string' ? fieldName.toLowerCase() : fieldName; - const thisCheck: ResolvablePromise = flippedPromise(); - this.addPromise(thisCheck); - - if (this.fieldChangePromises[fieldName as string] !== undefined) - this.fieldChangePromises[fieldName as string].resolve('superseded'); - this.fieldChangePromises[fieldName as string] = thisCheck; - - const checks: RA | undefined>> = [ - this.invokeRule('fieldChecks', fieldName, [this.resource]), - this.checkUnique(fieldName), - isTreeResource(this.resource as SpecifyResource) - ? treeBusinessRules( - this.resource as SpecifyResource, - fieldName as string - ) - : Promise.resolve({ valid: true }), - ]; - - return Promise.all(checks).then((results) => { - /* - * TEST: Check if the variable is necessary. The legacy js code called processCheckFieldResults first before resolving. - * Using the variable to maintain same functionality, as processCheckFieldResults might have side-effects, - * especially since pendingPromise is public. Assuming that legacy code had no related bugs to this. - */ - const resolvedResult: RA> = - thisCheck === this.fieldChangePromises[fieldName as string] - ? this.processCheckFieldResults(fieldName, results) - : [{ valid: true }]; - thisCheck.resolve('finished'); - return resolvedResult; - }); - } - private processCheckFieldResults( fieldName: keyof SCHEMA['fields'], results: RA | undefined> @@ -165,43 +171,34 @@ export class BusinessRuleManager { private async checkUnique( fieldName: keyof SCHEMA['fields'] ): Promise> { - const scopeFields = - this.rules?.uniqueIn === undefined - ? [] - : this.rules?.uniqueIn[ - this.resource.specifyModel.getField(fieldName as string) - ?.name as TableFields - ] ?? []; - const results: RA>> = scopeFields.map( - async (uniqueRule) => { - let scope = uniqueRule; - let fieldNames: readonly string[] | undefined = [fieldName as string]; - if (uniqueRule !== undefined && typeof uniqueRule !== 'string') { - fieldNames = fieldNames.concat(uniqueRule.otherFields); - scope = uniqueRule.field; - } - return this.uniqueIn( - (scope as string | undefined)?.toLowerCase(), - fieldNames - ); - } + const rules = getUniquenessRules(this.resource.specifyModel.name) ?? []; + const rulesToCheck = rules.filter(({ rule }) => + rule.fields.some( + (ruleFieldName) => + ruleFieldName.toLowerCase() === (fieldName as string).toLowerCase() + ) ); - Promise.all(results).then((results) => { + + const results = rulesToCheck.map(async ({ rule }) => this.uniqueIn(rule)); + void Promise.all(results).then((results) => results .flatMap((result: BusinessRuleResult) => result.localDuplicates) .filter((result) => result !== undefined) .forEach((duplicate: SpecifyResource | undefined) => { if (duplicate === undefined) return; const event = `${duplicate.cid}:${fieldName as string}`; - if (!this.watchers[event]) { - this.watchers[event] = () => - duplicate.on(`change:${fieldName as string}`, async () => - this.checkField(fieldName) + if (this.watchers[event] === undefined) { + this.watchers[event] = (): void => + duplicate.on( + `change:${fieldName as string}`, + () => void this.checkField(fieldName) ); - duplicate.once('remove', () => delete this.watchers[event]); + duplicate.once('remove', () => { + this.watchers = removeKey(this.watchers, event); + }); } - }); - }); + }) + ); return Promise.all(results).then((results) => { const invalids = results.filter((result) => !result.valid); return invalids.length === 0 @@ -219,178 +216,84 @@ export class BusinessRuleManager { }); } - private getUniqueInvalidReason( - scopeField: LiteralField | Relationship | undefined, - field: RA - ): string { - if (field.length > 1) - return scopeField - ? formsText.valuesOfMustBeUniqueToField({ - values: formatConjunction(field.map((fld) => fld.label)), - fieldName: scopeField.label, - }) - : formsText.valuesOfMustBeUniqueToDatabase({ - values: formatConjunction(field.map((fld) => fld.label)), - }); - else - return scopeField - ? formsText.valueMustBeUniqueToField({ - fieldName: scopeField.label, - }) - : formsText.valueMustBeUniqueToDatabase(); - } - private async uniqueIn( - scope: string | undefined, - fieldNames: RA | string | undefined + rule: UniquenessRule ): Promise> { - if (fieldNames === undefined) { - return { - valid: false, - reason: formsText.valueMustBeUniqueToDatabase(), - }; - } - fieldNames = Array.isArray(fieldNames) ? fieldNames : [fieldNames]; - - const fieldValues = fieldNames.map((value) => this.resource.get(value)); - - const fieldInfo = fieldNames.map( - (field) => this.resource.specifyModel.getField(field)! - ); - - const fieldIsToOne = fieldInfo.map( - (field) => field?.type === 'many-to-one' - ); - - const fieldIds = fieldValues.map((value, index) => { - if ( - fieldIsToOne[index] !== undefined && - value !== undefined && - value !== null - ) { - return idFromUrl(value); - } - return undefined; - }); - - const scopeFieldInfo = - scope !== null && scope !== undefined - ? (this.resource.specifyModel.getField(scope) as Relationship) - : undefined; - - const allNullOrUndefinedToOnes = fieldIds.reduce( - (previous, _current, index) => - previous && fieldIsToOne[index] ? fieldIds[index] === null : false, - true - ); - const invalidResponse: BusinessRuleResult = { valid: false, - // eslint-disable-next-line - reason: fieldInfo.some((field) => field === undefined) - ? '' - : this.getUniqueInvalidReason(scopeFieldInfo, fieldInfo), + reason: getUniqueInvalidReason( + rule.scopes.map( + (scope) => + getFieldsFromPath(this.resource.specifyModel, scope).at( + -1 + ) as Relationship + ), + rule.fields.map((field) => + this.resource.specifyModel.strictGetField(field) + ) + ), }; - if (allNullOrUndefinedToOnes) return { valid: true }; - - const hasSameValues = (other: SpecifyResource): boolean => { - const hasSameValue = ( - fieldValue: number | string | null, - fieldName: string - ): boolean => { - if (other.id != null && other.id === this.resource.id) return false; - if (other.cid === this.resource.cid) return false; - const otherValue = other.get(fieldName); - - return fieldValue === otherValue; - }; - - return fieldValues.reduce( - (previous, current, index) => - previous && hasSameValue(current, fieldNames![index]), + const hasSameValues = ( + other: SpecifyResource, + fieldValues: IR + ): boolean => { + if (other.id != null && other.id === this.resource.id) return false; + if (other.cid === this.resource.cid) return false; + + return Object.entries(fieldValues).reduce( + (result, [fieldName, value]) => { + const field = other.specifyModel.getField(fieldName); + const adjustedValue = + field?.isRelationship && + typeof value === 'number' && + field.type === 'many-to-one' + ? getResourceApiUrl(field.relatedModel.name, value) + : value; + return result && adjustedValue === other.get(fieldName); + }, true ); }; - if (scope === undefined) { - const filters: Partial> = {}; - - for (const [f, fieldName] of fieldNames.entries()) { - filters[fieldName] = fieldIds[f] || fieldValues[f]; - } - const others = new this.resource.specifyModel.LazyCollection({ - filters: filters as Partial< - CommonFields & - IR & - SCHEMA['fields'] & { - readonly orderby: string; - readonly domainfilter: boolean; - } - >, - }); - return others - .fetch() - .then((fetchedCollection) => - fetchedCollection.models.some((other: SpecifyResource) => - hasSameValues(other) - ) - ? invalidResponse - : { valid: true } - ); - } else { - const localCollection = this.resource.collection ?? { models: [] }; - - if ( - typeof localCollection.field?.name === 'string' && - localCollection.field.name.toLowerCase() !== scope + const filters = Object.fromEntries( + await Promise.all( + [...rule.fields, ...rule.scopes].map(async (field) => { + const related: SpecifyResource | number | string | null = + await this.resource.getRelated( + field.replaceAll(djangoLookupSeparator, backboneFieldSeparator) + ); + return [field, related.id === undefined ? related : related.id]; + }) ) - return { valid: true }; - - const localResources = filterArray(localCollection.models); + ) as unknown as Partial< + CommonFields & + Readonly> & + SCHEMA['fields'] & { + readonly orderby: string; + } + >; - const duplicates = localResources.filter((resource) => - hasSameValues(resource) + if ( + Object.entries(filters).some(([_field, value]) => value === undefined) + ) { + const localCollection = this.resource.collection ?? { models: [] }; + const duplicates = localCollection.models.filter((other) => + hasSameValues(other, filters as IR) ); - if (duplicates.length > 0) { overwriteReadOnly(invalidResponse, 'localDuplicates', duplicates); return invalidResponse; - } - - const relatedPromise: Promise> = - this.resource.getRelated(scope); - - return relatedPromise.then(async (related) => { - if (!related) return { valid: true }; - const filters: Partial> = {}; - for (let f = 0; f < fieldNames!.length; f++) { - filters[fieldNames![f]] = fieldIds[f] || fieldValues[f]; - } - const others = new this.resource.specifyModel.ToOneCollection({ - related, - field: scopeFieldInfo, - filters: filters as Partial< - CommonFields & - IR & - SCHEMA['fields'] & { - readonly orderby: string; - readonly domainfilter: boolean; - } - >, - }); - - return others.fetch().then((fetchedCollection) => { - const inDatabase = fetchedCollection.models.filter( - (otherResource) => otherResource !== undefined - ); - - return inDatabase.some((other) => hasSameValues(other)) - ? invalidResponse - : { valid: true }; - }); - }); + } else return { valid: true }; } + + return new this.resource.specifyModel.LazyCollection({ + filters, + }) + .fetch() + .then((fetchedCollection) => + fetchedCollection.models.length > 0 ? invalidResponse : { valid: true } + ); } private async invokeRule( @@ -398,7 +301,7 @@ export class BusinessRuleManager { fieldName: keyof SCHEMA['fields'] | undefined, args: RA ): Promise { - if (this.rules === undefined || ruleName === 'uniqueIn') { + if (this.rules === undefined) { return undefined; } let rule = this.rules[ruleName]; @@ -422,11 +325,30 @@ export class BusinessRuleManager { * However, rule will never be this.rules["fieldChecks"] */ // @ts-expect-error - return rule.apply(undefined, args); + return rule(...args); } } +/* eslint-enable functional/no-this-expression */ + +export function getFieldsFromPath( + model: SpecifyModel, + fieldPath: string +): RA { + const fields = fieldPath.split(djangoLookupSeparator); + + let currentModel = model; + return fields.map((fieldName) => { + const field = currentModel.strictGetField(fieldName); + if (field.isRelationship) { + currentModel = field.relatedModel; + } + return field; + }); +} -export function attachBusinessRules(resource: SpecifyResource) { +export function attachBusinessRules( + resource: SpecifyResource +): void { const businessRuleManager = new BusinessRuleManager(resource); overwriteReadOnly(resource, 'saveBlockers', new SaveBlockers(resource)); overwriteReadOnly(resource, 'businessRuleManager', businessRuleManager); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/domain.ts b/specifyweb/frontend/js_src/lib/components/DataModel/domain.ts index 704dcabc3d0..a9eb0a98cf7 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/domain.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/domain.ts @@ -4,7 +4,7 @@ import { raise } from '../Errors/Crash'; import { getCollectionPref } from '../InitialContext/remotePrefs'; import { hasTablePermission } from '../Permissions/helpers'; import { fetchCollection } from './collection'; -import { toTable } from './helpers'; +import { djangoLookupSeparator, toTable } from './helpers'; import type { AnySchema } from './helperTypes'; import type { SpecifyResource } from './legacyTypes'; import { getResourceApiUrl, idFromUrl } from './resource'; @@ -147,7 +147,7 @@ export async function fetchCollectionsForResource( domainResource.specifyModel.name ) .map((level) => level.toLowerCase()) - .join('__'); + .join(djangoLookupSeparator); return fieldsBetween.length === 0 ? undefined : fetchCollection( diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts b/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts index a6c377d6502..3da9edc27ba 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts @@ -35,6 +35,106 @@ export const specialFields = new Set([ '_tableName', ]); +/** + * Lookups for operaters and relationships in Queries on the backend are + * separated by `__` + * The api supports the same syntax in query paramters. + * For example `/api/specify/collectionobject/?collection__discipline__name__startswith="Invert"&catalognumber__gt=000000100` + * fetches all collectionobjects in disciplines which names start with + * "Invert" with catalognumbers greater than 100 + */ +export const djangoLookupSeparator = '__'; + +export const backboneFieldSeparator = '.'; + +/** + * Formats a relationship lookup which is used in a query passed to the backend. + * For example `formatRelationshipPath('collection', 'discipline', 'division')` + * becomes `'collection__discipline__division'` + */ +export const formatRelationshipPath = (...fields: RA): string => + fields.join(djangoLookupSeparator); + +const weekDayMap = { + Sunday: 1, + Monday: 2, + Tuesday: 3, + Wednesday: 4, + Thursday: 5, + Friday: 6, + Saturday: 7, +}; + +/** + * Use this to construct a query using a lookup for Django. + * Returns an object which can be used as a filter when fetched from the backend. + * Example: backendFilter('number1').isIn([1, 2, 3]) is the equivalent + * of {number1__in: [1, 2, 3].join(',')} + * + * See the Django docs at: + * https://docs.djangoproject.com/en/3.2/ref/models/querysets/#field-lookups + */ +export const backendFilter = (field: string) => ({ + equals: (value: number | string) => ({ + [[field, 'exact'].join(djangoLookupSeparator)]: value, + }), + contains: (value: string) => ({ + [[field, 'contains'].join(djangoLookupSeparator)]: value, + }), + caseInsensitiveContains: (value: string) => ({ + [[field, 'icontains'].join(djangoLookupSeparator)]: value, + }), + caseInsensitiveStartsWith: (value: string) => ({ + [[field, 'istartswith'].join(djangoLookupSeparator)]: value, + }), + startsWith: (value: string) => ({ + [[field, 'startswith'].join(djangoLookupSeparator)]: value, + }), + caseInsensitiveEndsWith: (value: string) => ({ + [[field, 'iendswith'].join(djangoLookupSeparator)]: value, + }), + endsWith: (value: string) => ({ + [[field, 'endswith'].join(djangoLookupSeparator)]: value, + }), + isIn: (value: RA) => ({ + [[field, 'in'].join(djangoLookupSeparator)]: value.join(','), + }), + isNull: (value: 'false' | 'true' = 'true') => ({ + [[field, 'isnull'].join(djangoLookupSeparator)]: value, + }), + greaterThan: (value: number) => ({ + [[field, 'gt'].join(djangoLookupSeparator)]: value, + }), + greaterThanOrEqualTo: (value: number) => ({ + [[field, 'gte'].join(djangoLookupSeparator)]: value, + }), + lessThan: (value: number) => ({ + [[field, 'lt'].join(djangoLookupSeparator)]: value, + }), + lessThanOrEqualTo: (value: number) => ({ + [[field, 'lte'].join(djangoLookupSeparator)]: value, + }), + matchesRegex: (value: string) => ({ + [[field, 'regex'].join(djangoLookupSeparator)]: value, + }), + + dayEquals: (value: number) => ({ + [[field, 'day'].join(djangoLookupSeparator)]: value, + }), + monthEquals: (value: number) => ({ + [[field, 'lte'].join(djangoLookupSeparator)]: value, + }), + yearEquals: (value: number) => ({ + [[field, 'year'].join(djangoLookupSeparator)]: value, + }), + weekEquals: (value: number) => ({ + [[field, 'week'].join(djangoLookupSeparator)]: value, + }), + weekDayEquals: (value: keyof typeof weekDayMap) => ({ + [[field, 'week_day'].join(djangoLookupSeparator)]: weekDayMap[value], + }), +}); + // REFACTOR: get rid of the need for this export function resourceToModel( resource: SerializedModel | SerializedResource, @@ -213,7 +313,7 @@ export async function fetchDistantRelated( fields .slice(0, -1) .map(({ name }) => name) - .join('.') + .join(backboneFieldSeparator) ); const field = fields?.at(-1); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index 08649caa384..cb2f472ade6 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -27,7 +27,6 @@ export type SpecifyResource = { readonly cid: string; readonly noValidation?: boolean; readonly populated: boolean; - readonly isBeingInitialized: boolean; readonly createdBy?: 'clone'; readonly specifyModel: SpecifyModel; readonly saveBlockers?: Readonly>; @@ -175,6 +174,7 @@ export type SpecifyResource = { fetch(): Promise>; viewUrl(): string; isNew(): boolean; + isBeingInitialized(): boolean; clone(cloneAll: boolean): Promise>; // eslint-disable-next-line @typescript-eslint/naming-convention toJSON(): SerializedModel; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts index bab51031e1c..1197d2de2f8 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts @@ -10,7 +10,6 @@ import { userPreferences } from '../Preferences/userPreferences'; import { formatUrl } from '../Router/queryString'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { addMissingFields } from './addMissingFields'; -import { businessRuleDefs } from './businessRuleDefs'; import { serializeResource } from './helpers'; import type { AnySchema, @@ -21,6 +20,8 @@ import type { SpecifyResource } from './legacyTypes'; import { getModel, schema } from './schema'; import type { SpecifyModel } from './specifyModel'; import type { Tables } from './types'; +import { getUniquenessRules } from './uniquenessRules'; +import { getFieldsFromPath } from './businessRules'; // FEATURE: use this everywhere export const resourceEvents = eventListener<{ @@ -267,23 +268,20 @@ const uniqueFields = [ export const getUniqueFields = (model: SpecifyModel): RA => f.unique([ - ...Object.entries(businessRuleDefs[model.name]?.uniqueIn ?? {}) - .filter( - /* - * When cloning a resource, do not carry over the field which have - * uniqueness rules which are scoped to one of the institutional - * hierarchy tables or should be globally unique. - * All other uniqueness rules can be cloned - */ - ([_field, [uniquenessScope]]: readonly [ - string, - RA | string> | string | undefined> - ]) => - typeof uniquenessScope === 'string' - ? uniquenessScope in schema.domainLevelIds - : uniquenessScope === undefined - ) - .map(([fieldName]) => model.strictGetField(fieldName).name), + ...filterArray( + (getUniquenessRules(model.name) ?? []) + .filter(({ rule: { scopes } }) => + scopes.every( + (fieldPath) => + ( + getFieldsFromPath(model, fieldPath).at(-1)?.name ?? '' + ).toLowerCase() in schema.domainLevelIds + ) + ) + .flatMap(({ rule: { fields } }) => + fields.flatMap((field) => model.getField(field)?.name) + ) + ), /* * Each attachment is assumed to refer to a unique attachment file * See https://github.com/specify/specify7/issues/1754#issuecomment-1157796585 diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js index c112ff41964..0d8bc826afd 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js @@ -8,7 +8,7 @@ import { softFail } from '../Errors/Crash'; import { Backbone } from './backbone'; import { attachBusinessRules } from './businessRules'; import { initializeResource } from './domain'; -import { specialFields } from './helpers'; +import { backboneFieldSeparator, specialFields } from './helpers'; import { getFieldsToNotClone, getResourceApiUrl, @@ -82,8 +82,8 @@ export const ResourceBase = Backbone.Model.extend({ * More specifically, returns true while this resource holds a reference * to Backbone's save() and fetch() in _save and _fetch */ - get isBeingInitialized() { - return this._save === null && this._fetch === null; + isBeingInitialized(){ + return this._save !== null || this._fetch !== null; }, constructor() { @@ -501,7 +501,7 @@ export const ResourceBase = Backbone.Model.extend({ prePop: false, noBusinessRules: false, }; - const path = _(fieldName).isArray() ? fieldName : fieldName.split('.'); + const path = _(fieldName).isArray() ? fieldName : fieldName.split(backboneFieldSeparator); // First make sure we actually have this object. return this.fetch() @@ -761,7 +761,7 @@ export const ResourceBase = Backbone.Model.extend({ .rest(myPath.length - 1) .reverse(); // REFACTOR: use mappingPathToString in all places like this - return other.rget(diff.join('.')).then((common) => { + return other.rget(diff.join(backboneFieldSeparator)).then((common) => { if (common === undefined) return undefined; self.set(_(diff).last(), common.url()); return common; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/schemaBase.ts b/specifyweb/frontend/js_src/lib/components/DataModel/schemaBase.ts index b4ca2f60d68..fdd71a5b8c3 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/schemaBase.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/schemaBase.ts @@ -36,7 +36,6 @@ export type Schema = { readonly referenceSymbol: string; readonly treeSymbol: string; readonly fieldPartSeparator: string; - readonly pathJoinSymbol: string; }; const schema: Writable = { @@ -71,11 +70,6 @@ const schema: Writable = { treeSymbol: '$', // Separator for partial fields (date parts in Query Builder) fieldPartSeparator: '-', - /* - * A symbol that is used to join multiple mapping path elements together when - * there is a need to represent a mapping path as a string - */ - pathJoinSymbol: '.', }; /** Careful, the order here matters */ diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts b/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts index 57a17bd3e01..07805e7b2f4 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts @@ -19,6 +19,7 @@ import { LazyCollection, ToOneCollection, } from './collectionApi'; +import { backboneFieldSeparator } from './helpers'; import type { AnySchema, CommonFields, @@ -324,7 +325,10 @@ export class SpecifyModel { if (unparsedName === '') return undefined; if (typeof unparsedName !== 'string') throw new Error('Invalid field name'); - const splitName = unparsedName.toLowerCase().trim().split('.'); + const splitName = unparsedName + .toLowerCase() + .trim() + .split(backboneFieldSeparator); let fields = filterArray([ this.fields.find((field) => field.name.toLowerCase() === splitName[0]), ]); @@ -340,7 +344,7 @@ export class SpecifyModel { const alias = this.fieldAliases[splitName[0]]; if (typeof alias === 'string') { const aliasFields = this.getFields( - [alias, ...splitName.slice(1)].join('.') + [alias, ...splitName.slice(1)].join(backboneFieldSeparator) ); if (Array.isArray(aliasFields)) fields = aliasFields; else @@ -355,12 +359,12 @@ export class SpecifyModel { splitName.length > 1 && splitName[0].toLowerCase() === this.name.toLowerCase() ) - return this.getFields(splitName.slice(1).join('.')); + return this.getFields(splitName.slice(1).join(backboneFieldSeparator)); else if (fields.length === 0) return undefined; else if (splitName.length === 1) return fields; else if (splitName.length > 1 && fields[0].isRelationship) { const subFields = defined(fields[0].relatedModel).getFields( - splitName.slice(1).join('.') + splitName.slice(1).join(backboneFieldSeparator) ); if (subFields === undefined) return undefined; return [...fields, ...subFields]; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniquenessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/uniquenessRules.ts new file mode 100644 index 00000000000..7402517be56 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniquenessRules.ts @@ -0,0 +1,169 @@ +import React from 'react'; + +import { formsText } from '../../localization/forms'; +import { ajax } from '../../utils/ajax'; +import { f } from '../../utils/functools'; +import { + type GetOrSet, + type IR, + type RA, + type RR, + setDevelopmentGlobal, +} from '../../utils/types'; +import { formatConjunction } from '../Atoms/Internationalization'; +import { load } from '../InitialContext'; +import { strictGetModel } from './schema'; +import type { LiteralField, Relationship } from './specifyField'; +import type { Tables } from './types'; + +export type UniquenessRule = { + readonly id: number | null; + readonly fields: RA; + readonly scopes: RA; + readonly modelName: keyof Tables; + readonly isDatabaseConstraint: boolean; +}; + +export type UniquenessRules = Partial< + RR< + keyof Tables, + | RA<{ + readonly rule: UniquenessRule; + readonly duplicates: UniquenessRuleValidation; + }> + | undefined + > +>; + +export type UniquenessRuleValidation = { + readonly totalDuplicates: number; + readonly fields: RA<{ + readonly fields: IR; + readonly duplicates: number; + }>; +}; + +let uniquenessRules: UniquenessRules = {}; + +export const fetchContext = f + .all({ + schemaBase: import('./schemaBase').then( + async ({ fetchContext }) => fetchContext + ), + schema: import('./schema').then(async ({ fetchContext }) => fetchContext), + }) + .then(async ({ schemaBase }) => + load( + `/businessrules/uniqueness_rules/${schemaBase.domainLevelIds.discipline}/`, + 'application/json' + ) + ) + .then((data) => + Object.fromEntries( + Object.entries(data).map(([lowercaseTableName, rules]) => { + // Convert all lowercase table names from backend to PascalCase + const tableName = strictGetModel(lowercaseTableName).name; + const getDuplicates = ( + uniqueRule: UniquenessRule + ): UniquenessRuleValidation => + uniquenessRules[tableName]?.find( + ({ rule }) => rule.id === uniqueRule.id + )?.duplicates ?? { totalDuplicates: 0, fields: [] }; + + return [ + tableName, + rules?.map(({ rule }) => ({ + rule: { ...rule }, + duplicates: getDuplicates(rule), + })), + ]; + }) + ) + ) + .then((data) => { + uniquenessRules = data; + setDevelopmentGlobal('_uniquenessRules', data); + return data; + }); + +export function getUniquenessRules(): UniquenessRules | undefined; +export function getUniquenessRules( + tableName: TABLE_NAME +): UniquenessRules[TABLE_NAME] | undefined; +export function getUniquenessRules( + tableName?: TABLE_NAME +): UniquenessRules | UniquenessRules[TABLE_NAME] { + return Object.keys(uniquenessRules).length === 0 + ? undefined + : tableName === undefined + ? uniquenessRules + : uniquenessRules[tableName]; +} + +export function useTableUniquenessRules( + tableName: keyof Tables +): readonly [ + ...tableRules: GetOrSet, + setCachedTableRules: (value: UniquenessRules[keyof Tables]) => void +] { + const [rawModelRules = [], setTableUniquenessRules] = React.useState( + uniquenessRules[tableName] + ); + + const setStoredUniquenessRules = ( + value: UniquenessRules[keyof Tables] + ): void => { + uniquenessRules = { + ...uniquenessRules, + [tableName]: value, + }; + }; + + return [rawModelRules, setTableUniquenessRules, setStoredUniquenessRules]; +} + +export function getUniqueInvalidReason( + scopeFields: RA, + fields: RA +): string { + if (fields.length > 1) + return scopeFields.length > 0 + ? formsText.valuesOfMustBeUniqueToField({ + values: formatConjunction(fields.map(({ label }) => label)), + fieldName: formatConjunction(scopeFields.map(({ label }) => label)), + }) + : formsText.valuesOfMustBeUniqueToDatabase({ + values: formatConjunction(fields.map(({ label }) => label)), + }); + else + return scopeFields.length > 0 + ? formsText.valueMustBeUniqueToField({ + fieldName: formatConjunction(scopeFields.map(({ label }) => label)), + }) + : formsText.valueMustBeUniqueToDatabase(); +} + +export async function validateUniqueness< + TABLE_NAME extends keyof Tables, + SCHEMA extends Tables[TABLE_NAME] +>( + table: TABLE_NAME, + fields: RA, + scopes: RA +): Promise { + return ajax( + '/businessrules/uniqueness_rules/validate/', + { + // eslint-disable-next-line @typescript-eslint/naming-convention + headers: { Accept: 'application/json' }, + method: 'POST', + body: { + table, + rule: { + fields, + scopes, + }, + }, + } + ).then(({ data }) => data); +} diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json deleted file mode 100644 index e3d82d62b0c..00000000000 --- a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "Accession": { - "accessionNumber": ["division"] - }, - "AccessionAgent": { - "role": [ - { - "field": "accession", - "otherFields": ["agent"] - }, - { - "field": "repositoryagreement", - "otherFields": ["agent"] - } - ], - "agent": [ - { - "field": "accession", - "otherFields": ["role"] - }, - { - "field": "repositoryagreement", - "otherFields": ["role"] - } - ] - }, - "Appraisal": { - "appraisalNumber": ["accession"] - }, - "Author": { - "agent": ["referencework"], - "ordernumber": ["referencework"] - }, - "BorrowAgent": { - "role": [ - { - "field": "borrow", - "otherFields": ["agent"] - } - ], - "agent": [ - { - "field": "borrow", - "otherFields": ["role"] - } - ] - }, - "Collection": { - "collectionName": ["discipline"], - "code": ["discipline"] - }, - "CollectingEvent": { - "uniqueIdentifier": [null] - }, - "CollectionObject": { - "catalogNumber": ["collection"], - "uniqueIdentifier": [null], - "guid": [null] - }, - "Collector": { - "agent": ["collectingevent"] - }, - "Determiner": { - "agent": ["determination"] - }, - "Discipline": { - "name": ["division"] - }, - "DisposalAgent": { - "role": [ - { - "field": "disposal", - "otherFields": ["agent"] - } - ], - "agent": [ - { - "field": "disposal", - "otherFields": ["role"] - } - ] - }, - "Division": { - "name": ["institution"] - }, - "Extractor": { - "agent": ["dnasequence"] - }, - "FundingAgent": { - "agent": ["collectingtrip"] - }, - "Gift": { - "giftNumber": ["discipline"] - }, - "GiftAgent": { - "role": [ - { - "field": "gift", - "otherFields": ["agent"] - } - ], - "agent": [ - { - "field": "gift", - "otherFields": ["role"] - } - ] - }, - "GroupPerson": { - "member": ["group"] - }, - "Institution": { - "name": [null] - }, - "Loan": { - "loanNumber": ["discipline"] - }, - "LoanAgent": { - "role": [ - { - "field": "loan", - "otherFields": ["agent"] - } - ], - "agent": [ - { - "field": "loan", - "otherFields": ["role"] - } - ] - }, - "Locality": { - "uniqueIdentifier": [null] - }, - "LocalityCitation": { - "referenceWork": ["locality"] - }, - "PcrPerson": { - "agent": ["dnasequence"] - }, - "Permit": { - "permitNumber": [null] - }, - "PickList": { - "name": ["collection"] - }, - "PrepType": { - "name": ["collection"] - }, - "RepositoryAgreement": { - "repositoryAgreementNumber": ["division"] - }, - "SpAppResourceData": { - "spAppResource": [null] - }, - "SpecifyUser": { - "name": [null] - }, - "TaxonTreeDef": { - "name": ["discipline"] - }, - "TaxonTreeDefItem": { - "name": ["treeDef"], - "title": ["treeDef"] - } -} diff --git a/specifyweb/frontend/js_src/lib/components/Errors/JsonError.tsx b/specifyweb/frontend/js_src/lib/components/Errors/JsonError.tsx index b854b90291e..b9d65797b34 100644 --- a/specifyweb/frontend/js_src/lib/components/Errors/JsonError.tsx +++ b/specifyweb/frontend/js_src/lib/components/Errors/JsonError.tsx @@ -17,26 +17,30 @@ type JsonResponse = { }; function createJsonResponse(error: string): JsonResponse { - const json = JSON.parse(error); - const hasLocalizationKey = typeof json.data?.localizationKey === 'string'; + const json = JSON.parse(error) as JsonResponse; + const data = + typeof json.data === 'string' + ? JSON.parse(json.data.replaceAll("'", '"')) + : json.data; + const hasLocalizationKey = typeof data?.localizationKey === 'string'; return { exception: json.exception, message: hasLocalizationKey - ? resolveBackendLocalization(json) + ? resolveBackendLocalization(data) : json.message, - data: json.data, - formattedData: jsonStringify(json.data, 2), + data, + formattedData: jsonStringify(data, 2), traceback: json.traceback, }; } export function formatJsonBackendResponse(error: string): JSX.Element { const response = createJsonResponse(error); - if (response.exception == 'BusinessRuleException') - return formatBusinessRuleException(error); - else if (response.exception == 'TreeBusinessRuleException') - return formatTreeBusinessRuleException(error); - else return formatBasicResponse(error); + return response.exception === 'BusinessRuleException' + ? formatBusinessRuleException(response) + : response.exception === 'TreeBusinessRuleException' + ? formatTreeBusinessRuleException(response) + : formatBasicResponse(response); } /** @@ -54,7 +58,7 @@ function JsonBackendResponseFooter({ readonly response: JsonResponse; readonly isDataOpen?: boolean; }): JSX.Element { - const hasData = response.data != null; + const hasData = response.data === undefined || response.data === null; return ( <> {hasData && ( @@ -98,8 +102,7 @@ function BusinessRuleExceptionHeader({ /** * Formats a general, non-specify specific backend error. */ -function formatBasicResponse(error: string): JSX.Element { - const response = createJsonResponse(error); +function formatBasicResponse(response: JsonResponse): JSX.Element { return ( <>

{response.exception}

@@ -109,8 +112,7 @@ function formatBasicResponse(error: string): JSX.Element { ); } -function formatBusinessRuleException(error: string): JSX.Element { - const response = createJsonResponse(error); +function formatBusinessRuleException(response: JsonResponse): JSX.Element { const table: string = response.data.table; return ( <> @@ -127,8 +129,7 @@ function formatBusinessRuleException(error: string): JSX.Element { * to potentially stylize/format the json data in an easy to see * and read way */ -function formatTreeBusinessRuleException(error: string): JSX.Element { - const response = createJsonResponse(error); +function formatTreeBusinessRuleException(response: JsonResponse): JSX.Element { const table: string = response.data.tree; return ( <> diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx index 3f5f79e0adb..5fa2c08a567 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx @@ -14,6 +14,7 @@ import { columnDefinitionsToCss, DataEntry } from '../Atoms/DataEntry'; import { icons } from '../Atoms/Icons'; import { useAttachment } from '../Attachments/Plugin'; import { AttachmentViewer } from '../Attachments/Viewer'; +import { backboneFieldSeparator } from '../DataModel/helpers'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import type { Relationship } from '../DataModel/specifyField'; @@ -45,7 +46,9 @@ const cellToLabel = ( text: cell.ariaLabel, title: cell.type === 'Field' || cell.type === 'SubView' - ? model.getField(cell.fieldNames?.join('.') ?? '')?.getLocalizedDesc() + ? model + .getField(cell.fieldNames?.join(backboneFieldSeparator) ?? '') + ?.getLocalizedDesc() : undefined, }); @@ -91,7 +94,7 @@ export function FormTable({ sortField === undefined ? undefined : { - sortField: sortField.fieldNames.join('.'), + sortField: sortField.fieldNames.join(backboneFieldSeparator), ascending: sortField.direction === 'asc', } ); @@ -225,7 +228,7 @@ export function FormTable({ const isSortable = cell.type === 'Field' || cell.type === 'SubView'; const fieldName = isSortable - ? cell.fieldNames?.join('.') + ? cell.fieldNames?.join(backboneFieldSeparator) : undefined; return ( resource.specifyModel.getFields(fieldNames?.join('.') ?? ''), + () => + resource.specifyModel.getFields( + fieldNames?.join(backboneFieldSeparator) ?? '' + ), [resource.specifyModel, fieldNames] ); return ( @@ -117,7 +120,10 @@ const cellRenderers: { cellData: { fieldNames, formType, isButton, icon, viewName, sortField }, }) { const fields = React.useMemo( - () => rawResource.specifyModel.getFields(fieldNames?.join('.') ?? ''), + () => + rawResource.specifyModel.getFields( + fieldNames?.join(backboneFieldSeparator) ?? '' + ), [rawResource, fieldNames] ); const data = useDistantRelated(rawResource, fields); diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/QueryComboBox.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/QueryComboBox.tsx index fdc30936cbf..3c043b857cd 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/QueryComboBox.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/QueryComboBox.tsx @@ -13,7 +13,11 @@ import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { DataEntry } from '../Atoms/DataEntry'; import { LoadingContext } from '../Core/Contexts'; -import { serializeResource, toTable } from '../DataModel/helpers'; +import { + backboneFieldSeparator, + serializeResource, + toTable, +} from '../DataModel/helpers'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { @@ -272,13 +276,17 @@ export function QueryComboBox({ typeSearch.searchFields .map((fields) => makeComboBoxQuery({ - fieldName: fields.map(({ name }) => name).join('.'), + fieldName: fields + .map(({ name }) => name) + .join(backboneFieldSeparator), value, isTreeTable: isTreeModel(field.relatedModel.name), typeSearch, specialConditions: getQueryComboBoxConditions({ resource, - fieldName: fields.map(({ name }) => name).join('.'), + fieldName: fields + .map(({ name }) => name) + .join(backboneFieldSeparator), collectionRelationships: typeof collectionRelationships === 'object' ? collectionRelationships diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx index d3d253d9aee..42813f067a8 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx @@ -6,6 +6,7 @@ import type { Parser } from '../../utils/parser/definitions'; import { getValidationAttributes } from '../../utils/parser/definitions'; import type { IR, RA } from '../../utils/types'; import { Textarea } from '../Atoms/Form'; +import { backboneFieldSeparator } from '../DataModel/helpers'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import type { LiteralField, Relationship } from '../DataModel/specifyField'; @@ -238,7 +239,7 @@ export function FormField({ field={data.field} fieldDefinition={fieldDefinition as FieldTypes['Checkbox']} isRequired={rest.isRequired && mode !== 'search'} - name={fields?.map(({ name }) => name).join('.')} + name={fields?.map(({ name }) => name).join(backboneFieldSeparator)} resource={data.resource} /> )} diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/parseSelect.ts b/specifyweb/frontend/js_src/lib/components/FormFields/parseSelect.ts index 48f14c69a4f..bc3c234e7f9 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/parseSelect.ts +++ b/specifyweb/frontend/js_src/lib/components/FormFields/parseSelect.ts @@ -2,6 +2,7 @@ import type { IR } from '../../utils/types'; import { defined } from '../../utils/types'; +import { backboneFieldSeparator } from '../DataModel/helpers'; export const columnToFieldMapper = ( sqlSelectQuery: string @@ -23,7 +24,7 @@ function parseSqlQuery(sqlSelectQuery: string): IR { Array.from( sqlSelectQuery.matchAll(reJoin), ([_match, fieldWithTable, alias]) => { - const [table, fieldName] = fieldWithTable.split('.'); + const [table, fieldName] = fieldWithTable.split(backboneFieldSeparator); const col = defined(columnMapping[table]); columnMapping[alias] = `${col}.${fieldName}`; } @@ -32,10 +33,12 @@ function parseSqlQuery(sqlSelectQuery: string): IR { } function columnToField(columnMapping: IR, columnName: string): string { - const column = columnName.split('.'); + const column = columnName.split(backboneFieldSeparator); return column.length === 1 ? columnName - : [...columnMapping[column[0]].split('.'), column[1]].slice(1).join('.'); + : [...columnMapping[column[0]].split(backboneFieldSeparator), column[1]] + .slice(1) + .join(backboneFieldSeparator); } export const exportsForTests = { diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/cells.ts b/specifyweb/frontend/js_src/lib/components/FormParse/cells.ts index 4683275b538..19ba6892a52 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/cells.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/cells.ts @@ -16,6 +16,7 @@ import { getBooleanAttribute, getParsedAttribute, } from '../../utils/utils'; +import { backboneFieldSeparator } from '../DataModel/helpers'; import { getModel } from '../DataModel/schema'; import type { SpecifyModel } from '../DataModel/specifyModel'; import type { Tables } from '../DataModel/types'; @@ -125,7 +126,7 @@ const processCellType: { const rawFieldName = getParsedAttribute(cell, 'name'); const fields = model?.getFields(rawFieldName ?? ''); const fieldNames = fields?.map(({ name }) => name); - const fieldsString = fieldNames?.join('.'); + const fieldsString = fieldNames?.join(backboneFieldSeparator); setLogContext({ field: fieldsString ?? rawFieldName }); @@ -143,7 +144,11 @@ const processCellType: { const resolvedFields = (fieldDefinition.type === 'Plugin' && fieldDefinition.pluginDefinition.type === 'PartialDateUI' - ? model.getFields(fieldDefinition.pluginDefinition.dateFields.join('.')) + ? model.getFields( + fieldDefinition.pluginDefinition.dateFields.join( + backboneFieldSeparator + ) + ) : undefined) ?? fields; if ( @@ -216,7 +221,7 @@ const processCellType: { const rawSortField = getProperty('sortField'); const parsedSort = f.maybe(rawSortField, toLargeSortConfig); const sortFields = relationship!.relatedModel.getFields( - parsedSort?.fieldNames.join('.') ?? '' + parsedSort?.fieldNames.join(backboneFieldSeparator) ?? '' ); const formType = getParsedAttribute(cell, 'defaultType') ?? ''; return { diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/index.ts b/specifyweb/frontend/js_src/lib/components/FormParse/index.ts index 604dccae707..3f1442a927f 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/index.ts @@ -13,6 +13,7 @@ import { defined, filterArray } from '../../utils/types'; import { getParsedAttribute } from '../../utils/utils'; import { parseXml } from '../AppResources/codeMirrorLinters'; import { formatDisjunction } from '../Atoms/Internationalization'; +import { backboneFieldSeparator } from '../DataModel/helpers'; import { parseJavaClassName } from '../DataModel/resource'; import { strictGetModel } from '../DataModel/schema'; import type { SpecifyModel } from '../DataModel/specifyModel'; @@ -284,8 +285,9 @@ function parseFormTableDefinition( : undefined) ?? labelsForCells[cell.id ?? '']?.text ?? (cell.type === 'Field' || cell.type === 'SubView' - ? model?.getField(cell.fieldNames?.join('.') ?? '')?.label ?? - (cell.fieldNames?.join('.') as LocalizedString) + ? model?.getField(cell.fieldNames?.join(backboneFieldSeparator) ?? '') + ?.label ?? + (cell.fieldNames?.join(backboneFieldSeparator) as LocalizedString) : undefined), // Remove labels from checkboxes (as labels would be in the table header) ...(cell.type === 'Field' && cell.fieldDefinition.type === 'Checkbox' diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/postProcessFormDef.ts b/specifyweb/frontend/js_src/lib/components/FormParse/postProcessFormDef.ts index 154b7687b2b..4d91aedc1f3 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/postProcessFormDef.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/postProcessFormDef.ts @@ -3,6 +3,7 @@ import type { LocalizedString } from 'typesafe-i18n'; import { f } from '../../utils/functools'; import type { IR, RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; +import { backboneFieldSeparator } from '../DataModel/helpers'; import type { SpecifyModel } from '../DataModel/specifyModel'; import type { CellTypes, FormCellDefinition } from './cells'; import type { ParsedFormDefinition } from './index'; @@ -232,7 +233,9 @@ const postProcessLabel = ( }); function addLabelTitle(cell: LabelCell, model: SpecifyModel): LabelCell { - const field = model.getField(cell.fieldNames?.join('.') ?? ''); + const field = model.getField( + cell.fieldNames?.join(backboneFieldSeparator) ?? '' + ); return { ...cell, text: @@ -245,9 +248,9 @@ function addLabelTitle(cell: LabelCell, model: SpecifyModel): LabelCell { (cell.id === 'divLabel' ? model.getField('division')?.label : undefined) ?? - (cell.fieldNames?.join('.').toLowerCase() === 'this' + (cell.fieldNames?.join(backboneFieldSeparator).toLowerCase() === 'this' ? undefined - : (cell.fieldNames?.join('.') as LocalizedString)) ?? + : (cell.fieldNames?.join(backboneFieldSeparator) as LocalizedString)) ?? '', title: cell?.title ?? field?.getLocalizedDesc(), }; @@ -332,7 +335,8 @@ const addMissingLabel = ( */ label: cell.fieldDefinition.label ?? - model?.getField(cell.fieldNames?.join('.') ?? '')?.label ?? + model?.getField(cell.fieldNames?.join(backboneFieldSeparator) ?? '') + ?.label ?? cell.ariaLabel, }, } @@ -344,7 +348,8 @@ const addMissingLabel = ( ? undefined : cell.ariaLabel ?? (cell.type === 'Field' || cell.type === 'SubView' - ? model?.getField(cell.fieldNames?.join('.') ?? '')?.label + ? model?.getField(cell.fieldNames?.join(backboneFieldSeparator) ?? '') + ?.label : undefined), }); diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx index 7045cec52cd..3c9fd17296b 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSet.tsx @@ -14,7 +14,7 @@ import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; import { LoadingContext } from '../Core/Contexts'; import { DEFAULT_FETCH_LIMIT, fetchCollection } from '../DataModel/collection'; -import { serializeResource } from '../DataModel/helpers'; +import { backendFilter, serializeResource } from '../DataModel/helpers'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { @@ -84,7 +84,7 @@ export function RecordSetWrapper({ recordSet: recordSet.id, limit: 1, }, - { id__lt: recordSetItemId } + backendFilter('id').lessThan(recordSetItemId) ); setIndex(totalCount); }) diff --git a/specifyweb/frontend/js_src/lib/components/Forms/OtherCollectionView.tsx b/specifyweb/frontend/js_src/lib/components/Forms/OtherCollectionView.tsx index f03408e4889..31ef4f062bf 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/OtherCollectionView.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/OtherCollectionView.tsx @@ -9,6 +9,7 @@ import { filterArray } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; import { Container, Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; +import { backboneFieldSeparator } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import { schema } from '../DataModel/schema'; import type { Collection } from '../DataModel/types'; @@ -33,7 +34,11 @@ export function useAvailableCollections(): RA> { // FEATURE: support sorting by related model sortFunction( (collection) => - collection[fieldNames.join('.') as keyof Collection['fields']], + collection[ + fieldNames.join( + backboneFieldSeparator + ) as keyof Collection['fields'] + ], direction === 'desc' ) ); diff --git a/specifyweb/frontend/js_src/lib/components/Forms/dataObjFormatters.ts b/specifyweb/frontend/js_src/lib/components/Forms/dataObjFormatters.ts index 8d0520beb23..4ec8848ed88 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/dataObjFormatters.ts +++ b/specifyweb/frontend/js_src/lib/components/Forms/dataObjFormatters.ts @@ -21,6 +21,7 @@ import { KEY, sortFunction, } from '../../utils/utils'; +import { backboneFieldSeparator } from '../DataModel/helpers'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { schema } from '../DataModel/schema'; @@ -216,7 +217,9 @@ export async function format( * no fields */ const isEmptyResource = fields - .map(({ fieldName }) => resource.get(fieldName.split('.')[0])) + .map(({ fieldName }) => + resource.get(fieldName.split(backboneFieldSeparator)[0]) + ) .every((value) => value === undefined || value === null || value === ''); return isEmptyResource diff --git a/specifyweb/frontend/js_src/lib/components/Header/ChooseCollection.tsx b/specifyweb/frontend/js_src/lib/components/Header/ChooseCollection.tsx index 34d47396af0..030bea678eb 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/ChooseCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/ChooseCollection.tsx @@ -7,7 +7,10 @@ import { sortFunction, toLowerCase } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { Select } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; -import { serializeResource } from '../DataModel/helpers'; +import { + backboneFieldSeparator, + serializeResource, +} from '../DataModel/helpers'; import { schema } from '../DataModel/schema'; import { userInformation } from '../InitialContext/userInformation'; import { Dialog } from '../Molecules/Dialog'; @@ -28,7 +31,11 @@ export function ChooseCollection(): JSX.Element { .sort( sortFunction( (collection) => - collection[toLowerCase(fieldNames.join('.') as 'description')], + collection[ + toLowerCase( + fieldNames.join(backboneFieldSeparator) as 'description' + ) + ], direction === 'desc' ) ) diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts index 9ae703c243b..c05a44450ce 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts @@ -100,6 +100,8 @@ export const initialContext = Promise.all([ import('./userInformation'), // Fetch user permissions (NOT CACHED) import('../Permissions'), + // Fetch the discipline's uniquenessRules (NOT CACHED) + import('../DataModel/uniquenessRules'), ]).then(async (modules) => Promise.all(modules.map(async ({ fetchContext }) => fetchContext)) ); diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/Sorting.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/Sorting.tsx index 84eb612a77d..a495002788d 100644 --- a/specifyweb/frontend/js_src/lib/components/Molecules/Sorting.tsx +++ b/specifyweb/frontend/js_src/lib/components/Molecules/Sorting.tsx @@ -6,6 +6,7 @@ import type { SortConfigs } from '../../utils/cache/definitions'; import type { RA } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; import { icons } from '../Atoms/Icons'; +import { backboneFieldSeparator } from '../DataModel/helpers'; import type { SubViewSortField } from '../FormParse/cells'; export type SortConfig = { @@ -83,13 +84,13 @@ export function useSortConfig( export const toSmallSortConfig = (sortConfig: SubViewSortField): string => `${sortConfig.direction === 'desc' ? '-' : ''}${sortConfig.fieldNames.join( - '.' + backboneFieldSeparator )}`; export const toLargeSortConfig = (sortConfig: string): SubViewSortField => ({ fieldNames: (sortConfig.startsWith('-') ? sortConfig.slice(1) : sortConfig - ).split('.'), + ).split(backboneFieldSeparator), direction: sortConfig.startsWith('-') ? 'desc' : 'asc', }); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index cce0398cf36..0512d4c30a7 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -373,7 +373,7 @@ function TableHeaderCell({ readonly sortConfig: QueryField['sortType']; readonly onSortChange?: (sortType: QueryField['sortType']) => void; }): JSX.Element { - // TableName refers to the table the filed is from, not the base table name of the query + // TableName refers to the table the field is from, not the base table name of the query const tableName = fieldSpec?.table?.name; const content = diff --git a/specifyweb/frontend/js_src/lib/components/Reports/Report.tsx b/specifyweb/frontend/js_src/lib/components/Reports/Report.tsx index 1978475baff..c508ca0b4a6 100644 --- a/specifyweb/frontend/js_src/lib/components/Reports/Report.tsx +++ b/specifyweb/frontend/js_src/lib/components/Reports/Report.tsx @@ -25,6 +25,7 @@ import { import { UploadAttachment } from '../Attachments/Plugin'; import { LoadingContext } from '../Core/Contexts'; import { fetchCollection } from '../DataModel/collection'; +import { backendFilter } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyModel } from '../DataModel/specifyModel'; import type { SpAppResource, SpQuery } from '../DataModel/types'; @@ -154,9 +155,7 @@ async function fixupImages(definition: Document): Promise> { { limit: 0, }, - { - title__in: Object.keys(fileNames).join(','), - } + backendFilter('title').isIn(Object.keys(fileNames)) ).then(({ records }) => records); const indexedAttachments = Object.fromEntries( attachments.map((record) => [record.title ?? '', record]) diff --git a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx index f884e812a03..38ccb9f69ed 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx @@ -7,6 +7,7 @@ import { interactionsText } from '../../localization/interactions'; import { mergingText } from '../../localization/merging'; import { queryText } from '../../localization/query'; import { reportsText } from '../../localization/report'; +import { schemaText } from '../../localization/schema'; import { treeText } from '../../localization/tree'; import { userText } from '../../localization/user'; import { welcomeText } from '../../localization/welcome'; @@ -207,6 +208,14 @@ export const overlayRoutes: RA = [ ({ AttachmentsImportOverlay }) => AttachmentsImportOverlay ), }, + { + path: 'configure/uniqueness/:tableName', + title: schemaText.uniquenessRules(), + element: () => + import('../SchemaConfig/TableUniquenessRules').then( + ({ TableUniquenessRules }) => TableUniquenessRules + ), + }, ], }, ]; diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Hooks.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Hooks.tsx index b26a8f35452..43a02beed9f 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Hooks.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Hooks.tsx @@ -7,6 +7,7 @@ import type { RA } from '../../utils/types'; import { defined } from '../../utils/types'; import { group, replaceItem } from '../../utils/utils'; import { fetchCollection } from '../DataModel/collection'; +import { backendFilter, formatRelationshipPath } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import { getModel } from '../DataModel/schema'; import type { @@ -125,9 +126,9 @@ export function useContainerItems( { limit: 0, }, - { - itemName__container: container.id, - } + backendFilter( + formatRelationshipPath('itemName', 'container') + ).equals(container.id) ).then(({ records }) => Object.fromEntries( group(records.map((name) => [name.itemName, name])) @@ -138,9 +139,9 @@ export function useContainerItems( { limit: 0, }, - { - itemDesc__container: container.id, - } + backendFilter( + formatRelationshipPath('itemDesc', 'container') + ).equals(container.id) ).then(({ records }) => Object.fromEntries( group( diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Table.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Table.tsx index dbbe363ec59..8ede7e0035c 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Table.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Table.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; import { Input, Label } from '../Atoms/Form'; +import { Link } from '../Atoms/Link'; import { getField } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import { schema } from '../DataModel/schema'; @@ -99,6 +100,13 @@ export function SchemaConfigTable({ } /> + + + {schemaText.uniquenessRules()} + + JSON.stringify(tableRules) !== JSON.stringify(storedInitialRules), + [storedInitialRules, tableRules] + ); + + const [unloadProtected, setUnloadProtected] = React.useState(false); + useUnloadProtect(changesMade, mainText.leavePageConfirmationDescription()); + + const saveBlocked = React.useMemo( + () => tableRules.some(({ duplicates }) => duplicates.totalDuplicates !== 0), + [tableRules] + ); + + const fields = React.useMemo( + () => model.literalFields.filter((field) => !field.isVirtual), + [model] + ); + + const relationships = React.useMemo( + () => + model.relationships.filter( + (relationship) => + (['many-to-one', 'one-to-one'] as RA).includes( + relationship.type + ) && !relationship.isVirtual + ), + [model] + ); + + const handleRuleValidation = React.useCallback( + (newRule: UniquenessRule, index: number) => { + const filteredRule: UniquenessRule = { + ...newRule, + fields: newRule.fields.filter( + (field, index) => newRule.fields.indexOf(field) === index + ), + scopes: newRule.scopes.filter( + (scope, index) => newRule.scopes.indexOf(scope) === index + ), + }; + loading( + validateUniqueness( + model.name, + filteredRule.fields as unknown as RA, + filteredRule.scopes as unknown as RA + ).then((duplicates) => { + const isNewRule = index > tableRules.length; + setTableRules((previous) => + isNewRule + ? [...previous!, { rule: filteredRule, duplicates }] + : replaceItem(tableRules, index, { + rule: filteredRule, + duplicates, + }) + ); + + return filteredRule; + }) + ); + }, + [loading, model.name, tableRules, setTableRules] + ); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const SaveButton = saveBlocked ? Submit.Red : Submit.Save; + + return ( + + + handleRuleValidation( + { + id: null, + modelName: model.name, + fields: [fields[0].name], + isDatabaseConstraint: false, + scopes: [], + }, + tableRules.length + ) + } + > + {schemaText.addUniquenessRule()} + + + {commonText.close()} + + {commonText.save()} + + + } + header={schemaText.tableUniquenessRules({ tableName: model.name })} + icon={saveBlocked ? 'error' : 'info'} + modal + onClose={(): void => { + if (changesMade) setUnloadProtected(true); + else handleClose(); + }} + > +
{ + loading( + ajax( + `/businessrules/uniqueness_rules/${schema.domainLevelIds.discipline}/`, + { + // eslint-disable-next-line @typescript-eslint/naming-convention + headers: { Accept: 'application/json' }, + method: 'PUT', + body: { + rules: tableRules.map(({ rule }) => rule), + model: model.name, + }, + } + ).then((): void => { + void setStoredTableRules(tableRules); + return void setStoredInitialRules(tableRules); + }) + ); + }} + > +
+ + + + + + + {tableRules?.map(({ rule, duplicates }, index) => ( + handleRuleValidation(newRule, index)} + onRemoved={(): void => + setTableRules(removeItem(tableRules, index)) + } + /> + ))} +
{schemaText.uniqueFields()}{schemaText.scope()}
+ + {unloadProtected && ( + setUnloadProtected(false)} + onConfirm={handleClose} + > + {mainText.leavePageConfirmationDescription()} + + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/UniquenessRuleRow.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/UniquenessRuleRow.tsx new file mode 100644 index 00000000000..08f93e5865a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/UniquenessRuleRow.tsx @@ -0,0 +1,323 @@ +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { useValidation } from '../../hooks/useValidation'; +import { commonText } from '../../localization/common'; +import { schemaText } from '../../localization/schema'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import { insertItem, removeItem, replaceItem } from '../../utils/utils'; +import { H2 } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { className } from '../Atoms/className'; +import { Input } from '../Atoms/Form'; +import { icons } from '../Atoms/Icons'; +import { getFieldsFromPath } from '../DataModel/businessRules'; +import type { LiteralField, Relationship } from '../DataModel/specifyField'; +import type { SpecifyModel } from '../DataModel/specifyModel'; +import type { + UniquenessRule, + UniquenessRuleValidation, +} from '../DataModel/uniquenessRules'; +import { getUniqueInvalidReason } from '../DataModel/uniquenessRules'; +import { raise } from '../Errors/Crash'; +import { Dialog } from '../Molecules/Dialog'; +import { userPreferences } from '../Preferences/userPreferences'; +import { downloadDataSet } from '../WorkBench/helpers'; +import { PickList } from './Components'; +import { UniquenessRuleScope } from './UniquenessRuleScope'; + +export function UniquenessRuleRow({ + rule, + model, + formId, + fields, + relationships, + isReadOnly, + fetchedDuplicates, + onChange: handleChanged, + onRemoved: handleRemoved, +}: { + readonly rule: UniquenessRule; + readonly model: SpecifyModel; + readonly formId: string; + readonly fields: RA; + readonly relationships: RA; + readonly isReadOnly: boolean; + readonly fetchedDuplicates: UniquenessRuleValidation; + readonly onChange: (newRule: typeof rule) => void; + readonly onRemoved: () => void; +}): JSX.Element { + const readOnly = rule.isDatabaseConstraint || isReadOnly; + + const [isModifyingRule, _, __, toggleModifyingRule] = useBooleanState(); + + const hasDuplicates = fetchedDuplicates.totalDuplicates !== 0; + + const { validationRef } = useValidation( + !isModifyingRule && hasDuplicates + ? schemaText.uniquenessDuplicatesFound() + : undefined, + false + ); + + const invalidUniqueReason = getUniqueInvalidReason( + rule.scopes.map( + (scope) => getFieldsFromPath(model, scope).at(-1) as Relationship + ), + filterArray(rule.fields.map((field) => model.getField(field))) + ); + + return ( + + + {readOnly ? null : ( + + {icons.pencil} + + )} + {rule.fields.map((field, index) => ( + name === field) ?? + relationships.find(({ name }) => name === field))!.localization + .name! + } + /> + ))} + + + field.localization.name!) + .join(' -> ') + } + /> + {isModifyingRule && ( + + )} + + + ); +} + +function ModifyUniquenessRule({ + rule, + model, + readOnly, + invalidUniqueReason, + fields, + relationships, + fetchedDuplicates, + onChange: handleChanged, + onRemoved: handleRemoved, + onClose: handleClose, +}: { + readonly rule: UniquenessRule; + readonly model: SpecifyModel; + readonly readOnly: boolean; + readonly invalidUniqueReason: string; + readonly fields: RA; + readonly relationships: RA; + readonly fetchedDuplicates: UniquenessRuleValidation; + readonly onChange: (newRule: typeof rule) => void; + readonly onRemoved: () => void; + readonly onClose: () => void; +}): JSX.Element { + const [separator] = userPreferences.use( + 'queryBuilder', + 'behavior', + 'exportFileDelimiter' + ); + + const uniqueFields = React.useMemo( + () => + fields.map((field) => [field.name, field.localization.name!] as const), + [fields] + ); + + const uniqueRelationships = React.useMemo( + () => + relationships.map( + (field) => [field.name, field.localization.name!] as const + ), + [relationships] + ); + + const hasDuplicates = fetchedDuplicates.totalDuplicates !== 0; + + const { validationRef } = useValidation( + hasDuplicates ? schemaText.uniquenessDuplicatesFound() : undefined, + false + ); + + return ( + + { + handleRemoved(); + handleClose(); + }} + > + {commonText.delete()} + + + {hasDuplicates && ( + { + const fileName = [ + model.name, + ' ', + rule.fields.map((field) => field).join(','), + '-in_', + rule.scopes.length === 0 + ? schemaText.database() + : getFieldsFromPath(model, rule.scopes[0]) + .map((field) => field.name) + .join('_'), + '.csv', + ].join(''); + + const columns = [ + schemaText.numberOfDuplicates(), + ...Object.keys(fetchedDuplicates.fields[0].fields).map( + (fieldPath) => { + const field = getFieldsFromPath(model, fieldPath).at(-1)!; + return field.isRelationship + ? `${field.name}_id` + : field.name; + } + ), + ]; + + const rows = fetchedDuplicates.fields.map( + ({ duplicates, fields }) => [ + duplicates.toString(), + ...Object.values(fields).map((fieldValue) => + JSON.stringify(fieldValue) + ), + ] + ); + + downloadDataSet(fileName, rows, columns, separator).catch( + raise + ); + }} + > + {schemaText.exportDuplicates()} + + )} + {commonText.close()} + + } + dimensionsKey="ModifyUniquenessRule" + header={schemaText.configureUniquenessRule()} + icon={icons.pencilAt} + modal + onClose={handleClose} + > + <> +

{invalidUniqueReason}

+

{schemaText.uniqueFields()}

+ {rule.fields.map((field, index) => ( +
+ { + const newField = + fields.find(({ name }) => name === value) ?? + relationships.find(({ name }) => name === value); + if (newField === undefined) return; + handleChanged({ + ...rule, + fields: replaceItem(rule.fields, index, newField.name), + }); + }} + /> + {rule.fields.length > 1 && ( + + handleChanged({ + ...rule, + fields: removeItem(rule.fields, index), + }) + } + /> + )} + {rule.fields.length - 1 === index && ( + + handleChanged({ + ...rule, + fields: insertItem( + rule.fields, + rule.fields.length, + fields.find(({ name }) => !rule.fields.includes(name))! + .name + ), + }) + } + > + {commonText.add()} + + )} +
+ ))} +

{schemaText.scope()}

+ + field.localization.name!) + .join(' -> ') + } + /> + +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/UniquenessRuleScope.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/UniquenessRuleScope.tsx new file mode 100644 index 00000000000..e45dcff15fd --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/UniquenessRuleScope.tsx @@ -0,0 +1,190 @@ +import React from 'react'; + +import { useLiveState } from '../../hooks/useLiveState'; +import { schemaText } from '../../localization/schema'; +import type { RA } from '../../utils/types'; +import { replaceItem } from '../../utils/utils'; +import { Button } from '../Atoms/Button'; +import { icons } from '../Atoms/Icons'; +import { getFieldsFromPath } from '../DataModel/businessRules'; +import { djangoLookupSeparator } from '../DataModel/helpers'; +import { strictGetModel } from '../DataModel/schema'; +import type { RelationshipType } from '../DataModel/specifyField'; +import type { SpecifyModel } from '../DataModel/specifyModel'; +import type { Tables } from '../DataModel/types'; +import type { UniquenessRule } from '../DataModel/uniquenessRules'; +import type { HtmlGeneratorFieldData } from '../WbPlanView/LineComponents'; +import { getMappingLineProps } from '../WbPlanView/LineComponents'; +import { MappingView } from '../WbPlanView/MapperComponents'; +import type { MappingLineData } from '../WbPlanView/navigator'; + +export function UniquenessRuleScope({ + rule, + model, + onChange: handleChanged, +}: { + readonly rule: UniquenessRule; + readonly model: SpecifyModel; + readonly onChange: (newRule: typeof rule) => void; +}): JSX.Element { + const databaseMappingPathField = 'database'; + + const [mappingPath, setMappingPath] = React.useState>( + rule.scopes.length === 0 + ? [databaseMappingPathField] + : rule.scopes[0].split(djangoLookupSeparator) + ); + + const databaseScopeData: Readonly> = { + database: { + isDefault: true, + isEnabled: true, + isRelationship: false, + optionLabel: schemaText.database(), + }, + }; + + const getValidScopeRelationships = ( + model: SpecifyModel + ): Readonly> => + Object.fromEntries( + model.relationships + .filter( + (relationship) => + !(['one-to-many', 'many-to-many'] as RA).includes( + relationship.type + ) && !relationship.isVirtual + ) + .map((relationship) => [ + relationship.name, + { + isDefault: false, + isEnabled: true, + isRelationship: true, + optionLabel: relationship.localization.name!, + tableName: relationship.relatedModel.name, + }, + ]) + ); + + const updateLineData = ( + mappingLines: RA, + mappingPath: RA + ): RA => + mappingLines.map((lineData, index) => ({ + ...lineData, + fieldsData: Object.fromEntries( + Object.entries(lineData.fieldsData).map(([field, data]) => [ + field, + { ...data, isDefault: mappingPath[index] === field }, + ]) + ), + })); + + const databaseLineData: RA = [ + { + customSelectSubtype: 'simple', + tableName: model.name, + fieldsData: { + ...databaseScopeData, + ...getValidScopeRelationships(model), + }, + }, + ]; + + const [lineData, setLineData] = useLiveState>( + React.useCallback( + () => + updateLineData( + mappingPath.map((_, index) => { + const databaseScope = index === 0 ? databaseScopeData : {}; + const modelPath = + index === 0 + ? model + : getFieldsFromPath( + model, + mappingPath.slice(0, index + 1).join(djangoLookupSeparator) + )[index].model; + return { + customSelectSubtype: 'simple', + tableName: modelPath.name, + fieldsData: { + ...databaseScope, + ...getValidScopeRelationships(modelPath), + }, + }; + }), + mappingPath + ), + [rule.scopes, model] + ) + ); + + const getRelationshipData = (newTableName: keyof Tables): MappingLineData => { + const newModel = strictGetModel(newTableName); + + return { + customSelectSubtype: 'simple', + tableName: newModel.name, + fieldsData: getValidScopeRelationships(newModel), + }; + }; + + return ( + + updateLineData( + [ + ...lineData.slice(0, index + 1), + getRelationshipData(rest.newTableName!), + ], + newMappingPath + ) + ); + if (isDoubleClick) + handleChanged({ + ...rule, + scopes: [newMappingPath.join(djangoLookupSeparator)], + }); + } else { + setMappingPath([databaseMappingPathField]); + setLineData(databaseLineData); + if (isDoubleClick) + handleChanged({ + ...rule, + scopes: [], + }); + } + }, + })} + > + { + handleChanged({ + ...rule, + scopes: + mappingPath.length === 1 && mappingPath[0] === 'database' + ? [] + : [mappingPath.join(djangoLookupSeparator)], + }); + }} + > + {icons.arrowRight} + + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Security/UserHooks.tsx b/specifyweb/frontend/js_src/lib/components/Security/UserHooks.tsx index 1c3585327d5..b9e20186744 100644 --- a/specifyweb/frontend/js_src/lib/components/Security/UserHooks.tsx +++ b/specifyweb/frontend/js_src/lib/components/Security/UserHooks.tsx @@ -6,7 +6,7 @@ import { f } from '../../utils/functools'; import type { IR, RA, RR } from '../../utils/types'; import { group } from '../../utils/utils'; import { fetchCollection } from '../DataModel/collection'; -import { serializeResource } from '../DataModel/helpers'; +import { backendFilter, serializeResource } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { @@ -151,9 +151,7 @@ export function useUserAgents( limit: 1, specifyUser: userId, }, - { - division__in: divisions.map(([id]) => id).join(','), - } + backendFilter('division').isIn(divisions.map(([id]) => id)) ).then(({ records }) => records) : Promise.resolve([serializeResource(userInformation.agent)]) : Promise.resolve([]) diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx index 15979cb6837..4ab02cfcb6a 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx @@ -7,6 +7,7 @@ import { specifyNetworkText } from '../../localization/specifyNetwork'; import { f } from '../../utils/functools'; import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; +import { backboneFieldSeparator } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { schema } from '../DataModel/schema'; @@ -97,7 +98,9 @@ function getFields(query: SerializedResource): RA { return fields; } const localityField = fields.find(({ mappingPath }) => - mappingPath.join('.').startsWith('collectingEvent.locality') + mappingPath + .join(backboneFieldSeparator) + .startsWith('collectingEvent.locality') ); return localityField === undefined ? ([ @@ -168,7 +171,7 @@ export function extractQueryTaxonId( const pairedFields = filterArray( fields.flatMap(({ mappingPath }, index) => schema.models[baseTableName].getField( - getGenericMappingPath(mappingPath).join('.') + getGenericMappingPath(mappingPath).join(backboneFieldSeparator) ) === idField ? fields[index]?.filters.map(({ type, isNot, startValue }) => type === 'equal' && !isNot ? f.parseInt(startValue) : undefined diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingHelpers.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingHelpers.ts index 85247bbd174..7bfd1331f94 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingHelpers.ts @@ -88,10 +88,10 @@ export function parsePartialField( } export const mappingPathToString = (mappingPath: MappingPath): string => - mappingPath.join(schema.pathJoinSymbol); + mappingPath.join('.'); export const splitJoinedMappingPath = (string: string): MappingPath => - string.split(schema.pathJoinSymbol); + string.split('.'); export type SplitMappingPath = { readonly headerName: string; diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/wbView.js b/specifyweb/frontend/js_src/lib/components/WorkBench/wbView.js index 4483b5e2342..588e5894c73 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/wbView.js +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/wbView.js @@ -36,7 +36,7 @@ import { iconClassName, legacyNonJsxIcons } from '../Atoms/Icons'; import { Link } from '../Atoms/Link'; import { legacyLoadingContext } from '../Core/Contexts'; import { Backbone } from '../DataModel/backbone'; -import { serializeResource } from '../DataModel/helpers'; +import { backendFilter, serializeResource } from '../DataModel/helpers'; import { getModel, schema, strictGetModel } from '../DataModel/schema'; import { crash, raise } from '../Errors/Crash'; import { getIcon, unknownIcon } from '../InitialContext/icons'; @@ -1576,7 +1576,7 @@ export const WBView = Backbone.View.extend({ ); const model = getModel(tableName); const resources = new model.LazyCollection({ - filters: { id__in: matches.ids.join(',') }, + filters: backendFilter('id').isIn(matches.ids), }); (hasTablePermission(model.name, 'read') diff --git a/specifyweb/frontend/js_src/lib/hooks/useValidation.tsx b/specifyweb/frontend/js_src/lib/hooks/useValidation.tsx index 287ea70e6f6..7ddc3f0a780 100644 --- a/specifyweb/frontend/js_src/lib/hooks/useValidation.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/useValidation.tsx @@ -19,7 +19,8 @@ export function useValidation< T extends Input = HTMLInputElement | HTMLTextAreaElement >( // Can set validation message from state or a prop - message: RA | string = '' + message: RA | string = '', + clearOnTyping: boolean = true ): { // Set this as a ref prop on an input readonly validationRef: React.RefCallback; @@ -49,7 +50,7 @@ export function useValidation< const input = inputRef.current; return listen(input, 'input', (): void => { - if (input.validity.customError) { + if (input.validity.customError && clearOnTyping) { validationMessageRef.current = ''; input.setCustomValidity(''); } diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index d3348f8e048..c44865dce50 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -81,6 +81,12 @@ export const schemaText = createDictionary({ 'uk-ua': 'Стосунки', 'de-ch': 'Beziehungen', }, + database: { + 'en-us': 'Database', + }, + setScope: { + 'en-us': 'Set Scope', + }, caption: { 'en-us': 'Caption', 'ru-ru': 'Подпись', @@ -353,6 +359,21 @@ export const schemaText = createDictionary({ 'uk-ua': 'ID Поле', 'de-ch': 'Feld-ID', }, + tableUniquenessRules: { + 'en-us': '{tableName:string} Uniqueness Rules', + }, + uniquenessRules: { + 'en-us': 'Uniqueness Rules', + }, + uniqueFields: { + 'en-us': 'Unique Fields', + }, + addUniquenessRule: { + 'en-us': 'Add Uniqueness Rule', + }, + configureUniquenessRule: { + 'en-us': 'Configure Uniqueness Rule', + }, scope: { 'en-us': 'Scope', 'es-es': 'Alcance', @@ -361,6 +382,15 @@ export const schemaText = createDictionary({ 'uk-ua': 'Область застосування', 'de-ch': 'Anwendungsbereich', }, + uniquenessDuplicatesFound: { + 'en-us': 'Duplicates found in database', + }, + exportDuplicates: { + 'en-us': 'Export Duplicates', + }, + numberOfDuplicates: { + 'en-us': 'Number of Duplicates', + }, schemaViewTitle: { 'en-us': 'Schema Config: {tableName:string}', 'es-es': 'Configuración de esquema: {tableName:string}', diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/businessrules/uniqueness_rules/3.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/businessrules/uniqueness_rules/3.json new file mode 100644 index 00000000000..8e4f6b5ea37 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/businessrules/uniqueness_rules/3.json @@ -0,0 +1,410 @@ +{ + "Accession": [ + { + "rule": { + "id": 1, + "fields": ["accessionNumber"], + "scopes": ["division"], + "modelName": "Accession", + "isDatabaseConstraint": false + } + } + ], + "Accessionagent": [ + { + "rule": { + "id": 2, + "fields": ["role", "agent"], + "scopes": ["accession"], + "modelName": "Accessionagent", + "isDatabaseConstraint": true + } + } + ], + "Appraisal": [ + { + "rule": { + "id": 3, + "fields": ["appraisalNumber"], + "scopes": ["accession"], + "modelName": "Appraisal", + "isDatabaseConstraint": true + } + } + ], + "Author": [ + { + "rule": { + "id": 4, + "fields": ["agent"], + "scopes": ["referenceWork"], + "modelName": "Author", + "isDatabaseConstraint": true + } + }, + { + "rule": { + "id": 5, + "fields": ["orderNumber"], + "scopes": ["referenceWork"], + "modelName": "Author", + "isDatabaseConstraint": false + } + } + ], + "Borrowagent": [ + { + "rule": { + "id": 6, + "fields": ["role", "agent"], + "scopes": ["borrow"], + "modelName": "Borrowagent", + "isDatabaseConstraint": true + } + } + ], + "Collection": [ + { + "rule": { + "id": 7, + "fields": ["collectionName"], + "scopes": ["discipline"], + "modelName": "Collection", + "isDatabaseConstraint": false + } + }, + { + "rule": { + "id": 8, + "fields": ["code"], + "scopes": ["discipline"], + "modelName": "Collection", + "isDatabaseConstraint": false + } + } + ], + "Collectingevent": [ + { + "rule": { + "id": 9, + "fields": ["uniqueIdentifier"], + "scopes": [], + "modelName": "Collectingevent", + "isDatabaseConstraint": true + } + } + ], + "Collectionobject": [ + { + "rule": { + "id": 10, + "fields": ["catalogNumber"], + "scopes": ["collection"], + "modelName": "Collectionobject", + "isDatabaseConstraint": true + } + }, + { + "rule": { + "id": 11, + "fields": ["uniqueIdentifier"], + "scopes": [], + "modelName": "Collectionobject", + "isDatabaseConstraint": true + } + }, + { + "rule": { + "id": 12, + "fields": ["guid"], + "scopes": [], + "modelName": "Collectionobject", + "isDatabaseConstraint": false + } + } + ], + "Collector": [ + { + "rule": { + "id": 13, + "fields": ["agent"], + "scopes": ["collectingEvent"], + "modelName": "Collector", + "isDatabaseConstraint": true + } + } + ], + "Determiner": [ + { + "rule": { + "id": 14, + "fields": ["agent"], + "scopes": ["determination"], + "modelName": "Determiner", + "isDatabaseConstraint": true + } + } + ], + "Discipline": [ + { + "rule": { + "id": 15, + "fields": ["name"], + "scopes": ["division"], + "modelName": "Discipline", + "isDatabaseConstraint": false + } + } + ], + "Disposalagent": [ + { + "rule": { + "id": 16, + "fields": ["role", "agent"], + "scopes": ["disposal"], + "modelName": "Disposalagent", + "isDatabaseConstraint": true + } + } + ], + "Division": [ + { + "rule": { + "id": 17, + "fields": ["name"], + "scopes": ["institution"], + "modelName": "Division", + "isDatabaseConstraint": false + } + } + ], + "Extractor": [ + { + "rule": { + "id": 18, + "fields": ["agent"], + "scopes": ["dnaSequence"], + "modelName": "Extractor", + "isDatabaseConstraint": true + } + } + ], + "Fundingagent": [ + { + "rule": { + "id": 19, + "fields": ["agent"], + "scopes": ["collectingTrip"], + "modelName": "Fundingagent", + "isDatabaseConstraint": true + } + } + ], + "Gift": [ + { + "rule": { + "id": 20, + "fields": ["giftNumber"], + "scopes": ["discipline"], + "modelName": "Gift", + "isDatabaseConstraint": false + } + } + ], + "Giftagent": [ + { + "rule": { + "id": 21, + "fields": ["role", "agent"], + "scopes": ["gift"], + "modelName": "Giftagent", + "isDatabaseConstraint": true + } + } + ], + "Groupperson": [ + { + "rule": { + "id": 22, + "fields": ["member"], + "scopes": ["group"], + "modelName": "Groupperson", + "isDatabaseConstraint": true + } + } + ], + "Institution": [ + { + "rule": { + "id": 23, + "fields": ["name"], + "scopes": [], + "modelName": "Institution", + "isDatabaseConstraint": false + } + } + ], + "Loan": [ + { + "rule": { + "id": 24, + "fields": ["loanNumber"], + "scopes": ["discipline"], + "modelName": "Loan", + "isDatabaseConstraint": false + } + } + ], + "Loanagent": [ + { + "rule": { + "id": 25, + "fields": ["role", "agent"], + "scopes": ["loan"], + "modelName": "Loanagent", + "isDatabaseConstraint": true + } + } + ], + "Locality": [ + { + "rule": { + "id": 26, + "fields": ["uniqueIdentifier"], + "scopes": [], + "modelName": "Locality", + "isDatabaseConstraint": true + } + } + ], + "Localitycitation": [ + { + "rule": { + "id": 27, + "fields": ["referenceWork"], + "scopes": ["locality"], + "modelName": "Localitycitation", + "isDatabaseConstraint": true + } + } + ], + "Pcrperson": [ + { + "rule": { + "id": 28, + "fields": ["agent"], + "scopes": ["dnaSequence"], + "modelName": "Pcrperson", + "isDatabaseConstraint": true + } + } + ], + "Permit": [ + { + "rule": { + "id": 29, + "fields": ["permitNumber"], + "scopes": [], + "modelName": "Permit", + "isDatabaseConstraint": false + } + } + ], + "Picklist": [ + { + "rule": { + "id": 30, + "fields": ["name"], + "scopes": ["collection"], + "modelName": "Picklist", + "isDatabaseConstraint": false + } + } + ], + "Preparation": [ + { + "rule": { + "id": 31, + "fields": ["barCode"], + "scopes": ["collectionobject__collection"], + "modelName": "Preparation", + "isDatabaseConstraint": true + } + } + ], + "Preptype": [ + { + "rule": { + "id": 32, + "fields": ["name"], + "scopes": ["collection"], + "modelName": "Preptype", + "isDatabaseConstraint": false + } + } + ], + "Repositoryagreement": [ + { + "rule": { + "id": 33, + "fields": ["repositoryAgreementNumber"], + "scopes": ["division"], + "modelName": "Repositoryagreement", + "isDatabaseConstraint": false + } + } + ], + "Spappresourcedata": [ + { + "rule": { + "id": 34, + "fields": ["spAppResource"], + "scopes": [], + "modelName": "Spappresourcedata", + "isDatabaseConstraint": false + } + } + ], + "Specifyuser": [ + { + "rule": { + "id": 35, + "fields": ["name"], + "scopes": [], + "modelName": "Specifyuser", + "isDatabaseConstraint": true + } + } + ], + "Taxontreedef": [ + { + "rule": { + "id": 36, + "fields": ["name"], + "scopes": ["discipline"], + "modelName": "Taxontreedef", + "isDatabaseConstraint": false + } + } + ], + "Taxontreedefitem": [ + { + "rule": { + "id": 37, + "fields": ["name"], + "scopes": ["treeDef"], + "modelName": "Taxontreedefitem", + "isDatabaseConstraint": false + } + }, + { + "rule": { + "id": 38, + "fields": ["title"], + "scopes": ["treeDef"], + "modelName": "Taxontreedefitem", + "isDatabaseConstraint": false + } + } + ] +} diff --git a/specifyweb/specify/api.py b/specifyweb/specify/api.py index aa25a607b60..1ad01e91257 100644 --- a/specifyweb/specify/api.py +++ b/specifyweb/specify/api.py @@ -23,7 +23,8 @@ from specifyweb.permissions.permissions import enforce, check_table_permissions, check_field_permissions, table_permissions_checker from . import models -from .autonumbering import autonumber_and_save, AutonumberOverflowException +from .autonumbering import autonumber_and_save +from .uiformatters import AutonumberOverflowException from .filter_by_col import filter_by_collection from .auditlog import auditlog from .calculated_fields import calculate_extra_fields diff --git a/specifyweb/specify/api_tests.py b/specifyweb/specify/api_tests.py index 8305e29a451..739ec5624b1 100644 --- a/specifyweb/specify/api_tests.py +++ b/specifyweb/specify/api_tests.py @@ -10,14 +10,19 @@ from django.test import TestCase, Client from specifyweb.permissions.models import UserPolicy -from specifyweb.specify import api, models +from specifyweb.specify import api, models, scoping from specifyweb.specify.record_merging import fix_record_data +from specifyweb.businessrules.uniqueness_rules import UNIQUENESS_DISPATCH_UID, check_unique, apply_default_uniqueness_rules +from specifyweb.businessrules.orm_signal_handler import connect_signal, disconnect_signal def get_table(name: str): return getattr(models, name.capitalize()) + class MainSetupTearDown: def setUp(self): + disconnect_signal('pre_save', None, dispatch_uid=UNIQUENESS_DISPATCH_UID) + connect_signal('pre_save', check_unique, None, dispatch_uid=UNIQUENESS_DISPATCH_UID) self.institution = models.Institution.objects.create( name='Test Institution', isaccessionsglobal=True, @@ -48,6 +53,8 @@ def setUp(self): division=self.division, datatype=self.datatype) + apply_default_uniqueness_rules(self.discipline) + self.collection = models.Collection.objects.create( catalognumformatname='test', collectionname='TestCollection', @@ -1228,6 +1235,73 @@ def _get_record_data(pre_merge=False): self.assertDictEqual(merged_data, _get_record_data()) +class ScopingTests(ApiTests): + def setUp(self): + super(ScopingTests, self).setUp() + + self.other_division = models.Division.objects.create( + institution=self.institution, + name='Other Division') + + self.other_discipline = models.Discipline.objects.create( + geologictimeperiodtreedef=self.geologictimeperiodtreedef, + geographytreedef=self.geographytreedef, + division=self.other_division, + datatype=self.datatype) + + self.other_collection = models.Collection.objects.create( + catalognumformatname='test', + collectionname='OtherCollection', + isembeddedcollectingevent=False, + discipline=self.other_discipline) + def test_explicitly_defined_scope(self): + accession = models.Accession.objects.create( + accessionnumber="ACC_Test", + division=self.division + ) + accession_scope = scoping.Scoping(accession).get_scope_model() + self.assertEqual(accession_scope.id, self.institution.id) + + loan = models.Loan.objects.create( + loannumber = "LOAN_Test", + discipline=self.other_discipline + ) + + loan_scope = scoping.Scoping(loan).get_scope_model() + self.assertEqual(loan_scope.id, self.other_discipline.id) + + def test_infered_scope(self): + disposal = models.Disposal.objects.create( + disposalnumber = "DISPOSAL_TEST" + ) + disposal_scope = scoping.Scoping(disposal).get_scope_model() + self.assertEqual(disposal_scope.id, self.institution.id) + loan = models.Loan.objects.create( + loannumber = "Inerred_Loan", + division=self.other_division, + discipline=self.other_discipline + ) + inferred_loan_scope = scoping.Scoping(loan)._infer_scope()[1] + self.assertEqual(inferred_loan_scope.id, self.other_division.id) + + collection_object_scope = scoping.Scoping(self.collectionobjects[0]).get_scope_model() + self.assertEqual(collection_object_scope.id, self.collection.id) + + def test_in_same_scope(self): + collection_objects_same_collection = (self.collectionobjects[0], self.collectionobjects[1]) + self.assertEqual(scoping.in_same_scope(*collection_objects_same_collection), True) + other_collectionobject = models.Collectionobject.objects.create( + catalognumber="other-co", + collection=self.other_collection + ) + self.assertEqual(scoping.in_same_scope(other_collectionobject, self.collectionobjects[0]), False) + + agent = models.Agent.objects.create( + agenttype=1, + division=self.other_division + ) + self.assertEqual(scoping.in_same_scope(agent, other_collectionobject), True) + self.assertEqual(scoping.in_same_scope(self.collectionobjects[0], agent), False) diff --git a/specifyweb/specify/autonumbering.py b/specifyweb/specify/autonumbering.py index 104ec3ca79c..3173baded66 100644 --- a/specifyweb/specify/autonumbering.py +++ b/specifyweb/specify/autonumbering.py @@ -3,13 +3,16 @@ """ +from .uiformatters import UIFormatter, get_uiformatters +from .lock_tables import lock_tables import logging -from typing import List, Tuple, Sequence +from typing import List, Tuple, Sequence, Set + +from specifyweb.specify.scoping import Scoping +from specifyweb.specify.datamodel import datamodel logger = logging.getLogger(__name__) -from .lock_tables import lock_tables -from .uiformatters import UIFormatter, get_uiformatters, AutonumberOverflowException def autonumber_and_save(collection, user, obj) -> None: uiformatters = get_uiformatters(collection, user, obj.__class__.__name__) @@ -27,6 +30,7 @@ def autonumber_and_save(collection, user, obj) -> None: logger.debug("no fields to autonumber for %s", obj) obj.save() + def do_autonumbering(collection, obj, fields: List[Tuple[UIFormatter, Sequence[str]]]) -> None: logger.debug("autonumbering %s fields: %s", obj, fields) @@ -38,8 +42,46 @@ def do_autonumbering(collection, obj, fields: List[Tuple[UIFormatter, Sequence[s for formatter, vals in fields ] - with lock_tables(obj._meta.db_table): + with lock_tables(*get_tables_to_lock(collection, obj, [formatter.field_name for formatter, _ in fields])): for apply_autonumbering_to in thunks: apply_autonumbering_to(obj) obj.save() + + +def get_tables_to_lock(collection, obj, field_names) -> Set[str]: + # TODO: Include the fix for https://github.com/specify/specify7/issues/4148 + from specifyweb.businessrules.models import UniquenessRule + + obj_table = obj._meta.db_table + scope_table = Scoping(obj).get_scope_model() + + tables = set([obj._meta.db_table, 'django_migrations', + UniquenessRule._meta.db_table, 'discipline', scope_table._meta.db_table]) + + rules = UniquenessRule.objects.filter( + modelName=obj_table, discipline=collection.discipline) + + for rule in rules: + fields = rule.fields.filter(fieldPath__in=field_names) + if len(fields) > 0: + rule_scopes = rule.fields.filter(isScope=True) + for scope in rule_scopes: + tables.update(get_tables_from_field_path( + obj_table, scope.fieldPath)) + + return tables + + +def get_tables_from_field_path(model: str, field_path: str) -> List[str]: + tables = [] + table = datamodel.get_table_strict(model) + relationships = field_path.split('__') + + for relationship in relationships: + other_model = table.get_relationship( + relationship).relatedModelName.lower() + tables.append(other_model) + table = datamodel.get_table_strict(other_model) + + return tables diff --git a/specifyweb/specify/filter_by_col.py b/specifyweb/specify/filter_by_col.py index 2eb0e894f0b..7da25f3b7c2 100644 --- a/specifyweb/specify/filter_by_col.py +++ b/specifyweb/specify/filter_by_col.py @@ -5,7 +5,7 @@ from django.core.exceptions import FieldError from django.db.models import Q -from . import scoping +from .scoping import ScopeType from .models import Geography, Geologictimeperiod, Lithostrat, Taxon, Storage, \ Attachment @@ -18,11 +18,11 @@ def filter_by_collection(queryset, collection, strict=True): if queryset.model is Attachment: return queryset.filter( Q(scopetype=None) | - Q(scopetype=scoping.GLOBAL_SCOPE) | - Q(scopetype=scoping.COLLECTION_SCOPE, scopeid=collection.id) | - Q(scopetype=scoping.DISCIPLINE_SCOPE, scopeid=collection.discipline.id) | - Q(scopetype=scoping.DIVISION_SCOPE, scopeid=collection.discipline.division.id) | - Q(scopetype=scoping.INSTITUTION_SCOPE, scopeid=collection.discipline.division.institution.id)) + Q(scopetype=ScopeType.GLOBAL) | + Q(scopetype=ScopeType.COLLECTION, scopeid=collection.id) | + Q(scopetype=ScopeType.DISCIPLINE, scopeid=collection.discipline.id) | + Q(scopetype=ScopeType.DIVISION, scopeid=collection.discipline.division.id) | + Q(scopetype=ScopeType.INSTITUTION, scopeid=collection.discipline.division.institution.id)) if queryset.model in (Geography, Geologictimeperiod, Lithostrat): return queryset.filter(definition__disciplines=collection.discipline) diff --git a/specifyweb/specify/lock_tables.py b/specifyweb/specify/lock_tables.py index 20277acd8de..e6c2e75fbce 100644 --- a/specifyweb/specify/lock_tables.py +++ b/specifyweb/specify/lock_tables.py @@ -1,8 +1,9 @@ +from django.db import connection from contextlib import contextmanager import logging + logger = logging.getLogger(__name__) -from django.db import connection @contextmanager def lock_tables(*tables): @@ -12,9 +13,8 @@ def lock_tables(*tables): yield else: try: - cursor.execute('lock tables %s write' % ','.join(tables)) + cursor.execute('lock tables %s' % + ' write, '.join(tables) + ' write') yield finally: cursor.execute('unlock tables') - - diff --git a/specifyweb/specify/scoping.py b/specifyweb/specify/scoping.py index 377e94c2369..b4e1e4b574d 100644 --- a/specifyweb/specify/scoping.py +++ b/specifyweb/specify/scoping.py @@ -1,96 +1,134 @@ from collections import namedtuple +from typing import Tuple +from django.db.models import Model +from django.core.exceptions import ObjectDoesNotExist from . import models -COLLECTION_SCOPE = 0 -DISCIPLINE_SCOPE = 1 -DIVISION_SCOPE = 2 -INSTITUTION_SCOPE = 3 -GLOBAL_SCOPE = 10 -class Scoping(namedtuple('Scoping', 'obj')): +class ScopeType: + COLLECTION = 0 + DISCIPLINE = 1 + DIVISION = 2 + INSTITUTION = 3 + GLOBAL = 10 + - def __call__(self): +class Scoping(namedtuple('Scoping', 'obj')): + def __call__(self) -> Tuple[int, Model]: + """ + Returns the ScopeType and related Model instance of the + hierarchical position the `obj` occupies. + Tries and infers the scope based on the fields/relationships + on the model, and resolves the 'higher' scope before a more + specific scope if applicable for the object + """ table = self.obj.__class__.__name__.lower() - scope = getattr(self, table, lambda: None)() + scope = getattr(self, table, lambda: None)() if scope is None: - inferred_scope = self._infer_scope() - if inferred_scope is None: return self._default_institution_scope() + return self._infer_scope() + return scope + def get_scope_model(self) -> Model: + return self.__call__()[1] + ################################################################################ + def accession(self): institution = models.Institution.objects.get() if institution.isaccessionsglobal: - return INSTITUTION_SCOPE, institution.id + return ScopeType.INSTITUTION, institution else: return self._simple_division_scope() - def agent(self): return self._simple_division_scope() - - def borrow(self): return self._simple_collection_scope() - - def collectingevent(self): return self._simple_discipline_scope() - - def collectionobject(self): return self._simple_collection_scope() - - def conservdescription(self): return self._simple_division_scope() - def conservevent(self): return Scoping(self.obj.conservdescription)() - def dnasequence(self): return self._simple_collection_scope() - - def dnasequencing(self): return self._simple_collection_scope() - - def exchangein(self): return self._simple_division_scope() - - def exchangeout(self): return self._simple_division_scope() - - def fieldnotebook(self): return self._simple_discipline_scope() - def fieldnotebookpage(self): return Scoping(self.obj.pageset)() def fieldnotebookpageset(self): return Scoping(self.obj.fieldnotebook)() - def gift(self): return self._simple_discipline_scope() - - def loan(self): return self._simple_discipline_scope() + def gift(self): + if has_related(self.obj, 'discipline'): + return self._simple_discipline_scope() - def locality(self): return self._simple_discipline_scope() + def loan(self): + if has_related(self.obj, 'discipline'): + return self._simple_discipline_scope() def permit(self): - return INSTITUTION_SCOPE, self.obj.institution_id - - def preparation(self): return self._simple_collection_scope() + if has_related(self.obj, 'institution'): + return ScopeType.INSTITUTION, self.obj.institution def referencework(self): - institution = models.Institution.objects.get() - return INSTITUTION_SCOPE, institution.id - - def repositoryagreement(self): return self._simple_division_scope() + if has_related(self.obj, 'institution'): + return ScopeType.INSTITUTION, self.obj.institution def taxon(self): - return DISCIPLINE_SCOPE, self.obj.definition.discipline.id + return ScopeType.DISCIPLINE, self.obj.definition.discipline ############################################################################# - def _simple_discipline_scope(self): - return DISCIPLINE_SCOPE, self.obj.discipline_id - - def _simple_division_scope(self): - return DIVISION_SCOPE, self.obj.division_id + def _simple_discipline_scope(self) -> Tuple[int, Model]: + return ScopeType.DISCIPLINE, self.obj.discipline + + def _simple_division_scope(self) -> Tuple[int, Model]: + return ScopeType.DIVISION, self.obj.division + + def _simple_collection_scope(self) -> Tuple[int, Model]: + if hasattr(self.obj, "collectionmemberid"): + try: + """ + Collectionmemberid is not a primary key, but a plain + numerical field, meaning the Collection it is + supposed to 'reference' may not exist anymore + """ + collection = models.Collection.objects.get( + pk=self.obj.collectionmemberid) + except ObjectDoesNotExist: + if not hasattr(self.obj, "collection"): + raise + collection = self.obj.collection + else: + collection = self.obj.collection - def _simple_collection_scope(self): - return COLLECTION_SCOPE, self.obj.collectionmemberid + return ScopeType.COLLECTION, collection def _infer_scope(self): - if hasattr(self.obj, "division_id"): return self._simple_division_scope() - if hasattr(self.obj, "discipline_id") : return self._simple_discipline_scope() - if hasattr(self.obj, "collectionmemberid"): return self._simple_collection_scope() + if has_related(self.obj, "division"): + return self._simple_division_scope() + if has_related(self.obj, "discipline"): + return self._simple_discipline_scope() + if has_related(self.obj, "collectionmemberid") or has_related(self.obj, "collection"): + return self._simple_collection_scope() + + return self._default_institution_scope() # If the table has no scope, and scope can not be inferred then scope to institution - def _default_institution_scope(self): + def _default_institution_scope(self) -> Tuple[int, Model]: institution = models.Institution.objects.get() - return INSTITUTION_SCOPE, institution.id + return ScopeType.INSTITUTION, institution + + +def has_related(model_instance, field_name: str) -> bool: + return hasattr(model_instance, field_name) and getattr(model_instance, field_name, None) is not None + + +def in_same_scope(object1: Model, object2: Model) -> bool: + """ + Determines whether two Model Objects are in the same scope. + Travels up the scoping heirarchy until a matching scope can be resolved + """ + scope1_type, scope1 = Scoping(object1)() + scope2_type, scope2 = Scoping(object2)() + + if scope1_type > scope2_type: + while scope2_type != scope1_type: + scope2_type, scope2 = Scoping(scope2)() + elif scope1_type < scope2_type: + while scope2_type != scope1_type: + scope1_type, scope1 = Scoping(scope1)() + + return scope1.id == scope2.id diff --git a/specifyweb/urls.py b/specifyweb/urls.py index 9f23db89862..e8ce9282102 100644 --- a/specifyweb/urls.py +++ b/specifyweb/urls.py @@ -4,6 +4,7 @@ from .accounts import urls as accounts_urls from .attachment_gw import urls as attachment_urls from .barvis import urls as tt_urls +from .businessrules import urls as bus_urls from .context import urls as context_urls from .export import urls as export_urls from .express_search import urls as es_urls @@ -51,6 +52,7 @@ url(r'^stored_query/', include(query_urls)), # permissions added url(r'^attachment_gw/', include(attachment_urls)), url(r'^barvis/', include(tt_urls)), + url(r'^businessrules/', include(bus_urls)), url(r'^report_runner/', include(report_urls)), # permissions added url(r'^interactions/', include(interaction_urls)), # permissions added url(r'^notifications/', include(notification_urls)),