From 3714059b0be0ce7d2357f7c92b6c6fcf89c99693 Mon Sep 17 00:00:00 2001 From: Enric Pou Date: Wed, 11 Oct 2023 17:45:56 +0200 Subject: [PATCH] Refactor tests --- mosquito_alert/bites/tests/test_models.py | 40 ++- .../breeding_sites/tests/test_models.py | 17 +- .../epidemiology/tests/factories.py | 7 + .../epidemiology/tests/test_models.py | 95 +++--- mosquito_alert/geo/models.py | 77 +++-- mosquito_alert/geo/tests/factories.py | 8 +- mosquito_alert/geo/tests/fuzzy.py | 4 +- mosquito_alert/geo/tests/test_models.py | 295 +++++++++------- mosquito_alert/images/tests/test_models.py | 20 +- .../migrations/0005_alter_report_user.py | 27 ++ mosquito_alert/reports/models.py | 1 + mosquito_alert/reports/tests/test_models.py | 72 +++- ..._speciedistribution_created_at_and_more.py | 45 +++ mosquito_alert/taxa/models.py | 38 ++- mosquito_alert/taxa/tests/test_models.py | 316 ++++++++---------- mosquito_alert/utils/tests/test_models.py | 13 +- 16 files changed, 629 insertions(+), 446 deletions(-) create mode 100644 mosquito_alert/reports/migrations/0005_alter_report_user.py create mode 100644 mosquito_alert/taxa/migrations/0005_speciedistribution_created_at_and_more.py diff --git a/mosquito_alert/bites/tests/test_models.py b/mosquito_alert/bites/tests/test_models.py index 5d2b11b6..40ef46c8 100644 --- a/mosquito_alert/bites/tests/test_models.py +++ b/mosquito_alert/bites/tests/test_models.py @@ -1,37 +1,35 @@ import pytest -from django.db.models.deletion import ProtectedError -from django.db.utils import IntegrityError +from django.db import models from django.utils import timezone -from mosquito_alert.individuals.tests.factories import IndividualFactory +from mosquito_alert.utils.tests.test_models import AbstractDjangoModelTestMixin from ..models import Bite from .factories import BiteFactory @pytest.mark.django_db -class TestBiteModel: - def test_bite_is_protected_on_individual_delete(self): - individual = IndividualFactory() - _ = BiteFactory(individual=individual) +class TestBiteModel(AbstractDjangoModelTestMixin): + model = Bite + factory_cls = BiteFactory - with pytest.raises(ProtectedError): - individual.delete() + # fields + def test_individual_can_be_null(self): + assert self.model._meta.get_field("individual").null - def test_bite_allow_null_individual(self): - _ = BiteFactory(individual=None) + def test_individual_deletion_is_protected(self): + _on_delete = self.model._meta.get_field("individual").remote_field.on_delete + assert _on_delete == models.PROTECT - def test_do_not_allow_null_bodypart(self): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - _ = BiteFactory(body_part=None) + def test_datetime_auto_now_add(self): + assert self.model._meta.get_field("datetime").auto_now_add - def test_auto_datetime(self, freezer): - # freezer is fixture from pytest-freezegun - bite = BiteFactory() - assert bite.datetime == timezone.now() + def test_body_part_can_not_be_null(self): + assert not self.model._meta.get_field("body_part").null - def test__str__(self, freezer): - # freezer is fixture from pytest-freezegun - bite = BiteFactory(body_part=Bite.BodyParts.HEAD) + # meta + @pytest.mark.freeze_time + def test__str__(self): + bite = self.factory_cls(body_part=Bite.BodyParts.HEAD) expected_str = "{} ({})".format(Bite.BodyParts.HEAD.label, timezone.now().strftime("%Y-%m-%d %H:%M:%S")) assert bite.__str__() == expected_str diff --git a/mosquito_alert/breeding_sites/tests/test_models.py b/mosquito_alert/breeding_sites/tests/test_models.py index ad65f262..accc4ad2 100644 --- a/mosquito_alert/breeding_sites/tests/test_models.py +++ b/mosquito_alert/breeding_sites/tests/test_models.py @@ -1,13 +1,24 @@ import pytest +from mosquito_alert.utils.tests.test_models import AbstractDjangoModelTestMixin + +from ..models import BreedingSite from .factories import BreedingSiteFactory @pytest.mark.django_db -class TestBreedingSiteModel: - def test_allow_null_type(self): - _ = BreedingSiteFactory(type=None) +class TestBreedingSiteModel(AbstractDjangoModelTestMixin): + model = BreedingSite + factory_cls = BreedingSiteFactory + + # fields + def test_type_can_be_null(self): + assert self.model._meta.get_field("type").null + + def test_type_can_be_blank(self): + assert self.model._meta.get_field("type").blank + # meta def test__str__with_location_type(self): obj = BreedingSiteFactory() loc = obj.location diff --git a/mosquito_alert/epidemiology/tests/factories.py b/mosquito_alert/epidemiology/tests/factories.py index 5b60a9ac..ffc92032 100644 --- a/mosquito_alert/epidemiology/tests/factories.py +++ b/mosquito_alert/epidemiology/tests/factories.py @@ -25,5 +25,12 @@ def diseases(self, create, extracted, **kwargs): self.diseases.add(*extracted) + @classmethod + def _after_postgeneration(cls, instance, create, results=None): + # diseases is already set. Do not call obj.save againg + if results: + _ = results.pop("diseases", None) + super()._after_postgeneration(instance=instance, create=create, results=results) + class Meta: model = DiseaseVector diff --git a/mosquito_alert/epidemiology/tests/test_models.py b/mosquito_alert/epidemiology/tests/test_models.py index 066df2e7..0ba9030a 100644 --- a/mosquito_alert/epidemiology/tests/test_models.py +++ b/mosquito_alert/epidemiology/tests/test_models.py @@ -1,39 +1,29 @@ import pytest -from django.db.utils import DataError, IntegrityError +from django.db import models from mosquito_alert.taxa.models import Taxon from mosquito_alert.taxa.tests.factories import SpecieDistributionFactory, TaxonFactory +from mosquito_alert.utils.tests.test_models import AbstractDjangoModelTestMixin from ..models import Disease, DiseaseVector, DiseaseVectorDistribution from .factories import DiseaseFactory, DiseaseVectorFactory @pytest.mark.django_db -class TestDiseaseModel: - def test_default_order_by_name(self): - d1 = DiseaseFactory(name="Z") - d2 = DiseaseFactory(name="A") - - assert frozenset(Disease.objects.all()) == frozenset([d2, d1]) - - def test_name_must_be_unique(self): - with pytest.raises(IntegrityError, match=r"unique constraint"): - _ = DiseaseFactory.create_batch(size=2, name="Unique Name") - - @pytest.mark.parametrize( - "name, output_raises", - [ - ("a" * 63, False), - ("a" * 64, False), - ("a" * 65, True), - ], - ) - def test_name_max_length_is_64(self, name, output_raises): - if output_raises: - with pytest.raises(DataError): - DiseaseFactory(name=name) - else: - DiseaseFactory(name=name) +class TestDiseaseModel(AbstractDjangoModelTestMixin): + model = Disease + factory_cls = DiseaseFactory + + # fields + def test_name_is_unique(self): + assert self.model._meta.get_field("name").unique + + def test_name_max_length_is_64(self): + assert self.model._meta.get_field("name").max_length == 64 + + # meta + def test_ordering_ascending_name(self): + assert self.model._meta.ordering == ["name"] def test__str__(self): disease = DiseaseFactory(name="Malaria") @@ -41,46 +31,38 @@ def test__str__(self): @pytest.mark.django_db -class TestDiseaseVectorModel: - def test_diseasse_vector_one2one_taxon(self, taxon_specie): - with pytest.raises(IntegrityError, match=r"unique constraint"): - _ = DiseaseVectorFactory.create_batch(size=2, taxon=taxon_specie) +class TestDiseaseVectorModel(AbstractDjangoModelTestMixin): + model = DiseaseVector + factory_cls = DiseaseVectorFactory - def test_disease_vector_pk_is_taxon(self, taxon_specie): - dv = DiseaseVectorFactory(taxon=taxon_specie) + # fields + def test_taxon_fk_is_unique(self): + assert self.model._meta.get_field("taxon").unique - assert dv.pk == taxon_specie.pk + def test_taxon_is_pk(self): + assert self.model._meta.get_field("taxon").primary_key - def test_cascade_taxon_deletion(self): - dv = DiseaseVectorFactory() - dv.taxon.delete() + def test_taxon_deletion_is_cascaded(self): + _on_delete = self.model._meta.get_field("taxon").remote_field.on_delete + assert _on_delete == models.CASCADE - assert DiseaseVector.objects.all().count() == 0 + def test_taxon_related_name(self): + assert self.model._meta.get_field("taxon").remote_field.related_name == "disease_vector" def test_taxon_can_not_be_null(self): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - DiseaseVectorFactory(taxon=None) + assert not self.model._meta.get_field("taxon").null - def test_taxon_related_name(self, taxon_specie): - dv = DiseaseVectorFactory(taxon=taxon_specie) - - assert taxon_specie.disease_vector == dv + def test_diseases_related_name(self): + assert self.model._meta.get_field("diseases").remote_field.related_name == "disease_vectors" + # methods def test_taxon_should_be_species_rank(self): with pytest.raises(ValueError, match=r"Taxon must be species rank."): DiseaseVectorFactory(taxon__rank=Taxon.TaxonomicRank.CLASS) - def test_default_ordering_is_taxon_name(self, taxon_root): - dv1 = DiseaseVectorFactory(taxon__name="z", taxon__parent=taxon_root) - dv2 = DiseaseVectorFactory(taxon__name="a", taxon__parent=taxon_root) - - assert list(DiseaseVector.objects.all()) == [dv2, dv1] - - def test_diseases_related_name(self): - disease = DiseaseFactory() - dv = DiseaseVectorFactory(diseases=[disease]) - - assert list(disease.disease_vectors.all()) == [dv] + # meta + def test_default_ordering_is_taxon_name_asc(self, taxon_root): + assert self.model._meta.ordering == ["taxon__name"] def test__str__(self): dv = DiseaseVectorFactory(taxon__name="Random name") @@ -89,7 +71,10 @@ def test__str__(self): @pytest.mark.django_db -class TestDiseaseVectorDistribution: +class TestDiseaseVectorDistribution(AbstractDjangoModelTestMixin): + model = DiseaseVectorDistribution + factory_cls = None + def test_should_filter_by_taxon_vectors(self, taxon_root, country_bl): taxon_not_vector = TaxonFactory(parent=taxon_root, rank=Taxon.TaxonomicRank.SPECIES) disease_vector = DiseaseVectorFactory(taxon__parent=taxon_root, taxon__name="test") diff --git a/mosquito_alert/geo/models.py b/mosquito_alert/geo/models.py index 1bca9cc9..1d9b5390 100644 --- a/mosquito_alert/geo/models.py +++ b/mosquito_alert/geo/models.py @@ -1,5 +1,6 @@ from django.contrib.gis import geos from django.contrib.gis.db import models +from django.core.exceptions import ValidationError from django.db.models import Q from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ @@ -77,36 +78,62 @@ def update_descendants_boundaries(self): # will not be called for these cases. self.get_descendants().update(boundary=self.boundary) - def save(self, *args, **kwargs): + def clean_boundary_type_field(self): try: if not self.is_root() and (root := self.get_root()): if self.boundary_type != root.boundary_type: - raise ValueError(f"Only {root.boundary_type} boundary layer nodes are allowed in {root} tree.") + raise ValidationError( + f"Only {root.boundary_type} boundary layer nodes are allowed in {root} tree." + ) except self.__class__.DoesNotExist: # self.get_root() has not found any result. pass + def clean_level_field(self): + if not self.parent: + return + + # Checking that the current level is smaller than its parent + if self.level <= self.parent.level: + raise ValidationError("Level must be ascending order from parent to children.") + + def _clean_custom_fields(self, exclude=None) -> None: + if exclude is None: + exclude = [] + + errors = {} + if "boundary_type" not in exclude: + try: + self.clean_boundary_type_field() + except ValidationError as e: + errors["boundary_type"] = e.error_list + + if "level" not in exclude: + try: + self.clean_level_field() + except ValidationError as e: + errors["level"] = e.error_list + + if errors: + raise ValidationError(errors) + + def clean_fields(self, exclude=None) -> None: + super().clean_fields(exclude=exclude) + self._clean_custom_fields(exclude=exclude) + + def save(self, *args, **kwargs): # Using depth as level default value if self._state.adding: - # If creating - if self.parent: - # Getting from parent + 1 - self.level = self.parent.level + 1 - else: - # Getting from node depth. - # Substracting 1 since root depth starts from 1, while level start from 0. - self.level = 0 - else: - # If updating object - if self.parent: - # Checking that the current level is smaller than its parent - if self.level <= self.parent.level: - raise ValueError("Level must be ascending order from parent to children.") - - # Inherit the boundary owner from the parent. - if self.parent and (p_boundary := self.parent.boundary): - # NOTE: If someday we need to on self.boundary update, change the below update() method. - self.boundary = p_boundary + if self.level is None: + self.level = self.parent.level + 1 if self.parent else 0 + + if self.boundary is None: + # Inherit the boundary owner from the parent. + if self.parent and (p_boundary := self.parent.boundary): + # NOTE: If someday we need to on self.boundary update, change the below update() method. + self.boundary = p_boundary + + self._clean_custom_fields() super().save(*args, **kwargs) @@ -146,14 +173,14 @@ class Boundary(ParentManageableNodeMixin, TimeStampedModel, MP_Node): # Custom Properties node_order_by = ["name"] # Needed for django-treebeard - @cached_property - def geometry(self): - return self.get_geometry() - @property def boundary_type(self): return self.boundary_layer.boundary_type + @cached_property + def geometry(self): + return self.get_geometry() + # Methods def get_geometry(self): try: diff --git a/mosquito_alert/geo/tests/factories.py b/mosquito_alert/geo/tests/factories.py index 497256e2..f917d7a4 100644 --- a/mosquito_alert/geo/tests/factories.py +++ b/mosquito_alert/geo/tests/factories.py @@ -13,7 +13,6 @@ class BoundaryLayerFactory(DjangoModelFactory): ) boundary_type = factory.Faker("random_element", elements=BoundaryLayer.BoundaryType.values) name = factory.Faker("name") - level = factory.Faker("random_int", min=0, max=5) description = factory.Faker("paragraph") class Meta: @@ -61,6 +60,13 @@ def boundaries(self, create, extracted, **kwargs): self.boundaries.add(*extracted) + @classmethod + def _after_postgeneration(cls, instance, create, results=None): + # boundaries is already set. Do not call obj.save againg + if results: + _ = results.pop("boundaries", None) + super()._after_postgeneration(instance=instance, create=create, results=results) + class Meta: model = Location diff --git a/mosquito_alert/geo/tests/fuzzy.py b/mosquito_alert/geo/tests/fuzzy.py index ebbe9fa2..9aefdf58 100644 --- a/mosquito_alert/geo/tests/fuzzy.py +++ b/mosquito_alert/geo/tests/fuzzy.py @@ -18,7 +18,7 @@ class FuzzyPolygon(factory.fuzzy.BaseFuzzyAttribute): def __init__(self, srid=None, length=None, **kwargs): if length is None: - length = random.randgen.randrange(3, 20, 1) + length = random.randgen.randrange(3, 6, 1) if length < 3: raise Exception("Polygon needs to be 3 or greater in length.") self.length = length @@ -39,7 +39,7 @@ class FuzzyMultiPolygon(factory.fuzzy.BaseFuzzyAttribute): def __init__(self, srid=None, length=None, **kwargs): if length is None: - length = random.randgen.randrange(2, 20, 1) + length = random.randgen.randrange(2, 5, 1) if length < 2: raise Exception("MultiPolygon needs to be 2 or greater in length.") self.length = length diff --git a/mosquito_alert/geo/tests/test_models.py b/mosquito_alert/geo/tests/test_models.py index ccb7e152..d8cafe6a 100644 --- a/mosquito_alert/geo/tests/test_models.py +++ b/mosquito_alert/geo/tests/test_models.py @@ -1,90 +1,120 @@ +from abc import ABC +from unittest.mock import patch + import pytest from django.contrib.gis.geos import MultiPolygon, Point, Polygon -from django.db.models.deletion import ProtectedError +from django.core.exceptions import ValidationError +from django.db import models from django.db.utils import IntegrityError -from mosquito_alert.utils.tests.test_models import BaseTestTimeStampedModel +from mosquito_alert.utils.tests.test_models import AbstractDjangoModelTestMixin, BaseTestTimeStampedModel from ..models import Boundary, BoundaryGeometry, BoundaryLayer, Location -from .factories import BoundaryFactory, BoundaryGeometryFactory, BoundaryLayerFactory, LocationFactory +from .factories import ( + BoundaryFactory, + BoundaryGeometryFactory, + BoundaryLayerFactory, + DummyGeoLocatedModelFactory, + LocationFactory, +) from .fuzzy import FuzzyMultiPolygon, FuzzyPoint, FuzzyPolygon from .models import DummyGeoLocatedModel @pytest.mark.django_db -class TestBoundaryLayerModel: +class TestBoundaryLayerModel(AbstractDjangoModelTestMixin): + model = BoundaryLayer + factory_cls = BoundaryLayerFactory + + # fields def test_boundary_can_be_null(self): - BoundaryLayerFactory(boundary=None) + assert self.model._meta.get_field("boundary").null + + def test_boundary_can_be_blank(self): + assert self.model._meta.get_field("boundary").blank + + def test_boundary_on_delete_is_protected(self): + _on_delete = self.model._meta.get_field("boundary").remote_field.on_delete + assert _on_delete == models.PROTECT + + def test_boundary_related_name(self): + assert self.model._meta.get_field("boundary").remote_field.related_name == "boundary_layers" def test_boundary_type_can_not_be_null(self): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - BoundaryLayerFactory(boundary_type=None) + assert not self.model._meta.get_field("boundary_type").null + + def test_boundary_type_is_db_index(self): + assert self.model._meta.get_field("boundary_type").db_index def test_name_can_not_be_null(self): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - BoundaryLayerFactory(name=None) + assert not self.model._meta.get_field("name").null + + def test_name_max_length_is_64(self): + assert self.model._meta.get_field("name").max_length == 64 + + def test_level_can_not_be_null(self): + assert not self.model._meta.get_field("level").null + + def test_level_can_be_blank(self): + assert self.model._meta.get_field("level").blank - @pytest.mark.parametrize("fieldname", ["boundary_type", "level"]) - def test_indexed_fields(self, fieldname): - assert BoundaryLayer._meta.get_field(fieldname).db_index + def test_level_is_db_index(self): + assert self.model._meta.get_field("level").db_index def test_description_can_be_null(self): - BoundaryLayerFactory(description=None) + assert self.model._meta.get_field("description").null - def test_protect_on_bounadary_delete(self): - bl = BoundaryLayerFactory(boundary=None) - boundary = BoundaryFactory(boundary_layer=bl) - bl.boundary = boundary - bl.save() + def test_description_can_be_blank(self): + assert self.model._meta.get_field("description").blank - with pytest.raises(ProtectedError): - boundary.delete() + # properties + def test_node_order_by_name(self): + assert self.model.node_order_by == ["name"] - def test_trees_must_have_same_boundary_type(self): - adm_root_node = BoundaryLayerFactory( - boundary=None, boundary_type=BoundaryLayer.BoundaryType.ADMINISTRATIVE.value - ) + # methods + def test_children_layers_must_have_same_boundary_type_than_parents(self): + adm_root_node = self.factory_cls(boundary=None, boundary_type=BoundaryLayer.BoundaryType.ADMINISTRATIVE.value) - with pytest.raises(ValueError): - _ = BoundaryLayerFactory( + with pytest.raises(ValidationError): + _ = self.factory_cls( boundary_type=BoundaryLayer.BoundaryType.STATISTICAL.value, parent=adm_root_node, ) - def test_auto_level_from_parent(self, country_bl): - bl = BoundaryLayerFactory(level=None, parent=country_bl, boundary_type=country_bl.boundary_type) + def test_level_is_inferred_from_parent_if_not_set(self, country_bl): + bl = self.factory_cls(level=None, parent=country_bl, boundary_type=country_bl.boundary_type) assert bl.level == country_bl.level + 1 def test_auto_level_to_0_if_root(self): - bl = BoundaryLayerFactory(level=None, parent=None) + bl = self.factory_cls(level=None, parent=None) assert bl.level == 0 def test_raise_on_level_update_lower_than_parent(self, country_bl): - bl = BoundaryLayerFactory(level=None, parent=country_bl, boundary_type=country_bl.boundary_type) + bl = self.factory_cls(level=None, parent=country_bl, boundary_type=country_bl.boundary_type) bl.level = 0 - with pytest.raises(ValueError): + with pytest.raises(ValidationError): bl.save() def test_boundary_owner_is_inherited_from_parent(self): - bl = BoundaryLayerFactory(boundary=None) + bl = self.factory_cls(boundary=None) boundary = BoundaryFactory(boundary_layer=bl) bl.boundary = boundary bl.save() - child_bl = BoundaryLayerFactory(boundary=None, parent=bl, boundary_type=bl.boundary_type) + child_bl = self.factory_cls(boundary=None, parent=bl, boundary_type=bl.boundary_type) assert child_bl.boundary == boundary def test_update_descendants_boundaries_on_update(self): - bl = BoundaryLayerFactory(boundary=None) + bl = self.factory_cls(boundary=None) boundary = BoundaryFactory(boundary_layer=bl, code="code1") bl.boundary = boundary bl.save() - child_bl = BoundaryLayerFactory(parent=bl, boundary_type=bl.boundary_type) + child_bl = self.factory_cls(parent=bl, boundary_type=bl.boundary_type) new_boundary = BoundaryFactory(boundary_layer=bl, code="code2") @@ -95,10 +125,11 @@ def test_update_descendants_boundaries_on_update(self): assert child_bl.boundary == new_boundary + # meta def test_unique_type_level_by_boundary(self): b = BoundaryFactory() with pytest.raises(IntegrityError, match=r"unique constraint"): - _ = BoundaryLayerFactory.create_batch( + _ = self.factory_cls.create_batch( size=2, boundary=b, boundary_type=BoundaryLayer.BoundaryType.ADMINISTRATIVE.value, @@ -107,7 +138,7 @@ def test_unique_type_level_by_boundary(self): # Same with bounary null with pytest.raises(IntegrityError, match=r"unique constraint"): - _ = BoundaryLayerFactory.create_batch( + _ = self.factory_cls.create_batch( size=2, boundary=None, boundary_type=BoundaryLayer.BoundaryType.ADMINISTRATIVE.value, @@ -115,7 +146,7 @@ def test_unique_type_level_by_boundary(self): ) def test__str__(self): - bl = BoundaryLayerFactory( + bl = self.factory_cls( boundary_type=BoundaryLayer.BoundaryType.ADMINISTRATIVE.value, name="test", ) @@ -128,56 +159,50 @@ class TestBoundaryModel(BaseTestTimeStampedModel): model = Boundary factory_cls = BoundaryFactory + # fields def test_boundary_layer_cannot_be_null(self): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - BoundaryFactory(boundary_layer=None) + assert not self.model._meta.get_field("boundary_layer").null - def test_cascade_boundary_layer_deletion(self): - b = BoundaryFactory() - b.boundary_layer.delete() - assert Boundary.objects.all().count() == 0 + def test_boundary_layer_on_delete_cascade(self): + _on_delete = self.model._meta.get_field("boundary_layer").remote_field.on_delete + assert _on_delete == models.CASCADE def test_code_cannot_be_null(self): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - _ = BoundaryFactory(code=None) + assert not self.model._meta.get_field("code").null - @pytest.mark.parametrize("fieldname", ["code", "name"]) - def test_indexed_fields(self, fieldname): - assert Boundary._meta.get_field(fieldname).db_index + def test_code_max_length_is_16(self): + assert self.model._meta.get_field("code").max_length == 16 - # def test_name_cannot_be_null(self): - # # NOTE: modeltranslation does not deal with nullable values - # with pytest.raises(IntegrityError, match=r"not-null constraint"): - # _ = BoundaryFactory(name=None) - - def test_boundary_type_property(self): - b = BoundaryFactory() - assert b.boundary_type == b.boundary_layer.boundary_type + def test_code_is_db_index(self): + assert self.model._meta.get_field("code").db_index - def test_geometry_property_return_None_if_no_boundarygeometry(self, country_bl): - b = BoundaryFactory(boundary_layer=country_bl) + def test_name_can_not_be_null(self): + assert not self.model._meta.get_field("name").null - assert b.geometry is None + def test_name_max_length_is_128(self): + assert self.model._meta.get_field("name").max_length == 128 - def test_geometry_property_return_geometry(self, country_bl): - b = BoundaryFactory(boundary_layer=country_bl) - b_geom = BoundaryGeometryFactory(boundary=b) + def test_name_is_db_index(self): + assert self.model._meta.get_field("name").db_index - assert b_geom.geometry == b.geometry + # custom properties + def test_node_order_by_name(self): + assert self.model.node_order_by == ["name"] - def test_get_geometry_method_returns_geometry(self, country_bl): - b = BoundaryFactory(boundary_layer=country_bl) - b_geom = BoundaryGeometryFactory(boundary=b) + def test_boundary_type_property(self): + b = self.factory_cls() + assert b.boundary_type == b.boundary_layer.boundary_type - assert b_geom.geometry.equals_exact(b.get_geometry()) + def test_geometry_property_returns_same_as_get_geometry(self): + obj = self.factory_cls() - def test_get_geometry_method_return_None_if_no_boudnarygeometry(self, country_bl): - b = BoundaryFactory(boundary_layer=country_bl) + with patch.object(obj, "get_geometry", return_value="mocking_test") as mocked_method: + assert obj.geometry == "mocking_test" - assert b.get_geometry() is None + mocked_method.assert_called_once() def test_geometry_property_is_cached(self, country_bl, django_assert_num_queries): - b = BoundaryFactory(boundary_layer=country_bl) + b = self.factory_cls(boundary_layer=country_bl) _ = BoundaryGeometryFactory(boundary=b) with django_assert_num_queries(1): @@ -187,8 +212,20 @@ def test_geometry_property_is_cached(self, country_bl, django_assert_num_queries with django_assert_num_queries(0): _ = b.geometry + # methods + def test_get_geometry_method_returns_geometry(self, country_bl): + b = self.factory_cls(boundary_layer=country_bl) + b_geom = BoundaryGeometryFactory(boundary=b) + + assert b_geom.geometry.equals_exact(b.get_geometry()) + + def test_get_geometry_method_return_None_if_no_boudnarygeometry(self, country_bl): + b = self.factory_cls(boundary_layer=country_bl) + + assert b.get_geometry() is None + def test_update_geometry(self): - b = BoundaryFactory() + b = self.factory_cls() b_geom = BoundaryGeometryFactory(boundary=b) b_new_geom = FuzzyMultiPolygon(srid=4326).fuzz() @@ -199,7 +236,7 @@ def test_update_geometry(self): assert b_geom.geometry.equals_exact(b_new_geom) def test_update_geometry_to_None(self): - b = BoundaryFactory() + b = self.factory_cls() _ = BoundaryGeometryFactory(boundary=b) b.geometry = None @@ -208,7 +245,7 @@ def test_update_geometry_to_None(self): assert BoundaryGeometry.objects.filter(boundary=b).count() == 0 def test_update_geometry_from_None(self): - b = BoundaryFactory() + b = self.factory_cls() mpoly = FuzzyMultiPolygon().fuzz() b.geometry = mpoly @@ -216,12 +253,13 @@ def test_update_geometry_from_None(self): assert BoundaryGeometry.objects.get(boundary=b).geometry.equals_exact(mpoly) + # meta def test_unique_boundarylayer_code(self, country_bl): with pytest.raises(IntegrityError, match=r"unique constraint"): - BoundaryFactory.create_batch(size=2, code="ES", boundary_layer=country_bl) + self.factory_cls.create_batch(size=2, code="ES", boundary_layer=country_bl) def test__str__(self): - b = BoundaryFactory(name="Random boundary", code="RND") + b = self.factory_cls(name="Random boundary", code="RND") expected_output = "Random boundary (RND)" assert b.__str__() == expected_output @@ -231,38 +269,31 @@ class TestBoundaryGeometryModel(BaseTestTimeStampedModel): model = BoundaryGeometry factory_cls = BoundaryGeometryFactory - def test_boundary_is_primary_key(self, country_bl): - b = BoundaryFactory(boundary_layer=country_bl) - b_geom = BoundaryGeometryFactory(boundary=b) - - assert b_geom.pk == b.pk - - def test_cascading_deletion_on_boundary(self, country_bl): - b = BoundaryFactory(boundary_layer=country_bl) - _ = BoundaryGeometryFactory(boundary=b) + # fields + def test_boundary_fk_is_unique(self): + assert self.model._meta.get_field("boundary").unique - b.delete() + def test_boundary_is_pk(self): + assert self.model._meta.get_field("boundary").primary_key - assert BoundaryGeometry.objects.all().count() == 0 + def test_boundary_can_not_be_null(self): + assert not self.model._meta.get_field("boundary").null - def test_boundary_should_only_have_one_boundarygeometry(self, country_bl): - b = BoundaryFactory(boundary_layer=country_bl) - with pytest.raises(IntegrityError, match=r"unique"): - _ = BoundaryGeometryFactory.create_batch(size=2, boundary=b) + def test_boundary_on_delete_cascade(self): + _on_delete = self.model._meta.get_field("boundary").remote_field.on_delete + assert _on_delete == models.CASCADE - def test_boundary_related_name(self, country_bl): - b = BoundaryFactory(boundary_layer=country_bl) - b_geom = BoundaryGeometryFactory(boundary=b) + def test_boundary_related_name(self): + assert self.model._meta.get_field("boundary").remote_field.related_name == "geometry_model" - assert b.geometry_model == b_geom + def test_geometry_class_is_multipolygon(self): + assert self.model._meta.get_field("geometry").geom_class == MultiPolygon def test_geometry_srid_is_4326(self): - assert BoundaryGeometry._meta.get_field("geometry").srid == 4326 + assert self.model._meta.get_field("geometry").srid == 4326 - def test_geometry_can_not_be_null(self, country_bl): - b = BoundaryFactory(boundary_layer=country_bl) - with pytest.raises(IntegrityError, match=r"not-null constraint"): - _ = BoundaryGeometryFactory(boundary=b, geometry=None) + def test_geometry_can_not_be_null(self): + assert not self.model._meta.get_field("geometry").null def test_geometry_can_not_be_empty(self, country_bl): b = BoundaryFactory(boundary_layer=country_bl) @@ -334,25 +365,33 @@ def test_update_linked_locations_on_geometry_update(self, country_bl): @pytest.mark.django_db -class TestLocationModel: - def test_point_srid_is_4326(self): - assert Location._meta.get_field("point").srid == 4326 +class TestLocationModel(AbstractDjangoModelTestMixin): + model = Location + factory_cls = LocationFactory + + # fields + def test_boundaries_related_name(self): + assert self.model._meta.get_field("boundaries").remote_field.related_name == "locations" + + def test_boundaries_can_be_blank(self): + assert self.model._meta.get_field("boundaries").blank def test_point_can_not_be_null(self): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - LocationFactory(point=None) + assert not self.model._meta.get_field("point").null - def test_location_type_can_be_null(self): - LocationFactory(location_type=None) + def test_point_can_not_be_blank(self): + assert not self.model._meta.get_field("point").blank - def test_boundaries_related_name(self): - b = BoundaryFactory() + def test_point_srid_is_4326(self): + assert self.model._meta.get_field("point").srid == 4326 - loc1 = LocationFactory(boundaries=[b]) - loc2 = LocationFactory(boundaries=[b]) + def test_location_type_can_be_null(self): + assert self.model._meta.get_field("location_type").null - assert frozenset(list(b.locations.all())) == frozenset([loc1, loc2]) + def test_location_type_can_be_blank(self): + assert self.model._meta.get_field("location_type").blank + # methods def test_update_boundaries_on_create(self, country_bl): bbox_poly_a = (0, 0, 10, 10) # x0, y0, x1, y1 point_in_a = (5, 5) @@ -401,6 +440,7 @@ def test_update_boundaries_on_point_update(self, country_bl): assert list(location_in_a.boundaries.all()) == [boundary_b] + # meta def test__str__with_location_type(self): point = FuzzyPoint(srid=4326).fuzz() loc = LocationFactory(point=point) @@ -416,24 +456,31 @@ def test__str__without_location_type(self): assert loc.__str__() == expected_output -@pytest.mark.django_db -class TestGeoLocatedModel: - def test_null_location_should_raise(self): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - _ = DummyGeoLocatedModel.objects.create(location=None) +class BaseTestGeoLocatedModel(AbstractDjangoModelTestMixin, ABC): + def test_location_fk_is_unique(self): + assert self.model._meta.get_field("location").unique - def test_protect_deletion_from_location(self): - loc = LocationFactory() - _ = DummyGeoLocatedModel.objects.create(location=loc) + def test_location_can_not_be_null(self): + assert not self.model._meta.get_field("location").null - with pytest.raises(ProtectedError): - loc.delete() + def test_location_on_delete_protect(self): + _on_delete = self.model._meta.get_field("location").remote_field.on_delete + assert _on_delete == models.PROTECT + + def test_location_related_name(self): + assert self.model._meta.get_field("location").remote_field.related_name == "+" def test_location_is_deleted_on_self_delete(self): loc = LocationFactory() - geo_loc = DummyGeoLocatedModel.objects.create(location=loc) + geo_loc = self.factory_cls(location=loc) assert Location.objects.all().count() == 1 geo_loc.delete() assert Location.objects.all().count() == 0 + + +@pytest.mark.django_db +class TestDummyGeoLocatedModel(BaseTestGeoLocatedModel): + model = DummyGeoLocatedModel + factory_cls = DummyGeoLocatedModelFactory diff --git a/mosquito_alert/images/tests/test_models.py b/mosquito_alert/images/tests/test_models.py index 9f7deaf9..4a7142b3 100644 --- a/mosquito_alert/images/tests/test_models.py +++ b/mosquito_alert/images/tests/test_models.py @@ -1,6 +1,7 @@ import os import pytest +from django.db import models from PIL import Image from mosquito_alert.utils.tests.test_models import BaseTestTimeStampedModel @@ -15,16 +16,19 @@ class TestPhoto(BaseTestTimeStampedModel): model = Photo factory_cls = PhotoFactory + # fields def test_user_can_be_null(self): - self.factory_cls(user=None) + assert self.model._meta.get_field("user").null - def test_user_is_set_null_if_deleted(self, user): - p = self.factory_cls(user=user) + def test_user_can_be_blank(self): + assert self.model._meta.get_field("user").blank - user.delete() + def test_user_on_delete_set_null(self): + _on_delete = self.model._meta.get_field("user").remote_field.on_delete + assert _on_delete == models.SET_NULL - p.refresh_from_db() - assert p.user is None + def test_user_related_name(self): + assert self.model._meta.get_field("user").remote_field.related_name == "photos" def test_image_is_uploaded_in_images_path(self): p = self.factory_cls() @@ -43,7 +47,7 @@ def test_image_filename_is_kept_if_already_uuid(self): assert filename == "5c240771-4f46-4d0b-972e-a2c0edd31451" def test_image_is_converted_to_webp(self): - assert Photo._meta.get_field("image")._original_spec.format == "WEBP" + assert self.model._meta.get_field("image")._original_spec.format == "WEBP" # Check filename extension p = self.factory_cls(image__filename="example.png", image__format="PNG") @@ -80,6 +84,7 @@ def test_exif_is_preserved_from_original_image(self): assert original_exif == new_exif + # properties def test_exif_dict(self): p = self.factory_cls(image__from_path=testdata.TESTEXIFIMAGE_PATH) assert p.exif_dict == { @@ -135,6 +140,7 @@ def test_exif_dict(self): "WhiteBalance": "0", } + # meta def test_default_orderding_shows_newest_first(self): assert self.model._meta.ordering == ["-created_at"] diff --git a/mosquito_alert/reports/migrations/0005_alter_report_user.py b/mosquito_alert/reports/migrations/0005_alter_report_user.py new file mode 100644 index 00000000..9c508030 --- /dev/null +++ b/mosquito_alert/reports/migrations/0005_alter_report_user.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.3 on 2023-10-11 14:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("reports", "0004_remove_report_created_at_cannot_be_future_dated_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="report", + name="user", + field=models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reports", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/mosquito_alert/reports/models.py b/mosquito_alert/reports/models.py index d36702bb..2c590825 100644 --- a/mosquito_alert/reports/models.py +++ b/mosquito_alert/reports/models.py @@ -34,6 +34,7 @@ class Report(GeoLocatedModel, FlagModeratedModel, TimeStampedModel, PolymorphicM blank=True, editable=False, on_delete=models.SET_NULL, + related_name="reports", ) photos = SortedManyToManyField(Photo, blank=True) diff --git a/mosquito_alert/reports/tests/test_models.py b/mosquito_alert/reports/tests/test_models.py index aa871143..63feb7a9 100644 --- a/mosquito_alert/reports/tests/test_models.py +++ b/mosquito_alert/reports/tests/test_models.py @@ -51,25 +51,48 @@ class TestReport(BaseTestTimeStampedModel): model = Report factory_cls = ReportFactory + # fields def test_user_can_be_null(self): assert self.model._meta.get_field("user").null + def test_user_can_be_blank(self): + assert self.model._meta.get_field("user").blank + + def test_user_is_not_editable(self): + assert not self.model._meta.get_field("user").editable + def test_user_set_null_on_delete(self): _on_delete = self.model._meta.get_field("user").remote_field.on_delete assert _on_delete == models.SET_NULL + def test_user_related_name(self): + assert self.model._meta.get_field("user").remote_field.related_name == "reports" + + def test_photos_can_be_blank(self): + assert self.model._meta.get_field("photos").blank + + def test_photos_can_be_sorted(self): + assert self.model._meta.get_field("photos").sorted + def test_uuid_raise_if_set_and_not_uuid(self): with pytest.raises(ValidationError, match=r"is not a valid UUID"): self.factory_cls(uuid="random_string") - def test_uuid_is_auto_set_to_uuidv4(self): - obj = self.factory_cls() - assert isinstance(obj.uuid, uuid.UUID) - assert obj.uuid.version == 4 + def test_uuid_default_is_uuid4(self): + assert self.model._meta.get_field("uuid").default == uuid.uuid4 + + def test_uuid_is_not_editable(self): + assert not self.model._meta.get_field("uuid").editable def test_uuid_must_be_unique(self): assert self.model._meta.get_field("uuid").unique + def test_observed_at_can_not_be_null(self): + assert not self.model._meta.get_field("observed_at").null + + def test_observed_at_can_be_blank(self): + assert self.model._meta.get_field("observed_at").blank + @pytest.mark.freeze_time def test_observed_at_is_kept_when_set(self): observed_at = timezone.now() - timedelta(days=1) @@ -82,22 +105,22 @@ def test_observed_at_copy_created_at_if_not_set(self): obj = self.factory_cls(observed_at=None, created_at=created_at) assert obj.observed_at == created_at - @pytest.mark.freeze_time - def test_observed_at_must_be_before_created_at(self): - with pytest.raises(IntegrityError, match=r"violates check constraint"): - self.factory_cls( - observed_at=timezone.now() + timedelta(seconds=10), - ) + def test_published_can_not_be_null(self): + assert not self.model._meta.get_field("published").null - def test_published_default_value(self): + def test_published_default_value_is_False(self): assert not self.model._meta.get_field("published").default def test_notes_can_be_null(self): assert self.model._meta.get_field("notes").null + def test_notes_can_be_blank(self): + assert self.model._meta.get_field("notes").blank + def test_tags_are_empty_by_default(self): assert self.factory_cls().tags.count() == 0 + # meta def test_ordering_shows_newest_first(self): assert self.model._meta.ordering == ["-created_at"] @@ -105,6 +128,13 @@ def test__str__(self): obj = self.factory_cls() assert obj.__str__() == f"{obj.__class__.__name__} ({obj.uuid})" + @pytest.mark.freeze_time + def test_observed_at_must_be_before_created_at(self): + with pytest.raises(IntegrityError, match=r"violates check constraint"): + self.factory_cls( + observed_at=timezone.now() + timedelta(seconds=10), + ) + class BaseTestReversionedReport: def test_report_is_versioned_in_create(self): @@ -155,7 +185,9 @@ class TestBiteReport(BaseTestReversionedReport, TestReport): def test_published_default_value(self): assert self.factory_cls().published - # Custom tests + # fields + def test_bites_related_name(self): + assert self.model._meta.get_field("bites").remote_field.related_name == "reports" class TestBreedingSiteReport(BaseTestReversionedReport, TestReport): @@ -173,6 +205,13 @@ def test_breeding_site_can_be_blank(self): def test_breeding_site_can_not_be_null(self): assert not self.model._meta.get_field("breeding_site").null + def test_breeding_site_deletion_is_cascade(self): + _on_delete = self.model._meta.get_field("breeding_site").remote_field.on_delete + assert _on_delete == models.CASCADE + + def test_breeding_site_related_name(self): + assert self.model._meta.get_field("breeding_site").remote_field.related_name == "reports" + def test_has_water_can_not_be_null(self): assert not self.model._meta.get_field("has_water").null @@ -219,11 +258,14 @@ class TestIndividualReport(BaseTestReversionedReport, TestReport): # Custom tests def test_individuals_related_name(self): - assert IndividualReport._meta.get_field("individuals").remote_field.related_name == "reports" + assert self.model._meta.get_field("individuals").remote_field.related_name == "reports" - def test_taxon_can_be_None(self): + def test_taxon_can_be_null(self): assert self.model._meta.get_field("taxon").null - def test_taxon_deletion_is_protected(self, taxon_specie): + def test_taxon_can_be_blank(self): + assert self.model._meta.get_field("taxon").blank + + def test_taxon_deletion_is_protected(self): _on_delete = self.model._meta.get_field("taxon").remote_field.on_delete assert _on_delete == models.PROTECT diff --git a/mosquito_alert/taxa/migrations/0005_speciedistribution_created_at_and_more.py b/mosquito_alert/taxa/migrations/0005_speciedistribution_created_at_and_more.py new file mode 100644 index 00000000..864bf5a9 --- /dev/null +++ b/mosquito_alert/taxa/migrations/0005_speciedistribution_created_at_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.3 on 2023-10-11 14:55 + +from django.db import migrations, models +import django.db.models.functions.datetime +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + ("taxa", "0004_taxon_updated_at_alter_taxon_created_at_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="speciedistribution", + name="created_at", + field=models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False), + ), + migrations.AddField( + model_name="speciedistribution", + name="updated_at", + field=models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False), + ), + migrations.AddConstraint( + model_name="speciedistribution", + constraint=models.CheckConstraint( + check=models.Q(("created_at__lte", django.db.models.functions.datetime.Now())), + name="taxa_speciedistribution_created_at_cannot_be_future_dated", + ), + ), + migrations.AddConstraint( + model_name="speciedistribution", + constraint=models.CheckConstraint( + check=models.Q(("updated_at__lte", django.db.models.functions.datetime.Now())), + name="taxa_speciedistribution_updated_at_cannot_be_future_dated", + ), + ), + migrations.AddConstraint( + model_name="speciedistribution", + constraint=models.CheckConstraint( + check=models.Q(("updated_at__gte", models.F("created_at"))), + name="taxa_speciedistribution_updated_at_must_be_after_created_at", + ), + ), + ] diff --git a/mosquito_alert/taxa/models.py b/mosquito_alert/taxa/models.py index 41e5f29b..abefc029 100644 --- a/mosquito_alert/taxa/models.py +++ b/mosquito_alert/taxa/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +from django.core.exceptions import ValidationError from django.db import models from django.db.models import Q from django.db.models.signals import ModelSignal @@ -65,14 +66,37 @@ def is_specie(self): return self.rank >= self.TaxonomicRank.SPECIES_COMPLEX # Methods + def clean_rank_field(self): + if not self.parent: + return + + if self.rank <= self.parent.rank: + raise ValidationError("Child taxon must have a higher rank than their parent.") + + def _clean_custom_fields(self, exclude=None) -> None: + if exclude is None: + exclude = [] + + errors = {} + if "rank" not in exclude: + try: + self.clean_rank_field() + except ValidationError as e: + errors["rank"] = e.error_list + + if errors: + raise ValidationError(errors) + + def clean_fields(self, exclude=None) -> None: + super().clean_fields(exclude=exclude) + self._clean_custom_fields(exclude=exclude) + def save(self, *args, **kwargs): if self.name and self.is_specie: # Capitalize only first letter self.name = self.name.capitalize() - if self.parent: - if self.rank <= self.parent.rank: - raise ValueError("Child taxon must have a higher rank than their parent.") + self._clean_custom_fields() super().save(*args, **kwargs) @@ -89,7 +113,7 @@ def __str__(self) -> str: return f"{self.name} [{self.get_rank_display()}]" -class SpecieDistribution(LifecycleModel): +class SpecieDistribution(LifecycleModel, TimeStampedModel): class DataSource(models.TextChoices): SELF = "self", "Mosquito Alert" ECDC = "ecdc", _("European Centre for Disease Prevention and Control") @@ -120,7 +144,7 @@ class DistributionStatus(models.TextChoices): history = ProxyAwareHistoricalRecords( inherit=True, cascade_delete_history=True, - excluded_fields=["boundary", "taxon", "source"], # Tracking status only + excluded_fields=("boundary", "taxon", "source", "created_at", "updated_at"), # Tracking status only ) # Methods @@ -180,10 +204,10 @@ def save(self, *args, **kwargs): del self.skip_history_when_saving # Meta and String - class Meta: + class Meta(TimeStampedModel.Meta): verbose_name = _("specie distribution") verbose_name_plural = _("species distribution") - constraints = [ + constraints = TimeStampedModel.Meta.constraints + [ models.UniqueConstraint(fields=["boundary", "taxon", "source"], name="unique_boundary_taxon_source"), ] diff --git a/mosquito_alert/taxa/tests/test_models.py b/mosquito_alert/taxa/tests/test_models.py index 37752b34..b7e3352a 100644 --- a/mosquito_alert/taxa/tests/test_models.py +++ b/mosquito_alert/taxa/tests/test_models.py @@ -1,9 +1,10 @@ from contextlib import nullcontext as does_not_raise +from unittest.mock import PropertyMock, patch import pytest -from django.core.exceptions import FieldDoesNotExist -from django.db.models.deletion import ProtectedError -from django.db.utils import DataError, IntegrityError +from django.core.exceptions import ValidationError +from django.db import models +from django.db.utils import IntegrityError from mosquito_alert.geo.tests.factories import BoundaryFactory from mosquito_alert.utils.tests.test_models import BaseTestTimeStampedModel @@ -17,133 +18,79 @@ class TestTaxonModel(BaseTestTimeStampedModel): model = Taxon factory_cls = TaxonFactory + # classmethods def test_get_root_return_root_node(self, taxon_root): - assert Taxon.get_root() == taxon_root + assert self.model.get_root() == taxon_root def test_get_root_return_None_if_root_node_does_not_exist(self): - Taxon.objects.all().delete() + self.model.objects.all().delete() - assert Taxon.get_root() is None + assert self.model.get_root() is None - @pytest.mark.parametrize( - "name, rank, expected_result", - [ - ("Insecta", Taxon.TaxonomicRank.CLASS, "Insecta"), - ("INSECTA", Taxon.TaxonomicRank.CLASS, "INSECTA"), - ("DIptera", Taxon.TaxonomicRank.ORDER, "DIptera"), - ("GenUs", Taxon.TaxonomicRank.GENUS, "GenUs"), - ("Anopheles Gambiae Sensu Lato", Taxon.TaxonomicRank.SPECIES_COMPLEX, "Anopheles gambiae sensu lato"), - ("Aedes Albopictus", Taxon.TaxonomicRank.SPECIES, "Aedes albopictus"), - ("Aedes albopictus", Taxon.TaxonomicRank.SPECIES, "Aedes albopictus"), - ("AEDES ALBOPICTUS", Taxon.TaxonomicRank.SPECIES, "Aedes albopictus"), - ], - ) - def test_name_is_capitalized_when_is_rank_specie(self, name, rank, expected_result): - taxon = self.factory_cls(name=name, rank=rank) - assert taxon.name == expected_result + # fields + def test_rank_can_not_be_null(self): + assert not self.model._meta.get_field("rank").null - taxon.name = "" - taxon.save() + def test_rank_can_not_be_blank(self): + assert not self.model._meta.get_field("rank").blank - # On change name - taxon.name = name - taxon.save() - assert taxon.name == expected_result + def test_name_can_not_be_null(self): + assert not self.model._meta.get_field("name").null - def test_name_is_not_capitalized_when_not_species(self): - taxon = self.factory_cls(name="Aedes Albopictus", rank=Taxon.TaxonomicRank.SPECIES) - assert taxon.name == "Aedes albopictus" + def test_name_can_not_be_blank(self): + assert not self.model._meta.get_field("name").blank - # On change name - taxon.name = "AEDES ALBOPICTUS" - taxon.save() - assert taxon.name == "Aedes albopictus" + def test_name_max_length_is_32(self): + assert self.model._meta.get_field("name").max_length == 32 - @pytest.mark.parametrize( - "rank, expected_result", - [ - (Taxon.TaxonomicRank.CLASS, False), - (Taxon.TaxonomicRank.SUBGENUS, False), - (Taxon.TaxonomicRank.SPECIES, True), - (Taxon.TaxonomicRank.SPECIES_COMPLEX, True), - ], - ) - def test_is_species_property(self, rank, expected_result): - taxon = self.factory_cls(rank=rank) - assert taxon.is_specie == expected_result + def test_common_name_can_be_null(self): + assert self.model._meta.get_field("common_name").null - def test__str__(self, taxon_root): - taxon = self.factory_cls(name="Aedes Albopictus", rank=Taxon.TaxonomicRank.SPECIES, parent=taxon_root) + def test_common_name_can_not_be_blank(self): + assert self.model._meta.get_field("common_name").blank - expected_result = "Aedes albopictus [Species]" - assert taxon.__str__() == expected_result + def test_common_name_max_length_is_64(self): + assert self.model._meta.get_field("common_name").max_length == 64 - def test_raise_when_rank_higher_than_parent_rank(self, taxon_specie): - with pytest.raises(ValueError): - _ = self.factory_cls(rank=Taxon.TaxonomicRank.CLASS, parent=taxon_specie) + def test_gbif_id_can_be_null(self): + assert self.model._meta.get_field("gbif_id").null - def test_unique_name_rank_constraint(self, taxon_root): - with pytest.raises(IntegrityError): - # Create duplicate children name - _ = TaxonFactory.create_batch( - 2, - name="Same Name", - rank=Taxon.TaxonomicRank.SPECIES, - parent=taxon_root, - ) + def test_gbif_id_can_be_blank(self): + assert self.model._meta.get_field("gbif_id").blank - def test_unique_root_constraint(self, taxon_root): - with pytest.raises(IntegrityError): - self.factory_cls(parent=None, name="", rank=taxon_root.rank) + # properties + def test_node_order_by_name(self): + assert self.model.node_order_by == ["name"] - def test_null_common_name_is_allowed(self, taxon_root): - self.factory_cls(common_name=None, parent=taxon_root, rank=Taxon.TaxonomicRank.SPECIES) + @pytest.mark.parametrize("gbif_id, expected_result", [(None, ""), (12345, "https://www.gbif.org/species/12345")]) + def test_gbif_url(self, gbif_id, expected_result): + assert self.factory_cls(gbif_id=gbif_id).gbif_url == expected_result - def test_null_name_is_not_allowed_on_change(self, taxon_specie): - taxon_specie.name = None + def test_is_specie_must_be_true_for_taxon_with_rank_species_complex_or_higher(self): + obj = self.factory_cls.build(rank=Taxon.TaxonomicRank.SPECIES_COMPLEX) - with pytest.raises(IntegrityError, match=r"not-null constraint"): - taxon_specie.save() + assert obj.is_specie - def test_null_name_is_not_allowed_on_create(self, taxon_root): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - self.factory_cls(name=None, parent=taxon_root, rank=Taxon.TaxonomicRank.SPECIES) + obj.rank = Taxon.TaxonomicRank.SPECIES_COMPLEX.value - 1 - @pytest.mark.parametrize( - "name, output_raises", - [ - ("a" * 31, False), - ("a" * 32, False), - ("a" * 33, True), - ], - ) - def test_name_max_length_is_32(self, name, output_raises): - if output_raises: - with pytest.raises(DataError): - self.factory_cls(name=name, rank=Taxon.TaxonomicRank.SPECIES) - else: - self.factory_cls(name=name, rank=Taxon.TaxonomicRank.SPECIES) + assert not obj.is_specie + # methods @pytest.mark.parametrize( - "common_name, output_raises", + "name, expected_result, is_specie", [ - ("a" * 63, False), - ("a" * 64, False), - ("a" * 65, True), + ("dumMy StrangE nAme", "Dummy strange name", True), + ("dumMy StrangE nAme", "dumMy StrangE nAme", False), ], ) - def test_common_name_max_length_is_64(self, common_name, output_raises): - if output_raises: - with pytest.raises(DataError): - self.factory_cls(common_name=common_name, rank=Taxon.TaxonomicRank.SPECIES) - else: - self.factory_cls(common_name=common_name, rank=Taxon.TaxonomicRank.SPECIES) - - def test_tree_is_ordered_by_name_on_create(self, taxon_root): - z_child = self.factory_cls(name="z", rank=Taxon.TaxonomicRank.SPECIES, parent=taxon_root) - a_child = self.factory_cls(name="a", rank=Taxon.TaxonomicRank.SPECIES, parent=taxon_root) + def test_name_is_capitalized_on_save_only_for_species(self, name, expected_result, is_specie): + with patch( + f"{self.model.__module__}.{self.model.__name__}.is_specie", new_callable=PropertyMock + ) as mocked_is_specie: + mocked_is_specie.return_value = is_specie + obj = self.factory_cls(name=name) - assert frozenset(Taxon.objects.all()) == frozenset([taxon_root, a_child, z_child]) + assert obj.name == expected_result def test_tree_is_ordered_by_name_on_parent_change(self, taxon_root): z_child = self.factory_cls(name="z", rank=Taxon.TaxonomicRank.GENUS, parent=taxon_root) @@ -160,140 +107,161 @@ def test_tree_is_ordered_by_name_on_parent_change(self, taxon_root): assert frozenset(Taxon.objects.all()) == frozenset([taxon_root, z_child, a_child, b_child]) - def test_gbif_id_can_be_null(self): - assert Taxon._meta.get_field("gbif_id").null + def test_raise_when_rank_higher_than_parent_rank(self, taxon_specie): + with pytest.raises(ValidationError): + _ = self.factory_cls(rank=Taxon.TaxonomicRank.CLASS, parent=taxon_specie) - def test_gbif_id_can_be_blank(self): - assert Taxon._meta.get_field("gbif_id").blank + # meta + def test_unique_name_rank_constraint(self, taxon_root): + with pytest.raises(IntegrityError): + # Create duplicate children name + _ = TaxonFactory.create_batch( + 2, + name="Same Name", + rank=Taxon.TaxonomicRank.SPECIES, + parent=taxon_root, + ) - # properties - @pytest.mark.parametrize("gbif_id, expected_result", [(None, ""), (12345, "https://www.gbif.org/species/12345")]) - def test_gbif_url(self, gbif_id, expected_result): - assert self.factory_cls(gbif_id=gbif_id).gbif_url == expected_result + def test_unique_root_constraint(self, taxon_root): + with pytest.raises(IntegrityError): + self.factory_cls(parent=None, name="", rank=taxon_root.rank) + + def test__str__(self, taxon_root): + taxon = self.factory_cls(name="Aedes Albopictus", rank=Taxon.TaxonomicRank.SPECIES, parent=taxon_root) + + expected_result = "Aedes albopictus [Species]" + assert taxon.__str__() == expected_result @pytest.mark.django_db -class TestSpecieDistributionModel: +class TestSpecieDistributionModel(BaseTestTimeStampedModel): + model = SpecieDistribution + factory_cls = SpecieDistributionFactory + + # fields def test_boundary_can_not_be_null(self): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - SpecieDistributionFactory(boundary=None) + assert not self.model._meta.get_field("boundary").null + + def test_boundary_can_not_be_blank(self): + assert not self.model._meta.get_field("boundary").blank def test_boundary_is_protected_on_delete(self): - sd = SpecieDistributionFactory() + _on_delete = self.model._meta.get_field("boundary").remote_field.on_delete + assert _on_delete == models.PROTECT - with pytest.raises(ProtectedError): - sd.boundary.delete() + def test_boundary_related_name(self): + assert self.model._meta.get_field("boundary").remote_field.related_name == "+" def test_taxon_can_not_be_null(self): - with pytest.raises(IntegrityError, match=r"not-null constraint"): - SpecieDistributionFactory(taxon=None) + assert not self.model._meta.get_field("taxon").null + + def test_taxon_can_not_be_blank(self): + assert not self.model._meta.get_field("taxon").blank def test_taxon_is_protected_on_delete(self): - sd = SpecieDistributionFactory() + _on_delete = self.model._meta.get_field("taxon").remote_field.on_delete + assert _on_delete == models.PROTECT - with pytest.raises(ProtectedError): - sd.taxon.delete() + def test_taxon_related_name(self): + assert self.model._meta.get_field("taxon").remote_field.related_name == "distribution" - @pytest.mark.parametrize( - "taxon_rank, expected_raise", - [ - (Taxon.TaxonomicRank.DOMAIN, pytest.raises(ValueError)), - (Taxon.TaxonomicRank.KINGDOM, pytest.raises(ValueError)), - (Taxon.TaxonomicRank.PHYLUM, pytest.raises(ValueError)), - (Taxon.TaxonomicRank.CLASS, pytest.raises(ValueError)), - (Taxon.TaxonomicRank.ORDER, pytest.raises(ValueError)), - (Taxon.TaxonomicRank.FAMILY, pytest.raises(ValueError)), - (Taxon.TaxonomicRank.GENUS, pytest.raises(ValueError)), - (Taxon.TaxonomicRank.SUBGENUS, pytest.raises(ValueError)), - (Taxon.TaxonomicRank.SPECIES_COMPLEX, does_not_raise()), - (Taxon.TaxonomicRank.SPECIES, does_not_raise()), - ], - ) - def test_taxon_is_allowed_to_be_species_only(self, taxon_rank, expected_raise): - with expected_raise: - assert SpecieDistributionFactory(taxon__rank=taxon_rank) + def test_source_can_not_be_null(self): + assert not self.model._meta.get_field("source").null - def test_taxon_related_name_is_distribution(self): - sd = SpecieDistributionFactory() + def test_source_can_not_be_blank(self): + assert not self.model._meta.get_field("source").blank - assert frozenset(sd.taxon.distribution.all()) == frozenset([sd]) + def test_status_can_not_be_null(self): + assert not self.model._meta.get_field("status").null + + def test_status_can_not_be_blank(self): + assert not self.model._meta.get_field("status").blank + + # properties + def test_history_only_tracks_status(self): + assert self.model.history.model.history_object.fields_included == [ + self.model._meta.get_field("id"), + self.model._meta.get_field("status"), + ] @pytest.mark.parametrize( - "fieldname, expected_raise", + "is_specie, expected_raise", [ - ("boundary", pytest.raises(FieldDoesNotExist)), - ("taxon", pytest.raises(FieldDoesNotExist)), - ("source", pytest.raises(FieldDoesNotExist)), - ("status", does_not_raise()), + (True, does_not_raise()), + (False, pytest.raises(ValueError)), ], ) - def test_monitored_changes_in_fields(self, fieldname, expected_raise): - with expected_raise: - SpecieDistribution.history.model._meta.get_field(fieldname) + def test_taxon_is_allowed_to_be_species_only(self, is_specie, expected_raise): + with patch(f"{Taxon.__module__}.{Taxon.__name__}.is_specie", new_callable=PropertyMock) as mocked_is_specie: + mocked_is_specie.return_value = is_specie + with expected_raise: + _ = self.factory_cls() def test_new_history_record_is_created_on_status_change(self): - sd = SpecieDistributionFactory(status=SpecieDistribution.DistributionStatus.ABSENT) + sd = self.factory_cls(status=SpecieDistribution.DistributionStatus.ABSENT) - assert SpecieDistribution.objects.all().count() == 1 - assert SpecieDistribution.history.all().count() == 1 + assert self.model.objects.all().count() == 1 + assert self.model.history.all().count() == 1 assert sd.history.last().status == SpecieDistribution.DistributionStatus.ABSENT sd.status = SpecieDistribution.DistributionStatus.REPORTED sd.save() - assert SpecieDistribution.objects.all().count() == 1 - assert SpecieDistribution.history.all().count() == 2 + assert self.model.objects.all().count() == 1 + assert self.model.history.all().count() == 2 assert sd.history.first().history_type == "~" assert sd.history.first().status == SpecieDistribution.DistributionStatus.REPORTED def test_no_history_record_created_if_status_is_not_changed(self, country_bl, taxon_root): - sd = SpecieDistributionFactory( + sd = self.factory_cls( boundary__boundary_layer=country_bl, taxon=TaxonFactory(parent=taxon_root, rank=Taxon.TaxonomicRank.SPECIES), source=SpecieDistribution.DataSource.SELF, ) - assert SpecieDistribution.objects.all().count() == 1 - assert SpecieDistribution.history.all().count() == 1 + assert self.model.objects.all().count() == 1 + assert self.model.history.all().count() == 1 sd.boundary = BoundaryFactory(boundary_layer=country_bl) sd.save() - assert SpecieDistribution.objects.all().count() == 1 - assert SpecieDistribution.history.all().count() == 1 + assert self.model.objects.all().count() == 1 + assert self.model.history.all().count() == 1 sd.taxon = TaxonFactory(parent=taxon_root, rank=Taxon.TaxonomicRank.SPECIES) sd.save() - assert SpecieDistribution.objects.all().count() == 1 - assert SpecieDistribution.history.all().count() == 1 + assert self.model.objects.all().count() == 1 + assert self.model.history.all().count() == 1 sd.source = SpecieDistribution.DataSource.ECDC sd.save() - assert SpecieDistribution.objects.all().count() == 1 - assert SpecieDistribution.history.all().count() == 1 + assert self.model.objects.all().count() == 1 + assert self.model.history.all().count() == 1 def test_historic_record_type_is_created_on_creation(self): - sd = SpecieDistributionFactory() + sd = self.factory_cls() assert sd.history.first().history_type == "+" def test_historical_records_are_deleted_on_master_deletion(self): - sd = SpecieDistributionFactory(status=SpecieDistribution.DistributionStatus.ABSENT) + sd = self.factory_cls(status=SpecieDistribution.DistributionStatus.ABSENT) - assert SpecieDistribution.objects.all().count() == 1 - assert SpecieDistribution.history.all().count() == 1 + assert self.model.objects.all().count() == 1 + assert self.model.history.all().count() == 1 sd.delete() - assert SpecieDistribution.objects.all().count() == 0 - assert SpecieDistribution.history.all().count() == 0 + assert self.model.objects.all().count() == 0 + assert self.model.history.all().count() == 0 + + # meta def test_unique_contraint_boundary_taxon_source(self): - sd = SpecieDistributionFactory() + sd = self.factory_cls() with pytest.raises(IntegrityError, match=r"unique constraint"): - _ = SpecieDistributionFactory(boundary=sd.boundary, taxon=sd.taxon, source=sd.source) + _ = self.factory_cls(boundary=sd.boundary, taxon=sd.taxon, source=sd.source) diff --git a/mosquito_alert/utils/tests/test_models.py b/mosquito_alert/utils/tests/test_models.py index da9b59a3..6f26be53 100644 --- a/mosquito_alert/utils/tests/test_models.py +++ b/mosquito_alert/utils/tests/test_models.py @@ -7,7 +7,6 @@ from django.db.utils import IntegrityError from django.utils import timezone from django.utils.timesince import timesince -from factory import SubFactory from .factories import DummyObservableModelFactory, DummyTimeStampedModelFactory from .models import ( @@ -393,18 +392,8 @@ def test_overrides_using_save(self): created and updated_at fields. After that, only created_at may be modified manually. """ - # Creating related element in advance due to avoid - # saving objects without having saved their related - # objects. - build_kwargs = {} - for k, v in self.factory_cls._meta.declarations.items(): - if isinstance(v, SubFactory): - build_kwargs[k] = v.get_factory().create() created_at = timezone.now() - timedelta(weeks=52) - obj = self.factory_cls.build(**build_kwargs) - obj.created_at = created_at - obj.updated_at = created_at - obj.save() + obj = self.factory_cls(created_at=created_at, updated_at=created_at) assert obj.created_at == created_at assert obj.updated_at == created_at