Esempio n. 1
0
class LifeListTaxon(TaxonCount):
    """:fa:`dove,style=fas` :fa:`list` A single :py:class:`.Taxon` in a user's :py:class:`.LifeList`"""

    descendant_obs_count: int = field(
        default=0, doc='Number of observations of taxon children')
    direct_obs_count: int = field(
        default=0,
        doc='Number of observations of this exact taxon (excluding children)')

    @property
    def indent_level(self) -> int:
        """Indentation level corresponding to this item's rank level"""
        return int(((70 - self.rank_level) / 5))

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Rank': self.rank,
            'Name': self.name,
            'Count': self.count,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'rank', 'name', 'count']
Esempio n. 2
0
class ControlledTermCount(BaseModel):
    """:fa:`tag` A count + histogram of a controlled term and value"""

    count: int = field(default=0, doc='')
    histogram: Dict[int, int] = field(factory=dict)
    term = LazyProperty(ControlledTerm.from_json, type=ControlledTerm)
    value = LazyProperty(ControlledTermValue.from_json, type=ControlledTermValue)

    @classmethod
    def from_json(cls, value: JsonResponse, user_id: int = None, **kwargs) -> 'ControlledTermCount':
        """Rename some response fields before initializing"""
        value['histogram'] = value.pop('month_of_year', None)
        value['term'] = value.pop('controlled_attribute', None)
        value['value'] = value.pop('controlled_value', None)
        return super(ControlledTermCount, cls).from_json(value)

    @property
    def term_label(self) -> str:
        return self.term.label

    @property
    def value_label(self) -> str:
        return self.value.label

    @property
    def _row(self) -> TableRow:
        return {
            'Term': self.term.label,
            'Value': self.value.label,
            'Count': self.count,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['term_label', 'value_label', 'count']
Esempio n. 3
0
class EstablishmentMeans(BaseModel):
    """:fa:`exclamation-triangle` The establishment means for a taxon in a given location"""

    establishment_means: str = field(
        default=None,
        options=ESTABLISTMENT_MEANS,
        converter=lambda x: str(x).lower(),
        doc='Establishment means label',
    )
    establishment_means_description: str = field(
        default=None,
        options=ESTABLISTMENT_MEANS,
        doc='Establishment means description')
    place: property = LazyProperty(
        Place.from_json,
        type=Place,
        doc='Location that the establishment means applies to')

    # TODO: Is establishment_means_label ever different from establishment_means?
    @property
    def establishment_means_label(self) -> str:
        return self.establishment_means

    @establishment_means_label.setter
    def establishment_means_label(self, value: str):
        self.establishment_means = value

    def __str__(self) -> str:
        return f'EstablishmentMeans({self.establishment_means})'
Esempio n. 4
0
class ObservationField(BaseModel):
    """:fa:`tag` An observation field **definition**, based on the schema of
    `GET /observation_fields <https://www.inaturalist.org/pages/api+reference#get-observation_fields>`_.
    """

    allowed_values: List[str] = field(converter=safe_split, factory=list)
    created_at: datetime = datetime_now_field(doc='Date and time the observation field was created')
    datatype: str = field(default=None)  # Enum
    description: str = field(default=None)
    name: str = field(default=None)
    updated_at: datetime = datetime_now_field(
        doc='Date and time the observation field was last updated'
    )
    user_id: int = field(default=None)
    users_count: int = field(default=None)
    uuid: str = field(default=None)
    values_count: int = field(default=None)

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Type': self.datatype,
            'Name': self.name,
            'Description': self.description,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'datatype', 'name', 'description']
Esempio n. 5
0
class ProjectObservation(BaseModel):
    """:fa:`binoculars` Metadata about an observation that has been added to a project"""

    preferences: Dict = field(factory=dict)  # Example: {'allows_curator_coordinate_access': True}
    project: Dict = field(factory=dict)  # Example: {'id': 24237}
    user_id: int = field(default=None)
    uuid: str = field(default=None, doc='Universally unique identifier')
    user: property = LazyProperty(
        User.from_json, type=User, doc='User that added the observation to the project'
    )

    @property
    def _str_attrs(self) -> List[str]:
        return ['project', 'user_id']
Esempio n. 6
0
class ConservationStatus(IdWrapperMixin, BaseModel):
    """:fa:`exclamation-triangle` The conservation status of a taxon in a given location, based on the schema of:

    * ``Taxon.conservation_status`` from :v1:`GET /taxa <Taxa/get_taxa>`
    * ``Observation.taxon.conservation_statuses`` from :v1:`GET /observations <Observations/get_observations>`
    * ``conservation_status`` from :v1:`GET /observation/{id}/taxon_summary <Observations/get_observations_id_taxon_summary>`
    """

    authority: str = field(default=None,
                           doc='Data source for conservation status')
    created_at: DateTime = datetime_field(
        doc='Date and time the record was created')
    description: str = field(default=None,
                             doc='Description of conservation status')
    geoprivacy: str = field(
        default=None,
        doc=
        'Default geoprivacy level; may be obscured or private for protected species',
    )
    iucn: int = field(default=None, doc='IUCD ID, if applicable')
    source_id: int = field(default=None)
    status: str = field(
        default=None,
        converter=upper,
        options=CONSERVATION_STATUSES,
        doc='Short code for conservation status',
    )
    status_name: str = field(default=None,
                             doc='Full name of conservation status')
    taxon_id: int = field(default=None, doc='Taxon ID')
    updated_at: DateTime = datetime_field(
        doc='Date and time the record was last updated')
    url: str = field(default=None, doc='Link to data source with more details')

    # Lazy-loaded nested model objects
    place: property = LazyProperty(  # type: ignore
        Place.from_json,
        type=Place,
        doc='Location that the conservation status applies to')
    updater: User = LazyProperty(
        User.from_json, type=User,
        doc='User that last updated the record')  # type: ignore
    user: User = LazyProperty(
        User.from_json, type=User,
        doc='User that created the record')  # type: ignore

    # TODO: Are these needed? They appear to be redundant with `status` and `status_name`
    # iucn_status: str = field(default=None)
    # iucn_status_code: str = field(default=None)

    @property
    def _str_attrs(self) -> List[str]:
        return ['status_name', 'status', 'authority']
