Skip to content

Commit

Permalink
Similar refactoring for annotation, place, project, taxon, and user c…
Browse files Browse the repository at this point in the history
…ontrollers
  • Loading branch information
JWCook committed Dec 12, 2023
1 parent 44b1a2e commit 2d19325
Show file tree
Hide file tree
Showing 8 changed files with 308 additions and 47 deletions.
47 changes: 39 additions & 8 deletions pyinaturalist/controllers/annotation_controller.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from logging import getLogger
from typing import Dict, List

from pyinaturalist.constants import API_V2, IntOrStr
from pyinaturalist.controllers import BaseController
from pyinaturalist.docs import document_common_args, document_controller_params
from pyinaturalist.models import Annotation, ControlledTerm
from pyinaturalist.session import delete, post
from pyinaturalist.v1 import get_controlled_terms, get_controlled_terms_for_taxon

logger = getLogger(__name__)


class AnnotationController(BaseController):
""":fa:`tag` Controller for Annotation and ControlledTerm requests"""
Expand All @@ -22,13 +24,41 @@ def term_lookup(self) -> Dict[int, ControlledTerm]:
self._term_lookup = {term.id: term for term in self.all()}
return self._term_lookup

@document_controller_params(get_controlled_terms)
def all(self, **params) -> List[ControlledTerm]:
"""List controlled terms and their possible values
.. rubric:: Notes
* API reference: :v1:`GET /controlled_terms <Controlled_Terms/get_controlled_terms>`
Example:
>>> terms = client.annotations
>>> pprint(response[0])
1: Life Stage
2: Adult
3: Teneral
4: Pupa
...
"""
response = get_controlled_terms(**params)
return ControlledTerm.from_json_list(response['results'])

@document_controller_params(get_controlled_terms_for_taxon)
def for_taxon(self, taxon_id: int, **params) -> List[ControlledTerm]:
"""List controlled terms that are valid for the specified taxon.
.. rubric:: Notes
* API reference: :v1:`GET /controlled_terms/for_taxon <Controlled_Terms/get_controlled_terms_for_taxon>`
Example:
>>> client.annotations.for_taxon(12345)
Args:
taxon_id: Taxon ID to get controlled terms for
Raises:
:py:exc:`.TaxonNotFound`: If an invalid ``taxon_id`` is specified
"""
response = get_controlled_terms_for_taxon(taxon_id, **params)
return ControlledTerm.from_json_list(response['results'])

Expand All @@ -47,10 +77,12 @@ def lookup(self, annotations: List[Annotation]) -> List[Annotation]:
if term:
annotation.controlled_attribute = term
annotation.controlled_value = term.get_value_by_id(annotation.controlled_value.id)
else:
logger.warning(
f'No controlled attribute found for ID: {annotation.controlled_attribute.id}'
)
return annotations

