Repository: rogers-obrien-rad/ProPyCore (language: python)

Issue:
[BUG] `get_budget_details()` should be a POST request
### Current Behavior
<!-- A clear and concise description of what the bug is. -->
Currently, the `get_budget_details()` function uses a GET request when the [documentation](https://developers.procore.com/reference/rest/v1/budget-details?version=1.0#list-budget-details) says the request should be a POST.

### Expected behavior 
<!-- A clear and concise description of what you expected to happen. -->
We need to use the `post_request()` method instead. 

### Screenshots
<!-- If applicable, add screenshots to help explain your problem. -->
```python
def get_budget_details(self, company_id, project_id, budget_view_id):
        """
        Return a list of all rows from the Budget Detail Report for a Project and Budget View.
        
        Parameters
        ----------
        company_id : int
            unique identifier for the company
        project_id : int
            unique identifier for the project
        budget_view_id : int
            unique identifier for the budget view

        Returns
        -------
        """
        params = {
            "project_id": project_id
        }

        headers = {
            "Procore-Company-Id": f"{company_id}"
        }

        details = self.get_request(
            api_url=f"{self.endpoint}/{budget_view_id}/budget_details",
            additional_headers=headers,
            params=params
        )

        return details
```

### Additional context
<!-- Add any other context about the problem here. -->


Discussion / hints:

Fix patch:
diff --git a/ProPyCore/__init__.py b/ProPyCore/__init__.py
new file mode 100644
index 0000000..45dd294
--- /dev/null
+++ b/ProPyCore/__init__.py
@@ -0,0 +1,4 @@
+from .access import *
+from .utils import *
+from .exceptions import *
+from .procore import Procore
diff --git a/ProPyCore/access/__init__.py b/ProPyCore/access/__init__.py
new file mode 100644
index 0000000..c8c01b0
--- /dev/null
+++ b/ProPyCore/access/__init__.py
@@ -0,0 +1,10 @@
+from .base import *
+from .budgets import *
+from .companies import *
+from .directory import *
+from .documents import *
+from .generic_tools import *
+from .projects import *
+from .rfis import *
+from .submittals import *
+from .tasks import *
diff --git a/ProPyCore/access/base.py b/ProPyCore/access/base.py
new file mode 100644
index 0000000..4adc421
--- /dev/null
+++ b/ProPyCore/access/base.py
@@ -0,0 +1,212 @@
+import urllib
+import requests
+
+from ..exceptions import raise_exception
+
+class Base:
+    """
+    Base class for Procore API access
+    """
+    
+    def __init__(self, access_token, server_url) -> None:
+        """
+        Initializes important API access parameters
+
+        Creates
+        -------
+        __access_token : str
+            token to access Procore resources
+        __server_url : str
+            base url to send GET/POST requests
+        """
+        self.__access_token = access_token
+        self.__server_url = server_url
+
+    def get_request(self, api_url, additional_headers=None, params=None):
+        """
+        Create a HTTP Get request
+
+        Parameters
+        ----------
+        api_url : str
+            endpoint for the specific API call
+        additional_headers : dict, default None
+            additional headers beyond Authorization
+        params : dict, default None
+            GET parameters to parse
+
+        Returns
+        -------
+        response : dict
+            GET response in json
+        """
+        if params is None:
+            url = self.__server_url + api_url
+        else:
+            url = self.__server_url + api_url + "?" + urllib.parse.urlencode(params)
+
+        headers = {"Authorization": f"Bearer {self.__access_token}"}
+        if additional_headers is not None:
+            for key, value in additional_headers.items():
+                headers[key] = value
+
+        response = requests.get(url, headers=headers)
+        
+        if response.ok:
+            return response.json()
+        else:
+            raise_exception(response)
+
+    def post_request(self, api_url, additional_headers=None, params=None, data=None, files=None):
+        """
+        Create a HTTP Get request
+
+        Parameters
+        ----------
+        api_url : str
+            endpoint for the specific API call
+        additional_headers : dict, default None
+            additional headers beyond Authorization
+        data : dict, default None
+            POST data to send
+        files : list of tuple, default None
+            open files to send to Procore
+
+        Returns
+        -------
+        response : HTTP response object
+            GET response details in json
+        """
+        # Get URL
+        if params is None:
+            url = self.__server_url + api_url
+        else:
+            url = self.__server_url + api_url + "?" + urllib.parse.urlencode(params)
+
+        # Get Headers
+        headers = {"Authorization": f"Bearer {self.__access_token}"}
+        if additional_headers is not None:
+            for key, value in additional_headers.items():
+                headers[key] = value
+
+        # Make the request with file if necessary
+        if files is None:
+            response = requests.post(
+                url,
+                headers=headers,
+                json=data # use json for folder creation
+            )
+        else:
+            response = requests.post(
+                url,
+                headers=headers,
+                data=data, # use data for file creation
+                files=files
+            )
+        
+        if response.ok:
+            return response.json()
+        else:
+            raise_exception(response)
+
+    def patch_request(self, api_url, additional_headers=None, params=None, data=None, files=False):
+        """
+        Create a HTTP PATCH request
+
+        Parameters
+        ----------
+        api_url : str
+            endpoint for the specific API call
+        additional_headers : dict, default None
+            additional headers beyond Authorization
+        params : dict, default None
+            PATCH parameters to parse
+        data : dict, default None
+            POST data to send
+        files : dict or boolean, default False
+            False - updating folder so use json request
+            True - updating file, but no file to include
+            dict - updating file with new document
+
+        Returns
+        -------
+        response : HTTP response object
+            PATCH response details in json
+        """
+        # Get URL
+        if params is None:
+            url = self.__server_url + api_url
+        else:
+            url = self.__server_url + api_url + "?" + urllib.parse.urlencode(params)
+
+        # Get Headers
+        headers = {"Authorization": f"Bearer {self.__access_token}"}
+        if additional_headers is not None:
+            for key, value in additional_headers.items():
+                headers[key] = value
+        
+        if files is False:
+            response = requests.patch(
+                url,
+                headers=headers,
+                json=data # json for folder update
+            )
+        elif files is True:
+            response = requests.patch(
+                url,
+                headers=headers,
+                data=data, # data for file update
+            )
+        else:
+            response = requests.patch(
+                url,
+                headers=headers,
+                data=data, # data for file update
+                files=files
+            )
+
+        if response.ok:
+            return response.json()
+        else:
+            raise_exception(response)
+    
+    def delete_request(self, api_url, additional_headers=None, params=None):
+        """
+        Execute a HTTP DELETE request
+
+        Parameters
+        ----------
+        api_url : str
+            endpoint for the specific API call
+        additional_headers : dict, default None
+            additional headers beyond Authorization
+        params : dict, default None
+            DELETE parameters to parse
+
+        Returns
+        -------
+        response : HTTP response object
+            DELETE response details in json
+        """
+        # Get URL
+        if params is None:
+            url = self.__server_url + api_url
+        else:
+            url = self.__server_url + api_url + "?" + urllib.parse.urlencode(params)
+
+        # Get Headers
+        headers = {"Authorization": f"Bearer {self.__access_token}"}
+        if additional_headers is not None:
+            for key, value in additional_headers.items():
+                headers[key] = value
+
+        # DELETE request
+        response = requests.delete(
+            url=url,
+            headers=headers,
+        )
+
+        if response.ok:
+            return {"status_code":response.status_code}
+        else:
+            raise_exception(response)
\ No newline at end of file
diff --git a/ProPyCore/access/budgets.py b/ProPyCore/access/budgets.py
new file mode 100644
index 0000000..afb8852
--- /dev/null
+++ b/ProPyCore/access/budgets.py
@@ -0,0 +1,254 @@
+from .base import Base
+from ..exceptions import NotFoundItemError
+
+class Budgets(Base):
+    """
+    Access and working with RFIs in a given project
+    """
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+        
+        self.endpoint = "/rest/v1.0/budget_views"
+
+    def get_views(self, company_id, project_id, page=1, per_page=100):
+        """
+        Lists the budget views: https://developers.procore.com/reference/rest/v1/budget-views?version=1.0#list-budget-views
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        page : int, default 1
+            page number
+        per_page : int, default 100
+            number of companies to include
+
+        Returns
+        -------
+        views : list of dict
+            views and their corresponding meta data
+        """
+        params = {
+            "project_id": project_id,
+            "page": page,
+            "per_page": per_page
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        views = self.get_request(
+            api_url=f"{self.endpoint}",
+            additional_headers=headers,
+            params=params
+        )
+
+        return views
+    
+    def find_view(self, company_id, project_id, identifier):
+        """
+        Finds specified budget view by looping through the results from get_views()
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        identifier : int or str
+            identifier for view which can be an id (int) or name (str)
+
+        Returns
+        -------
+        view : dict
+            budget view data
+
+        Raises
+        ------
+        NotFoundItemError
+        """
+        if isinstance(identifier, int):
+            key = "id"
+        else:
+            key = "name"
+
+        for view in self.get_views(company_id=company_id, project_id=project_id):
+            if view[key] == identifier:
+                return view
+
+        raise NotFoundItemError(f"Could not find view {identifier}")
+    
+    def get_budget_columns(self, company_id, project_id, budget_view_id):
+        """
+        Lists the columns in a budget view: https://developers.procore.com/reference/rest/v1/budget-detail-columns?version=1.0
+        This endpoint only returns the standard/source column meta data. It does not return any calculated columns.
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        budget_view_id : int
+            unique identifier for the budget view
+
+        Returns
+        -------
+        columns : list of dict
+            columns in a budget view
+        """
+        params = {
+            "project_id": project_id
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        columns = self.get_request(
+            api_url=f"{self.endpoint}/{budget_view_id}/budget_detail_columns",
+            additional_headers=headers,
+            params=params
+        )
+
+        return columns
+    
+    def find_budget_column(self, company_id, project_id, budget_view_id, identifier):
+        """
+        Finds specified budget view column by looping through the results from get_budget_columns()
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        budget_view_id : int
+            unique identifier for the budget view
+        identifier : int or str
+            identifier for view which can be an id (int) or name (str)
+
+        Returns
+        -------
+        column : dict
+            column data
+
+        Raises
+        ------
+        NotFoundItemError
+        """
+        if isinstance(identifier, int):
+            key = "id"
+        else:
+            key = "name"
+
+        for column in self.get_budget_columns(company_id=company_id, project_id=project_id, budget_view_id=budget_view_id):
+            if column[key] == identifier:
+                return column
+
+        raise NotFoundItemError(f"Could not find column {identifier}")
+    
+    def get_budget_rows(self, company_id, project_id, budget_view_id):
+        """
+        Lists the rows in a budget view: https://developers.procore.com/reference/rest/v1/budget-view-detail-rows?version=1.0#list-budget-view-detail-rows
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        budget_view_id : int
+            unique identifier for the budget view
+
+        Returns
+        -------
+        rows : list of dict
+            rows in a budget view
+        """
+        params = {
+            "project_id": project_id,
+            "budget_row_type": "all"
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        columns = self.get_request(
+            api_url=f"{self.endpoint}/{budget_view_id}/detail_rows",
+            additional_headers=headers,
+            params=params
+        )
+
+        return columns
+    
+    def find_budget_row(self, company_id, project_id, budget_view_id, identifier):
+        """
+        Finds specified budget view row by looping through the results from get_budget_rows()
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        budget_view_id : int
+            unique identifier for the budget view
+        identifier : int or str
+            identifier for view which can be an id (int) or cost_code (str)
+
+        Returns
+        -------
+        column : dict
+            column data
+
+        Raises
+        ------
+        NotFoundItemError
+        """
+        if isinstance(identifier, int):
+            key = "id"
+        else:
+            key = "cost_code"
+
+        for column in self.get_budget_rows(company_id=company_id, project_id=project_id, budget_view_id=budget_view_id):
+            if column[key] == identifier:
+                return column
+
+        raise NotFoundItemError(f"Could not find row {identifier}")
+    
+    def get_budget_details(self, company_id, project_id, budget_view_id):
+        """
+        Return a list of all rows from the Budget Detail Report for a Project and Budget View: https://developers.procore.com/reference/rest/v1/budget-details?version=1.0#list-budget-details
+        
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        budget_view_id : int
+            unique identifier for the budget view
+
+        Returns
+        -------
+        """
+        params = {
+            "project_id": project_id
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        details = self.post_request(
+            api_url=f"{self.endpoint}/{budget_view_id}/budget_details",
+            additional_headers=headers,
+            params=params
+        )
+
+        return details
\ No newline at end of file
diff --git a/ProPyCore/access/companies.py b/ProPyCore/access/companies.py
new file mode 100644
index 0000000..9b9b50f
--- /dev/null
+++ b/ProPyCore/access/companies.py
@@ -0,0 +1,162 @@
+from .base import Base
+from ..exceptions import NotFoundItemError
+
+class Companies(Base):
+    """
+    Access and working with Companies with App access
+    """
+
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+        self.endpoint = "/rest/v1.0/companies"
+
+    def get(self, page=1, per_page=100):
+        """
+        Gets all companies with the app installed
+
+        Parameters
+        ----------
+        per_page : int, default 100
+            number of companies to include
+
+        Returns
+        -------
+        companies : list of dict
+            list where each value is a dict with the company's id, active status (is_active), and name
+        """
+        params = {
+            "page": page,
+            "per_page": per_page,
+            "include_free_companies": True
+        }
+
+        companies = self.get_request(
+            api_url=self.endpoint,
+            params=params
+        )
+
+        return companies
+
+    def find(self, identifier):
+        """
+        Finds a company based on the identifier
+
+        Parameters
+        ----------
+        identifier : int or str
+            company id number or name
+        
+        Returns
+        -------
+        company : dict
+            company-specific dictionary
+        """
+        # determining which identifier to search for
+        if isinstance(identifier, int):
+            key = "id"
+        else:
+            key = "name"
+
+        for company in self.get():
+            if company[key] == identifier:
+                return company
+
+        raise NotFoundItemError(f"Could not find company {identifier}")
+    
+    def get_projects(self, company_id):
+        """
+        Gets all projects from the companies
+
+        Parameters
+        ----------
+        company_id : int
+            The identifier for the company
+
+        Returns
+        -------
+        projects : list of dict
+            list where each value is a project within the company
+        """
+        endpoint = f"{self.endpoint}/{company_id}/projects"
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        projects = self.get_request(
+            api_url=endpoint,
+            additional_headers=headers
+        )
+
+        return projects
+    
+    def get_regions(self, company_id, page=1, per_page=100):
+        """
+        Gets all regions for a specified company
+
+        Parameters
+        ----------
+        company_id : int
+            The identifier for the company
+        per_page : int, default 100
+            Number of regions to include per page
+
+        Returns
+        -------
+        regions : list of dict
+            List where each value is a dict with the region's details
+        """
+        endpoint = f"{self.endpoint}/{company_id}/project_regions"
+
+        params = {
+            "page": page,
+            "per_page": per_page
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        regions = self.get_request(
+            api_url=endpoint,
+            additional_headers=headers,
+            params=params
+        )
+
+        return regions
+
+    def get_project_types(self, company_id, page=1, per_page=100):
+        """
+        Gets all project types for a specified company
+
+        Parameters
+        ----------
+        company_id : int
+            The identifier for the company
+        per_page : int, default 100
+            Number of project types to include per page
+
+        Returns
+        -------
+        project_types : list of dict
+            List where each value is a dict with the project type's details
+        """
+        endpoint = f"{self.endpoint}/{company_id}/project_types"
+
+        params = {
+            "page": page,
+            "per_page": per_page
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        project_types = self.get_request(
+            api_url=endpoint,
+            additional_headers=headers,
+            params=params
+        )
+
+        return project_types
\ No newline at end of file
diff --git a/ProPyCore/access/directory.py b/ProPyCore/access/directory.py
new file mode 100644
index 0000000..3e0ffa0
--- /dev/null
+++ b/ProPyCore/access/directory.py
@@ -0,0 +1,214 @@
+from .base import Base
+from ..exceptions import NotFoundItemError
+
+class Directory(Base):
+    """
+    Wrapper class for Folders and Files - should NOT instantiate directly
+    Basic functionality for working with Folders and Files
+    """
+
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+        self.endpoint = None # create dummy endpoint so the methods have a reference
+
+    def get(self, company_id, project_id=None, per_page=10000):
+        """
+        Gets a list of all the projects from a certain company
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int, default None
+            unique identifier for the project
+            None specifies company-level
+        per_page : int, default 100
+            number of companies to include
+
+        Returns
+        -------
+        users : list of dict
+            list where each value is a dict with a user's information
+        """
+        users = []
+        n_users = 1
+        page = 1
+        while n_users > 0:
+            params = {
+                "page": page,
+                "per_page": per_page,
+                "company_id": company_id # this parameter is only used in Company Vendors, but including it for other requests does not seem to create any issues
+            }
+
+            headers = {
+                "Procore-Company-Id": f"{company_id}"
+            }
+
+            # url
+            url = self.get_url(
+                company_id=company_id,
+                project_id=project_id
+            )
+
+            users_per_page = self.get_request(
+                api_url=url,
+                additional_headers=headers,
+                params=params
+            )
+            n_users = len(users_per_page)
+
+            users += users_per_page
+            page += 1
+
+        return users
+
+    def find(self, company_id, user_id, project_id=None,):
+        """
+        Finds a user based on the identifier
+
+        Parameters
+        ----------
+        company_id : int
+            company id that the project is under
+        user_id : int or str
+            project id number or company name
+        project_id : int, default None
+            unique identifier for the project
+            None specifies company-level
+        
+        Returns
+        -------
+        project : dict
+            project-specific dictionary
+        """
+        if isinstance(user_id, int):
+            key = "id"
+        else:
+            key = "name"
+
+        for user in self.get(company_id=company_id, project_id=project_id):
+            if user[key] == user_id:
+                return user
+
+        raise NotFoundItemError(f"Could not find {user_id}")
+
+class Users(Directory):
+    """Access user information on a Company and Project Level"""
+
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+    def get_url(self, company_id, project_id=None):
+        """
+        Returns the url specific to Users at company or project level
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int, default None
+            unique identifier for the project
+            None specifies company-level
+
+        Returns
+        -------
+        <get_url> : str
+            url for Users request
+        """
+        if project_id is None:
+            return f"/rest/v1.1/companies/{company_id}/users"
+        else:
+            return f"/rest/v1.0/projects/{project_id}/users"
+
+    def add(self, company_id, project_id, user_id, permission_template_id=None):
+        """
+        Adds a company user to a given project
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int, default None
+            unique identifier for the project to add the user to
+        user_id : int
+            unique identifier of the user to add
+        permission_template_id : int, default None
+            level of permissions to give the added user
+        """
+        # 768372
+        data = {
+            "user":{
+                "permission_template_id": permission_template_id,
+            }
+        }
+
+        params = {
+            "project_id": project_id
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        self.post_request(
+            api_url=f"/rest/v1.0/projects/{project_id}/users/{user_id}/actions/add",
+            additional_headers=headers,
+            params=params,
+            data=data
+        )
+
+class Vendors(Directory):
+    """Access vendor information on a Company and Project Level"""
+
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+    def get_url(self, company_id, project_id=None):
+        """
+        Returns the url specific to Vendors at company or project level
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int, default None
+            unique identifier for the project
+            None specifies company-level
+
+        Returns
+        -------
+        <get_url> : str
+            url for Vendors request
+        """
+        if project_id is None:
+            # company level is included in the query parameters for this endpoint
+            return "/rest/v1.0/vendors"
+        else:
+            return f"/rest/v1.0/projects/{project_id}/vendors"
+
+class Trades(Directory):
+    """Access trade information on a Company Level only"""
+
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+    def get_url(self, company_id, project_id=None):
+        """
+        Returns the url specific to Trades at company level
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int, default None
+            unique identifier for the project
+            None specifies company-level
+
+        Returns
+        -------
+        <get_url> : str
+            url for Trades request
+        """
+        # can only access trades at the company level
+        return f"/rest/v1.0/companies/{company_id}/trades"
\ No newline at end of file
diff --git a/ProPyCore/access/documents.py b/ProPyCore/access/documents.py
new file mode 100644
index 0000000..0086e4d
--- /dev/null
+++ b/ProPyCore/access/documents.py
@@ -0,0 +1,553 @@
+from .base import Base
+from ..exceptions import NotFoundItemError, WrongParamsError, ProcoreException, NoPrivilegeError
+
+from warnings import warn
+from fuzzywuzzy import fuzz
+
+class Documents(Base):
+    """
+    Wrapper class for Folders and Files - should NOT instantiate directly
+    Basic functionality for working with Folders and Files
+    """
+
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+        self.endpoint = None # create dummy endpoint so the methods have a reference
+
+    def show(self, company_id, project_id, doc_id):
+        """
+        Show information regarding the given folder or file
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        doc_id : int
+            unique identifier for the folder or file
+
+        Returns
+        -------
+        doc_info : dict
+            request body
+        """
+        
+        params = {
+            "project_id": project_id
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        doc_info = self.get_request(
+            api_url=f"{self.endpoint}/{doc_id}",
+            additional_headers=headers,
+            params=params
+        )
+
+        return doc_info
+
+    def remove(self, company_id, project_id, doc_id):
+        """
+        Deletes the give document
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        doc_id : int
+            unique identifier for the folder or file
+
+        Returns
+        -------
+        doc_info : dict
+            request body
+        """
+        params = {
+            "project_id": project_id
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        doc_info = self.delete_request(
+            api_url=f"{self.endpoint}/{doc_id}",
+            additional_headers=headers,
+            params=params
+        )
+
+        return doc_info
+
+    def get(self, company_id, project_id, folder_id=None):
+        """
+        Gets all documents in a project
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+
+        Returns
+        -------
+        docs : list of dict
+            available docs and their corresponding response body
+        """
+        # get document type (file or folder) from endpoint
+        doc_type = self.endpoint.split("/")[-1][:-1] # remove last char which is an "s"
+
+        n_docs = 1
+        page = 1
+        docs = []
+        while n_docs > 0:
+
+            params = {
+                "view": "normal",
+                "sort": "name",
+                "page": page,
+                "per_page": 10000,
+                "filters[document_type]": doc_type,
+                "filters[is_in_recycle_bin]": False
+            }
+            if folder_id is not None:
+                params["filters[folder_id]"] = folder_id
+
+            headers = {
+                "Procore-Company-Id": f"{company_id}"
+            }
+
+            doc_info = self.get_request(
+                api_url=f"/rest/v1.0/projects/{project_id}/documents",
+                additional_headers=headers,
+                params=params
+            )
+
+            n_docs = len(doc_info)
+
+            for doc in doc_info:
+                if doc["is_deleted"] is False:
+                    docs.append(doc)
+
+            page += 1 
+
+        if len(docs) > 0:
+            return docs
+        else:
+            raise NotFoundItemError(f"No {doc_type}s are available in Project {project_id} from Parent ID {folder_id if folder_id is not None else 'Root'}")
+
+    def search(self, company_id, project_id, value, folder_id=None):
+        """
+        Searches through all available files to find the closet match to the given value.
+        For documents with the same match score, the last document to be found is returned.
+        Folders in root are searched completely first (in alphanumeric order) and then files
+        in the root are considered. 
+
+        Parameters
+        ----------
+        company : int or str
+            company id number or name
+        project : : int or str
+            project id number or name
+        value : str
+            search criteria
+        folder_id : int, default None
+            id of parent folder
+
+        Returns
+        -------
+        result : dict
+            document reference information
+        """
+        docs = self.get(
+            company_id=company_id,
+            project_id=project_id,
+            folder_id=folder_id
+        )
+
+        # get document type (file or folder) from endpoint
+        doc_type = self.endpoint.split("/")[-1][:-1] # remove last char which is an "s"
+
+        # dummy values for finding best match
+        score = 0
+        n_perfect = 0
+        result = {}
+        for doc in docs:
+            # filter for only active documents
+            if not doc["is_deleted"] and not doc["is_recycle_bin"] and doc["document_type"] == doc_type:
+                temp_score = fuzz.partial_ratio(value, doc["name"])
+                if temp_score == 100:
+                    n_perfect += 1
+                # update match values
+                if temp_score >= score:
+                    score = temp_score
+                    result = doc
+
+        # warn if multiple documents provided perfect match
+        if n_perfect > 1:
+            warn("Multiple 100% matches - try refining your search critera for better results")
+
+        # raise an error if the document can't be found
+        if score == 0:
+            raise NotFoundItemError(f"Could not find document {value}")
+        else:
+            result["search_criteria"] = {"value":value, "match":score}
+            return result
+
+class Folders(Documents):
+    """
+    Access to and working with Procore folders
+    """
+
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+        self.endpoint = "/rest/v1.0/folders"
+
+    def root(self, company_id, project_id):
+        """
+        Gets the list of root folders and files
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+
+        Returns
+        -------
+        docs : dict
+            json-like information on root folders and files
+        """
+        params = {
+            "project_id": project_id
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}" # needs to be a str
+        }
+
+        docs = self.get_request(
+            api_url=self.endpoint,
+            additional_headers=headers,
+            params=params
+        )
+
+        return docs
+
+    def create(self, company_id, project_id, folder_name, folder_id=None):
+        """
+        Creates a folder 
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        folder_name : str
+            name of the folder to create
+        folder_id : int or str, default None
+            the id of the parent folder to place this folder
+            if not included, the folder will placed at the root
+
+        Returns
+        -------
+        doc_info : dict
+            request body
+        """
+        if folder_id is None:
+            data = {
+                "folder":{
+                    "name": folder_name,
+                    "explicit_permissions": False
+                }
+            }
+        else:
+            data = {
+                "folder":{
+                    "name": folder_name,
+                    "parent_id": str(folder_id),
+                    "explicit_permissions": False
+                }
+            }
+
+        params = {
+            "project_id": project_id
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        try:
+            doc_info = self.post_request(
+                api_url=self.endpoint,
+                params=params,
+                additional_headers=headers,
+                data=data
+            )
+        except ProcoreException as e:
+            print(e)
+        
+        return doc_info
+        
+    def update(self, company_id, project_id, doc_id, folder_id=None, folder_name=None, private=None):
+        """
+        Updates the given folder
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        doc_id : int
+            unique identifier for the folder
+        folder_id : int, default None -> root
+            location where the folder exists
+        folder_name : str, default None
+            new name to assign to the folder
+        private : boolean, default None
+            permissions on the folder
+
+        Returns
+        -------
+        doc_info : dict
+            request body
+        """
+        params = {
+            "project_id": project_id
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        # building the body from available data
+        body = {}
+        for key, val in zip(["parent_id","name","explicit_permissions"], [folder_id, folder_name, private]):
+            if val is not None:
+                body[key] = val
+
+        data={
+            "folder": body
+        }
+
+        doc_info = self.patch_request(
+            api_url=f"{self.endpoint}/{doc_id}",
+            additional_headers=headers,
+            params=params,
+            data=data
+        )
+
+        return doc_info
+    
+    def find(self, company_id, project_id, identifier, folder_id=None):
+        """
+        Finds the information from the folder name
+
+        Parameters
+        ----------
+        company : int or str
+            company id number or name
+        project : : int or str
+            project id number or name
+        identifier : str
+            name of the folder to look for
+        folder_id : int, default None
+            parent id to get subfolder in
+            None specifies to start at the root
+
+        Returns
+        -------
+        <folder_info> : dict
+            folder-specific dictionary
+        """
+        folders = self.get(
+            company_id=company_id,
+            project_id=project_id,
+            folder_id=folder_id
+        )
+
+        for folder in folders:
+            if folder["name"] == identifier:
+                return self.show(
+                    company_id=company_id,
+                    project_id=project_id,
+                    doc_id=folder["id"]
+                )
+
+        raise NotFoundItemError(f"Could not find document {identifier}")
+
+class Files(Documents):
+    """
+    Access to and working with Procore files
+    """
+
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+        self.endpoint = "/rest/v1.0/files"
+
+    def create(self, company_id, project_id, filepath, folder_id=None, description=None):
+        """
+        Creates a file 
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        filepath : str
+            path to the file to upload
+        folder_id : int or str, default None
+            the id of the parent folder to place this folder
+            if not included, the folder will placed at the root
+        description : str, default None
+            optional description to include on the file
+
+        Returns
+        -------
+        doc_info : dict
+            request body
+        """
+        params = {
+            "project_id": project_id
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}",
+        }
+
+        filename = filepath.rsplit('/',1)[-1]
+        data = {
+            "file[name]": f"{filename}",
+            "file[description]": "None" if description is None else description,
+        }
+
+        if folder_id is not None:
+            data["file[parent_id]"] = int(folder_id)
+
+        file = [
+            ("file[data]", open(filepath, "rb"))
+        ]
+
+        try:
+            doc_info = self.post_request(
+                api_url=self.endpoint,
+                additional_headers=headers,
+                params=params,
+                data=data,
+                files=file
+            )
+        except ProcoreException as e:
+            print(e)
+        
+        return doc_info
+
+    def update(self, company_id, project_id, doc_id, filepath=None, folder_id=None, filename=None, description=None, private=None):
+        """
+        Updates the given folder
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        doc_id : int
+            unique identifier for the folder
+        folder_id : int, default None -> root
+            location where the folder exists
+        folder_name : str, default None
+            new name to assign to the folder
+        private : boolean, default None
+            permissions on the folder
+
+        Returns
+        -------
+        doc_info : dict
+            request body
+        """
+        params = {
+            "project_id": project_id
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        # building the body from available data
+        data = {}
+        for key, val in zip(["file[parent_id]","file[name]","file[description]","file[private]"], [folder_id, filename, description, private]):
+            if val is not None:
+                data[key] = val
+
+        if filepath is not None:
+            file = [
+                ("file[data]", open(filepath, "rb"))
+            ]
+
+            doc_info = self.patch_request(
+                api_url=f"{self.endpoint}/{doc_id}",
+                additional_headers=headers,
+                params=params,
+                data=data,
+                files=file
+            )
+        else:
+            doc_info = self.patch_request(
+                api_url=f"{self.endpoint}/{doc_id}",
+                additional_headers=headers,
+                params=params,
+                data=data,
+                files=True
+            )
+
+        return doc_info        
+    
+    def find(self, company_id, project_id, identifier, folder_id=None):
+        """
+        Finds the information from the folder name
+
+        Parameters
+        ----------
+        company : int or str
+            company id number or name
+        project : : int or str
+            project id number or name
+        identifier : str
+            name of the file or folder to look for
+        folder_id : int, default None
+            parent id to get subfolder in
+            None specifies to start at the root
+
+        Returns
+        -------
+        doc : dict
+            doc-specific dictionary
+        """
+        files = self.get(
+            company_id=company_id,
+            project_id=project_id,
+            folder_id=folder_id
+        )
+
+        for file in files:
+            if file["name"] == identifier:
+                return self.show(
+                    company_id=company_id,
+                    project_id=project_id,
+                    doc_id=file["id"]
+                )
+
+        raise NotFoundItemError(f"Could not find document {identifier}")
\ No newline at end of file
diff --git a/ProPyCore/access/generic_tools.py b/ProPyCore/access/generic_tools.py
new file mode 100644
index 0000000..c2a4f65
--- /dev/null
+++ b/ProPyCore/access/generic_tools.py
@@ -0,0 +1,394 @@
+from .base import Base
+from ..exceptions import NotFoundItemError, NoPrivilegeError, WrongParamsError, ProcoreException
+
+class GenericTool(Base):
+    """
+    Access and working with generic tool endpoints
+    """
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+        self.endpoint = "/rest/v1.0/companies"
+
+    def get_tools(self, company_id, per_page=100):
+        """
+        Gets all the available generic tools
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        per_page : int, default 100
+            number of generic tools to find per page
+
+        Returns
+        -------
+        tools : dict
+            available generic tools
+        """
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        # List of generic tools is likely less than 100, but this could lead to errors in the future
+        params = {
+            "page": 1,
+            "per_page": per_page
+        }
+
+        tools = self.get_request(
+            api_url=f"{self.endpoint}/{company_id}/generic_tools",
+            additional_headers=headers,
+            params=params
+        )
+
+        return tools
+    
+    def find_tool(self, company_id, identifier):
+        """
+        Finds a tools based on the identifier: id or title
+
+        Parameters
+        ----------
+        company_id : int
+            company id that the project is under
+        identifier : int or str
+            project id number or company name
+        
+        Returns
+        -------
+        tool : dict
+            tool-specific dictionary
+        """
+        if isinstance(identifier, int):
+            key = "id"
+        else:
+            key = "title"
+
+        for tool in self.get_tools(company_id=company_id):
+            if tool[key] == identifier:
+                return tool
+
+        raise NotFoundItemError(f"Could not find tool {identifier}")
+    
+    def get_tool_items(self, company_id, project_id, tool_id):
+        """
+        Gets all the available items for a specific tool
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        tool_id : int
+            unique identifier for the generic tool
+
+        Returns
+        -------
+        items : dict
+            available tool item data
+        """
+        n_items = 1
+        page = 1
+        items = []
+        while n_items > 0:
+            params = {
+                "view": "extended",
+                "sort": "created_at",
+                "page": page,
+                "per_page": 10000,
+                "filters[recycle_bin]": False
+            }
+
+            headers = {
+                "Procore-Company-Id": f"{company_id}"
+            }
+
+            item_info = self.get_request(
+                api_url=f"/rest/v1.0/projects/{project_id}/generic_tools/{tool_id}/generic_tool_items",
+                additional_headers=headers,
+                params=params
+            )
+
+            n_items = len(item_info)
+            if n_items > 0:
+                items += item_info
+
+            page += 1 
+
+        if len(items) > 0:
+            return items
+        else:
+            raise NotFoundItemError(f"No items are available in Project {project_id} for tool {tool_id}")
+    
+    def create_tool_item(self, company_id, project_id, tool_id, data):
+        """
+        Create new item for a specific tool
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        tool_id : int
+            unique identifier for the generic tool
+        data : dict
+            request body data for the new item
+
+        Returns
+        -------
+        item_info : dict
+            new item data
+        """
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        try:
+            item_info = self.post_request(
+                api_url=f"/rest/v1.0/projects/{project_id}/generic_tools/{tool_id}/generic_tool_items",
+                additional_headers=headers,
+                data=data
+            )
+        except ProcoreException as e:
+            if "403" in e:
+                raise NoPrivilegeError(f"Data connection app or permission template does not allow creation of generic tools")
+            else:
+                raise WrongParamsError(e)
+        
+        return item_info
+    
+    def find_tool_item(self, company_id, project_id, tool_id, identifier):
+        """
+        Finds a specific generic tool item based on the identifier
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        tool_id : int
+            unique identifier for the generic tool
+        identifier : int or str
+            item id number or company name
+
+        Returns
+        -------
+        tool_item : dict
+            response body for the given tool item
+        """
+        if isinstance(identifier, int):
+            key = "id"
+        else:
+            key = "title"
+
+        tool_items = self.get_tool_items(
+            company_id=company_id,
+            project_id=project_id,
+            tool_id=tool_id
+        )
+
+        for tool_item in tool_items:
+            if tool_item[key] == identifier:
+                return tool_item
+
+        raise NotFoundItemError(f"Could not find tool item {identifier}")
+    
+    def update_tool_item(self, company_id, project_id, tool_id, item_id, data):
+        """
+        Updates item for a specific tool
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        tool_id : int
+            unique identifier for the generic tool
+        item_id : int
+            unique identifier for the item to change
+        data : dict
+            request body data for the new item
+
+        Returns
+        -------
+        item_info : dict
+            updated response body item data
+        """
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        try:
+            item_info = self.patch_request(
+                api_url=f"/rest/v1.0/projects/{project_id}/generic_tools/{tool_id}/generic_tool_items/{item_id}",
+                additional_headers=headers,
+                data=data
+            )
+        except ProcoreException as e:
+            raise WrongParamsError(e)
+        
+        return item_info
+    
+    def delete_tool_item(self, company_id, project_id, tool_id, item_id):
+        """
+        Updates item for a specific tool
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        tool_id : int
+            unique identifier for the generic tool
+        item_id : int
+            unique identifier for the item to change
+
+        Returns
+        -------
+        <status> : str
+            status code and text of the delete
+        """
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        try:
+            _ = self.delete_request(
+                api_url=f"/rest/v1.0/projects/{project_id}/generic_tools/{tool_id}/generic_tool_items/{item_id}",
+                additional_headers=headers
+            )
+        except ProcoreException as e:
+            raise WrongParamsError(e)
+        
+        return "200: Success"
+    
+    def get_tool_statuses(self, company_id, tool_id):
+        """
+        Gets all the available statuses for a specific tool
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        tool_id : int
+            unique identifier for the generic tool
+
+        Returns
+        -------
+        items : dict
+            available tool item data
+        """
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        status_info = self.get_request(
+            api_url=f"/rest/v1.0/companies/{company_id}/generic_tools/{tool_id}/available_statuses",
+            additional_headers=headers
+        )
+        
+        return status_info
+    
+    def get_tool_created_statuses(self, company_id, tool_id):
+        """
+        Gets statuses that were created for a specific tool. Does not include the default statuses that each tool will have.
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        tool_id : int
+            unique identifier for the generic tool
+
+        Returns
+        -------
+        items : dict
+            available tool item data
+        """
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        status_info = self.get_request(
+            api_url=f"/rest/v1.0/companies/{company_id}/generic_tools/{tool_id}/statuses",
+            additional_headers=headers
+        )
+        
+        return status_info
+    
+    def create_tool_status(self, company_id, tool_id, data):
+        """
+        Create new status for a specific tool
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        tool_id : int
+            unique identifier for the generic tool
+        data : dict
+            request body data for the new item
+
+        Returns
+        -------
+        status_info : dict
+            response from new status creation
+        """
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        try:
+            status_info = self.post_request(
+                api_url=f"/rest/v1.0/companies/{company_id}/generic_tools/{tool_id}/statuses",
+                additional_headers=headers,
+                data=data
+            )
+        except ProcoreException as e:
+            raise WrongParamsError(e)
+        
+        return status_info
+    
+    def delete_tool_status(self, company_id, tool_id, status_id):
+        """
+        Delete status for a specific tool
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        tool_id : int
+            unique identifier for the generic tool
+        status_id : int
+            status id for removal
+
+        Returns
+        -------
+        status_info : dict
+            response from new status creation
+        """
+        
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        try:
+            _ = self.delete_request(
+                api_url=f"/rest/v1.0/companies/{company_id}/generic_tools/{tool_id}/statuses{status_id}",
+                additional_headers=headers
+            )
+        except ProcoreException as e:
+            raise WrongParamsError(e)
+        
+        return "200: Success"
\ No newline at end of file
diff --git a/ProPyCore/access/projects.py b/ProPyCore/access/projects.py
new file mode 100644
index 0000000..22f2551
--- /dev/null
+++ b/ProPyCore/access/projects.py
@@ -0,0 +1,77 @@
+from .base import Base
+from ..exceptions import NotFoundItemError
+
+class Projects(Base):
+    """
+    Access and working with projects from a given company
+    """
+
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+        self.endpoint = "/rest/v1.1/projects"
+
+    def get(self, company_id, per_page=100):
+        """
+        Gets a list of all the projects from a certain company
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        per_page : int, default 100
+            number of companies to include
+
+        Returns
+        -------
+        projects : list of dict
+            list where each value is a dict with the project's id, active status (is_active), and name
+        """
+        projects = []
+        n_projects = 1
+        page = 1
+        while n_projects > 0:
+            params = {
+                "company_id": company_id,
+                "page": page,
+                "per_page": per_page
+            }
+            
+            projects_per_page = self.get_request(
+                api_url=self.endpoint,
+                params=params
+            )
+            n_projects = len(projects_per_page)
+
+            projects += projects_per_page
+
+            page += 1
+
+        return projects
+
+    def find(self, company_id, identifier):
+        """
+        Finds a project based on the identifier
+
+        Parameters
+        ----------
+        company_id : int
+            company id that the project is under
+        identifier : int or str
+            project id number or company name
+        
+        Returns
+        -------
+        project : dict
+            project-specific dictionary
+        """
+        if isinstance(identifier, int):
+            key = "id"
+        else:
+            key = "name"
+
+        for project in self.get(company_id=company_id):
+            if project[key] == identifier:
+                return project
+
+        raise NotFoundItemError(f"Could not find project {identifier}")
diff --git a/ProPyCore/access/rfis.py b/ProPyCore/access/rfis.py
new file mode 100644
index 0000000..222b3ec
--- /dev/null
+++ b/ProPyCore/access/rfis.py
@@ -0,0 +1,113 @@
+from .base import Base
+
+from ..exceptions import NotFoundItemError
+
+class RFI(Base):
+    """
+    Access and working with RFIs in a given project
+    """
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+        self.endpoint = "/rest/v1.0/projects"
+
+    def get(self, company_id, project_id, page=1, per_page=100):
+        """
+        Gets all the available RFIs
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        page : int, default 1
+            page number
+        per_page : int, default 100
+            number of companies to include
+
+        Returns
+        -------
+        rfis : dict
+            available rfi data
+        """
+        params = {
+            "page": page,
+            "per_page": per_page
+        }
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        rfis = self.get_request(
+            api_url=f"{self.endpoint}/{project_id}/rfis",
+            additional_headers=headers,
+            params=params
+        )
+
+        return rfis
+
+    def show(self, company_id, project_id, rfi_id):
+        """
+        Shows the RFI info
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        rfi_id : int
+            unique identifier for the RFI
+
+        Returns
+        -------
+        rfi_info : dict
+            specific rfi information
+        """
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        rfi_info = self.get_request(
+            api_url=f"{self.endpoint}/{project_id}/rfis/{rfi_id}",
+            additional_headers=headers,
+        )
+
+        return rfi_info
+
+    def find(self, company_id, project_id, identifier):
+        """
+        Finds specified RFI and returns data - wrapper for show method
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        identifier : int or str
+            identifier for RFI which can be id (int) or number (str)
+
+        Returns
+        -------
+        rfi_info : dict
+            RFI data
+        """
+        if isinstance(identifier, int):
+            key = "id"
+        else:
+            key = "number"
+
+        for rfi in self.get(company_id=company_id, project_id=project_id):
+            if rfi[key] == identifier:
+                rfi_info = self.show(
+                    company_id=company_id,
+                    project_id=project_id,
+                    rfi_id=rfi["id"]
+                )
+                return rfi_info
+
+        raise NotFoundItemError(f"Could not find RFI {identifier}")
diff --git a/ProPyCore/access/submittals.py b/ProPyCore/access/submittals.py
new file mode 100644
index 0000000..4c176a0
--- /dev/null
+++ b/ProPyCore/access/submittals.py
@@ -0,0 +1,121 @@
+from .base import Base
+from ..exceptions import *
+
+class Submittal(Base):
+    """
+    Access and working with submittals in a given project
+    """
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+        self.endpoint = "/rest/v1.1/projects"
+
+    def get(self, company_id, project_id):
+        """
+        Gets all the available Submittals
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+
+        Returns
+        -------
+        submittals : dict
+            available submittal data
+        """
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        n_submittals = 1
+        page = 1
+        submittals = []
+        while n_submittals > 0:
+
+            params = {
+                "page": page,
+                "per_page": 100
+            }
+
+            headers = {
+                "Procore-Company-Id": f"{company_id}"
+            }
+
+            submittal_selection = self.get_request(
+                api_url=f"{self.endpoint}/{project_id}/submittals",
+                additional_headers=headers,
+                params=params
+            )
+
+            n_submittals = len(submittal_selection)
+            submittals += submittal_selection
+            page += 1 
+
+        return submittals
+
+    def show(self, company_id, project_id, submittal_id):
+        """
+        Shows the Submittal info
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        submittal_id : int
+            unique identifier for the submittal
+
+        Returns
+        -------
+        submittal_info : dict
+            specific submittal information
+        """
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        submittal_info = self.get_request(
+            api_url=f"{self.endpoint}/{project_id}/submittals/{submittal_id}",
+            additional_headers=headers,
+        )
+
+        return submittal_info
+
+    def find(self, company_id, project_id, identifier):
+        """
+        Finds specified submittal
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        identifier : int or str
+            identifier for Submittal
+
+        Returns
+        -------
+        submittal_info : dict
+            submittal data
+        """
+        if isinstance(identifier, int):
+            key = "id"
+        else:
+            key = "title"
+
+        for submittal in self.get(company_id=company_id, project_id=project_id):
+            if submittal[key] == identifier:
+                submittal_info = self.show(
+                    company_id=company_id,
+                    project_id=project_id,
+                    submittal_id=submittal["id"]
+                )
+                return submittal_info
+
+        raise NotFoundItemError(f"Could not find Submittal {identifier}")
\ No newline at end of file
diff --git a/ProPyCore/access/tasks.py b/ProPyCore/access/tasks.py
new file mode 100644
index 0000000..ceceea3
--- /dev/null
+++ b/ProPyCore/access/tasks.py
@@ -0,0 +1,127 @@
+from .base import Base
+from ..exceptions import *
+
+class Task(Base):
+    """
+    Access and working with submittals in a given project
+    """
+    def __init__(self, access_token, server_url) -> None:
+        super().__init__(access_token, server_url)
+
+        self.endpoint = "/rest/v1.0/tasks"
+
+    def get(self, company_id, project_id):
+        """
+        Gets all the available tasks
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+
+        Returns
+        -------
+        tasks : dict
+            available submittal data
+        """
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        n_tasks = 1
+        page = 1
+        tasks = []
+        while n_tasks > 0:
+
+            params = {
+                "project_id": project_id,
+                "page": page,
+                "per_page": 100
+            }
+
+            headers = {
+                "Procore-Company-Id": f"{company_id}"
+            }
+
+            task_selection = self.get_request(
+                api_url=f"{self.endpoint}",
+                additional_headers=headers,
+                params=params
+            )
+
+            n_tasks = len(task_selection)
+            tasks += task_selection
+            page += 1 
+
+        return tasks
+    
+    def show(self, company_id, project_id, task_id):
+        """
+        Shows the task info
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        task_id : int
+            unique identifier for the task
+
+        Returns
+        -------
+        task_info : dict
+            specific task information
+        """
+
+        headers = {
+            "Procore-Company-Id": f"{company_id}"
+        }
+
+        params = {
+            "project_id": project_id,
+        }
+
+        task_info = self.get_request(
+            api_url=f"{self.endpoint}/{task_id}",
+            additional_headers=headers,
+            params=params
+        )
+
+        return task_info
+
+    def find(self, company_id, project_id, identifier):
+        """
+        Finds specified tasks and returns data
+
+        Parameters
+        ----------
+        company_id : int
+            unique identifier for the company
+        project_id : int
+            unique identifier for the project
+        identifier : int or str
+            identifier for task which can be id (int) or name (str)
+
+        Returns
+        -------
+        task_info : dict
+            task data
+        """
+        if isinstance(identifier, int):
+            key = "id"
+        else:
+            key = "name"
+
+        for task in self.get(company_id=company_id, project_id=project_id):
+            if task[key] == identifier:
+                task_info = self.show(
+                    company_id=company_id,
+                    project_id=project_id,
+                    task_id=task["id"]
+                )
+                return task_info
+
+        raise NotFoundItemError(f"Could not find task {identifier}")
\ No newline at end of file
diff --git a/ProPyCore/exceptions.py b/ProPyCore/exceptions.py
new file mode 100644
index 0000000..28aed06
--- /dev/null
+++ b/ProPyCore/exceptions.py
@@ -0,0 +1,79 @@
+class ProcoreException(Exception):
+    """
+    The base exception class for Procore.
+    Parameters:
+        msg (str): Short description of the error.
+        response: Error response from the API call.
+    """
+
+    def __init__(self, msg, response=None):
+        super(ProcoreException, self).__init__(msg)
+        self.message = msg
+        self.response = response
+
+    def __str__(self):
+        return repr(self.message)
+
+
+class NotFoundClientError(ProcoreException):
+    """Client not found OAuth2 authorization, 404 error."""
+    pass
+
+
+class UnauthorizedClientError(ProcoreException):
+    """Wrong client secret and/or refresh token, 401 error."""
+    pass
+
+
+class ExpiredTokenError(ProcoreException):
+    """Expired (old) access token, 498 error."""
+    pass
+
+
+class InvalidTokenError(ProcoreException):
+    """Wrong/non-existing access token, 401 error."""
+    pass
+
+
+class NoPrivilegeError(ProcoreException):
+    """The user has insufficient privilege, 403 error."""
+    pass
+
+
+class WrongParamsError(ProcoreException):
+    """Some of the parameters (HTTP params or request body) are wrong, 400 error."""
+    pass
+
+
+class NotFoundItemError(ProcoreException):
+    """Not found the item from URL, 404 error."""
+    pass
+    
+
+class InternalServerError(ProcoreException):
+    """The rest Procore errors, 500 error."""
+    pass
+
+def raise_exception(response):
+    """
+    Raises an exception based on the provided status code
+
+    Parameters
+    ----------
+    status_code : int
+        valid get/request code
+    """
+    if response.status_code == 401:
+        raise UnauthorizedClientError('Wrong client secret or/and refresh token', response.text)
+    
+    elif response.status_code == 403:
+        raise NoPrivilegeError(f"Data connection app or permission template does not allow creation of files", response.text)
+
+    elif response.status_code == 404:
+        raise NotFoundClientError('Client ID doesn\'t exist', response.text)
+
+    elif response.status_code == 500:
+        raise InternalServerError('Internal server error', response.text)
+
+    else:
+        raise ProcoreException('Error: {0}'.format(response.status_code), response.text)
\ No newline at end of file
diff --git a/ProPyCore/procore.py b/ProPyCore/procore.py
new file mode 100644
index 0000000..8ff5002
--- /dev/null
+++ b/ProPyCore/procore.py
@@ -0,0 +1,119 @@
+from .exceptions import *
+from .access import companies, generic_tools, projects, documents, rfis, directory, submittals, tasks, budgets
+import requests
+import urllib
+from bs4 import BeautifulSoup
+
+class Procore:
+    """
+    Main class which creates a connection with the Procore APIs using OAuth2 (Client Credentials Grant Type).
+    This grant type allows access to Procore data without having to login as a specific user. 
+    """
+
+    def __init__(self, client_id, client_secret, redirect_uri, base_url, oauth_url) -> None:
+        """
+        Initialize the connection
+
+        Creates
+        -------
+        __client_id : str
+            app's client indentifier
+        __cliend_secret : str
+            app's secret access token
+        __redirect_uri : str
+            should be "" for Client Credentials Grant Type
+        __base_url : str
+            the base url for RESTful
+        __oauth_url : str
+            authorization url to set up access
+        __access_token : str
+            2-hour access token to pull/push data to Procore
+        """
+        self.__client_id = client_id
+        self.__client_secret = client_secret
+        
+        self.__redirect_uri = redirect_uri
+        self.__base_url = base_url
+        self.__oauth_url = oauth_url
+
+        self.__access_token = None
+
+        # get access token; resets from nothing
+        self.reset_access_token()
+
+        # create instances of procore endpoints
+        self.__companies__ = companies.Companies(access_token=self.__access_token, server_url=self.__base_url)
+        self.__projects__ = projects.Projects(access_token=self.__access_token, server_url=self.__base_url)
+
+        self.__folders__ = documents.Folders(access_token=self.__access_token, server_url=self.__base_url)
+        self.__files__ = documents.Files(access_token=self.__access_token, server_url=self.__base_url)
+
+        self.__rfis__ = rfis.RFI(access_token=self.__access_token, server_url=self.__base_url)
+        self.__submittals__ = submittals.Submittal(access_token=self.__access_token, server_url=self.__base_url)
+        self.__tasks__ = tasks.Task(access_token=self.__access_token, server_url=self.__base_url)
+        self.__tools__ = generic_tools.GenericTool(access_token=self.__access_token, server_url=self.__base_url)
+
+        self.__users__ = directory.Users(access_token=self.__access_token, server_url=self.__base_url)
+        self.__vendors__ = directory.Vendors(access_token=self.__access_token, server_url=self.__base_url)
+        self.__trades__ = directory.Trades(access_token=self.__access_token, server_url=self.__base_url)
+
+        self.__budgets__ = budgets.Budgets(access_token=self.__access_token, server_url=self.__base_url)
+        
+    def get_auth_code(self):
+        """
+        Gets the 10-minute temporary authorization token
+        """
+        # create url
+        params = {
+            "client_id": self.__client_id,
+            "response_type": "code",
+            "redirect_uri":  self.__redirect_uri
+        }
+        url = self.__oauth_url + "/oauth/authorize?" + urllib.parse.urlencode(params)
+        # GET
+        response = requests.get(url, headers={
+            "content-type": "application/json"
+        })
+
+        auth_code = None # pre-allocate
+        if response.ok:
+            # use BS to parse the code from the returned html
+            soup = BeautifulSoup(response.text, 'html.parser')
+            for tag in soup.find_all("meta"):
+                if tag.get("name", None) == "csrf-token":
+                    auth_code = tag.get("content", None)
+        else:
+            raise_exception(response=response)
+
+        return auth_code
+
+    def get_access_token(self, code):
+        '''
+        Gets access token from authorization code previously obtained from the get_auth_code call.
+        
+        Parameters
+        ----------
+        code : str
+            temporary authorization code
+
+        Returns
+        -------
+        <access_token> : str
+            2-hour access token
+        '''
+        client_auth = requests.auth.HTTPBasicAuth(self.__client_id, self.__client_secret)
+        post_data = {"grant_type": "client_credentials",
+                    "code": code,
+                    "redirect_uri": self.__redirect_uri
+                    }
+        response = requests.post(self.__base_url+"/oauth/token", auth=client_auth, data=post_data)
+        response_json = response.json()
+
+        return response_json["access_token"]#, response_json['created_at']
+
+    def reset_access_token(self):
+        """
+        Gets a new access token
+        """
+        temp_code = self.get_auth_code()
+        self.__access_token = self.get_access_token(temp_code)
diff --git a/ProPyCore/utils/__init__.py b/ProPyCore/utils/__init__.py
new file mode 100644
index 0000000..f5b6c63
--- /dev/null
+++ b/ProPyCore/utils/__init__.py
@@ -0,0 +1,1 @@
+from .logger import *
\ No newline at end of file
diff --git a/ProPyCore/utils/logger.py b/ProPyCore/utils/logger.py
new file mode 100644
index 0000000..e955217
--- /dev/null
+++ b/ProPyCore/utils/logger.py
@@ -0,0 +1,56 @@
+import logging
+import pathlib
+
+def setup(log_file_name, level="info", stream=False):
+    """
+    Creates a logging object
+    
+    Parameters
+    ----------
+    log_file_name : str
+        how to name the log file
+    level : str, default "info"
+        the logging level to display messages
+    stream : boolean, default False
+        whether to include output in a Stream
+
+    Returns
+    -------
+    logger : logging object
+        a logger to debug
+    """
+    # Create a custom logger
+    logger = logging.getLogger(__name__)
+
+    # Clearing log instances
+    if logger.hasHandlers():
+        logger.handlers.clear()
+
+    # Create handler
+    dir_path = pathlib.Path(__file__).resolve().parent
+    f_handler = logging.FileHandler(f'{dir_path}/{log_file_name}.log',mode='w')
+
+    # Set level
+    if level.lower() == "debug":
+        log_level = logging.DEBUG
+    else: # default to info
+        log_level = logging.INFO
+
+    logging.getLogger().setLevel(log_level)
+
+    # Create formatter and add it to handler
+    f_format = logging.Formatter('%(asctime)s: %(name)s (%(lineno)d) - %(levelname)s - %(message)s',datefmt='%m/%d/%y %H:%M:%S')
+    f_handler.setFormatter(f_format)
+
+    # Add handler to the logger
+    logger.addHandler(f_handler)
+
+    if stream:
+        # repeat the above steps but for a StreamHandler
+        c_handler = logging.StreamHandler()
+        c_handler.setLevel(log_level)
+        c_format = logging.Formatter('%(asctime)s: %(name)s (%(lineno)d) - %(levelname)s - %(message)s',datefmt='%m/%d/%y %H:%M:%S')
+        c_handler.setFormatter(c_format)
+        logger.addHandler(c_handler)
+
+    return logger
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 971fda0..225b4e1 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@
 
 setup(
     name="ProPyCore",
-    version="0.0.2",
+    version="0.0.4",
     author="Hagen E. Fritz",
     author_email="hfritz@r-o.com",
     description="Interact with Procore through Python for data connection applications",
diff --git a/snippets/get_budget_details.py b/snippets/get_budget_details.py
index 378bb66..0a8655b 100644
--- a/snippets/get_budget_details.py
+++ b/snippets/get_budget_details.py
@@ -3,7 +3,7 @@
 import pathlib
 sys.path.append(f"{pathlib.Path(__file__).resolve().parent.parent}")
 
-from propycore.procore import Procore
+from ProPyCore.procore import Procore
 
 from dotenv import load_dotenv
 import json
@@ -71,19 +71,20 @@
     # Example 4: Find row by name
     # ---------
     print("Example 4")
-    row_by_name = connection.__budgets__.find_budget_row(
-        company_id=company["id"],
-        project_id=project["id"],
-        budget_view_id=budget_view["id"],
-        identifier="None"
-    )
+    try:
+        row_by_name = connection.__budgets__.find_budget_row(
+            company_id=company["id"],
+            project_id=project["id"],
+            budget_view_id=budget_view["id"],
+            identifier="None"
+        )
 
-    print(json.dumps(row_by_name, indent=4))
+        print(json.dumps(row_by_name, indent=4))
+    except Exception as e:
+        print(e)
 
     # Example 5: Get budget details
     # ---------
-    # TODO: Getting ClientNotFoundError
-    '''
     print("Example 5")
     details = connection.__budgets__.get_budget_details(
         company_id=company["id"],
@@ -91,5 +92,4 @@
         budget_view_id=budget_view["id"]
     )
 
-    print(f"Number of budget line items: {len(details)}")
-    '''
\ No newline at end of file
+    print(f"Number of budget line items: {len(details)}")
\ No newline at end of file


Test patch:
diff --git a/tests/unit/test_budgets.py b/tests/unit/test_budgets.py
new file mode 100644
index 0000000..32f744c
--- /dev/null
+++ b/tests/unit/test_budgets.py
@@ -0,0 +1,141 @@
+import pytest
+from ProPyCore.access.budgets import Budgets
+from ProPyCore.exceptions import NotFoundItemError
+
+# Fixture for Budgets instance
+@pytest.fixture
+def budgets_instance(mocker):
+    mocker.patch('ProPyCore.access.budgets.Base.__init__', return_value=None)  # Mock the base class initializer
+    return Budgets('mock_access_token', 'mock_server_url')
+
+def test_get_views(budgets_instance, mocker):
+    # Mock the get_request method to return sample views with pagination
+    mock_response_page_1 = [{'id': 1, 'name': 'View-1'}, {'id': 2, 'name': 'View-2'}]
+    mock_response_page_2 = []
+
+    mocker.patch.object(budgets_instance, 'get_request', side_effect=[mock_response_page_1, mock_response_page_2])
+
+    response = budgets_instance.get_views(company_id=123, project_id=456)
+
+    assert isinstance(response, list)
+    assert response == mock_response_page_1
+
+def test_find_view_by_id(budgets_instance, mocker):
+    # Mock the get_views method
+    mock_get_response_page_1 = [{'id': 1, 'name': 'View-1'}, {'id': 2, 'name': 'View-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(budgets_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    view_info = budgets_instance.find_view(company_id=123, project_id=456, identifier=1)
+
+    assert isinstance(view_info, dict)
+    assert view_info == {'id': 1, 'name': 'View-1'}
+
+def test_find_view_by_name(budgets_instance, mocker):
+    # Mock the get_views method
+    mock_get_response_page_1 = [{'id': 1, 'name': 'View-1'}, {'id': 2, 'name': 'View-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(budgets_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    view_info = budgets_instance.find_view(company_id=123, project_id=456, identifier='View-2')
+
+    assert isinstance(view_info, dict)
+    assert view_info == {'id': 2, 'name': 'View-2'}
+
+def test_find_view_not_found(budgets_instance, mocker):
+    # Mock the get_views method to return sample views
+    mock_get_response_page_1 = [{'id': 1, 'name': 'View-1'}, {'id': 2, 'name': 'View-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(budgets_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    with pytest.raises(NotFoundItemError):
+        budgets_instance.find_view(company_id=123, project_id=456, identifier='Nonexistent View')
+
+def test_get_budget_columns(budgets_instance, mocker):
+    # Mock the get_request method to return sample columns
+    mock_response = [{'id': 1, 'name': 'Column-1'}, {'id': 2, 'name': 'Column-2'}]
+    mocker.patch.object(budgets_instance, 'get_request', return_value=mock_response)
+
+    response = budgets_instance.get_budget_columns(company_id=123, project_id=456, budget_view_id=789)
+
+    assert isinstance(response, list)
+    assert response == mock_response
+
+def test_find_budget_column_by_id(budgets_instance, mocker):
+    # Mock the get_budget_columns method
+    mock_get_response = [{'id': 1, 'name': 'Column-1'}, {'id': 2, 'name': 'Column-2'}]
+    mocker.patch.object(budgets_instance, 'get_request', return_value=mock_get_response)
+
+    column_info = budgets_instance.find_budget_column(company_id=123, project_id=456, budget_view_id=789, identifier=1)
+
+    assert isinstance(column_info, dict)
+    assert column_info == {'id': 1, 'name': 'Column-1'}
+
+def test_find_budget_column_by_name(budgets_instance, mocker):
+    # Mock the get_budget_columns method
+    mock_get_response = [{'id': 1, 'name': 'Column-1'}, {'id': 2, 'name': 'Column-2'}]
+    mocker.patch.object(budgets_instance, 'get_request', return_value=mock_get_response)
+
+    column_info = budgets_instance.find_budget_column(company_id=123, project_id=456, budget_view_id=789, identifier='Column-2')
+
+    assert isinstance(column_info, dict)
+    assert column_info == {'id': 2, 'name': 'Column-2'}
+
+def test_find_budget_column_not_found(budgets_instance, mocker):
+    # Mock the get_budget_columns method to return sample columns
+    mock_get_response = [{'id': 1, 'name': 'Column-1'}, {'id': 2, 'name': 'Column-2'}]
+    mocker.patch.object(budgets_instance, 'get_request', return_value=mock_get_response)
+
+    with pytest.raises(NotFoundItemError):
+        budgets_instance.find_budget_column(company_id=123, project_id=456, budget_view_id=789, identifier='Nonexistent Column')
+
+def test_get_budget_rows(budgets_instance, mocker):
+    # Mock the get_request method to return sample rows
+    mock_response = [{'id': 1, 'cost_code': '001'}, {'id': 2, 'cost_code': '002'}]
+    mocker.patch.object(budgets_instance, 'get_request', return_value=mock_response)
+
+    response = budgets_instance.get_budget_rows(company_id=123, project_id=456, budget_view_id=789)
+
+    assert isinstance(response, list)
+    assert response == mock_response
+
+def test_find_budget_row_by_id(budgets_instance, mocker):
+    # Mock the get_budget_rows method
+    mock_get_response = [{'id': 1, 'cost_code': '001'}, {'id': 2, 'cost_code': '002'}]
+    mocker.patch.object(budgets_instance, 'get_request', return_value=mock_get_response)
+
+    row_info = budgets_instance.find_budget_row(company_id=123, project_id=456, budget_view_id=789, identifier=1)
+
+    assert isinstance(row_info, dict)
+    assert row_info == {'id': 1, 'cost_code': '001'}
+
+def test_find_budget_row_by_cost_code(budgets_instance, mocker):
+    # Mock the get_budget_rows method
+    mock_get_response = [{'id': 1, 'cost_code': '001'}, {'id': 2, 'cost_code': '002'}]
+    mocker.patch.object(budgets_instance, 'get_request', return_value=mock_get_response)
+
+    row_info = budgets_instance.find_budget_row(company_id=123, project_id=456, budget_view_id=789, identifier='002')
+
+    assert isinstance(row_info, dict)
+    assert row_info == {'id': 2, 'cost_code': '002'}
+
+def test_find_budget_row_not_found(budgets_instance, mocker):
+    # Mock the get_budget_rows method to return sample rows
+    mock_get_response = [{'id': 1, 'cost_code': '001'}, {'id': 2, 'cost_code': '002'}]
+    mocker.patch.object(budgets_instance, 'get_request', return_value=mock_get_response)
+
+    with pytest.raises(NotFoundItemError):
+        budgets_instance.find_budget_row(company_id=123, project_id=456, budget_view_id=789, identifier='Nonexistent Row')
+
+def test_get_budget_details(budgets_instance, mocker):
+    # Mock the post_request method to return sample details
+    mock_response = [{'id': 1, 'cost_code': '001'}, {'id': 2, 'cost_code': '002'}]
+    mocker.patch.object(budgets_instance, 'post_request', return_value=mock_response)
+
+    response = budgets_instance.get_budget_details(company_id=123, project_id=456, budget_view_id=789)
+
+    assert isinstance(response, list)
+    assert response == mock_response
diff --git a/tests/unit/test_directory.py b/tests/unit/test_directory.py
new file mode 100644
index 0000000..e725e63
--- /dev/null
+++ b/tests/unit/test_directory.py
@@ -0,0 +1,172 @@
+import pytest
+from ProPyCore.access.directory import Users, Vendors, Trades
+from ProPyCore.exceptions import NotFoundItemError
+
+# Fixture for Users instance
+@pytest.fixture
+def users_instance(mocker):
+    mocker.patch('ProPyCore.access.directory.Base.__init__', return_value=None)  # Mock the base class initializer
+    return Users('mock_access_token', 'mock_server_url')
+
+# Fixture for Vendors instance
+@pytest.fixture
+def vendors_instance(mocker):
+    mocker.patch('ProPyCore.access.directory.Base.__init__', return_value=None)  # Mock the base class initializer
+    return Vendors('mock_access_token', 'mock_server_url')
+
+# Fixture for Trades instance
+@pytest.fixture
+def trades_instance(mocker):
+    mocker.patch('ProPyCore.access.directory.Base.__init__', return_value=None)  # Mock the base class initializer
+    return Trades('mock_access_token', 'mock_server_url')
+
+def test_get_users(users_instance, mocker):
+    # Mock the get_request method to return sample users with pagination
+    mock_response_page_1 = [{'id': 1, 'name': 'User-1'}, {'id': 2, 'name': 'User-2'}]
+    mock_response_page_2 = []
+
+    mocker.patch.object(users_instance, 'get_request', side_effect=[mock_response_page_1, mock_response_page_2])
+
+    response = users_instance.get(company_id=123)
+
+    assert isinstance(response, list)
+    assert response == mock_response_page_1
+
+def test_find_user_by_id(users_instance, mocker):
+    # Mock the get method
+    mock_get_response_page_1 = [{'id': 1, 'name': 'User-1'}, {'id': 2, 'name': 'User-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(users_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    user_info = users_instance.find(company_id=123, user_id=1)
+
+    assert isinstance(user_info, dict)
+    assert user_info == {'id': 1, 'name': 'User-1'}
+
+def test_find_user_by_name(users_instance, mocker):
+    # Mock the get method
+    mock_get_response_page_1 = [{'id': 1, 'name': 'User-1'}, {'id': 2, 'name': 'User-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(users_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    user_info = users_instance.find(company_id=123, user_id='User-2')
+
+    assert isinstance(user_info, dict)
+    assert user_info == {'id': 2, 'name': 'User-2'}
+
+def test_find_user_not_found(users_instance, mocker):
+    # Mock the get method to return sample users
+    mock_get_response_page_1 = [{'id': 1, 'name': 'User-1'}, {'id': 2, 'name': 'User-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(users_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    with pytest.raises(NotFoundItemError):
+        users_instance.find(company_id=123, user_id='Nonexistent User')
+
+def test_add_user_to_project(users_instance, mocker):
+    # Mock the post_request method
+    mocker.patch.object(users_instance, 'post_request', return_value=None)
+
+    users_instance.add(company_id=123, project_id=456, user_id=789, permission_template_id=101112)
+
+    users_instance.post_request.assert_called_once_with(
+        api_url="/rest/v1.0/projects/456/users/789/actions/add",
+        additional_headers={"Procore-Company-Id": "123"},
+        params={"project_id": 456},
+        data={"user": {"permission_template_id": 101112}}
+    )
+
+def test_get_vendors(vendors_instance, mocker):
+    # Mock the get_request method to return sample vendors with pagination
+    mock_response_page_1 = [{'id': 1, 'name': 'Vendor-1'}, {'id': 2, 'name': 'Vendor-2'}]
+    mock_response_page_2 = []
+
+    mocker.patch.object(vendors_instance, 'get_request', side_effect=[mock_response_page_1, mock_response_page_2])
+
+    response = vendors_instance.get(company_id=123)
+
+    assert isinstance(response, list)
+    assert response == mock_response_page_1
+
+def test_find_vendor_by_id(vendors_instance, mocker):
+    # Mock the get method
+    mock_get_response_page_1 = [{'id': 1, 'name': 'Vendor-1'}, {'id': 2, 'name': 'Vendor-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(vendors_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    vendor_info = vendors_instance.find(company_id=123, user_id=1)
+
+    assert isinstance(vendor_info, dict)
+    assert vendor_info == {'id': 1, 'name': 'Vendor-1'}
+
+def test_find_vendor_by_name(vendors_instance, mocker):
+    # Mock the get method
+    mock_get_response_page_1 = [{'id': 1, 'name': 'Vendor-1'}, {'id': 2, 'name': 'Vendor-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(vendors_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    vendor_info = vendors_instance.find(company_id=123, user_id='Vendor-2')
+
+    assert isinstance(vendor_info, dict)
+    assert vendor_info == {'id': 2, 'name': 'Vendor-2'}
+
+def test_find_vendor_not_found(vendors_instance, mocker):
+    # Mock the get method to return sample vendors
+    mock_get_response_page_1 = [{'id': 1, 'name': 'Vendor-1'}, {'id': 2, 'name': 'Vendor-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(vendors_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    with pytest.raises(NotFoundItemError):
+        vendors_instance.find(company_id=123, user_id='Nonexistent Vendor')
+
+def test_get_trades(trades_instance, mocker):
+    # Mock the get_request method to return sample trades with pagination
+    mock_response_page_1 = [{'id': 1, 'name': 'Trade-1'}, {'id': 2, 'name': 'Trade-2'}]
+    mock_response_page_2 = []
+
+    mocker.patch.object(trades_instance, 'get_request', side_effect=[mock_response_page_1, mock_response_page_2])
+
+    response = trades_instance.get(company_id=123)
+
+    assert isinstance(response, list)
+    assert response == mock_response_page_1
+
+def test_find_trade_by_id(trades_instance, mocker):
+    # Mock the get method
+    mock_get_response_page_1 = [{'id': 1, 'name': 'Trade-1'}, {'id': 2, 'name': 'Trade-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(trades_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    trade_info = trades_instance.find(company_id=123, user_id=1)
+
+    assert isinstance(trade_info, dict)
+    assert trade_info == {'id': 1, 'name': 'Trade-1'}
+
+def test_find_trade_by_name(trades_instance, mocker):
+    # Mock the get method
+    mock_get_response_page_1 = [{'id': 1, 'name': 'Trade-1'}, {'id': 2, 'name': 'Trade-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(trades_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    trade_info = trades_instance.find(company_id=123, user_id='Trade-2')
+
+    assert isinstance(trade_info, dict)
+    assert trade_info == {'id': 2, 'name': 'Trade-2'}
+
+def test_find_trade_not_found(trades_instance, mocker):
+    # Mock the get method to return sample trades
+    mock_get_response_page_1 = [{'id': 1, 'name': 'Trade-1'}, {'id': 2, 'name': 'Trade-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(trades_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    with pytest.raises(NotFoundItemError):
+        trades_instance.find(company_id=123, user_id='Nonexistent Trade')
diff --git a/tests/unit/test_submittals.py b/tests/unit/test_submittals.py
new file mode 100644
index 0000000..1d187a4
--- /dev/null
+++ b/tests/unit/test_submittals.py
@@ -0,0 +1,69 @@
+import pytest
+from ProPyCore.access.submittals import Submittal
+from ProPyCore.exceptions import NotFoundItemError
+
+# Fixture for Submittal instance
+@pytest.fixture
+def submittal_instance(mocker):
+    mocker.patch('ProPyCore.access.submittals.Base.__init__', return_value=None)  # Mock the base class initializer
+    return Submittal('mock_access_token', 'mock_server_url')
+
+def test_get_submittals(submittal_instance, mocker):
+    # Mock the get_request method to return sample submittals with pagination
+    mock_response_page_1 = [{'id': 1, 'title': 'Submittal-1'}, {'id': 2, 'title': 'Submittal-2'}]
+    mock_response_page_2 = []
+
+    mocker.patch.object(submittal_instance, 'get_request', side_effect=[mock_response_page_1, mock_response_page_2])
+
+    response = submittal_instance.get(company_id=123, project_id=456)
+
+    assert isinstance(response, list)
+    assert response == mock_response_page_1
+
+def test_show_submittal(submittal_instance, mocker):
+    # Mock the get_request method to return a specific submittal
+    mock_response = {'id': 1, 'title': 'Submittal-1'}
+    mocker.patch.object(submittal_instance, 'get_request', return_value=mock_response)
+
+    response = submittal_instance.show(company_id=123, project_id=456, submittal_id=1)
+
+    assert isinstance(response, dict)
+    assert response == mock_response
+
+def test_find_submittal_by_id(submittal_instance, mocker):
+    # Mock the get and show methods
+    mock_get_response_page_1 = [{'id': 1, 'title': 'Submittal-1'}, {'id': 2, 'title': 'Submittal-2'}]
+    mock_get_response_page_2 = []
+    mock_show_response = {'id': 1, 'title': 'Submittal-1'}
+
+    mocker.patch.object(submittal_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+    mocker.patch.object(submittal_instance, 'show', return_value=mock_show_response)
+
+    submittal_info = submittal_instance.find(company_id=123, project_id=456, identifier=1)
+
+    assert isinstance(submittal_info, dict)
+    assert submittal_info == mock_show_response
+
+def test_find_submittal_by_title(submittal_instance, mocker):
+    # Mock the get and show methods
+    mock_get_response_page_1 = [{'id': 1, 'title': 'Submittal-1'}, {'id': 2, 'title': 'Submittal-2'}]
+    mock_get_response_page_2 = []
+    mock_show_response = {'id': 2, 'title': 'Submittal-2'}
+
+    mocker.patch.object(submittal_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+    mocker.patch.object(submittal_instance, 'show', return_value=mock_show_response)
+
+    submittal_info = submittal_instance.find(company_id=123, project_id=456, identifier='Submittal-2')
+
+    assert isinstance(submittal_info, dict)
+    assert submittal_info == mock_show_response
+
+def test_find_submittal_not_found(submittal_instance, mocker):
+    # Mock the get method to return sample submittals
+    mock_get_response_page_1 = [{'id': 1, 'title': 'Submittal-1'}, {'id': 2, 'title': 'Submittal-2'}]
+    mock_get_response_page_2 = []
+
+    mocker.patch.object(submittal_instance, 'get_request', side_effect=[mock_get_response_page_1, mock_get_response_page_2])
+
+    with pytest.raises(NotFoundItemError):
+        submittal_instance.find(company_id=123, project_id=456, identifier='Nonexistent Submittal')