Esempio n. 7
0
class TaxonCount(Taxon):
    """:fa:`dove,style=fas` :fa:`list` A :py:class:`.Taxon` with an associated count, used in a
    :py:class:`.TaxonCounts` collection
    """

    count: int = field(default=0, doc='Number of observations of this taxon')

    @classmethod
    def from_json(cls,
                  value: JsonResponse,
                  user_id: int = None,
                  **kwargs) -> 'TaxonCount':
        """Flatten out count + taxon fields into a single-level dict before initializing"""
        if 'results' in value:
            value = value['results']
        if 'taxon' in value:
            value = value.copy()
            value.update(value.pop('taxon'))
        return super(TaxonCount, cls).from_json(value)

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Rank': self.rank,
            'Scientific name': f'{self.emoji} {self.name}',
            'Common name': self.preferred_common_name,
            'Count': self.count,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'full_name', 'count']
Esempio n. 8
0
class Observations(BaseModelCollection):
    """:fa:`binoculars` A collection of observations"""

    data: List[Observation] = field(factory=list,
                                    converter=Observation.from_json_list)

    @property
    def identifiers(self) -> List[User]:
        """Get all unique identifiers"""
        unique_users = {
            i.user.id: i.user
            for obs in self.data for i in obs.identifications
        }
        return list(unique_users.values())

    @property
    def observers(self) -> List[User]:
        """Get all unique observers"""
        unique_users = {obs.user.id: obs.user for obs in self.data}
        return list(unique_users.values())

    @property
    def taxa(self) -> List[Taxon]:
        """Get all unique taxa"""
        unique_taxa = {obs.taxon.id: obs.taxon for obs in self.data}
        return list(unique_taxa.values())

    @property
    def thumbnail_urls(self) -> List[str]:
        """Get thumbnails for all observation default photos"""
        return [obs.thumbnail_url for obs in self.data if obs.thumbnail_url]
Esempio n. 9
0
class IconPhoto(Photo):
    """:fa:`camera` Class used for displaying an iconic taxon in place of a taxon photo"""

    iconic_taxon_name: str = field(default=None, doc='Iconic taxon name')

    def __attrs_post_init__(self):
        self._url_format = self.url.replace('.png', '-{size}px.png')
        self.url = self.medium_url

    @classmethod
    def from_iconic_taxon(cls, iconic_taxon_name: str):
        url = f'{ICONIC_TAXA_BASE_URL}/{iconic_taxon_name.lower()}.png'
        return cls(
            url=url)  # type: ignore  # A weird false positive as of mypy 0.950

    @property
    def icon_url(self) -> Optional[str]:
        """Image URL (32px icon size)"""
        return self.url_size('icon')

    def url_size(self, size: str) -> str:
        size = size.replace('thumbnail', 'square').replace('thumb', 'square')
        return self._url_format.format(size=ICON_SIZES.get(size, 'square'))

    @property
    def _str_attrs(self) -> List[str]:
        return ['iconic_taxon_name', 'url']
Esempio n. 10
0
class TaxonCounts(BaseModelCollection):
    """:fa:`dove,style=fas` :fa:`list` A collection of taxa with an associated counts. Used with
    :v1:`GET /observations/species_counts <Observations/get_observations_species_counts>`.
    as well as :py:class:`.LifeList`.
    """

    data: List[TaxonCount] = field(factory=list,
                                   converter=TaxonCount.from_json_list)
Esempio n. 11
0
class ControlledTermCounts(BaseModelCollection):
    """:fa:`tag` :fa:`list` Used with
    :v1:`GET /observations/popular_field_values <Observations/get_observations_popular_field_values>`.
    """

    data: List[ControlledTermCount] = field(
        factory=list, converter=ControlledTermCount.from_json_list
    )
Esempio n. 12
0
class ControlledTerm(BaseModel):
    """:fa:`tag` A controlled term, based on the schema of
    `GET /controlled_terms <https://api.inaturalist.org/v1/docs/#!/Controlled_Terms/get_controlled_terms>`_.
    """

    is_value: bool = field(default=None)
    multivalued: bool = field(default=None)
    label: str = field(default=None)
    ontology_uri: str = field(default=None)
    uri: str = field(default=None)
    uuid: str = field(default=None)
    taxon_ids: List[int] = field(factory=list)
    values: property = LazyProperty(
        ControlledTermValue.from_json_list,
        type=List[ControlledTermValue],
        doc='Allowed values for this controlled term',
    )

    @property
    def value_labels(self) -> str:
        """Combined labels from all controlled term values"""
        return ', '.join([value.label for value in self.values])

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Label': self.label,
            'Values': self.value_labels,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'label', 'value_labels']
Esempio n. 13
0
class Annotation(BaseModel):
    """:fa:`tag` An annotation, meaning a **controlled term value** applied by a **user** to an **observation**.
    Based on the schema of annotations from
    `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_.
    """

    concatenated_attr_val: str = field(default=None)
    controlled_attribute_id: int = field(default=None)
    controlled_value_id: int = field(default=None)
    user_id: int = field(default=None)
    uuid: str = field(default=None)
    vote_score: int = field(default=None)
    votes: List = field(factory=list)
    user: property = LazyProperty(User.from_json, type=User, doc='User who added the annotation')

    @property
    def values(self) -> List[str]:
        """Split pipe-delimited annotation values into separate tokens"""
        if not self.concatenated_attr_val:
            return []
        return self.concatenated_attr_val.split('|')

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.controlled_attribute_id,
            'Values': ', '.join(self.values),
            'Votes': self.vote_score,
            'User': self.user.login,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['controlled_attribute_id', 'concatenated_attr_val']
Esempio n. 14
0
class Comment(BaseModel):
    """:fa:`comment` An observation comment, based on the schema of comments
    from `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_.
    """

    body: str = field(default='', doc='Comment text')
    created_at: datetime = datetime_now_field(
        doc='Date and time the comment was created')
    hidden: bool = field(default=None,
                         doc='Indicates if the comment is hidden')
    uuid: str = field(default=None, doc='Universally unique identifier')
    user: property = LazyProperty(User.from_json,
                                  type=User,
                                  doc='User that added the comment')

    # Unused attributes
    # created_at_details: Dict = field(factory=dict)
    # flags: List = field(factory=list)
    # moderator_actions: List = field(factory=list)

    @property
    def truncated_body(self):
        """Comment text, truncated"""
        truncated_body = self.body.replace('\n', ' ').strip()
        if len(truncated_body) > 50:
            truncated_body = truncated_body[:47].strip() + '...'
        return truncated_body

    @property
    def username(self) -> str:
        return self.user.login

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'User': self.username,
            'Created at': self.created_at,
            'Comment': self.truncated_body,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'username', 'created_at', 'truncated_body']
