Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor and refine cascade deletion rules #2806

Open
wants to merge 13 commits into
base: production
Choose a base branch
from
Open
2 changes: 1 addition & 1 deletion specifyweb/businessrules/rules/agent_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ def agent_types_other_and_group_do_not_have_addresses(agent):
# 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()
# agent.addresses.all().delete()
2 changes: 1 addition & 1 deletion specifyweb/frontend/js_src/.prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
lib/tests/fixtures/
lib/tests/ajax/static
lib/components/DataModel/*.js
*.snap
*.snap
3 changes: 1 addition & 2 deletions specifyweb/frontend/js_src/css/workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@
/* Cell navigation */

.wbs-form
:is(.wb-no-match-cell,
.wb-modified-cell, .htCommentCell, .wb-search-match-cell),
:is(.wb-no-match-cell, .wb-modified-cell, .htCommentCell, .wb-search-match-cell),
.wb-navigation-section {
@apply !bg-[color:var(--accent-color)];
}
Expand Down
36 changes: 35 additions & 1 deletion specifyweb/specify/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
logger = logging.getLogger(__name__)

from django import forms
from django.db import transaction
from django.db import transaction, router
from django.db.models.deletion import Collector
from django.http import (HttpResponse, HttpResponseBadRequest,
Http404, HttpResponseNotAllowed, QueryDict)
from django.core.exceptions import ObjectDoesNotExist, FieldError, FieldDoesNotExist
Expand All @@ -23,11 +24,13 @@
from specifyweb.permissions.permissions import enforce, check_table_permissions, check_field_permissions, table_permissions_checker

from . import models
from .datamodel import datamodel
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
from .deletion_rules import ADDITIONAL_DELETE_BLOCKERS

ReadPermChecker = Callable[[Any], None]

Expand Down Expand Up @@ -61,6 +64,37 @@ def default(self, obj):
def toJson(obj: Any) -> str:
return json.dumps(obj, cls=JsonEncoder)

def get_delete_blockers(obj):
using = router.db_for_write(obj.__class__, instance=obj)
model = obj.__class__.__name__
collector = Collector(using=using)
collector.delete_blockers = []
collector.collect([obj])
result = [
{
'table': sub_objs[0].__class__.__name__,
'field': field.name,
'ids': [sub_obj.id for sub_obj in sub_objs]
}
for field, sub_objs in collector.delete_blockers
]
# Check if the object has additional delete blockers.
# If so, add them to the response only if the object has data in the 'field'
if model in ADDITIONAL_DELETE_BLOCKERS:

def get_blocker_information(field_name):
other_table = getattr(obj, field_name).specify_model
otherside_field = obj.specify_model.get_relationship(field_name).otherSideName
sub_objs = getattr(models, other_table.django_name).objects.filter(**{otherside_field: obj.id})

return {"table": other_table.django_name, "field": otherside_field, "ids": [sub_obj.id for sub_obj in sub_objs]}

rel_data = ADDITIONAL_DELETE_BLOCKERS[model]
result.extend([get_blocker_information(field)
for field in rel_data if getattr(obj, field) is not None])

return result

class RecordSetException(Exception):
"""Raised for problems related to record sets."""
pass
Expand Down
115 changes: 63 additions & 52 deletions specifyweb/specify/build_models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from django.db import models
from typing import Dict

from specifyweb.businessrules.exceptions import AbortSave
from . import model_extras
from .datamodel import Datamodel, Table, Relationship, Field
from .deletion_rules import SPECIAL_DELETION_RULES, ADDITIONAL_DELETE_BLOCKERS

# this moduel's parent directory, 'specify'
appname = __name__.split('.')[-2]

# REFACTOR: generate this on the fly based on presence of
Expand All @@ -24,13 +28,14 @@
'PcrPerson': ('ordernumber',),
}

def make_model(module, table, datamodel):

def make_model(module: str, table: Table, datamodel: Datamodel) -> models.Model:
"""Returns a Django model class based on the
definition of a Specify table.
"""
attrs = dict(id = make_id_field(table.idColumn),
specify_model = table,
__module__ = module)
attrs = dict(id=make_id_field(table.idColumn),
specify_model=table,
__module__=module)

