Repository: osmlab/maproulette-python-client

Issue:
[Feature Request] Add support for the endpoint /challenge/{id}/addFileTasks
**Is your feature request related to a problem? Please describe.**
The endpoint [/challenge/{id}/addFileTasks](https://maproulette.org/docs/swagger-ui/index.html?url=/assets/swagger.json&docExpansion=none#/Challenge/addTasksToChallengeFromFile) is not implemented.
This endpoint is desired because of its parameter `removeUnmatched`, which allows to delete incomplete tasks.

Discussion:

Patch:
diff --git a/maproulette/api/challenge.py b/maproulette/api/challenge.py
index 18c6257..ca7f063 100644
--- a/maproulette/api/challenge.py
+++ b/maproulette/api/challenge.py
@@ -278,6 +278,32 @@ class Challenge(MapRouletteServer):
             body=data)
         return response
 
+    def add_file_tasks_to_challenge(self, data, challenge_id, line_by_line="true", remove_unmatched="false",
+                                    data_origin_date="", skip_snapshot="false"):
+        """Method to add tasks to an existing challenge with tasks as GeoJSON
+
+        :param data: a GeoJSON containing geometry of tasks to be added to a challenge
+        :param challenge_id: the ID corresponding to the challenge that tasks will be added to
+        :param line_by_line: whether or not the provided data is in line-by-line GeoJSON format
+        :param remove_unmatched: whether or not the JSON provided includes seperate GeoJSON on each line
+        :param data_origin_date: the date the data was sourced on
+        :param skip_snapshot: whether or not to skip recording a snapshot before proceeding
+        :returns: the API response from the PUT request
+        """
+        query_params = {
+            "lineByLine": str(line_by_line),
+            "removeUnmatched": str(remove_unmatched),
+            "dataOriginDate": str(data_origin_date),
+            "skipSnapshot": str(skip_snapshot)
+        }
+        body = {'json': ('data', data)} # server only accepts dict with key 'json'
+
+        response = self.put_file(
+            endpoint=f"/challenge/{challenge_id}/addFileTasks",
+            body=body,
+            params=query_params)
+        return response
+
     def delete_challenge(self, challenge_id, immediate="false"):
         """Method to delete a challenge by using the corresponding challenge ID
 
diff --git a/maproulette/api/maproulette_server.py b/maproulette/api/maproulette_server.py
index 6c035a6..b8e4974 100644
--- a/maproulette/api/maproulette_server.py
+++ b/maproulette/api/maproulette_server.py
@@ -180,6 +180,51 @@ class MapRouletteServer:
                 "status": response.status_code
             }
 
+    def put_file(self, endpoint, body=None, params=None):
+        """Method that completes a multipart PUT request to the MapRoulette API
+
+        :param endpoint: the server endpoint to use for the PUT request
+        :param body: the body of the request (optional)
+        :param params: the parameters that pertain to the request (optional)
+        :returns: a dictionary containing the API response status code as well as the decoded JSON response if decoding
+            was successful. If not, the response text is returned.
+        """
+        response = self.session.put(
+            self.url + endpoint,
+            params=params,
+            files=body
+        )
+        try:
+            response.raise_for_status()
+        except requests.exceptions.HTTPError as e:
+            if e.response.status_code == 400:
+                raise InvalidJsonError(
+                    message=self.parse_response_message(e.response),
+                    status=e.response.status_code
+                ) from None
+            elif e.response.status_code == 401:
+                raise UnauthorizedError(
+                    message=self.parse_response_message(e.response),
+                    status=e.response.status_code
+                ) from None
+            else:
+                raise HttpError(
+                    message=self.parse_response_message(e.response),
+                    status=e.response.status_code
+                ) from None
+        except (requests.ConnectionError, requests.Timeout) as e:
+            raise ConnectionUnavailableError(e) from None
+        try:
+            return {
+                "data": response.json(),
+                "status": response.status_code
+            }
+        except ValueError:
+            return {
+                "data": response.text,
+                "status": response.status_code
+            }
+
     def delete(self, endpoint, params=None):
         """Method that completes a DELETE request to the MapRoulette API
 


Test patch:
diff --git a/tests/test_challenge_api.py b/tests/test_challenge_api.py
index 17b3a97..9d4acf7 100644
--- a/tests/test_challenge_api.py
+++ b/tests/test_challenge_api.py
@@ -57,6 +57,22 @@ class TestChallengeAPI(unittest.TestCase):
             json=test_geojson,
             params=None)
 
+    @patch('maproulette.api.maproulette_server.requests.Session.put')
+    def test_add_file_tasks_to_challenge(self, mock_request, api_instance=api):
+        test_challenge_id = '12978'
+        data = {'json': ('data', test_geojson)}
+        query_params = {
+            "lineByLine": "true",
+            "removeUnmatched": "false",
+            "dataOriginDate": "",
+            "skipSnapshot": "false"
+        }
+        api_instance.add_file_tasks_to_challenge(test_geojson, test_challenge_id)
+        mock_request.assert_called_once_with(
+            f'{self.url}/challenge/12978/addFileTasks',
+            files=data,
+            params=query_params)
+
     @patch('maproulette.api.maproulette_server.requests.Session.post')
     def test_create_virtual_challenge(self, mock_request, api_instance=api):
         # TODO: add model for virtual challenge to aid in posting