Esempio n. 15
0
class LifeList(BaseModelCollection):
    """:fa:`dove,style=fas` :fa:`list` A user's life list, based on the schema of ``GET /observations/taxonomy``"""

    data: List[LifeListTaxon] = field(factory=list,
                                      converter=LifeListTaxon.from_json_list)
    count_without_taxon: int = field(
        default=0, doc='Number of observations without a taxon')
    user_id: int = field(default=None)

    @classmethod
    def from_json(cls,
                  value: JsonResponse,
                  user_id: int = None,
                  **kwargs) -> 'LifeList':
        count_without_taxon = value.get('count_without_taxon', 0)
        if 'results' in value:
            value = value['results']

        life_list_json = {
            'data': value,
            'user_id': user_id,
            'count_without_taxon': count_without_taxon,
        }
        return super(LifeList, cls).from_json(life_list_json)

    def get_count(self,
                  taxon_id: int,
                  count_field='descendant_obs_count') -> int:
        """Get an observation count for the specified taxon and its descendants, and handle unlisted taxa.
        **Note:** ``-1`` can be used an alias for ``count_without_taxon``.
        """
        if taxon_id == -1:
            return self.count_without_taxon
        return super().get_count(taxon_id, count_field=count_field)

    def tree(self):
        """**Experimental**

        Organize this life list into a taxonomic tree

        Returns:
            :py:class:`rich.tree.Tree`
        """
        return make_tree(self.data)
Esempio n. 16
0
class ProjectUser(User):
    """:fa:`user` A :py:class:`.User` with additional project-specific information"""

    project_id: int = field(default=None)
    project_user_id: int = field(default=None)
    role: str = field(default=None)

    @classmethod
    def from_json(cls, value: JsonResponse, **kwargs) -> 'ProjectUser':
        """Flatten out nested values"""
        user = value['user']
        user['project_id'] = value['project_id']
        user['project_user_id'] = value['id']
        user['role'] = value['role']
        return super(ProjectUser, cls).from_json(user, **kwargs)

    @property
    def _str_attrs(self) -> List[str]:
        return ['project_id', 'project_user_id', 'role']
Esempio n. 17
0
class ProjectObservationField(ObservationField):
    """:fa:`tag` An :py:class:`.ObservationField` with additional project-specific information"""

    project_observation_field_id: int = field(default=None)
    position: int = field(default=None)
    required: bool = field(default=None)

    @classmethod
    def from_json(cls, value: JsonResponse, **kwargs) -> 'ProjectObservationField':
        """Flatten out nested values"""
        obs_field = value['observation_field']
        obs_field['project_observation_field_id'] = value['id']
        obs_field['position'] = value['position']
        obs_field['required'] = value['required']
        return super(ProjectObservationField, cls).from_json(obs_field, **kwargs)

    @property
    def _str_attrs(self) -> List[str]:
        return ['project_observation_field_id', 'required']
Esempio n. 18
0
class SearchResult(BaseModel):
    """:fa:`search` A search result of any type, based on the schema of
    `GET /search <https://api.inaturalist.org/v1/docs/#!/Search/get_search>`_.
    """

    score: float = field(default=0, doc='Search result rank')
    type: str = field(default=None,
                      options=SEARCH_RESULT_TYPES,
                      doc='Search result type')
    matches: List[str] = field(factory=list, doc='Search terms matched')
    record: SearchResultRecord = field(default=None,
                                       doc='Search result object')

    # Convert value by datatype
    def __attrs_post_init__(self):
        if self.type in SEARCH_RESULT_TYPES and self.record is not None:
            result_cls = SEARCH_RESULT_TYPES[self.type]
            self.record = result_cls.from_json(self.record)

    @property
    def record_name(self) -> str:
        """Alias for type-specific name/title field"""
        name_attr = SEARCH_RESULT_TITLES[self.type]
        return getattr(self.record, name_attr)

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.record.id,
            'Type': self.type,
            'Score': f'{self.score:.2f}',
            'Name': self.record_name,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'type', 'score', 'record_name']
Esempio n. 19
0
class Message(BaseModel):
    """:fa:`user` A message from the user's inbox, based on the schema of
    :v1:`GET /messages <Messages/get_messages>`
    """

    user_id: int = field(default=None, doc='Corresponding user ID')
    thread_id: int = field(default=None, doc='Message thread ID')
    subject: str = field(default=None, doc='Message subject')
    body: str = field(default=None, doc='Message body')
    read_at: DateTime = datetime_field(doc='When the message was read')
    created_at: DateTime = datetime_field(doc='When the message was sent')
    updated_at: DateTime = datetime_field(
        doc='When the message was last edited')
    thread_flags: List[str] = field(factory=list)
    thread_messages_count: int = field(default=None,
                                       doc='Number of messages in the thread')

    from_user: property = LazyProperty(User.from_json,
                                       type=User,
                                       doc='Message sender')
    to_user: property = LazyProperty(User.from_json,
                                     type=User,
                                     doc='Message recipient')

    @property
    def truncated_body(self):
        """Comment text, truncated"""
        truncated_body = self.body.replace('\n', ' ').strip()
        if len(truncated_body) > 50:
            truncated_body = truncated_body[:47].strip() + '...'
        return truncated_body

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Date': self.created_at,
            'To': self.to_user.login,
            'From': self.from_user.login,
            'Subject': self.subject,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return [
            'id', 'created_at', 'self.to_user', 'self.from_user', 'subject',
            'truncated_body'
        ]

    def __str__(self) -> str:
        return (
            f'[{self.id}] Sent {self.created_at.strftime(DATETIME_SHORT_FORMAT)} '
            f'from {self.from_user.login} to {self.to_user.login}: {self.subject}'
        )
Esempio n. 20
0
class ObservationFieldValue(BaseModel):
    """:fa:`tag` An observation field **value**, based on the schema of ``Observation.ofvs``
    from `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_.
    """

    datatype: str = field(default=None)  # Enum
    field_id: int = field(default=None)
    name: str = field(default=None)
    taxon_id: int = field(default=None)
    user_id: int = field(default=None)
    uuid: str = field(default=None)
    value: OFVValue = field(default=None)
    taxon: property = LazyProperty(
        Taxon.from_json, type=Taxon, doc='Taxon that the observation field applies to'
    )
    user: property = LazyProperty(
        User.from_json, type=User, doc='User that applied the observation field value'
    )

    # Unused attrbiutes
    # name_ci: str = field(default=None)
    # value_ci: int = field(default=None)

    # Convert value by datatype
    def __attrs_post_init__(self):
        if self.datatype in OFV_DATATYPES and self.value is not None:
            converter = OFV_DATATYPES[self.datatype]
            try:
                self.value = converter(self.value)
            except ValueError:
                self.value = None

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Type': self.datatype,
            'Name': self.name,
            'Value': self.value,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'datatype', 'name', 'value']
