Exemplo n.º 1
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}'
        )
Exemplo n.º 2
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']
Exemplo n.º 3
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']
Exemplo n.º 4
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']
Exemplo n.º 5
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']
Exemplo n.º 6
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)