for field in table.fields:
fldname = field.name.lower()
Expand Down Expand Up @@ -72,34 +77,19 @@ def save(self, *args, **kwargs):

return model

def make_id_field(column):

def make_id_field(column: str) -> models.AutoField:
return models.AutoField(primary_key=True, db_column=column.lower())

def protect(collector, field, sub_objs, using):

def protect(collector: models.deletion.Collector, field: models.Field, sub_objs: models.query.QuerySet, using: str):
if hasattr(collector, 'delete_blockers'):
collector.delete_blockers.append((field, sub_objs))
else:
models.PROTECT(collector, field, sub_objs, using)

SPECIAL_DELETION_RULES = {
'Agent.specifyuser': models.SET_NULL,
'Recordsetitem.recordset': models.CASCADE,

# Handle workbench deletion using raw sql in business rules.
'Workbenchrow.workbench': models.DO_NOTHING,
'Workbenchdataitem.workbenchrow': models.DO_NOTHING,
'Workbenchrowimage.workbenchrow': models.DO_NOTHING,
'Workbenchrowexportedrelationship.workbenchrow': models.DO_NOTHING,

'Spappresourcedir.specifyuser': models.CASCADE,
'Spappresource.specifyuser': models.CASCADE,
'Spappresource.spappresourcedir': models.CASCADE,
'Spappresourcedata.spappresource': models.CASCADE,
'Spappresourcedata.spviewsetobj': models.CASCADE,
'Spreport.appresource': models.CASCADE,
}

def make_relationship(modelname, rel, datamodel):
def make_relationship(modelname: str, rel: Relationship, datamodel: Datamodel) -> models.ForeignKey or models.OneToOneField:
"""Return a Django relationship field for the given relationship definition.

modelname - name of the model this field will be part of
Expand All @@ -114,49 +104,57 @@ def make_relationship(modelname, rel, datamodel):
return models.IntegerField(db_column=rel.column, null=True)

if rel.type == 'one-to-many':
return None # only define the "to" side of the relationship
return None # only define the "to" side of the relationship
if rel.type == 'many-to-many':
# skip many-to-many fields for now.
return None

try:
on_delete = SPECIAL_DELETION_RULES["%s.%s" % (modelname.capitalize(), rel.name.lower())]
on_delete = SPECIAL_DELETION_RULES[f'{modelname.capitalize()}.{rel.name.lower()}']
except KeyError:
reverse = datamodel.reverse_relationship(rel)

if reverse and reverse.dependent:
if reverse is not None and reverse.dependent:

# Example: Current `rel` is Accessionagent.accession,
# Reverse (Accession.accessionAgents) is flagged as dependent
# (see dependent_fields in .load_datamodel.py), so Cascade Accession Agent
# when deleting Accession
on_delete = models.CASCADE
elif modelname in ADDITIONAL_DELETE_BLOCKERS.keys():
on_delete = protect
else:
on_delete = protect

def make_to_one(Field):
def make_to_one(Field: (models.ForeignKey or models.OneToOneField)) -> models.ForeignKey or models.OneToOneField:
"""Setup a field of the given 'Field' type which can be either
ForeignKey (many-to-one) or OneToOneField.
"""
if hasattr(rel, 'otherSideName'):
related_name = rel.otherSideName.lower()
else:
related_name = '+' # magic symbol means don't make reverse field
related_name = '+' # magic symbol means don't make reverse field

return Field('.'.join((appname, relatedmodel)),
db_column = rel.column,
related_name = related_name,
null = not rel.required,
on_delete = on_delete)
db_column=rel.column,
related_name=related_name,
null=not rel.required,
on_delete=on_delete)

if rel.type == 'many-to-one':
return make_to_one(models.ForeignKey)

if rel.type == 'one-to-one' and hasattr(rel, 'column'):
return make_to_one(models.OneToOneField)


class make_field(object):
"""An abstract "psuedo" metaclass that produces instances of the
appropriate Django model field type. Utilizes inheritance
mechanism to factor out common aspects of Field configuration.
"""
@classmethod
def get_field_class(cls, fld):
def get_field_class(cls: type, fld) -> models.Field:
"""Return the Django model field class to be used for
the given field definition. Defaults to returning the
'field_class' attribute of the class, but can be overridden
Expand All @@ -165,18 +163,18 @@ def get_field_class(cls, fld):
return cls.field_class