Esempio n. 21
0
class ControlledTermValue(BaseModel):
    """:fa:`tag` A controlled term **value**, based on the schema of
    `GET /controlled_terms <https://api.inaturalist.org/v1/docs/#!/Controlled_Terms/get_controlled_terms>`_.
    """

    blocking: bool = field(default=None)
    label: str = field(default=None)
    ontology_uri: str = field(default=None)
    uri: str = field(default=None)
    uuid: str = field(default=None)
    taxon_ids: List[int] = field(factory=list)

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'label']
Esempio n. 22
0
class TaxonSummary(BaseModel):
    """:fa:`dove,style=fas` :fa:`list` Information about an observation's taxon, within the context
    of the observation's location. Based on the schema of
    :v1:`GET /observations/{id}/taxon_summary <Observations/get_observations_id_taxon_summary>`
    """

    conservation_status: property = LazyProperty(
        ConservationStatus.from_json,
        type=ConservationStatus,
        doc='Conservation status of the taxon in the observation location',
    )
    listed_taxon: property = LazyProperty(
        ListedTaxon.from_json,
        type=ListedTaxon,
        doc='Details about the taxon on an "original" life list',
    )
    wikipedia_summary: str = field(default=None,
                                   doc='Taxon summary from Wikipedia article')

    @property
    def _str_attrs(self) -> List[str]:
        return ['conservation_status', 'listed_taxon']
Esempio n. 23
0
class Project(BaseModel):
    """:fa:`users` An iNaturalist project, based on the schema of
    `GET /projects <https://api.inaturalist.org/v1/docs/#!/Projects/get_projects>`_.
    """

    banner_color: str = field(default=None)
    created_at: datetime = datetime_now_field(doc='Date and time the project was created')
    description: str = field(default=None, doc='Project description')
    header_image_url: str = field(default=None)
    hide_title: bool = field(default=None)
    icon: str = field(default=None, doc='URL for project icon')
    is_umbrella: bool = field(
        default=None,
        doc='Indicates if this is an umbrella project (containing observations from other projects)',
    )
    location: Coordinates = coordinate_pair()
    place_id: int = field(default=None, doc='Project place ID')
    prefers_user_trust: bool = field(
        default=None,
        doc='Indicates if the project wants users to share hidden coordinates with the project admins',
    )
    project_observation_rules: List[Dict] = field(factory=list, doc='Project observation rules')
    project_type: str = field(default=None, options=PROJECT_TYPES, doc='Project type')  # Enum
    rule_preferences: List[Dict] = field(factory=list)
    search_parameters: List[Dict] = field(factory=list, doc='Filters for observations to include')
    site_features: List[Dict] = field(
        factory=list, doc='Details about if/when the project was featured on inaturalist.org'
    )
    slug: str = field(default=None, doc='URL slug')
    terms: str = field(default=None, doc='Project terms')
    title: str = field(default=None, doc='Project title')
    updated_at: DateTime = datetime_field(doc='Date and time the project was last updated')
    user_ids: List[int] = field(factory=list)

    # Lazy-loaded model objects
    admins: property = LazyProperty(
        ProjectUser.from_json_list, type=List[User], doc='Project admin users'
    )
    project_observation_fields: property = LazyProperty(
        ProjectObservationField.from_json_list,
        type=List[ProjectObservationField],
        doc='Observation fields used by the project',
    )
    user: property = LazyProperty(User.from_json, type=User, doc='User that created the project')

    # Unused attributes
    # flags: List = field(factory=list)
    # header_image_contain: bool = field(default=None)
    # header_image_file_name: str = field(default=None)
    # icon_file_name: str = field(default=None)
    # latitude: float = field(default=None)
    # longitude: float = field(default=None)
    # user_id: int = field(default=None)

    # Aliases
    @property
    def obs_fields(self):
        return self.project_observation_fields

    @property
    def obs_rules(self):
        return self.project_observation_rules

    @property
    def url(self) -> str:
        """Info URL on iNaturalist.org"""
        return f'{INAT_BASE_URL}/projects/{self.id}'

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Title': self.title,
            'Type': self.project_type,
            'URL': self.url,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'title']
Esempio n. 24
0
class Photo(BaseModel):
    """:fa:`camera` An observation photo, based on the schema of photos from:

    * `GET /observations <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations>`_
    * `GET /taxa <https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa>`
    """

    attribution: str = field(default=None, doc='License attribution')
    license_code: str = field(
        default=None,
        converter=format_license,
        options=ALL_LICENSES,
        doc='Creative Commons license code',
    )
    observation_id: int = field(default=None, doc='Associated observation ID')
    original_dimensions: Tuple[int,
                               int] = field(converter=format_dimensions,
                                            default=(0, 0),
                                            doc='Dimensions of original image')
    url: str = field(
        default=None,
        doc='Image URL; see properties for URLs of specific image sizes')
    user_id: int = field(default=None, doc='Associated user ID')
    uuid: str = field(default=None)
    _url_format: str = field(init=False, repr=False, default=None)

    # Unused attributes
    # flags: List = field(factory=list)

    def __attrs_post_init__(self):
        # If there's no URL, make a guess based on ID and license:
        self.url = self.url or (f'{PHOTO_CC_BASE_URL}/{self.id}/original.jpg'
                                if self.has_cc_license else
                                f'{PHOTO_BASE_URL}/{self.id}?size=original')

        # Get a URL format string to get different photo sizes. Note: default URL may be any size.
        for size in PHOTO_SIZES:
            if f'{size}.' in self.url:
                self._url_format = self.url.replace(size, '{size}')

    @property
    def ext(self) -> str:
        """File extension from URL"""
        return self.url.lower().split('.')[-1].split('?')[0]

    @property
    def dimensions_str(self) -> str:
        """Dimensions as a string, formatted as ``{width}x{height}``"""
        return f'{self.original_dimensions[0]}x{self.original_dimensions[1]}'

    @property
    def has_cc_license(self) -> bool:
        """Determine if this photo has a Creative Commons license"""
        return self.license_code in CC_LICENSES

    @property
    def info_url(self) -> str:
        """Photo info URL on iNaturalist.org"""
        return f'{PHOTO_INFO_BASE_URL}/{self.id}'

    @property
    def large_url(self) -> Optional[str]:
        """Image URL (large size)"""
        return self.url_size('large')

    @property
    def medium_url(self) -> Optional[str]:
        """Image URL (medium size)"""
        return self.url_size('medium')

    @property
    def mimetype(self) -> str:
        """MIME type of the image"""
        return f'image/{self.ext.replace("jpg", "jpeg")}'

    @property
    def original_url(self) -> Optional[str]:
        """Image URL (original size)"""
        return self.url_size('original')

    @property
    def small_url(self) -> Optional[str]:
        """Image URL (small size)"""
        return self.url_size('small')

    @property
    def square_url(self) -> Optional[str]:
        """Image URL (thumbnail size)"""
        return self.url_size('square')

    @property
    def thumbnail_url(self) -> Optional[str]:
        """Image URL (thumbnail size)"""
        return self.url_size('square')

    def url_size(self, size: str) -> Optional[str]:
        size = size.replace('thumbnail', 'square').replace('thumb', 'square')
        if not self._url_format or size not in PHOTO_SIZES:
            return None
        return self._url_format.format(size=size)

    def open(self, size: str = 'large') -> BinaryIO:
        """Download the image and return as a file-like object"""
        url = self.url_size(size) or self.url
        return requests.get(url, stream=True).raw

    def show(self, size: str = 'large'):
        """Display the image with the system's default image viewer. Requires ``pillow``."""
        from PIL import Image

        img = Image.open(self.open(size=size))
        img.show()

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'License': self.license_code,
            'Dimensions': self.dimensions_str,
            'URL': self.original_url,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'license_code', 'url']
