Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .decay_type import DecayType
from .distribution import Distribution
from .growth_form import GrowthForm
from .habitat import Habitat
Expand All @@ -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
Expand All @@ -24,10 +26,12 @@
"SpeciesSimilarity",
"Species",
"SpeciesCharacteristics",
"SpeciesCharacteristicsDecayType",
"SpeciesCharacteristicsGrowthForm",
"SpeciesCharacteristicsHabitat",
"SpeciesCharacteristicsNutritionMode",
"SpeciesCharacteristicsSubstrate",
"DecayType",
"GrowthForm",
"Habitat",
"NutritionMode",
Expand Down
19 changes: 19 additions & 0 deletions app/models/decay_type.py
Original file line number Diff line number Diff line change
@@ -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"<DecayType id={self.id} slug={self.slug!r}>"
5 changes: 5 additions & 0 deletions app/models/species_characteristics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<SpeciesCharacteristics species_id={self.species_id}>"
17 changes: 17 additions & 0 deletions app/models/species_characteristics_decay_type.py
Original file line number Diff line number Diff line change
@@ -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,
)
21 changes: 21 additions & 0 deletions app/repositories/species_change_request_repository.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +18,7 @@ class SpeciesChangeRequestRepository:
"substrate_ids": Substrate,
"nutrition_mode_ids": NutritionMode,
"habitat_ids": Habitat,
"decay_type_ids": DecayType,
}
CHARACTERISTICS_FIELDS = {
"lum_mycelium",
Expand Down Expand Up @@ -44,6 +46,7 @@ class SpeciesChangeRequestRepository:
"substrate_ids",
"nutrition_mode_ids",
"habitat_ids",
"decay_type_ids",
"season_start_month",
"season_end_month",
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions app/repositories/species_repository.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,6 +19,7 @@ class SpeciesRepository:
"nutrition_mode": NutritionMode,
"substrate": Substrate,
"habitat": Habitat,
"decay_type": DecayType,
}

@classmethod
Expand All @@ -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
),
Expand Down Expand Up @@ -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
),
Expand Down
29 changes: 29 additions & 0 deletions app/schemas/species_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/services/species_change_request/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class SpeciesChangeRequestService:
"substrate_ids",
"nutrition_mode_ids",
"habitat_ids",
"decay_type_ids",
"season_start_month",
"season_end_month",
"distribution_regions",
Expand Down
40 changes: 40 additions & 0 deletions app/services/species_change_request/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down
1 change: 1 addition & 0 deletions app/services/species_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
72 changes: 72 additions & 0 deletions migrations/versions/d1e2f3a4b5c6_create_decay_types_domain.py
Original file line number Diff line number Diff line change
@@ -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")
Loading