From 0a1970345b86ff9a188e911f83659ce119fc4b71 Mon Sep 17 00:00:00 2001 From: Ernesto Junior Date: Thu, 23 Apr 2026 08:19:40 -0300 Subject: [PATCH 1/2] feat: add decay_type domain with M2M relationship to species characteristics Co-Authored-By: Claude Sonnet 4.6 --- app/models/__init__.py | 4 ++ app/models/decay_type.py | 19 +++++ app/models/species_characteristics.py | 5 ++ .../species_characteristics_decay_type.py | 17 +++++ .../species_change_request_repository.py | 21 ++++++ app/repositories/species_repository.py | 4 ++ app/schemas/species_schemas.py | 29 ++++++++ .../species_change_request/validation.py | 40 +++++++++++ app/services/species_service.py | 1 + .../d1e2f3a4b5c6_create_decay_types_domain.py | 72 +++++++++++++++++++ 10 files changed, 212 insertions(+) create mode 100644 app/models/decay_type.py create mode 100644 app/models/species_characteristics_decay_type.py create mode 100644 migrations/versions/d1e2f3a4b5c6_create_decay_types_domain.py diff --git a/app/models/__init__.py b/app/models/__init__.py index f887957..2f66a84 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,3 +1,4 @@ +from .decay_type import DecayType from .distribution import Distribution from .growth_form import GrowthForm from .habitat import Habitat @@ -6,6 +7,7 @@ from .species import Species from .species_change_request import SpeciesChangeRequest, SpeciesPhotoRequest from .species_characteristics import SpeciesCharacteristics +from .species_characteristics_decay_type import SpeciesCharacteristicsDecayType from .species_characteristics_growth_form import SpeciesCharacteristicsGrowthForm from .species_characteristics_habitat import SpeciesCharacteristicsHabitat from .species_characteristics_nutrition_mode import SpeciesCharacteristicsNutritionMode @@ -24,10 +26,12 @@ "SpeciesSimilarity", "Species", "SpeciesCharacteristics", + "SpeciesCharacteristicsDecayType", "SpeciesCharacteristicsGrowthForm", "SpeciesCharacteristicsHabitat", "SpeciesCharacteristicsNutritionMode", "SpeciesCharacteristicsSubstrate", + "DecayType", "GrowthForm", "Habitat", "NutritionMode", diff --git a/app/models/decay_type.py b/app/models/decay_type.py new file mode 100644 index 0000000..08e8238 --- /dev/null +++ b/app/models/decay_type.py @@ -0,0 +1,19 @@ +from app.extensions import db + + +class DecayType(db.Model): + __tablename__ = "decay_types" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.Text, nullable=False, unique=True) + label_pt = db.Column(db.Text, nullable=False) + label_en = db.Column(db.Text, nullable=False) + is_active = db.Column(db.Boolean, nullable=False, server_default=db.true()) + species_characteristics = db.relationship( + "SpeciesCharacteristics", + secondary="species_characteristics_decay_types", + back_populates="decay_types", + ) + + def __repr__(self): + return f"" diff --git a/app/models/species_characteristics.py b/app/models/species_characteristics.py index f713b62..7b2de24 100644 --- a/app/models/species_characteristics.py +++ b/app/models/species_characteristics.py @@ -57,6 +57,11 @@ class SpeciesCharacteristics(db.Model): secondary="species_characteristics_habitats", back_populates="species_characteristics", ) + decay_types = db.relationship( + "DecayType", + secondary="species_characteristics_decay_types", + back_populates="species_characteristics", + ) def __repr__(self): return f"" diff --git a/app/models/species_characteristics_decay_type.py b/app/models/species_characteristics_decay_type.py new file mode 100644 index 0000000..fa924cd --- /dev/null +++ b/app/models/species_characteristics_decay_type.py @@ -0,0 +1,17 @@ +from app.extensions import db + + +class SpeciesCharacteristicsDecayType(db.Model): + __tablename__ = "species_characteristics_decay_types" + __table_args__ = (db.Index("idx_scdt_decay_type_id", "decay_type_id"),) + + species_id = db.Column( + db.BigInteger, + db.ForeignKey("species_characteristics.species_id", ondelete="CASCADE"), + primary_key=True, + ) + decay_type_id = db.Column( + db.Integer, + db.ForeignKey("decay_types.id", ondelete="CASCADE"), + primary_key=True, + ) diff --git a/app/repositories/species_change_request_repository.py b/app/repositories/species_change_request_repository.py index 915dc6a..9acbe75 100644 --- a/app/repositories/species_change_request_repository.py +++ b/app/repositories/species_change_request_repository.py @@ -1,5 +1,6 @@ import app.utils.object_storage as object_storage from app.extensions import db +from app.models.decay_type import DecayType from app.models.growth_form import GrowthForm from app.models.habitat import Habitat from app.models.nutrition_mode import NutritionMode @@ -17,6 +18,7 @@ class SpeciesChangeRequestRepository: "substrate_ids": Substrate, "nutrition_mode_ids": NutritionMode, "habitat_ids": Habitat, + "decay_type_ids": DecayType, } CHARACTERISTICS_FIELDS = { "lum_mycelium", @@ -44,6 +46,7 @@ class SpeciesChangeRequestRepository: "substrate_ids", "nutrition_mode_ids", "habitat_ids", + "decay_type_ids", "season_start_month", "season_end_month", } @@ -134,6 +137,9 @@ def get_species_by_id(cls, species_id: int) -> Species | None: selectinload(Species.characteristics).selectinload( SpeciesCharacteristics.substrates ), + selectinload(Species.characteristics).selectinload( + SpeciesCharacteristics.decay_types + ), selectinload(Species.similar_species_links), ) .filter(Species.id == species_id) @@ -226,6 +232,21 @@ def apply_species_updates(cls, species: Species, proposed_data: dict) -> Species nutrition_modes = [] characteristics.nutrition_modes = nutrition_modes continue + if field == "decay_type_ids": + decay_type_ids = value or [] + if decay_type_ids: + decay_types = ( + DecayType.query.filter( + DecayType.id.in_(decay_type_ids), + DecayType.is_active.is_(True), + ) + .order_by(DecayType.id.asc()) + .all() + ) + else: + decay_types = [] + characteristics.decay_types = decay_types + continue setattr(characteristics, field, value) continue setattr(species, field, value) diff --git a/app/repositories/species_repository.py b/app/repositories/species_repository.py index 09c1438..e4d4602 100644 --- a/app/repositories/species_repository.py +++ b/app/repositories/species_repository.py @@ -1,4 +1,5 @@ from app.extensions import db +from app.models.decay_type import DecayType from app.models.distribution import Distribution from app.models.growth_form import GrowthForm from app.models.habitat import Habitat @@ -18,6 +19,7 @@ class SpeciesRepository: "nutrition_mode": NutritionMode, "substrate": Substrate, "habitat": Habitat, + "decay_type": DecayType, } @classmethod @@ -39,6 +41,7 @@ def list( selectinload(Species.characteristics).selectinload(SpeciesCharacteristics.habitats), selectinload(Species.characteristics).selectinload(SpeciesCharacteristics.growth_forms), selectinload(Species.characteristics).selectinload(SpeciesCharacteristics.substrates), + selectinload(Species.characteristics).selectinload(SpeciesCharacteristics.decay_types), selectinload(Species.similar_species_links).selectinload( SpeciesSimilarity.similar_species ), @@ -89,6 +92,7 @@ def get(cls, species: str | None = "", is_visible: bool | None = None): selectinload(Species.characteristics).selectinload(SpeciesCharacteristics.habitats), selectinload(Species.characteristics).selectinload(SpeciesCharacteristics.growth_forms), selectinload(Species.characteristics).selectinload(SpeciesCharacteristics.substrates), + selectinload(Species.characteristics).selectinload(SpeciesCharacteristics.decay_types), selectinload(Species.similar_species_links).selectinload( SpeciesSimilarity.similar_species ), diff --git a/app/schemas/species_schemas.py b/app/schemas/species_schemas.py index ee5c705..f559a3c 100644 --- a/app/schemas/species_schemas.py +++ b/app/schemas/species_schemas.py @@ -132,6 +132,7 @@ class Meta: nutrition_modes = fields.List(fields.Integer(strict=True), required=False) substrates = fields.List(fields.Integer(strict=True), required=False) habitats = fields.List(fields.Integer(strict=True), required=False) + decay_types = fields.List(fields.Integer(strict=True), required=False) similar_species_ids = fields.List(fields.Integer(strict=True), required=False) @validates_schema @@ -193,6 +194,7 @@ class SpeciesCharacteristicsSchema(Schema): season_start_month = fields.Integer(allow_none=True) season_end_month = fields.Integer(allow_none=True) habitats = fields.Method("get_habitats", allow_none=True) + decay_types = fields.Method("get_decay_types", allow_none=True) similar_species = fields.Method("get_similar_species", allow_none=True) cultivation_possible = fields.Boolean(allow_none=True) iucn_assessment_year = fields.String(allow_none=True) @@ -246,6 +248,18 @@ def get_nutrition_modes(obj): for nutrition_mode in nutrition_modes ] + @staticmethod + def get_decay_types(obj): + decay_types = getattr(obj, "decay_types", None) or [] + return [ + { + "id": decay_type.id, + "label_pt": decay_type.label_pt, + "label_en": decay_type.label_en, + } + for decay_type in decay_types + ] + @staticmethod def get_similar_species(obj): species = getattr(obj, "species", None) @@ -281,6 +295,7 @@ class SpeciesWithPhotosSchema(Schema): growth_forms = fields.Method("get_growth_forms", allow_none=True) substrates = fields.Method("get_substrates", allow_none=True) habitats = fields.Method("get_habitats", allow_none=True) + decay_types = fields.Method("get_decay_types", allow_none=True) similar_species_ids = fields.Method("get_similar_species_ids", allow_none=True) season_start_month = fields.Method("get_season_start_month", allow_none=True) season_end_month = fields.Method("get_season_end_month", allow_none=True) @@ -408,6 +423,20 @@ def get_habitats(self, obj): for habitat in habitats ] + def get_decay_types(self, obj): + characteristics = getattr(obj, "characteristics", None) + if not characteristics: + return [] + decay_types = getattr(characteristics, "decay_types", None) or [] + return [ + { + "id": decay_type.id, + "label_pt": decay_type.label_pt, + "label_en": decay_type.label_en, + } + for decay_type in decay_types + ] + def get_similar_species_ids(self, obj): links = getattr(obj, "similar_species_links", None) or [] return sorted(link.similar_species_id for link in links) diff --git a/app/services/species_change_request/validation.py b/app/services/species_change_request/validation.py index d73ab80..5675f06 100644 --- a/app/services/species_change_request/validation.py +++ b/app/services/species_change_request/validation.py @@ -2,6 +2,7 @@ import requests from app.exceptions import AppError +from app.models.decay_type import DecayType from app.models.growth_form import GrowthForm from app.models.habitat import Habitat from app.models.nutrition_mode import NutritionMode @@ -323,6 +324,45 @@ def validate_proposed_data( en="`habitat_ids` contains invalid or inactive IDs", ) + decay_type_ids = proposed_data.get("decay_type_ids") + if decay_type_ids is not None: + if not isinstance(decay_type_ids, list): + raise AppError( + pt="`decay_type_ids` deve ser uma lista de inteiros", + en="`decay_type_ids` must be a list of integers", + ) + normalized_decay_type_ids = [] + for dtid in decay_type_ids: + if isinstance(dtid, bool) or not isinstance(dtid, int): + raise AppError( + pt="`decay_type_ids` deve conter apenas inteiros", + en="`decay_type_ids` must contain only integers", + ) + if dtid < 1: + raise AppError( + pt="`decay_type_ids` deve conter apenas inteiros >= 1", + en="`decay_type_ids` must contain only integers >= 1", + ) + normalized_decay_type_ids.append(dtid) + + unique_decay_type_ids = sorted(set(normalized_decay_type_ids)) + if len(unique_decay_type_ids) != len(normalized_decay_type_ids): + raise AppError( + pt="`decay_type_ids` contém IDs duplicados", + en="`decay_type_ids` contains duplicate IDs", + ) + + if unique_decay_type_ids: + active_count = DecayType.query.filter( + DecayType.id.in_(unique_decay_type_ids), + DecayType.is_active.is_(True), + ).count() + if active_count != len(unique_decay_type_ids): + raise AppError( + pt="`decay_type_ids` contém IDs inválidos ou inativos", + en="`decay_type_ids` contains invalid or inactive IDs", + ) + start = proposed_data.get("season_start_month") end = proposed_data.get("season_end_month") diff --git a/app/services/species_service.py b/app/services/species_service.py index f893253..7835f80 100644 --- a/app/services/species_service.py +++ b/app/services/species_service.py @@ -26,6 +26,7 @@ class SpeciesService: "substrates": "substrate_ids", "nutrition_modes": "nutrition_mode_ids", "habitats": "habitat_ids", + "decay_types": "decay_type_ids", } PATCH_BIGINT_FIELDS = { "mycobank_index_fungorum_id", diff --git a/migrations/versions/d1e2f3a4b5c6_create_decay_types_domain.py b/migrations/versions/d1e2f3a4b5c6_create_decay_types_domain.py new file mode 100644 index 0000000..4083eba --- /dev/null +++ b/migrations/versions/d1e2f3a4b5c6_create_decay_types_domain.py @@ -0,0 +1,72 @@ +"""create decay_types domain and link to species characteristics + +Revision ID: d1e2f3a4b5c6 +Revises: cb9fadb82e45 +Create Date: 2026-04-23 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d1e2f3a4b5c6" +down_revision = "f8060dfbdcce" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "decay_types", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("slug", sa.Text(), nullable=False), + sa.Column("label_pt", sa.Text(), nullable=False), + sa.Column("label_en", sa.Text(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug", name="uq_decay_types_slug"), + ) + + op.execute( + """ + INSERT INTO decay_types (slug, label_pt, label_en, is_active) + VALUES + ('white_rot', 'Podridão branca (lignina + celulose degradadas)', 'White rot (lignin + cellulose degraded)', true), + ('brown_rot', 'Podridão parda (celulose degradada, lignina preservada)', 'Brown rot (cellulose degraded, lignin remains)', true), + ('soft_rot', 'Podridão mole (celulose degradada por ascomicetos)', 'Soft rot (cellulose degraded by ascomycetes)', true), + ('litter_decomposition', 'Decomposição de serapilheira', 'Litter decomposition', true), + ('humus_formation', 'Formação de húmus', 'Humus formation', true), + ('not_applicable', 'Não aplicável — não saprotrófico', 'Not applicable — non-saprotrophic', true) + """ + ) + + op.create_table( + "species_characteristics_decay_types", + sa.Column("species_id", sa.BigInteger(), nullable=False), + sa.Column("decay_type_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["species_id"], + ["species_characteristics.species_id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["decay_type_id"], + ["decay_types.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("species_id", "decay_type_id"), + ) + op.create_index( + "idx_scdt_decay_type_id", + "species_characteristics_decay_types", + ["decay_type_id"], + unique=False, + ) + + +def downgrade(): + op.drop_index("idx_scdt_decay_type_id", table_name="species_characteristics_decay_types") + op.drop_table("species_characteristics_decay_types") + op.drop_table("decay_types") From b36b3ed150d50490e1772ff93f1643c04513a9c4 Mon Sep 17 00:00:00 2001 From: Ernesto Junior Date: Thu, 23 Apr 2026 08:22:35 -0300 Subject: [PATCH 2/2] fix: allow decay_type_ids in species change request proposed data Co-Authored-By: Claude Sonnet 4.6 --- app/services/species_change_request/service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/services/species_change_request/service.py b/app/services/species_change_request/service.py index cf22cd1..721def2 100644 --- a/app/services/species_change_request/service.py +++ b/app/services/species_change_request/service.py @@ -42,6 +42,7 @@ class SpeciesChangeRequestService: "substrate_ids", "nutrition_mode_ids", "habitat_ids", + "decay_type_ids", "season_start_month", "season_end_month", "distribution_regions",