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}' )
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']
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']
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']
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']
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)