Esempio n. 25
0
class Place(BaseModel):
    """:fa:`map-marker-alt,style=fas` A curated or community-contributed place. Handles data from the following endpoints:

    * `GET /places/{id} <https://api.inaturalist.org/v1/docs/#!/Places/get_places_id>`_
    * `GET /places/nearby <https://api.inaturalist.org/v1/docs/#!/Places/get_places_nearby>`_
    * `GET /taxa <https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa>`_  (``establishment_means.place``)
    """

    admin_level: int = field(default=None, doc='Administrative level, if any')
    ancestor_place_ids: List[str] = field(
        factory=list, doc='IDs of places that this place is contained within'
    )
    bbox_area: float = field(default=None, doc='Bounding box area')
    bounding_box_geojson: GeoJson = field(
        factory=dict, doc='Bounding box polygon that fully encloses the place'
    )
    category: str = field(
        default=None,
        validator=is_in(PLACE_CATEGORIES),
        doc='Place category (only applies to /places/nearby)',
    )
    display_name: str = field(default=None, doc='Place name as displayed on place info page')
    geometry_geojson: GeoJson = field(factory=dict, doc='Polygon representing place boundary')
    location: Coordinates = field(
        default=None,
        converter=convert_optional_lat_long,
        doc='Location in ``(latitude, logitude)`` decimal degrees',
    )
    name: str = field(default=None, doc='Place name')
    place_type: int = field(default=None, doc='Place type ID')
    place_type_name: str = field(default=None, doc='Place type name')
    slug: str = field(default=None, doc='Place URL slug')

    @classmethod
    def from_json(cls, value: JsonResponse, category: str = None, **kwargs) -> 'Place':
        value.setdefault('category', category)
        return super(Place, cls).from_json(value)

    @classmethod
    def from_json_list(cls, value: ResponseOrResults, **kwargs) -> List['Place']:
        """Optionally use results from /places/nearby to set Place.category"""
        json_value = dict(ensure_list(value)[0])
        if 'results' in json_value:
            json_value = json_value['results']

        if 'standard' in json_value and 'community' in json_value:
            places = [cls.from_json(item, category='standard') for item in json_value['standard']]
            places += [
                cls.from_json(item, category='community') for item in json_value['community']
            ]
            return places
        else:
            return super(Place, cls).from_json_list(value)

    @property
    def ancestry(self) -> str:
        """Handle slash-delimited 'ancestry' string from ``establishment_means.place``"""
        return '/'.join(self.ancestor_place_ids)

    @ancestry.setter
    def ancestry(self, value: str):
        self.ancestor_place_ids = value.split('/')

    @property
    def url(self) -> str:
        """Place info URL on iNaturalist.org"""
        return f'{INAT_BASE_URL}/places/{self.id}'

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Latitude': f'{self.location[0]:9.4f}',
            'Longitude': f'{self.location[1]:9.4f}',
            'Name': self.name,
            'Category': self.category,
            'URL': self.url,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'location', 'name']
Esempio n. 26
0
class Identification(BaseModel):
    """:fa:`fingerprint,style=fas` An observation identification, based on the schema of
    `GET /identifications <https://api.inaturalist.org/v1/docs/#!/Identifications/get_identifications>`_.
    """

    body: str = field(default=None, doc='Comment text')
    category: str = field(default=None,
                          options=ID_CATEGORIES,
                          doc='Identification category')
    created_at: datetime = datetime_now_field(
        doc='Date and time the identification was added')
    current: bool = field(
        default=None,
        doc='Indicates if the identification is the currently accepted one')
    current_taxon: bool = field(default=None)
    disagreement: bool = field(
        default=None,
        doc='Indicates if this identification disagrees with previous ones')
    hidden: bool = field(default=None)
    own_observation: bool = field(
        default=None, doc='Indicates if the indentifier is also the observer')
    previous_observation_taxon_id: int = field(
        default=None, doc='Previous observation taxon ID')
    taxon_change: bool = field(default=None)  # TODO: confirm type
    taxon_id: int = field(default=None, doc='Identification taxon ID')
    uuid: str = field(default=None, doc='Universally unique identifier')
    vision: bool = field(
        default=None,
        doc='Indicates if the taxon was selected from computer vision results')
    taxon: property = LazyProperty(Taxon.from_json,
                                   type=Taxon,
                                   doc='Identification taxon')
    user: property = LazyProperty(User.from_json,
                                  type=User,
                                  doc='User that added the indentification')

    @property
    def taxon_name(self) -> str:
        return self.taxon.full_name

    # Unused attributes
    # created_at_details: {}
    # spam: bool = field(default=None)
    # flags: List = field(factory=list)
    # moderator_actions: List = field(factory=list)
    # observation: {}

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Taxon ID': self.taxon.id,
            'Taxon': f'{self.taxon.emoji} {self.taxon.full_name}',
            'User': self.user.login,
            'Category': self.category.title(),
            'From CV': self.vision,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'taxon_name', 'created_at']