@classmethod
def make_args(cls, fld):
def make_args(cls, fld: Field) -> Dict[str, str or bool]:
"""Return a dict of arguments for the field constructor
based on the XML definition. These are common arguements
used by most field types.
"""
return dict(
db_column = fld.column.lower(),
db_index = fld.indexed,
unique = fld.unique,
null = not fld.required)
db_column=fld.column.lower(),
db_index=fld.indexed,
unique=fld.unique,
null=not fld.required)

def __new__(cls, fld, fldargs):
def __new__(cls, fld: Field, fldargs: Dict[str, bool]):
"""Override the instance constructor to return configured instances
of the appropriant Django model field for given parameters.

Expand All @@ -188,6 +186,7 @@ def __new__(cls, fld, fldargs):
args.update(fldargs)
return field_class(**args)


class make_string_field(make_field):
"""A specialization of make_field that handles string type data."""
field_class = models.CharField
Expand All @@ -199,36 +198,42 @@ def make_args(cls, fld):
"""
args = super(make_string_field, cls).make_args(fld)
args.update(dict(
max_length = fld.length,
blank = not fld.required))
max_length=fld.length,
blank=not fld.required))
return args


class make_text_field(make_field):
"""A specialization of make_field for Text fields."""
field_class = models.TextField


class make_integer_field(make_field):
"""A specialization of make_field for Integer fields."""
field_class = models.IntegerField


class make_date_field(make_field):
"""A specialization of make_field for Date fields."""
field_class = models.DateField


class make_float_field(make_field):
"""A specialization of make_field for Floating point number fields."""
field_class = models.FloatField


class make_datetime_field(make_field):
"""A specialization of make_field for timestamp fields."""
field_class = models.DateTimeField


class make_decimal_field(make_field):
"""A specialization of make_field for Decimal fields."""
field_class = models.DecimalField

@classmethod
def make_args(cls, fld):
def make_args(cls, fld: Field):
"""Augment the standard field options with those specific
to Decimal fields.
"""
Expand All @@ -238,23 +243,25 @@ def make_args(cls, fld):
# XML schema def. I don't think it really
# matters what values are here since
# the schema is already built.
max_digits = 22,
decimal_places = 10,
blank = not fld.required))
max_digits=22,
decimal_places=10,
blank=not fld.required))
return args


class make_boolean_field(make_field):
"""A specialization of make_field for Boolean type fields."""
field_class = models.BooleanField

@classmethod
def make_args(cls, fld):
def make_args(cls, fld: Field):
"""Make False the default as it was in Django 1.5"""
args = super(make_boolean_field, cls).make_args(fld)
if fld.required:
args['default'] = False
return args


# Map the field types used in specify_datamodel.xml to the
# appropriate field constructor functions.
field_type_map = {
Expand All @@ -271,9 +278,13 @@ def make_args(cls, fld):
'java.sql.Timestamp': make_datetime_field,
'java.math.BigDecimal': make_decimal_field,
'java.lang.Boolean': make_boolean_field,
}
}

# Build the table information as Django models
# See .models.py


def build_models(module, datamodel):
return { model.specify_model.tableId: model
for table in datamodel.tables
for model in [ make_model(module, table, datamodel) ]}
def build_models(module: str, datamodel: Datamodel) -> Dict[int, models.Model]:
return {model.specify_model.tableId: model
for table in datamodel.tables
for model in [make_model(module, table, datamodel)]}
18 changes: 0 additions & 18 deletions specifyweb/specify/case_insensitive_bool.py

This file was deleted.

Loading
Loading