# TODO: Allow passing labels instead of IDs
@document_common_args
def create(
self,
controlled_attribute_id: int,
Expand All @@ -68,10 +100,9 @@ def create(
resource_type: Resource type, if something other than an observation
Example:
Add a 'Plant phenology: Flowering' annotation to an observation (via IDs):
Add a 'Plant phenology: Flowering' annotation to an observation:
>>> annotation = client.annotations.create(12, 13, 164609837)
>>> client.annotations.create(12, 13, 164609837)
Returns:
The newly created Annotation object
Expand Down
69 changes: 61 additions & 8 deletions pyinaturalist/controllers/place_controller.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from typing import Optional

from pyinaturalist.constants import MultiIntOrStr
from pyinaturalist.constants import IntOrStr, MultiIntOrStr
from pyinaturalist.controllers import BaseController
from pyinaturalist.converters import ensure_list
from pyinaturalist.docs import document_controller_params
from pyinaturalist.models import Place
from pyinaturalist.paginator import AutocompletePaginator, Paginator
from pyinaturalist.v1 import get_places_autocomplete, get_places_by_id, get_places_nearby
Expand All @@ -12,22 +11,47 @@
class PlaceController(BaseController):
""":fa:`location-dot` Controller for Place requests"""

def __call__(self, place_id, **kwargs) -> Optional[Place]:
"""Get a single place by ID"""
def __call__(self, place_id: IntOrStr, **kwargs) -> Optional[Place]:
"""Get a single place by ID
Example:
>>> client.places(67591)
Args:
place_ids: A single place ID
"""
return self.from_ids(place_id, **kwargs).one()

def from_ids(self, place_ids: MultiIntOrStr, **params) -> Paginator[Place]:
"""Get places by ID
.. rubric:: Notes
* API reference: :v1:`GET /places/{id} <Places/get_places_id>`
Example:
>>> client.places.from_ids([67591, 89191])
Args:
place_ids: One or more place IDs
"""
return self.client.paginate(
get_places_by_id, Place, place_id=ensure_list(place_ids), **params
)

@document_controller_params(get_places_autocomplete)
def autocomplete(self, q: Optional[str] = None, **params) -> Paginator[Place]:
"""Given a query string, get places with names starting with the search term
.. rubric:: Notes
* API reference: :v1:`GET /places/autocomplete <Places/get_places_autocomplete>`
Example:
>>> client.places.autocomplete('Irkutsk')
Args:
q: Search query
"""
return self.client.paginate(
get_places_autocomplete,
Place,
Expand All @@ -37,10 +61,39 @@ def autocomplete(self, q: Optional[str] = None, **params) -> Paginator[Place]:
**params,
)

@document_controller_params(get_places_nearby)
def nearby(
self, nelat: float, nelng: float, swlat: float, swlng: float, **params
self,
nelat: float,
nelng: float,
swlat: float,
swlng: float,
name: Optional[str] = None,
**params
) -> Paginator[Place]:
"""Search for places near a given location
.. rubric:: Notes
* API reference: :v1:`GET /places/nearby <get_places_nearby>`
Example:
>>> bounding_box = (150.0, -50.0, -149.999, -49.999)
>>> client.places.nearby(*bounding_box)
Args:
nelat: NE latitude of bounding box
nelng: NE longitude of bounding box
swlat: SW latitude of bounding box
swlng: SW longitude of bounding box
name: Name must match this value
"""
return self.client.paginate(
get_places_nearby, Place, nelat=nelat, nelng=nelng, swlat=swlat, swlng=swlng, **params
get_places_nearby,
Place,
nelat=nelat,
nelng=nelng,
swlat=swlat,
swlng=swlng,
name=name,
**params,
)
106 changes: 99 additions & 7 deletions pyinaturalist/controllers/project_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from pyinaturalist.constants import IntOrStr, ListResponse, MultiInt, MultiIntOrStr
from pyinaturalist.controllers import BaseController
from pyinaturalist.converters import ensure_list
from pyinaturalist.docs import document_controller_params
from pyinaturalist.docs import copy_doc_signature
from pyinaturalist.docs import templates as docs
from pyinaturalist.models import Project
from pyinaturalist.paginator import Paginator
from pyinaturalist.v1 import (
Expand All @@ -19,27 +20,63 @@
class ProjectController(BaseController):
""":fa:`users` Controller for Project requests"""

def __call__(self, project_id, **kwargs) -> Optional[Project]:
"""Get a single project by ID"""
def __call__(self, project_id: int, **kwargs) -> Optional[Project]:
"""Get a single project by ID
Example:
>>> client.projects(1234)
Args:
project_id: A single project ID
"""
return self.from_ids(project_id, **kwargs).one()

def from_ids(self, project_ids: MultiIntOrStr, **params) -> Paginator[Project]:
"""Get projects by ID
Example:
>>> client.projects.from_id([1234, 5678])
Args:
project_ids: One or more project IDs
"""
return self.client.paginate(get_projects_by_id, Project, project_id=project_ids, **params)

@document_controller_params(get_projects)
@copy_doc_signature(docs._projects_params)
def search(self, **params) -> Paginator[Project]:
"""Search projects
.. rubric:: Notes
* API reference: :v1:`GET /projects <Projects/get_projects>`
Example:
Search for projects about invasive species within 400km of Vancouver, BC:
>>> client.projects.search(
>>> q='invasive',
>>> lat=49.27,
>>> lng=-123.08,
>>> radius=400,
>>> order_by='distance',
>>> )
"""
return self.client.paginate(get_projects, Project, **params)

def add_observations(
self, project_id: int, observation_ids: MultiInt, **params
) -> ListResponse:
"""Add an observation to a project
.. rubric:: Notes
* :fa:`lock` :ref:`Requires authentication <auth>`
* API reference: :v1:`POST projects/{id}/add <Projects/post_projects_id_add>`
* API reference: :v1:`POST /project_observations <Project_Observations/post_project_observations>`
Example:
>>> client.projects.add_observations(24237, 1234)
Args:
project_id: ID of project to add onto
observation_ids: One or more observation IDs to add
Expand All @@ -56,18 +93,73 @@ def add_observations(
responses.append(response)
return responses

@document_controller_params(update_project)
@copy_doc_signature(docs._project_update_params)
def update(self, project_id: IntOrStr, **params) -> Project:
"""Update a project
.. rubric:: Notes
* :fa:`lock` :ref:`Requires authentication <auth>`
* Undocumented endpoint; may be subject to braking changes in the future
* ``admin_attributes`` and ``project_observation_rules_attributes`` each accept a list of dicts
in the formats shown below. These can be obtained from :py:func:`get_projects`, modified, and
then passed to this function::
{
"admin_attributes": [
{"id": int, "role": str, "user_id": int, "_destroy": bool},
],
"project_observation_rules_attributes": [
{"operator": str, "operand_type": str, "operand_id": int, "id": int, "_destroy": bool},
],
}
Example:
>>> client.projects.update(
... 'api-test-project',
... title='Test Project',
... description='This is a test project',
... prefers_rule_native=True,
... access_token=access_token,
... )
"""
response = self.client.request(update_project, project_id, auth=True, **params)
return Project.from_json(response)

@document_controller_params(add_project_users)
def add_users(self, project_id: IntOrStr, user_ids: MultiInt, **params) -> Project:
"""Add users to project observation rules
.. rubric:: Notes
* :fa:`lock` :ref:`Requires authentication <auth>`
* This only affects observation rules, **not** project membership
Example:
>>> client.projects.add_users(1234, [1234, 5678])
Args:
project_id: Either numeric project ID or URL slug
user_ids: One or more user IDs to add. Only accepts numeric IDs.
"""
response = self.client.request(add_project_users, project_id, user_ids, auth=True, **params)
return Project.from_json(response)

@document_controller_params(delete_project_users)
def delete_users(self, project_id: IntOrStr, user_ids: MultiInt, **params) -> Project:
"""Remove users from project observation rules
.. rubric:: Notes
* :fa:`lock` :ref:`Requires authentication <auth>`
* This only affects observation rules, **not** project membership
Example:
>>> client.projects.delete_users(1234, [1234, 5678])
Args:
project_id: Either numeric project ID or URL slug
user_ids: One or more user IDs to remove. Only accepts numeric IDs.
"""
response = self.client.request(
delete_project_users, project_id, user_ids, auth=True, **params
)
Expand Down
Loading

0 comments on commit 2d19325

Please sign in to comment.