Esempio n. 27
0
class Observation(BaseModel):
    """:fa:`binoculars` An observation, based the schema of
    :v1:`GET /observations <Observations/get_observations>`
    """

    created_at: datetime = datetime_now_field(
        doc='Date and time the observation was created')
    captive: bool = field(
        default=None,
        doc='Indicates if the organism is non-wild (captive or cultivated)')
    community_taxon_id: int = field(
        default=None, doc='The current community identification taxon')
    description: str = field(default=None, doc='Observation description')
    faves: List[Dict] = field(
        factory=list,
        doc='Details on users who have favorited the observation')
    geoprivacy: str = field(default=None,
                            options=GEOPRIVACY_LEVELS,
                            doc='Location privacy level')
    identifications_count: int = field(default=None,
                                       doc='Total number of identifications')
    identifications_most_agree: bool = field(
        default=None,
        doc='Indicates if most identifications agree with the community ID')
    identifications_most_disagree: bool = field(
        default=None,
        doc='Indicates if most identifications disagree with the community ID')
    identifications_some_agree: bool = field(
        default=None,
        doc='Indicates if some identifications agree with the community ID')
    license_code: str = field(default=None,
                              converter=upper,
                              options=ALL_LICENSES,
                              doc='Creative Commons license code')
    location: Coordinates = coordinate_pair()
    mappable: bool = field(
        default=None, doc='Indicates if the observation can be shown on a map')
    num_identification_agreements: int = field(
        default=None,
        doc='Total identifications that agree with the community ID')
    num_identification_disagreements: int = field(
        default=None,
        doc='Total identifications that disagree with the community ID')
    oauth_application_id: str = field(
        default=None,
        doc='ID of the OAuth application used to create the observation, if any'
    )
    obscured: bool = field(
        default=None,
        doc=
        'Indicates if coordinates are obscured (showing a broad approximate location on the map)',
    )
    observed_on: DateTime = datetime_field(
        doc='Date and time the organism was observed')
    outlinks: List[Dict] = field(
        factory=list,
        doc='Linked observation pages on other sites (e.g., GBIF)')
    out_of_range: bool = field(
        default=None,
        doc='Indicates if the taxon is observed outside of its known range')
    owners_identification_from_vision: bool = field(
        default=None,
        doc=
        "Indicates if the owner's ID was selected from computer vision results"
    )
    place_guess: str = field(
        default=None, doc='Place name determined from observation coordinates')
    place_ids: List[int] = field(
        factory=list,
        doc='Place IDs associated with the observation coordinates')
    positional_accuracy: int = field(
        default=None,
        doc='GPS accuracy in meters (real accuracy, if obscured)')
    preferences: Dict[str, Any] = field(
        factory=dict,
        doc=
        'Any user observation preferences (related to community IDs, coordinate access, etc.)',
    )
    private_location: Coordinates = coordinate_pair(
        doc=
        ':fa:`lock` Private location in ``(latitude, logitude)`` decimal degrees'
    )
    private_place_ids: List[int] = field(
        factory=list,
        doc=
        ':fa:`lock` Place IDs associated with the private observation coordinates'
    )
    private_place_guess: str = field(
        default=None,
        doc=
        ':fa:`lock` Place name determined from private observation coordinates'
    )
    project_ids: List[int] = field(factory=list,
                                   doc='All associated project IDs')
    project_ids_with_curator_id: List[int] = field(
        factory=list,
        doc='Project IDs with a curator identification for this observation')
    project_ids_without_curator_id: List[int] = field(
        factory=list,
        doc='Project IDs without curator identification for this observation')
    public_positional_accuracy: int = field(
        default=None, doc='GPS accuracy in meters (not accurate if obscured)')
    quality_grade: str = field(default=None,
                               options=QUALITY_GRADES,
                               doc='Quality grade')
    quality_metrics: List[Dict] = field(factory=list,
                                        doc='Data quality assessment metrics')
    reviewed_by: List[int] = field(
        factory=list, doc='IDs of users who have reviewed the observation')
    site_id: int = field(
        default=None,
        doc=
        'Site ID for iNaturalist network members, or ``1`` for inaturalist.org'
    )
    sounds: List[Dict] = field(factory=list, doc='Observation sound files')
    species_guess: str = field(
        default=None, doc="Taxon name from observer's initial identification")
    tags: List[str] = field(factory=list,
                            doc='Arbitrary user tags added to the observation')
    updated_at: DateTime = datetime_field(
        doc='Date and time the observation was last updated')
    uri: str = field(default=None, doc='Link to observation details page')
    uuid: str = field(
        default=None,
        doc=
        'Universally unique ID; generally preferred over ``id`` where possible'
    )
    votes: List[Dict] = field(factory=list,
                              doc='Votes on data quality assessment metrics')

    # Lazy-loaded model objects
    annotations: property = LazyProperty(Annotation.from_json_list,
                                         type=List[Annotation],
                                         doc='Observation annotations')
    comments: property = LazyProperty(Comment.from_json_list,
                                      type=List[Comment],
                                      doc='Observation comments')
    identifications: property = LazyProperty(Identification.from_json_list,
                                             type=List[Identification],
                                             doc='Observation identifications')
    ofvs: property = LazyProperty(
        ObservationFieldValue.from_json_list,
        type=List[ObservationFieldValue],
        doc='Observation field values',
    )
    photos: property = LazyProperty(Photo.from_json_list,
                                    type=List[Photo],
                                    doc='Observation photos')
    project_observations: property = LazyProperty(
        ProjectObservation.from_json_list,
        type=List[ProjectObservation],
        doc='Details on any projects that the observation has been added to',
    )
    taxon: property = LazyProperty(Taxon.from_json,
                                   type=Taxon,
                                   doc='Observation taxon')
    user: property = LazyProperty(User.from_json, type=User, doc='Observer')

    # Additional attributes from API response that aren't needed; just left here for reference
    # cached_votes_total: int = field(default=None)
    # comments_count: int = field(default=None)
    # created_at_details: Dict = field(factory=dict)
    # created_time_zone: str = field(default=None)
    # faves_count: int = field(default=None)
    # flags: List = field(factory=list)
    # geojson: Dict = field(factory=dict)
    # id_please: bool = field(default=None)
    # map_scale: int = field(default=None)
    # non_owner_ids: List = field(factory=list)
    # observed_on_details: Dict = field(factory=dict)
    # observed_on_string: str = field(default=None)
    # observation_photos: List[Photo] = field(converter=Photo.from_dict_list, factory=list)
    # observed_time_zone: str = field(default=None)
    # spam: bool = field(default=None)
    # time_observed_at: DateTime = datetime_attr
    # time_zone_offset: str = field(default=None)

    # Attributes that will only be used during init and then omitted
    temp_attrs = [
        'created_at_details',
        'observed_on_string',
        'observed_time_zone',
        'time_zone_offset',
    ]

    # Convert observation timestamps prior to __attrs_init__
    def __init__(
        self,
        # created_at_details: Dict = None,
        # observed_on_string: str = None,
        # observed_time_zone: str = None,
        # time_zone_offset: str = None,
        **kwargs,
    ):
        created_at_details = kwargs.pop('created_at_details', None)
        observed_on_string = kwargs.pop('observed_on_string', None)
        observed_time_zone = kwargs.pop('observed_time_zone', None)
        time_zone_offset = kwargs.pop('time_zone_offset', None)

        tz_offset = time_zone_offset
        tz_name = observed_time_zone
        created_date = (created_at_details or {}).get('date')

        if not isinstance(kwargs.get('created_at'), datetime) and created_date:
            kwargs['created_at'] = convert_observation_timestamp(
                created_date, tz_offset, tz_name)
        if not isinstance(kwargs.get('observed_on'),
                          datetime) and observed_on_string:
            kwargs['observed_on'] = convert_observation_timestamp(
                observed_on_string, tz_offset, tz_name, ignoretz=True)

        if not kwargs.get('uri'):
            kwargs[
                'uri'] = f'{INAT_BASE_URL}/observations/{kwargs.get("id", "")}'

        self.__attrs_init__(**kwargs)  # type: ignore

    @classmethod
    def from_id(cls, id: int):
        """Lookup and create a new Observation object from an ID"""
        from pyinaturalist.v1 import get_observation

        json = get_observation(id)
        return cls.from_json(json)

    @property
    def photo_url(self) -> Optional[str]:
        """Original size photo URL for first observation photo (if any)"""
        if not self.photos:
            return None
        return self.photos[0].original_url

    @property
    def thumbnail_url(self) -> Optional[str]:
        """Thumbnail size photo URL for first observation photo (if any)"""
        if not self.photos:
            return None
        return self.photos[0].thumbnail_url

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Taxon ID': self.taxon.id,
            'Taxon': self.taxon.full_name,
            'Observed on': self.observed_on,
            'User': self.user.login,
            'Location': self.place_guess or self.location,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'taxon', 'observed_on', 'user', 'place_guess']
