Repository: niconoe/pyinaturalist
Pull request #349 (2022-03-10 04:11:22)

Fix a variety of project endpoint issues, and support adding users to project observation rules
Follow-up from #347

Add separate `add_project_users()` and `remove_project_users()` functions

codecov-commenter: # [Codecov](https://codecov.io/gh/niconoe/pyinaturalist/pull/349?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9) Report
> Merging [#349](https://codecov.io/gh/niconoe/pyinaturalist/pull/349?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9) (7d7d643) into [main](https://codecov.io/gh/niconoe/pyinaturalist/commit/1f4b250655f2a427664398396a62f0804f9da7ba?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9) (1f4b250) will **increase** coverage by `0.06%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/niconoe/pyinaturalist/pull/349/graphs/tree.svg?width=650&height=150&src=pr&token=EY3KFD2IJD&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9)](https://codecov.io/gh/niconoe/pyinaturalist/pull/349?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9)

```diff
@@            Coverage Diff             @@
##             main     #349      +/-   ##
==========================================
+ Coverage   94.09%   94.15%   +0.06%     
==========================================
  Files          55       55              
  Lines        2541     2551      +10     
  Branches      326      326              
==========================================
+ Hits         2391     2402      +11     
  Misses        111      111              
+ Partials       39       38       -1     
```


| [Impacted Files](https://codecov.io/gh/niconoe/pyinaturalist/pull/349?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9) | Coverage Δ | |
|---|---|---|
| [pyinaturalist/auth.py](https://codecov.io/gh/niconoe/pyinaturalist/pull/349/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9#diff-cHlpbmF0dXJhbGlzdC9hdXRoLnB5) | `100.00% <ø> (ø)` | |
| [pyinaturalist/docs/templates.py](https://codecov.io/gh/niconoe/pyinaturalist/pull/349/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9#diff-cHlpbmF0dXJhbGlzdC9kb2NzL3RlbXBsYXRlcy5weQ==) | `100.00% <ø> (ø)` | |
| [pyinaturalist/v1/\_\_init\_\_.py](https://codecov.io/gh/niconoe/pyinaturalist/pull/349/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9#diff-cHlpbmF0dXJhbGlzdC92MS9fX2luaXRfXy5weQ==) | `100.00% <ø> (ø)` | |
| [pyinaturalist/converters.py](https://codecov.io/gh/niconoe/pyinaturalist/pull/349/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9#diff-cHlpbmF0dXJhbGlzdC9jb252ZXJ0ZXJzLnB5) | `95.87% <100.00%> (ø)` | |
| [pyinaturalist/request\_params.py](https://codecov.io/gh/niconoe/pyinaturalist/pull/349/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9#diff-cHlpbmF0dXJhbGlzdC9yZXF1ZXN0X3BhcmFtcy5weQ==) | `97.90% <100.00%> (+0.69%)` | :arrow_up: |
| [pyinaturalist/session.py](https://codecov.io/gh/niconoe/pyinaturalist/pull/349/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9#diff-cHlpbmF0dXJhbGlzdC9zZXNzaW9uLnB5) | `97.61% <100.00%> (ø)` | |
| [pyinaturalist/v1/projects.py](https://codecov.io/gh/niconoe/pyinaturalist/pull/349/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9#diff-cHlpbmF0dXJhbGlzdC92MS9wcm9qZWN0cy5weQ==) | `96.22% <100.00%> (+0.87%)` | :arrow_up: |
| [pyinaturalist/v1/users.py](https://codecov.io/gh/niconoe/pyinaturalist/pull/349/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9#diff-cHlpbmF0dXJhbGlzdC92MS91c2Vycy5weQ==) | `89.47% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/niconoe/pyinaturalist/pull/349?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/niconoe/pyinaturalist/pull/349?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9). Last update [1f4b250...7d7d643](https://codecov.io/gh/niconoe/pyinaturalist/pull/349?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+No%C3%A9).


Patch:
diff --git a/HISTORY.md b/HISTORY.md
index 8e6e7b0..c4aa830 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -4,10 +4,14 @@
 [See all Issues & PRs for 0.17](https://github.com/niconoe/pyinaturalist/milestone/8?closed=1)
 
 ### New Endpoints
-* Added new **Project** endpoint: `update_project()`
+* Added new **Project** endpoint and helper functions:
+  * `update_project()`
+  * `add_project_users()`
+  * `delete_project_users()`
 
 ### Modified Endpoints
-* Allowed string values for `project_id` in `get_projects_by_id()` (e.g., a URL slug instead of a numeric ID)
+* Updated `get_projects_by_id()` to allow string values (URL slugs) for `project_id`
+* Updated `get_user_by_id()` to allow string values (usernames) for `user_id`
 
 ### Other Changes
 * Dropped support for python 3.6
diff --git a/docs/endpoints.md b/docs/endpoints.md
index c545c40..dde49ec 100644
--- a/docs/endpoints.md
+++ b/docs/endpoints.md
@@ -48,6 +48,7 @@ For all available endpoints, see: <http://api.inaturalist.org/v1/docs/>
 | GET    | /posts                             | {py:func}`.get_posts`
 | GET    | /projects                          | {py:func}`.get_projects`
 | GET    | /projects/{id}                     | {py:func}`.get_projects_by_id`
+| PUT    | /projects/{id}                     | {py:func}`.update_project`
 | GET    | /projects/{id}/members             |
 | GET    | /projects/{id}/subscriptions       |
 | POST   | /projects/{id}/add                 | {py:func}`.add_project_observation`
diff --git a/pyinaturalist/auth.py b/pyinaturalist/auth.py
index 95a9f42..6bad3d4 100644
--- a/pyinaturalist/auth.py
+++ b/pyinaturalist/auth.py
@@ -12,6 +12,7 @@ from pyinaturalist.session import get_local_session
 logger = getLogger(__name__)
 
 
+# TODO: Don't fetch OAuth token if JWT is cached
 def get_access_token(
     username: str = None,
     password: str = None,
diff --git a/pyinaturalist/converters.py b/pyinaturalist/converters.py
index 789e51c..3df886d 100644
--- a/pyinaturalist/converters.py
+++ b/pyinaturalist/converters.py
@@ -90,7 +90,7 @@ def convert_histogram(response: JsonResponse) -> HistogramResponse:
 
 def convert_csv_list(obj: Any) -> str:
     """Convert list parameters into an API-compatible (comma-delimited) string"""
-    if isinstance(obj, list):
+    if isinstance(obj, list) or isinstance(obj, tuple):
         return ','.join(map(str, obj))
     else:
         return obj
diff --git a/pyinaturalist/docs/templates.py b/pyinaturalist/docs/templates.py
index 61107de..bd7db97 100644
--- a/pyinaturalist/docs/templates.py
+++ b/pyinaturalist/docs/templates.py
@@ -512,7 +512,6 @@ def _project_update_params(
     user_id: int = None,
     admin_attributes: List[Dict] = None,
     project_observation_rules_attributes: List[Dict] = None,
-    remove_users: MultiInt = None,
 ):
     """Args:
     project_id: Numeric project ID or slug (the short name shown in project URL)
@@ -541,7 +540,6 @@ def _project_update_params(
     user_id: User ID of project owner
     admin_attributes: Admin users and their roles
     project_observation_rules_attributes: Rules for observations to include in the project
-    remove_users: One or more user IDs to remove from project observation rules.
     """
 
 
diff --git a/pyinaturalist/request_params.py b/pyinaturalist/request_params.py
index 46d0a04..3c2c6e3 100644
--- a/pyinaturalist/request_params.py
+++ b/pyinaturalist/request_params.py
@@ -34,6 +34,7 @@ from pyinaturalist.converters import (
 # Common parameters that can be passed to all API functions, and notes on where they are used
 COMMON_PARAMS = [
     'access_token',  # Used in session.prepare_request()
+    'allow_str_ids',  # Used in session.prepare_request()
     'dry_run',  # Used in session.request()
     'expire_after',  # Passed to requests_cache.CachedSession.send()
     'limit',  # Used in paginator.Paginator
@@ -62,15 +63,17 @@ def preprocess_request_body(body: Optional[RequestParams]) -> Optional[RequestPa
     """Perform type conversions, sanity checks, etc. on JSON-formatted request body"""
     if not body:
         return None
-    for resource_type in ['project', 'observation']:
-        if resource_type in body:
-            body[resource_type] = preprocess_request_params(body[resource_type])
+    for resource in ['project', 'observation']:
+        if resource in body:
+            body[resource] = preprocess_request_params(body[resource], convert_lists=False)
     else:
-        body = preprocess_request_params(body)
+        body = preprocess_request_params(body, convert_lists=False)
     return body
 
 
-def preprocess_request_params(params: Optional[RequestParams]) -> RequestParams:
+def preprocess_request_params(
+    params: Optional[RequestParams], convert_lists: bool = True
+) -> RequestParams:
     """Perform type conversions, sanity checks, etc. on request parameters"""
     if not params:
         return {}
@@ -79,7 +82,8 @@ def preprocess_request_params(params: Optional[RequestParams]) -> RequestParams:
     params = convert_pagination_params(params)
     params = convert_bool_params(params)
     params = convert_datetime_params(params)
-    params = convert_list_params(params)
+    if convert_lists:
+        params = convert_list_params(params)
     params = strip_empty_values(params)
     params, _ = split_common_params(params)
     return params
@@ -93,10 +97,11 @@ def convert_bool_params(params: RequestParams) -> RequestParams:
     return params
 
 
-def convert_url_ids(url: str, ids: MultiInt = None, only_int_ids: bool = True) -> str:
+def convert_url_ids(url: str, ids: MultiInt = None, allow_str_ids: bool = False) -> str:
     """If one or more resources are requested by ID, validate and update the request URL accordingly"""
     if ids:
-        url = url.rstrip('/') + '/' + validate_ids(ids, only_int_ids=only_int_ids)
+        str_ids = str(convert_csv_list(ids)) if allow_str_ids else validate_ids(ids)
+        url = url.rstrip('/') + '/' + str_ids
     return url
 
 
@@ -223,18 +228,16 @@ def split_common_params(params: RequestParams) -> Tuple[RequestParams, RequestPa
     return params, kwargs
 
 
-def validate_ids(ids: Any, only_int_ids: bool = True) -> str:
+def validate_ids(ids: Any) -> str:
     """Ensure ID(s) are all valid, and convert to a comma-delimited string if there are multiple
 
     Raises:
         :py:exc:`ValueError` if any values are not valid integers
     """
-    ids = ensure_list(ids, convert_csv=True)
-    if only_int_ids:
-        try:
-            ids = [int(value) for value in ids]
-        except (TypeError, ValueError):
-            raise ValueError(f'Invalid ID(s): {ids}; must specify integers only')
+    try:
+        ids = [int(value) for value in ensure_list(ids, convert_csv=True)]
+    except (TypeError, ValueError):
+        raise ValueError(f'Invalid ID(s): {ids}; must specify integers only')
     return convert_csv_list(ids)
 
 
diff --git a/pyinaturalist/session.py b/pyinaturalist/session.py
index 5a3f4ae..629eba0 100644
--- a/pyinaturalist/session.py
+++ b/pyinaturalist/session.py
@@ -159,6 +159,7 @@ def request(
     method: str,
     url: str,
     access_token: str = None,
+    allow_str_ids: bool = False,
     dry_run: bool = False,
     expire_after: ExpirationTime = None,
     files: FileOrPath = None,
@@ -196,6 +197,7 @@ def request(
         method=method,
         url=url,
         access_token=access_token,
+        allow_str_ids=allow_str_ids,
         files=files,
         headers=headers,
         ids=ids,
@@ -229,13 +231,13 @@ def prepare_request(
     ids: MultiInt = None,
     json: Dict = None,
     params: RequestParams = None,
-    only_int_ids: bool = True,
+    allow_str_ids: bool = False,
     **kwargs,
 ) -> PreparedRequest:
     """Translate ``pyinaturalist``-specific options into standard request arguments"""
     # Prepare request params and URL
     params = preprocess_request_params(params)
-    url = convert_url_ids(url, ids, only_int_ids)
+    url = convert_url_ids(url, ids, allow_str_ids)
 
     # Set auth header
     headers = headers or {}
diff --git a/pyinaturalist/v1/__init__.py b/pyinaturalist/v1/__init__.py
index e86740d..f577fc0 100644
--- a/pyinaturalist/v1/__init__.py
+++ b/pyinaturalist/v1/__init__.py
@@ -53,7 +53,9 @@ from pyinaturalist.v1.places import get_places_autocomplete, get_places_by_id, g
 from pyinaturalist.v1.posts import get_posts
 from pyinaturalist.v1.projects import (
     add_project_observation,
+    add_project_users,
     delete_project_observation,
+    delete_project_users,
     get_projects,
     get_projects_by_id,
     update_project,
diff --git a/pyinaturalist/v1/projects.py b/pyinaturalist/v1/projects.py
index 6586339..b0d2a1c 100644
--- a/pyinaturalist/v1/projects.py
+++ b/pyinaturalist/v1/projects.py
@@ -1,4 +1,4 @@
-from pyinaturalist.constants import PROJECT_ORDER_BY_PROPERTIES, JsonResponse, MultiInt
+from pyinaturalist.constants import PROJECT_ORDER_BY_PROPERTIES, IntOrStr, JsonResponse, MultiInt
 from pyinaturalist.converters import convert_all_coordinates, convert_all_timestamps, ensure_list
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
@@ -80,7 +80,7 @@ def get_projects_by_id(project_id: MultiInt, rule_details: bool = None, **params
         Response dict containing project records
     """
     response = get_v1(
-        'projects', ids=project_id, rule_details=rule_details, only_int_ids=False, **params
+        'projects', rule_details=rule_details, ids=project_id, allow_str_ids=True, **params
     )
 
     projects = response.json()
@@ -122,6 +122,27 @@ def add_project_observation(
     return response.json()
 
 
+def add_project_users(project_id: IntOrStr, user_ids: MultiInt, **params):
+    """Add users to project observation rules
+
+    .. rubric:: Notes
+
+    * :fa:`lock` :ref:`Requires authentication <auth>`
+    * This only affects observation rules, **not** project membership
+
+    Args:
+        project_id: Either numeric project ID or URL slug
+        user_ids: One or more user IDs to add. Only accepts numeric IDs.
+    """
+    rules = _get_project_rules(project_id)
+    for user_id in ensure_list(user_ids):
+        rules.append(
+            {'operand_id': user_id, 'operand_type': 'User', 'operator': 'observed_by_user?'}
+        )
+
+    return update_project(project_id, project_observation_rules_attributes=rules, **params)
+
+
 # TODO: This may not yet be working as intended
 @document_request_params(docs._project_observation_params)
 def delete_project_observation(
@@ -155,9 +176,30 @@ def delete_project_observation(
     # )
 
 
+def delete_project_users(project_id: IntOrStr, user_ids: MultiInt, **params):
+    """Remove users from project observation rules
+
+    .. rubric:: Notes
+
+    * :fa:`lock` :ref:`Requires authentication <auth>`
+    * This only affects observation rules, **not** project membership
+
+    Args:
+        project_id: Either numeric project ID or URL slug
+        user_ids: One or more user IDs to remove. Only accepts numeric IDs.
+    """
+    rules = _get_project_rules(project_id)
+    str_user_ids = [str(user_id) for user_id in ensure_list(user_ids)]
+    for rule in rules:
+        if rule['operand_type'] == 'User' and str(rule['operand_id']) in str_user_ids:
+            rule['_destroy'] = True
+
+    return update_project(project_id, project_observation_rules_attributes=rules, **params)
+
+
 # TODO: Support image uploads for `cover` and `icon`
 @document_request_params(docs._project_update_params)
-def update_project(project_id, **params):
+def update_project(project_id: IntOrStr, **params):
     """Update a project
 
     .. rubric:: Notes
@@ -192,19 +234,15 @@ def update_project(project_id, **params):
     params, kwargs = split_common_params(params)
     kwargs['timeout'] = kwargs.get('timeout') or 60  # This endpoint can be a bit slow
 
-    # Remove any specified users from project observation rules by setting _destroy flag
-    remove_users = [str(user_id) for user_id in ensure_list(params.get('remove_users'))]
-    if remove_users:
-        project = get_projects_by_id(project_id)['results'][0]
-        rules = project.get('project_observation_rules', [])
-        for rule in rules:
-            if rule['operand_type'] == 'User' and str(rule['id']) in remove_users:
-                rule['_destroy'] = True
-        params['project_observation_rules_attributes'] = rules
-
     response = put_v1(
         f'projects/{project_id}',
         json={'project': params},
         **kwargs,
     )
     return response.json()
+
+
+# TODO: bypass cache for this call?
+def _get_project_rules(project_id):
+    project = get_projects_by_id(project_id)['results'][0]
+    return project.get('project_observation_rules', [])
diff --git a/pyinaturalist/v1/users.py b/pyinaturalist/v1/users.py
index a36c787..4ce9810 100644
--- a/pyinaturalist/v1/users.py
+++ b/pyinaturalist/v1/users.py
@@ -1,6 +1,6 @@
 from logging import getLogger
 
-from pyinaturalist.constants import JsonResponse
+from pyinaturalist.constants import IntOrStr, JsonResponse
 from pyinaturalist.converters import convert_all_timestamps, convert_generic_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
@@ -9,7 +9,7 @@ from pyinaturalist.v1 import get_v1
 logger = getLogger(__name__)
 
 
-def get_user_by_id(user_id: int, **params) -> JsonResponse:
+def get_user_by_id(user_id: IntOrStr, **params) -> JsonResponse:
     """Get a user by ID
 
     .. rubric:: Notes
@@ -32,7 +32,7 @@ def get_user_by_id(user_id: int, **params) -> JsonResponse:
     Returns:
         Response dict containing user record
     """
-    response = get_v1('users', ids=user_id, **params)
+    response = get_v1('users', ids=user_id, allow_str_ids=True, **params)
     results = response.json()['results']
     if not results:
         return {}


Test patch:
diff --git a/test/controllers/test_project_controller.py b/test/controllers/test_project_controller.py
index 79f7f5d..bc74f51 100644
--- a/test/controllers/test_project_controller.py
+++ b/test/controllers/test_project_controller.py
@@ -25,7 +25,7 @@ def test_from_id(requests_mock):
     assert project.id == project_id
     assert project.location == (48.777404, -122.306929)
     assert project.project_observation_rules == project.obs_rules
-    assert project.obs_rules[0]['id'] == 1234
+    assert project.obs_rules[0]['id'] == 19344
     assert project.search_parameters[0]['field'] == 'quality_grade'
     assert project.user_ids[-1] == 3387092 and len(project.user_ids) == 33
 
diff --git a/test/sample_data/get_projects.json b/test/sample_data/get_projects.json
index 4f0d5c1..0842257 100644
--- a/test/sample_data/get_projects.json
+++ b/test/sample_data/get_projects.json
@@ -11,8 +11,8 @@
             "title": "PNW Invasive Plant EDDR",
             "banner_color": "#4551b7",
             "project_observation_rules": [
-                {"operand_id": 555472, "operand_type": "User", "operator":"observed_by_user?", "id": 1234},
-                {"operand_id": 555472, "operand_type": "User", "operator":"observed_by_user?", "id": 5678}
+                {"operand_id": 1234, "operand_type": "User", "operator":"observed_by_user?", "id": 19344},
+                {"operand_id": 5678, "operand_type": "User", "operator":"observed_by_user?", "id": 19344}
             ],
             "site_features": [
                 {"noteworthy": true, "site_id": 1, "featured_at": "2021-03-16T16:41:32.477Z"}
diff --git a/test/test_request_params.py b/test/test_request_params.py
index b4a02f5..edcacf2 100644
--- a/test/test_request_params.py
+++ b/test/test_request_params.py
@@ -182,9 +182,9 @@ def test_preprocess_request_params(mock_bool, mock_datetime, mock_list, mock_str
 @patch('pyinaturalist.request_params.convert_datetime_params')
 @patch('pyinaturalist.request_params.convert_list_params')
 @patch('pyinaturalist.request_params.strip_empty_values')
-def test_preprocess_request_body(mock_bool, mock_datetime, mock_list, mock_strip):
+def test_preprocess_request_body(mock_bool, mock_list, mock_datetime, mock_strip):
     preprocess_request_body({'id': 1})
-    assert all([mock_bool.called, mock_datetime.called, mock_list.called, mock_strip.called])
+    assert all([mock_bool.called, mock_datetime.called, not mock_list.called, mock_strip.called])
 
 
 def test_validate_multiple_choice_param():
diff --git a/test/v1/test_projects.py b/test/v1/test_projects.py
index a90b8c4..83a5701 100644
--- a/test/v1/test_projects.py
+++ b/test/v1/test_projects.py
@@ -6,7 +6,9 @@ from dateutil.tz import tzutc
 from pyinaturalist.constants import API_V1_BASE_URL
 from pyinaturalist.v1 import (
     add_project_observation,
+    add_project_users,
     delete_project_observation,
+    delete_project_users,
     get_projects,
     get_projects_by_id,
     update_project,
@@ -61,6 +63,20 @@ def test_add_project_observation(requests_mock):
     assert response['id'] == 54986584
 
 
+@patch('pyinaturalist.v1.projects.put_v1')
+def test_add_project_users(mock_put, requests_mock):
+    requests_mock.get(
+        f'{API_V1_BASE_URL}/projects/1234',
+        json=SAMPLE_DATA['get_projects'],
+        status_code=200,
+    )
+    add_project_users(1234, [9999])
+
+    project_params = mock_put.call_args[1]['json']['project']
+    rules = project_params['project_observation_rules_attributes']
+    assert rules[-1]['operand_id'] == 9999
+
+
 def test_delete_project_observation(requests_mock):
     requests_mock.delete(
         f'{API_V1_BASE_URL}/projects/1234/remove',
@@ -73,26 +89,26 @@ def test_delete_project_observation(requests_mock):
 
 
 @patch('pyinaturalist.v1.projects.put_v1')
-def test_update_project(mock_put):
-    update_project(1234, title='New Title', description='New Description')
-
-    request_args = mock_put.call_args[1]
-    project_params = request_args['json']['project']
-    assert request_args['timeout'] == 60
-    assert project_params['title'] == 'New Title'
-    assert project_params['description'] == 'New Description'
-
-
-@patch('pyinaturalist.v1.projects.put_v1')
-def test_update_project__remove_users(mock_put, requests_mock):
+def test_delete_project_users(mock_put, requests_mock):
     requests_mock.get(
         f'{API_V1_BASE_URL}/projects/1234',
         json=SAMPLE_DATA['get_projects'],
         status_code=200,
     )
-    update_project(1234, remove_users=[5678])
+    delete_project_users(1234, [5678])
 
     project_params = mock_put.call_args[1]['json']['project']
     rules = project_params['project_observation_rules_attributes']
-    assert rules[0]['id'] == 1234 and not rules[0].get('_destroy')
-    assert rules[1]['id'] == 5678 and rules[1]['_destroy'] is True
+    assert rules[0]['operand_id'] == 1234 and not rules[0].get('_destroy')
+    assert rules[1]['operand_id'] == 5678 and rules[1]['_destroy'] is True
+
+
+@patch('pyinaturalist.v1.projects.put_v1')
+def test_update_project(mock_put):
+    update_project(1234, title='New Title', description='New Description')
+
+    request_args = mock_put.call_args[1]
+    project_params = request_args['json']['project']
+    assert request_args['timeout'] == 60
+    assert project_params['title'] == 'New Title'
+    assert project_params['description'] == 'New Description'