Esempio n. 28
0
class ListedTaxon(IdWrapperMixin, EstablishmentMeans):
    """:fa:`dove,style=fas` :fa:`list` A taxon with additional stats associated with a list
    (aka `"original life list" <https://www.inaturalist.org/blog/43337-upcoming-changes-to-lists>`_),
    based on the schema of:

    * ``Taxon.listed_taxa`` from :v1:`GET /taxa/{id} <Taxa/get_taxa_id>`
    * ``TaxonSummary.listed_taxon`` from  :v1:`GET /observations/{id}/taxon_summary <Observations/get_observations_id_taxon_summary>`
    """

    comments_count: int = field(default=0,
                                doc='Number of comments for this listed taxon')
    created_at: DateTime = datetime_field(
        doc='Date and time the record was created')
    description: str = field(default=None, doc='Listed taxon description')
    first_observation_id: int = field(
        default=None, doc='Oldest recent observation ID in the list')
    last_observation_id: int = field(
        default=None, doc='Most recent observation ID in the list')
    list_id: int = field(default=None, doc='List ID')
    list_title: str = field(default=None, doc='List title')
    manually_added: bool = field(
        default=None,
        doc='Indicates if the taxon was manually added to the list')
    observations_count: int = field(
        default=0, doc='Number of observations of this taxon in the list')
    occurrence_status_level: int = field(default=None, doc='')
    primary_listing: bool = field(
        default=None,
        doc='Indicates if this is the primary listing for this taxon')
    source_id: int = field(default=None, doc='')
    taxon_id: int = field(default=None, doc='')
    taxon_range_id: int = field(default=None, doc='')
    updated_at: DateTime = datetime_field(
        doc='Date and time the record was last updated')

    # Lazy-loaded nested model objects
    updater: User = field(default=None,
                          converter=User.from_json,
                          doc='User that last updated the record')
    user: User = field(default=None,
                       converter=User.from_json,
                       doc='User that created the record')

    @property
    def list(self) -> Dict:
        """Alias to handle differences between taxa and taxon_summary endpoints"""
        return {'id': self.list_id, 'title': self.list_title}

    @list.setter
    def list(self, value: Dict):
        self.list_id = int(value.get('id', -1))
        self.list_title = str(value.get('title', ''))

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Taxon ID': self.taxon_id,
            'Place ID': self.place.id,
            'Life list': self.list_title or self.list_id,
            'Establishment means': self.establishment_means,
            'Observations': self.observations_count,
            'Comments': self.comments_count,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return [
            'id', 'taxon_id', 'place', 'establishment_means',
            'observations_count'
        ]

    def __str__(self) -> str:
        return BaseModel.__str__(self)
Esempio n. 29
0
class Taxon(BaseModel):
    """:fa:`dove,style=fas` An iNaturalist taxon, based on the schema of
    `GET /taxa <https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa>`_.

    Can be constructed from either a full or partial JSON record. Examples of partial records
    include nested ``ancestors``, ``children``, and results from :py:func:`.get_taxa_autocomplete`.
    """

    ancestry: str = field(default=None,
                          doc='Slash-delimited string of ancestor IDs',
                          repr=False)
    ancestor_ids: List[int] = field(
        factory=list,
        converter=ensure_list,  # Handle arrays when converting from a dataframe
        doc='Taxon IDs of ancestors, from highest rank to lowest',
    )
    complete_rank: str = field(
        default=None,
        doc='Complete or "leaf taxon" rank, e.g. species or subspecies')
    complete_species_count: int = field(
        default=None, doc='Total number of species descended from this taxon')
    created_at: DateTime = datetime_field(
        doc='Date and time the taxon was added to iNaturalist')
    current_synonymous_taxon_ids: List[int] = field(
        factory=list, doc='Taxon IDs of taxa that are accepted synonyms')
    extinct: bool = field(default=None,
                          doc='Indicates if the taxon is extinct')
    gbif_id: int = field(default=None, doc='GBIF taxon ID')
    iconic_taxon_id: int = field(
        default=0,
        doc='ID of the iconic taxon (e.g., general taxon "category")')
    iconic_taxon_name: str = field(
        default=None,
        doc='Name of the iconic taxon (e.g., general taxon "category")')
    is_active: bool = field(
        default=None,
        doc='Indicates if the taxon is active (and not renamed, moved, etc.)')
    listed_places: bool = field(
        default=None,
        doc='Indicates if there are listed places for this taxon')
    listed_taxa_count: int = field(
        default=None,
        doc='Number of listed taxa from this taxon + descendants')
    matched_term: str = field(
        default=None, doc='Matched search term, from autocomplete results')
    name: str = field(
        default=None,
        doc=
        'Taxon name; contains full scientific name at species level and below')
    names: List[Dict] = field(
        factory=list,
        doc=
        'All regional common names; only returned if ``all_names`` is specified'
    )
    observations_count: int = field(
        default=None,
        doc='Total number of observations of this taxon and its descendants')
    parent_id: int = field(default=None, doc='Taxon ID of immediate ancestor')
    preferred_common_name: str = field(
        default='', doc='Common name for the preferred place, if any')
    preferred_establishment_means: str = field(
        default=None,
        doc=
        'Establishment means for this taxon in the given preferred place (if any)'
    )
    rank_level: int = field(
        default=None,
        doc=
        'Number indicating rank level, for easier comparison between ranks (kingdom=higest)',
    )
    ranges: bool = field(default=None,
                         doc='Indicates if there is range data for this taxon')
    rank: str = field(default=None, options=RANKS, doc='Taxon rank')
    taxon_changes_count: int = field(
        default=None, doc='Number of curator changes to this taxon')
    taxon_schemes_count: int = field(
        default=None, doc='Taxon schemes that include this taxon')
    vision: bool = field(
        default=None,
        doc='Indicates if this taxon is included in the computer vision model')
    wikipedia_summary: str = field(default=None,
                                   doc='Taxon summary from Wikipedia article')
    wikipedia_url: str = field(default=None,
                               doc='URL to Wikipedia article for the taxon')

    # Lazy-loaded model objects
    ancestors: property = LazyProperty(BaseModel.from_json_list)
    children: property = LazyProperty(BaseModel.from_json_list)
    conservation_status: property = LazyProperty(
        ConservationStatus.from_json,
        type=ConservationStatus,
        doc='Conservation status of the taxon in a given location',
    )
    conservation_statuses: property = LazyProperty(
        ConservationStatus.from_json_list,
        type=List[ConservationStatus],
        doc='Conservation statuses of the taxon in different locations',
    )
    default_photo: property = LazyProperty(Photo.from_json,
                                           type=Photo,
                                           doc='Taxon default photo')
    establishment_means: property = LazyProperty(
        EstablishmentMeans.from_json,
        type=EstablishmentMeans,
        doc='Establishment means for a taxon in a given location',
    )
    listed_taxa: property = LazyProperty(
        ListedTaxon.from_json_list,
        type=List[ListedTaxon],
        doc='Details about this taxon associated with a list',
    )
    taxon_photos: property = LazyProperty(
        Photo.from_json_list,
        type=List[Photo],
        doc='All taxon photos shown on taxon info page')

    # Unused attributes
    # atlas_id: int = field(default=None)
    # flag_counts: Dict[str, int] = field(factory=dict)  # {"unresolved": 1, "resolved": 2}
    # min_species_ancestry: str = field(default=None)  # Used internally by iNaturalist for Elasticsearch aggregations
    # min_species_taxon_id: int = field(default=None)
    # partial: bool = field(default=None, repr=False)
    # photos_locked: bool = field(default=None)
    # universal_search_rank: int = field(default=None)

    def __attrs_post_init__(self):
        # Look up iconic taxon name, if only ID is provided
        if not self.iconic_taxon_name:
            self.iconic_taxon_name = ICONIC_TAXA.get(self.iconic_taxon_id,
                                                     'Unknown')

        # If default photo is missing, use iconic taxon icon
        if not self.default_photo:
            self.default_photo = self.icon

        # If only ancestor string (or objects) are provided, split into IDs
        if self.ancestry and not self.ancestor_ids:
            delimiter = ',' if ',' in self.ancestry else '/'
            self.ancestor_ids = [
                int(x) for x in self.ancestry.split(delimiter)
            ]
        elif self.ancestors and not self.ancestor_ids:
            self.ancestor_ids = [t.name for t in self.ancestors]

    @classmethod
    def from_sorted_json_list(cls, value: JsonResponse) -> List['Taxon']:
        """Sort Taxon objects by rank then by name"""
        taxa = cls.from_json_list(value)
        taxa.sort(key=_get_rank_name_idx)
        return taxa

    @property
    def child_ids(self) -> List[int]:
        """Taxon IDs of direct children, sorted by rank then name"""
        return [t.id for t in self.children]

    @property
    def emoji(self) -> str:
        """Get an emoji representing the taxon"""
        for taxon_id in [self.id] + list(reversed(self.ancestor_ids)):
            if taxon_id in EMOJI:
                return EMOJI[taxon_id]
        return ICONIC_EMOJI.get(self.iconic_taxon_id, '❓')

    @property
    def full_name(self) -> str:
        """Taxon rank, scientific name, common name (if available), and emoji"""
        if not self.name and not self.rank:
            return 'unknown taxon'
        elif not self.name:
            return f'{self.rank.title()}: {self.id}'
        elif not self.rank:
            return self.name

        common_name = f' ({self.preferred_common_name})' if self.preferred_common_name else ''
        return f'{self.rank.title()}: {self.name}{common_name}'

    @property
    def icon(self) -> IconPhoto:
        return IconPhoto.from_iconic_taxon(self.iconic_taxon_name)

    @property
    def icon_url(self) -> str:
        """Iconic URL for the icon of the iconic taxon"""
        return str(self.icon.thumbnail_url)

    @property
    def gbif_url(self) -> str:
        """URL for the GBIF info page for this taxon"""
        return f'https://www.gbif.org/species/{self.gbif_id}'

    @property
    def parent(self) -> 'Taxon':
        """Immediate parent, if any"""
        return self.ancestors[-1] if self.ancestors else None

    @property
    def taxonomy(self) -> Dict[str, str]:
        """Ancestor + current taxon as a ``{rank: name}`` dict"""
        return {t.rank: t.name for t in self.ancestors + [self]}

    @property
    def url(self) -> str:
        """Info URL on iNaturalist.org"""
        return f'{INAT_BASE_URL}/taxa/{self.id}'

    # TODO: Probably remove this
    @classmethod
    def from_id(cls, id: int) -> 'Taxon':
        """Lookup and create a new Taxon object by ID"""
        from pyinaturalist.v1 import get_taxa_by_id

        r = get_taxa_by_id(id)
        return cls.from_json(r['results'][0])

    def load_full_record(self):
        """Update this Taxon with full taxon info, including ancestors + children"""
        t = Taxon.from_id(self.id)
        for key in fields_dict(Taxon).keys():
            key = key.lstrip(
                '_'
            )  # Use getters/setters for LazyProperty instead of temp attrs
            setattr(self, key, getattr(t, key))

    @property
    def _row(self) -> TableRow:
        return {
            'ID': self.id,
            'Rank': self.rank,
            'Scientific name': f'{self.emoji} {self.name}',
            'Common name': self.preferred_common_name,
        }

    @property
    def _str_attrs(self) -> List[str]:
        return ['id', 'full_name']