Repository: cedarville-university/tdxlib (language: python)

Issue:
Bad auth credentials results in TypeError
A bad username or password goes undetected until a function attempts to use `self.token`. Ideally, an error would be raised when the status code on the Authn call is not 200.

```python
      "Authorization": 'Bearer ' + self.token,
         TypeError: can only concatenate str (not "NoneType") to str
     ==================================== short test summary info =====================================
     FAILED tests/test_connector.py::test_connectivity - TypeError: can only concatenate str (not "N...
```
allows check auth exp to return false if no authenticated at all. Als…
adds checks to make sure authentication succeeds before proceeding to other calls.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml
index 21f2f01..a58d550 100644
--- a/.github/workflows/pythonpublish.yml
+++ b/.github/workflows/pythonpublish.yml
@@ -17,6 +17,9 @@ jobs:
       run: |
         python -m pip install --upgrade pip
         pip install setuptools wheel twine
+    - name: Run Unit Tests
+      run: |
+        python -m unittest
     - name: Build and publish
       env:
         TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
diff --git a/.gitignore b/.gitignore
index 84a2a1f..3a94d48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
 .DS_Store
 *.ini
 *.json
+*.log
+.vcsode
 .idea/
 __pycache__/
 venv/
diff --git a/examples/example_ticket_generation.py b/examples/example_ticket_generation.py
index fde9d43..49900c7 100644
--- a/examples/example_ticket_generation.py
+++ b/examples/example_ticket_generation.py
@@ -116,7 +116,7 @@
 
     try:
         ticket_data = tdx.generate_ticket(title_text, ticket_type, account, responsible, ticket, body_text, prefix,
-                                          location=location, room=room, form=form, requestor=requestor, group=False,
+                                          location=location, room=room, form=form, requestor=requestor, responsible_is_group=False,
                                           due_date=due_date, active_days=active_days)
         if DEBUG:
             tdxlib.tdx_utils.print_nice(ticket_data.export(validate=False))
diff --git a/setup.py b/setup.py
index af0e7b7..5243e14 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@
 setup(
     name='TDXLib',
     description='a python library for interacting with the TeamDynamix Web API',
-    version='0.3.5',
+    version='0.4.0',
     author='Nat Biggs, Stephen Gaines, Josiah Lansford',
     author_email='tdxlib@cedarville.edu',
     packages=['tdxlib'],
diff --git a/tdxlib/__init__.py b/tdxlib/__init__.py
index 8e9135f..2b13279 100644
--- a/tdxlib/__init__.py
+++ b/tdxlib/__init__.py
@@ -8,4 +8,4 @@
 import tdxlib.tdx_utils
 
 
-__version__ = "0.3.5"
+__version__ = "0.4.0"
diff --git a/tdxlib/tdx_asset_integration.py b/tdxlib/tdx_asset_integration.py
index 56c543c..eacd36b 100644
--- a/tdxlib/tdx_asset_integration.py
+++ b/tdxlib/tdx_asset_integration.py
@@ -2,17 +2,18 @@
 import datetime
 import tdxlib.tdx_utils
 import tdxlib.tdx_integration
+from typing import Union
 from tdxlib.tdx_api_exceptions import *
 
 
 class TDXAssetIntegration(tdxlib.tdx_integration.TDXIntegration):
-    def __init__(self, filename=None):
+    def __init__(self, filename: str = None):
         tdxlib.tdx_integration.TDXIntegration.__init__(self, filename)
         if self.asset_app_id is None:
             raise ValueError("Asset App Id is required. Check your INI file for 'assetappid = 000'")
         self.clean_cache()
 
-    def clean_cache(self):
+    def clean_cache(self) -> None:
         super().clean_cache()
         self.cache['product_model'] = {}
         self.cache['product_type'] = {}
@@ -24,7 +25,7 @@ def clean_cache(self):
         self.cache['custom_attributes'].append(self.get_all_custom_attributes(
             tdxlib.tdx_integration.TDXIntegration.component_ids['configuration_item'], app_id=self.asset_app_id))
 
-    def _make_asset_call(self, url, action, post_body=None):
+    def _make_asset_call(self, url: str, action: str, post_body: Union[dict, list] = None) -> Union[list, dict]:
         url_string = '/' + str(self.asset_app_id) + '/assets'
         if len(url) > 0:
             url_string += '/' + url
@@ -40,7 +41,7 @@ def _make_asset_call(self, url, action, post_body=None):
             return self.make_patch(url_string, post_body)
         raise TdxApiHTTPRequestError('No method' + action + 'or no post information')
 
-    def make_call(self, url, action, post_body=None):
+    def make_call(self, url: str, action: str, post_body: dict = None) -> Union[list, dict]:
         """
         Makes an HTTP call using the Assets API information.
 
@@ -162,7 +163,7 @@ def get_product_type_by_name_id(self, key: str) -> dict:
         raise TdxApiObjectNotFoundError(f'No product type found for {str(key)}')
 
     def create_product_type(self, name: str, description: str = None, parent=None, order: int = 1,
-                            active: bool = True)-> dict:
+                            active: bool = True) -> dict:
         """
         Creates a new Product Type with the information provided.
 
@@ -186,7 +187,7 @@ def create_product_type(self, name: str, description: str = None, parent=None, o
             data['Description'] = description
         return self.make_call('models/types', 'post', data)
 
-    def update_product_type(self, product_type, updated_values: dict)-> dict:
+    def update_product_type(self, product_type: Union[str, dict], updated_values: dict) -> dict:
         """
         Updates an existing product type
 
@@ -209,7 +210,7 @@ def update_product_type(self, product_type, updated_values: dict)-> dict:
         return self.make_call(f'models/types/{product_type_id}', 'put', product_type)
 
     def search_product_types(self, search_string: str = '*', active: bool = True, root_only: bool = False,
-                             parent=None)-> list:
+                             parent=None) -> list:
         """
         Searches product types by parent, text, or parent.
 
@@ -242,7 +243,7 @@ def get_all_product_models(self) -> list:
         return self.make_call("models", 'get')
 
     # TODO: Provide option for lower memory use by allowing use of search instead of getting all.
-    def get_product_model_by_name_id(self, key: str) -> dict:
+    def get_product_model_by_name_id(self, key: Union[str, int]) -> dict:
         """
         Gets a specific product model object
 
@@ -258,7 +259,7 @@ def get_product_model_by_name_id(self, key: str) -> dict:
                 return product_model
         raise TdxApiObjectNotFoundError(f'No product model found for {str(key)}')
 
-    def get_all_product_models_of_type(self, product_type) -> list:
+    def get_all_product_models_of_type(self, product_type: Union[str, dict]) -> list:
         """
         Get all product models of a specific type
 
@@ -279,8 +280,8 @@ def get_all_product_models_of_type(self, product_type) -> list:
                 result.append(model)
         return result
 
-    def create_product_model(self, name: str, product_type, source: str, description: str = None,
-                             part_number: str = None, active: bool = True, attributes: dict= None) -> dict:
+    def create_product_model(self, name: str, product_type: Union[str, dict], source: str, description: str = None,
+                             part_number: str = None, active: bool = True, attributes: dict = None) -> dict:
         """
         Creates a new Product Model with the information provided.
 
@@ -325,7 +326,7 @@ def get_all_vendors(self) -> list:
         return self.make_call("vendors", 'get')
 
     # TODO: Provide option for lower memory use by allowing use of search instead of getting all.
-    def get_vendor_by_name_id(self, key):
+    def get_vendor_by_name_id(self, key: str) -> dict:
         """
         Gets a specific vendor object
 
@@ -344,7 +345,7 @@ def get_vendor_by_name_id(self, key):
     # TODO: def update_vendor(self, updated_values)-> dict:
     # TODO: def search_vendor(self, key, etc) -> list:
 
-    def create_vendor(self, name, email: str = None, description: str = None, account_number: str = None,
+    def create_vendor(self, name: str, email: str = None, description: str = None, account_number: str = None,
                       additional_info: dict = None, active=True) -> dict:
 
         """
@@ -354,7 +355,7 @@ def create_vendor(self, name, email: str = None, description: str = None, accoun
        :param email: An email contact for the new vendor (optional)
        :param description: A description of the model (optional)
        :param account_number: An account number with the vendor (optional)
-       :param active: Boolean indicating whether or not the new vendor should be active (optional, default True)
+       :param active: Boolean indicating whether the new vendor should be active (optional, default True)
        :param additional_info: Dict of other info for the vendor (including CAs, no validation yet -- build by hand)
 
        :return: dict of created product type
@@ -374,7 +375,7 @@ def create_vendor(self, name, email: str = None, description: str = None, accoun
 
     # TODO: def delete_vendor(self)-> dict:
 
-    def get_asset_by_id(self, asset_id: str) -> dict:
+    def get_asset_by_id(self, asset_id: Union[str, int]) -> dict:
         """
         Gets a specfic asset object, including the full list of attributes.
 
@@ -385,7 +386,7 @@ def get_asset_by_id(self, asset_id: str) -> dict:
         """
         return self.make_call(str(asset_id), 'get')
 
-    def add_asset_user(self, asset_id: str, user_uid: str):
+    def add_asset_user(self, asset_id: str, user_uid: str) -> dict:
         """
         Adds a users to an asset
 
@@ -407,7 +408,7 @@ def get_asset_users(self, asset_id: str) -> list:
         """
         return self.make_call(f'{asset_id}/users', 'get')
 
-    def delete_asset_users(self, asset_id: str, users: list):
+    def delete_asset_users(self, asset_id: str, users: list[Union[str, dict]]):
         """
         Deletes specified users of an asset
 
@@ -423,12 +424,16 @@ def delete_asset_users(self, asset_id: str, users: list):
         for user in users:
             if isinstance(user, str):
                 id_to_delete = user
-            else:
+            elif isinstance(user, dict):
                 id_to_delete = user['Value']
+            else:
+                raise TdxApiObjectTypeError(
+                    f"Users of type {str(type(user))} not parseable."
+                )
             results.append(self.make_call(f'{asset_id}/users/{id_to_delete}', 'delete'))
 
-    def search_assets(self, criteria, max_results=25, retired=False, disposed=False, full_record=False,
-                      all_statuses: bool = False) -> list:
+    def search_assets(self, criteria: Union[str, dict], max_results=25, retired=False, disposed=False,
+                      full_record=False, all_statuses: bool = False) -> list:
         """
         Searches for assets, based on criteria
 
@@ -474,7 +479,7 @@ def search_assets(self, criteria, max_results=25, retired=False, disposed=False,
         search_body = {'MaxResults': str(max_results)}
         if isinstance(criteria, str):
             search_body['SearchText'] = criteria
-            search_body['StatusIDs'] =  default_statuses
+            search_body['StatusIDs'] = default_statuses
         elif isinstance(criteria, dict):
             search_body.update(criteria)
             if 'StatusIDs' not in search_body:
@@ -491,11 +496,11 @@ def search_assets(self, criteria, max_results=25, retired=False, disposed=False,
         else:
             return asset_list
 
-    def find_asset_by_tag(self, tag, full_record: bool = False, all_statuses: bool = True) -> dict:
+    def find_asset_by_tag(self, tag: str, full_record: bool = False, all_statuses: bool = True) -> dict:
         """
         Gets an asset based on its asset tag
 
-        :param tag: asset tag as a string or int
+        :param tag: asset tag as a string
         :param full_record: boolean indicating whether to fetch the full Asset record, or just summary info
         :param all_statuses: gets assets, regardless of what their status is (default: True)
 
@@ -503,8 +508,7 @@ def find_asset_by_tag(self, tag, full_record: bool = False, all_statuses: bool =
         :return: the single asset with the corresponding tag
 
         """
-        if type(tag) is str:
-            tag = tag.lstrip('0')
+        tag = tag.lstrip('0')
         search_params = {'SearchText': str(tag)}
         result = self.search_assets(search_params, disposed=True, retired=True,
                                     full_record=full_record, all_statuses=all_statuses)
@@ -517,11 +521,11 @@ def find_asset_by_tag(self, tag, full_record: bool = False, all_statuses: bool =
         raise TdxApiObjectNotFoundError(
             f"{str(len(result))} assets with tag {str(tag)} found.")
 
-    def find_asset_by_sn(self, sn, full_record: bool = False, all_statuses: bool = True) -> dict:
+    def find_asset_by_sn(self, sn: str, full_record: bool = False, all_statuses: bool = True) -> dict:
         """
         Gets an asset based on its serial number
 
-        :param sn: serial number as a string or int
+        :param sn: serial number as a string
         :param full_record: boolean indicating whether to fetch the full Asset record, or just summary info
         :param all_statuses: gets assets, regardless of what their status is (default: True)
 
@@ -536,8 +540,9 @@ def find_asset_by_sn(self, sn, full_record: bool = False, all_statuses: bool = T
         raise TdxApiObjectNotFoundError(
             f"{str(len(result))} assets with SN {str(sn)} found.")
 
-    def get_assets_by_location(self, location, max_results: int = 5000, full_record: bool = False,
-                               retired: bool = False, disposed: bool = False, all_statuses: bool = False) -> list:
+    def get_assets_by_location(self, location: Union[str, dict, list], max_results: int = 5000,
+                               full_record: bool = False, retired: bool = False, disposed: bool = False,
+                               all_statuses: bool = False) -> list:
         """
         Gets all assets in a location
 
@@ -607,8 +612,8 @@ def get_assets_by_owner(self, person: str, max_results: int = 25, full_record: b
                                   max_results=max_results, full_record=full_record,
                                   disposed=disposed, retired=retired, all_statuses=all_statuses)
 
-    def get_assets_by_requesting_department(self, dept, max_results: int = 25, full_record: bool = False,
-                                            retired: bool = False, disposed: bool = False,
+    def get_assets_by_requesting_department(self, dept: Union[dict, str], max_results: int = 25,
+                                            full_record: bool = False, retired: bool = False, disposed: bool = False,
                                             all_statuses: bool = False) -> list:
         """
         Gets all assets requested by a particular account/department in TDX
@@ -632,8 +637,9 @@ def get_assets_by_requesting_department(self, dept, max_results: int = 25, full_
                                   max_results=max_results, full_record=full_record,
                                   disposed=disposed, retired=retired, all_statuses=all_statuses)
 
-    def get_assets_by_product_model(self, model, max_results: int = 25, full_record: bool = False,
-                                    retired: bool = False, disposed: bool = False, all_statuses: bool = False) -> list:
+    def get_assets_by_product_model(self, model: Union[dict, str, int], max_results: int = 25,
+                                    full_record: bool = False, retired: bool = False, disposed: bool = False,
+                                    all_statuses: bool = False) -> list:
         """
         Gets all assets of a certain product model
 
@@ -648,7 +654,7 @@ def get_assets_by_product_model(self, model, max_results: int = 25, full_record:
         :return: a list of assets of the specified model
 
         """
-        if isinstance(model, str):
+        if isinstance(model, str) or isinstance(model, int):
             model = self.get_product_model_by_name_id(model)
         if not isinstance(model, dict):
             raise TdxApiObjectTypeError("Can't search assets with type" +
@@ -657,8 +663,9 @@ def get_assets_by_product_model(self, model, max_results: int = 25, full_record:
                                   max_results=max_results, full_record=full_record,
                                   disposed=disposed, retired=retired, all_statuses=all_statuses)
 
-    def get_assets_by_product_type(self, product_type, max_results: int = 25, full_record: bool = False,
-                                   retired: bool = False, disposed: bool = False, all_statuses: bool = False) -> list:
+    def get_assets_by_product_type(self, product_type: Union[dict, str, int], max_results: int = 25,
+                                   full_record: bool = False, retired: bool = False, disposed: bool = False,
+                                   all_statuses: bool = False) -> list:
         """
         Gets all assets of a certain product type
 
@@ -684,7 +691,8 @@ def get_assets_by_product_type(self, product_type, max_results: int = 25, full_r
                                   max_results=max_results, full_record=full_record,
                                   disposed=disposed, retired=retired, all_statuses=all_statuses)
 
-    def update_assets(self, assets, changed_attributes: dict, clear_custom_attributes=False) -> list:
+    def update_assets(self, assets: Union[dict, str, int, list], changed_attributes: dict,
+                      clear_custom_attributes: bool = False) -> list:
         """
         Updates data in a list of assets
 
@@ -745,7 +753,7 @@ def update_assets(self, assets, changed_attributes: dict, clear_custom_attribute
             updated_assets.append(self.make_call(str(full_asset['ID']), 'post', full_asset))
         return updated_assets
 
-    def change_asset_owner(self, asset, new_owner, new_dept=None) -> list:
+    def change_asset_owner(self, asset: Union[dict, str, int, list], new_owner, new_dept=None) -> list:
         """
         Updates owner data in a list of assets
 
@@ -776,7 +784,7 @@ def change_asset_owner(self, asset, new_owner, new_dept=None) -> list:
                 )
         return self.update_assets(asset, changed_attributes)
 
-    def change_asset_location(self, asset, new_location, new_room=None):
+    def change_asset_location(self, asset: Union[dict, str, int, list], new_location, new_room=None):
         """
         Updates Location data in a list of assets
 
@@ -809,7 +817,7 @@ def change_asset_location(self, asset, new_location, new_room=None):
                 )
         return self.update_assets(asset, changed_attributes)
 
-    def change_asset_requesting_dept(self, asset, new_dept)-> list:
+    def change_asset_requesting_dept(self, asset: Union[dict, str, int, list], new_dept) -> list:
         """
         Updates Requesting Department data in a list of assets
 
@@ -830,7 +838,8 @@ def change_asset_requesting_dept(self, asset, new_dept)-> list:
             )
         return self.update_assets(asset, changed_attributes)
 
-    def build_asset_custom_attribute_value(self, custom_attribute, value) -> dict:
+    def build_asset_custom_attribute_value(self, custom_attribute: Union[dict, str, int],
+                                           value: Union[datetime.datetime, str, int]) -> dict:
         """
         Builds a custom attribute for an asset from the name of the attribute and value.
 
@@ -853,9 +862,11 @@ def build_asset_custom_attribute_value(self, custom_attribute, value) -> dict:
             value = ca_choice['ID']
         if isinstance(value, datetime.datetime):
             value = tdxlib.tdx_utils.export_tdx_date(value)
+        if isinstance(value, int):
+            value = str(value)
         return {'ID': ca['ID'], 'Value': value}
 
-    def change_asset_custom_attribute_value(self, asset, custom_attributes: list) -> list:
+    def change_asset_custom_attribute_value(self, asset: Union[dict, str, int, list], custom_attributes: list) -> list:
         """
         Takes a correctly formatted list of CA's (from build_asset_custom_attribute_value, for instance)
         and updates one or more assets with the new values.
@@ -867,7 +878,8 @@ def change_asset_custom_attribute_value(self, asset, custom_attributes: list) ->
         to_change = {'Attributes': custom_attributes}
         return self.update_assets(asset, to_change)
 
-    def clear_asset_custom_attributes(self, asset: dict, attributes_to_clear: list):
+    def clear_asset_custom_attributes(self, asset: Union[dict, str, int],
+                                      attributes_to_clear: Union[str, list]) -> dict:
         """
         Takes a list of CA names and removes those custom attributes from the provided asset
 
@@ -877,24 +889,32 @@ def clear_asset_custom_attributes(self, asset: dict, attributes_to_clear: list):
         """
         if not isinstance(attributes_to_clear, list):
             attributes_to_clear = [attributes_to_clear]
-        full_asset = self.get_asset_by_id(asset['ID'])
+        if isinstance(asset, int) or isinstance(asset, str):
+            full_asset = self.get_asset_by_id(asset)
+        elif isinstance(asset, dict):
+            full_asset = self.get_asset_by_id(asset['ID'])
+        else:
+            raise TdxApiObjectTypeError(
+                f"Asset of type {str(type(asset))} not searchable."
+            )
         to_change = {'Attributes': []}
         for ca in full_asset['Attributes']:
             if ca['Name'] not in attributes_to_clear:
                 to_change['Attributes'].append(ca)
         return self.update_assets(full_asset, to_change, clear_custom_attributes=True)[0]
 
-    def get_asset_custom_attribute_value_by_name(self, asset, key: str, id_only: bool=False) -> str:
+    def get_asset_custom_attribute_value_by_name(self, asset: Union[dict, str, int], key: str, id_only: bool = False) \
+            -> str:
         """
         Returns the current value of a specific CA in the specified asset
 
         :param asset: asset to get CA value for, in dict or ID form
         :param key: Name or ID of CA to find in the asset
-        :param id: (Default: False) Return the ID of the value, instead of ValueText (only for choice-based CA's)
+        :param id_only: (Default: False) Return the ID of the value, instead of ValueText (only for choice-based CA's)
 
         :return: a string representation of the value or ID
         """
-        if isinstance(asset, str):
+        if isinstance(asset, str) or isinstance(asset, int):
             this_asset = self.get_asset_by_id(asset)
         elif isinstance(asset, dict):
             this_asset = asset
@@ -910,7 +930,7 @@ def get_asset_custom_attribute_value_by_name(self, asset, key: str, id_only: boo
                 else:
                     return ca['ValueText']
 
-    def move_child_assets(self, source_asset, target_asset) -> list:
+    def move_child_assets(self, source_asset: Union[dict, str, int], target_asset: Union[dict, str, int]) -> list:
         """
         Moves child assets from one parent asset to another
 
@@ -933,8 +953,9 @@ def move_child_assets(self, source_asset, target_asset) -> list:
         children = self.search_assets(search_params, all_statuses=True)
         return self.update_assets(children, update_params)
 
-    def copy_asset_attributes(self, source_asset, target_asset, copy_name=False, exclude=None, new_status: str = None,
-                              new_name: str = None, is_full_source=False):
+    def copy_asset_attributes(self, source_asset: dict, target_asset: dict, copy_name: bool = False,
+                              exclude: list = None, new_status: str = None, new_name: str = None,
+                              is_full_source: bool = False):
         """
         Copies asset attributes from one asset to another. Does not include attributes like Serial Number, Asset Tag,
             and other hardware-specific fields.
@@ -979,10 +1000,12 @@ def copy_asset_attributes(self, source_asset, target_asset, copy_name=False, exc
             updated_source = self.update_assets(source_id, update_params)
         return [updated_target, updated_source]
 
-    def build_asset(self, asset_name, serial_number, status_name, location_name=None, room_name=None,
-                    asset_tag=None, acquisition_date=None, asset_lifespan=None, requester=None,
-                    requesting_dept=None, owner=None, owning_dept=None, parent=None, external_id=None,
-                    product_model=None, form=None, asset_custom_attributes=None):
+    def build_asset(self, asset_name: str, serial_number: str, status_name: str, location_name: str = None,
+                    room_name: str = None, asset_tag: str = None, acquisition_date: datetime.datetime = None,
+                    asset_lifespan_years: int = None, requester: str = None, requesting_dept: str = None,
+                    owner: str = None, owning_dept: str = None, parent: Union[int, str] = None, external_id: str = None,
+                    product_model: str = None, form: str = None, asset_custom_attributes: Union[list, dict] = None,
+                    supplier: str = None, replacement_date: datetime.datetime = None) -> dict:
         """
         Makes a correctly-formatted dict of asset attributes for inputting into create_asset() function
 
@@ -992,8 +1015,8 @@ def build_asset(self, asset_name, serial_number, status_name, location_name=None
         :param location_name: String with name of location for new asset (optional)
         :param room_name: String with name of room for new asset (optional, requires location_name)
         :param asset_tag: String with asset tag value for new asset (optional)
-        :param acquisition_date: Building name of location (Default: date of execution)
-        :param asset_lifespan: Years you expect this device to be in service (Default: 4)
+        :param acquisition_date: Datetime for Acquisition date (Default: date of execution)
+        :param asset_lifespan_years: Number of years you expect this device to be in service (Default: None)
         :param requester: String with email of requester for new asset (Default: integration username)
         :param requesting_dept: Account Name of requesting department for new asset
         :param owner: String with Email of owner of new asset
@@ -1003,9 +1026,13 @@ def build_asset(self, asset_name, serial_number, status_name, location_name=None
         :param product_model: String with name of product model
         :param form: Name of the Asset form to use
         :param asset_custom_attributes: a dictionary of asset custom attribute values (or list from asset['Attributes'])
+        :param supplier: name of a TDX vendor to set as supplier (Default: manufacturer from product model)
+        :param replacement_date: datetime object for expected replacement of asset (Default: set by asset_lifespan)
 
         :return: dict usable in create_asset()
 
+        :rtype: dict
+
         """
         # set defaults
         if not acquisition_date:
@@ -1040,8 +1067,12 @@ def build_asset(self, asset_name, serial_number, status_name, location_name=None
             data['LocationID'] = building['ID']
             if room_name:
                 data['LocationRoomID'] = self.get_room_by_name(building, room_name)['ID']
-        if asset_lifespan:
-            expected_replacement_date = acquisition_date + datetime.timedelta(days=(int(asset_lifespan*365.25)))
+        if replacement_date:
+            expected_replacement_date = replacement_date
+            data['ExpectedReplacementDate'] = expected_replacement_date.strftime('%Y-%m-%dT%H:%M:%SZ')
+        elif asset_lifespan_years:
+            expected_replacement_date = acquisition_date + \
+                                        datetime.timedelta(days=(int(asset_lifespan_years * 365.25)))
             data['ExpectedReplacementDate'] = expected_replacement_date.strftime('%Y-%m-%dT%H:%M:%SZ')
         if requester:
             data['RequestingCustomerID'] = self.get_person_by_name_email(requester)['UID']
@@ -1071,9 +1102,11 @@ def build_asset(self, asset_name, serial_number, status_name, location_name=None
                     parent_asset = self.search_assets(parent, max_results=1, all_statuses=True)
                 if parent_asset:
                     data['ParentID'] = parent_asset['ID']
+        if supplier:
+            data['SupplierID'] = self.get_vendor_by_name_id(supplier)['ID']
         return data
 
-    def create_asset(self, asset, check_duplicate=True):
+    def create_asset(self, asset: dict, check_duplicate: bool = True) -> dict:
         """
         Creates an asset
 
diff --git a/tdxlib/tdx_integration.py b/tdxlib/tdx_integration.py
index c830a53..b3746b3 100644
--- a/tdxlib/tdx_integration.py
+++ b/tdxlib/tdx_integration.py
@@ -29,7 +29,7 @@ class TDXIntegration:
         'vendor': 28
     }
 
-    def __init__(self, filename=None):
+    def __init__(self, filename=None, config=None):
         self.settings = None
         self.api_url = None
         self.username = None
@@ -45,8 +45,16 @@ def __init__(self, filename=None):
         if filename is not None:
             self.config.read(filename)
         else:
-            filename = 'tdxlib.ini'
-            self.config.read(filename)
+            if not config:
+                filename = 'tdxlib.ini'
+                self.config.read(filename)
+            else:
+                self.config.read_dict(config)
+                if 'TDX API Settings' not in self.config:
+                    raise ValueError(
+                        "Missing config section: 'TDX API Settings'")
+
+
         if 'TDX API Settings' not in self.config:
             self.config['TDX API Settings'] = {
                 'orgname': 'myuniversity',
@@ -178,18 +186,18 @@ def __init__(self, filename=None):
         if fullhost is None:
             self.api_url = 'https://' + self.org_name + '.teamdynamix.com' + api_end
         else:
-            self.api_url = 'https://' + fullhost + '/' + api_end
+            self.api_url = 'https://' + fullhost + api_end
         if self.password == 'Prompt':
             pass_prompt = 'Enter the TDX Password for user ' + self.username + '(this password will not be stored): '
             self.password = getpass.getpass(pass_prompt)
         if self.log_level:
             self.logger = logging.getLogger('tdx_integration')
             self.logger.setLevel(logging.getLevelName(self.log_level))
-        self.auth()
-        self.cache = {}
+        if not (self.auth()):
+            self.logger.error(f"Login Failed. Username or password in {filename} likely incorrect.")
         self.clean_cache()
 
-    def auth(self):
+    def auth(self) -> bool:
         try:
             response = requests.post(
                 url=str(self.api_url) + '/auth',
@@ -203,7 +211,7 @@ def auth(self):
             )
             if response.status_code != 200:
                 raise tdxlib.tdx_api_exceptions.TdxApiHTTPError(" Response code: " + str(response.status_code) + " " +
-                                                                response.reason + "\n" + " Returned: " + response.text)
+                                                                response.reason + " " + " Returned: " + response.text)
             else:
                 self.token = response.text
                 time.sleep(1)
@@ -213,22 +221,30 @@ def auth(self):
                                      options={'verify_signature':False},
                                      audience="https://www.teamdynamix.com/")
                 self.token_exp = decoded['exp']
+                return True
 
         except requests.exceptions.RequestException as e:
             self.logger.warning(f"Auth request Failed. Exception: {str(e)}")
+            return False
         except tdxlib.tdx_api_exceptions.TdxApiHTTPError as e:
             self.logger.error(str(e))
+            return False
 
-    def _check_auth_exp(self):
+    def _check_auth_exp(self) -> bool:
         """
         Internal method to check the expiration of the stored access token.
         If it is expired, call auth() to get a new token.
         """
         # If token is expired or will expire in the next minute, get new token
-        if self.token_exp and self.token_exp < time.time() + 60:
-            self.logger.info(f"Token expires at {str(datetime.datetime.utcfromtimestamp(self.token_exp))}. "
-                             f"Getting new token...")
-            self.auth()
+        if self.token_exp:
+            if self.token_exp < time.time() + 60:
+                self.logger.info(f"Token expires at {str(datetime.datetime.utcfromtimestamp(self.token_exp))}. "
+                                 f"Getting new token...")
+                return self.auth()
+            else:
+                return True
+        else:
+            return self.auth()
 
     def _rate_limit(self, skew_mitigation_secs=5):
         """
@@ -259,12 +275,14 @@ def make_get(self, request_url: str, retries: int = 3):
 
         """
         self._rate_limit()
-        self._check_auth_exp()
         get_url = self.api_url + request_url
         response = None
         attempts = 0
         while attempts < retries:
             try:
+                if not (self._check_auth_exp()):
+                    raise tdxlib.tdx_api_exceptions.TdxApiHTTPError(
+                        f"Login Failed. Username or password in config likely incorrect.")
                 response = requests.get(
                     url=get_url,
                     headers={
@@ -274,7 +292,7 @@ def make_get(self, request_url: str, retries: int = 3):
                 )
                 if response.status_code != 200:
                     err_string = " Response code: " + str(response.status_code) + \
-                        " " + response.reason + "\n" + " Returned: " + response.text
+                        " " + response.reason + " " + " Returned: " + response.text
                     raise tdxlib.tdx_api_exceptions.TdxApiHTTPError(err_string)
                 val = response.json()
                 self.cache['rate_limit']['remaining'] = int(response.headers['X-RateLimit-Remaining'])
@@ -305,10 +323,12 @@ def make_post(self, request_url: str, body: dict):
 
         """
         self._rate_limit()
-        self._check_auth_exp()
         post_url = self.api_url + request_url
         response = None
         try:
+            if not (self._check_auth_exp()):
+                raise tdxlib.tdx_api_exceptions.TdxApiHTTPError(
+                    f"Login Failed. Username or password in config likely incorrect.")
             response = requests.post(
                 url=post_url,
                 headers={
@@ -351,7 +371,6 @@ def make_file_post(self, request_url: str, file: BinaryIO, filename: str = None)
         :return: the API's response as a python dict
         """
         self._rate_limit()
-        self._check_auth_exp()
         post_url = self.api_url + request_url
         response = None
         if filename:
@@ -359,6 +378,9 @@ def make_file_post(self, request_url: str, file: BinaryIO, filename: str = None)
         else:
             files = {'file': file}
         try:
+            if not (self._check_auth_exp()):
+                raise tdxlib.tdx_api_exceptions.TdxApiHTTPError(
+                    f"Login Failed. Username or password in config likely incorrect.")
             response = requests.post(
                 url=post_url,
                 headers={
@@ -397,10 +419,12 @@ def make_put(self, request_url: str, body: dict):
 
         """
         self._rate_limit()
-        self._check_auth_exp()
         put_url = self.api_url + request_url
         response = None
         try:
+            if not (self._check_auth_exp()):
+                raise tdxlib.tdx_api_exceptions.TdxApiHTTPError(
+                    f"Login Failed. Username or password in config likely incorrect.")
             response = requests.put(
                 url=put_url,
                 headers={
@@ -437,9 +461,12 @@ def make_delete(self, request_url: str):
 
         """
         self._rate_limit()
-        self._check_auth_exp()
+
         delete_url = self.api_url + request_url
         try:
+            if not (self._check_auth_exp()):
+                raise tdxlib.tdx_api_exceptions.TdxApiHTTPError(
+                    f"Login Failed. Username or password in config likely incorrect.")
             response = requests.delete(
                 url=delete_url,
                 headers={
@@ -473,10 +500,12 @@ def make_patch(self, request_url: str, body: list):
 
         """
         self._rate_limit()
-        self._check_auth_exp()
         patch_url = self.api_url + request_url
         response = None
         try:
+            if not (self._check_auth_exp()):
+                raise tdxlib.tdx_api_exceptions.TdxApiHTTPError(
+                    f"Login Failed. Username or password in config likely incorrect.")
             response = requests.patch(
                 url=patch_url,
                 headers={
diff --git a/tdxlib/tdx_ticket_integration.py b/tdxlib/tdx_ticket_integration.py
index 878b590..5a2e36b 100644
--- a/tdxlib/tdx_ticket_integration.py
+++ b/tdxlib/tdx_ticket_integration.py
@@ -1,7 +1,10 @@
+import tdx_api_exceptions
+import tdx_ticket
 import tdxlib.tdx_ticket
 import datetime
 import tdxlib.tdx_integration
 import tdxlib.tdx_api_exceptions
+from typing import Union
 from typing import BinaryIO
 
 
@@ -27,8 +30,8 @@ class TDXTicketIntegration(tdxlib.tdx_integration.TDXIntegration):
         'Requested': 6
     }
 
-    def __init__(self, filename=None):
-        tdxlib.tdx_integration.TDXIntegration.__init__(self, filename)
+    def __init__(self, filename: str = None, config=None):
+        tdxlib.tdx_integration.TDXIntegration.__init__(self, filename, config)
         if self.ticket_app_id is None:
             raise ValueError("Ticket App Id is required. Check your config file for 'ticketappid = 000'")
         self.clean_cache()
@@ -52,7 +55,7 @@ def clean_cache(self):
     def get_url_string(self):
         return '/' + str(self.ticket_app_id) + '/tickets'
 
-    def _make_ticket_call(self, url, action, post_body=None):
+    def _make_ticket_call(self, url: str, action: str, post_body: dict = None) -> dict:
         url_string = self.get_url_string()
         if len(url) > 0:
             url_string += '/' + url
@@ -68,13 +71,13 @@ def _make_ticket_call(self, url, action, post_body=None):
             return self.make_patch(url_string, post_body)
         raise tdxlib.tdx_api_exceptions.TdxApiHTTPRequestError('No method ' + action + ' or no post information')
 
-    def make_call(self, url: str, action: str, post_body=None):
+    def make_call(self, url: str, action: str, post_body: dict = None):
         """
         Makes an HTTP call using the Tickets API information.
 
         :param url: The URL (everything after tickets/) to call
         :param action: The HTTP action (get, put, post, delete, patch) to perform.
-        :param post_body: A python dict of the information to post, put, or patch. Not used for get/delete.
+        :param post_body: A dict of the information to post, put, or patch. Not used for get/delete.
 
         :return: the API's response as a python dict or list
 
@@ -84,9 +87,9 @@ def make_call(self, url: str, action: str, post_body=None):
     def get_all_ticket_custom_attributes(self):
         return self.get_all_custom_attributes(TDXTicketIntegration.component_ids['ticket'], app_id=self.ticket_app_id)
 
-    def get_ticket_custom_attribute_by_name(self, key: str) -> dict:
+    def get_ticket_custom_attribute_by_name_id(self, key: str) -> dict:
         """
-        Gets a ticket custom attribute based on its name. This hard-codes the component ID for tickets.
+        Gets a ticket custom attribute based on its name or ID. This includes hard-coded the component ID for tickets.
 
         :param key: A full or partial name of the CA to get.
 
@@ -97,6 +100,34 @@ def get_ticket_custom_attribute_by_name(self, key: str) -> dict:
         """
         return self.get_custom_attribute_by_name_id(key, TDXTicketIntegration.component_ids['ticket'])
 
+    # Alias names
+    get_ticket_custom_attribute_by_name = get_ticket_custom_attribute_by_name_id
+
+    def build_ticket_custom_attribute_value(self, custom_attribute: Union[str, dict], value: Union[str, int]) -> dict:
+        """
+        Builds a custom attribute for a ticket from the name of the attribute and value.
+
+        :param custom_attribute: name of custom attribute (or dict of info from )
+        :param value: name of value to set, or value to set to
+
+        :return: list of updated assets in dict format (for use in change_custom_attribute_value())
+
+        """
+        if isinstance(custom_attribute, str) or isinstance(custom_attribute, int):
+            ca = self.get_ticket_custom_attribute_by_name_id(str(custom_attribute))
+        elif isinstance(custom_attribute, dict):
+            ca = custom_attribute
+        else:
+            raise tdx_api_exceptions.TdxApiObjectTypeError(
+                f"Custom Attribute of type {str(type(custom_attribute))} not searchable."
+            )
+        if len(ca['Choices']) > 0:
+            ca_choice = self.get_custom_attribute_choice_by_name_id(ca, value)
+            value = ca_choice['ID']
+        if isinstance(value, datetime.datetime):
+            value = tdxlib.tdx_utils.export_tdx_date(value)
+        return {'ID': ca['ID'], 'Value': value}
+
     # #### GETTING TICKETS #### #
 
     def get_ticket_by_id(self, ticket_id: int) -> tdxlib.tdx_ticket.TDXTicket:
@@ -176,7 +207,7 @@ def search_tickets(self, criteria: dict, max_results: int = 25, closed: bool = F
 
     # #### CHANGING TICKETS #### #
 
-    def edit_ticket(self, ticket, changed_attributes: dict,
+    def edit_ticket(self, ticket: Union[tdx_ticket.TDXTicket, str, int], changed_attributes: dict,
                     notify: bool = False) -> tdxlib.tdx_ticket.TDXTicket:
         """
         Edits one ticket, based on a dict of parameters to change.
@@ -225,7 +256,8 @@ def edit_tickets(self, ticket_list: list, changed_attributes: dict,
                 print('.', end='')
         return edited_tickets
 
-    def reassign_ticket(self, ticket_id: int, responsible: str, group=False) -> tdxlib.tdx_ticket.TDXTicket:
+    def reassign_ticket(self, ticket_id: Union[str, int], responsible: str, group: bool = False) \
+            -> tdxlib.tdx_ticket.TDXTicket:
         """
         Reassigns a ticket  to a person or group
     
@@ -244,7 +276,7 @@ def reassign_ticket(self, ticket_id: int, responsible: str, group=False) -> tdxl
             reassign = {'ResponsibleUid': self.get_person_by_name_email(responsible)['UID']}
         return self.edit_ticket(ticket_id, reassign)
 
-    def reschedule_ticket(self, ticket_id, start_date: datetime.datetime = False,
+    def reschedule_ticket(self, ticket_id: Union[str, int], start_date: datetime.datetime = False,
                           end_date: datetime.datetime = False) -> tdxlib.tdx_ticket.TDXTicket:
         """
         Reschedules the start and end dates of a ticket. This is impossible if the ticket has a task.
@@ -268,7 +300,7 @@ def reschedule_ticket(self, ticket_id, start_date: datetime.datetime = False,
         }
         return self.edit_ticket(ticket_id, new_dates)
 
-    def update_ticket(self, ticket_id: int, comments: str, new_status: str = None, notify: list = None,
+    def update_ticket(self, ticket_id: Union[str, int], comments: str, new_status: str = None, notify: list = None,
                       private: bool = True) -> dict:
         """
         Sends an update to a ticket feed.
@@ -298,7 +330,7 @@ def update_ticket(self, ticket_id: int, comments: str, new_status: str = None, n
             data['NewStatusID'] = 0
         return self.make_call(url_string, 'post', data)
 
-    def upload_attachment(self, ticket_id: int, file: BinaryIO, filename: str = None):
+    def upload_attachment(self, ticket_id: Union[str, int], file: BinaryIO, filename: str = None):
         """
         Uploads an attachment to a ticket.
 
@@ -316,7 +348,7 @@ def upload_attachment(self, ticket_id: int, file: BinaryIO, filename: str = None
 
     # #### GETTING TICKET ATTRIBUTES #### #
 
-    def get_ticket_feed(self, ticket_id: int) -> list:
+    def get_ticket_feed(self, ticket_id: Union[str, int]) -> list:
         """
         Gets the feed entries from a ticket.
 
@@ -342,7 +374,7 @@ def get_all_ticket_forms(self) -> list:
         url_string = "forms"
         return self.make_call(url_string, 'get')
 
-    def get_ticket_form_by_name_id(self, key) -> dict:
+    def get_ticket_form_by_name_id(self, key: Union[str, int]) -> dict:
         """
         Gets ticket form based on ID or Name.
 
@@ -375,7 +407,7 @@ def get_all_ticket_types(self) -> list:
         url_string = "types"
         return self.make_call(url_string, 'get')
 
-    def get_ticket_type_by_name_id(self, key) -> dict:
+    def get_ticket_type_by_name_id(self, key: Union[str, int]) -> dict:
         """
         Gets ticket type based on ID or Name.
 
@@ -409,7 +441,7 @@ def get_all_ticket_statuses(self) -> list:
         url_string = "statuses"
         return self.make_call(url_string, 'get')
 
-    def get_ticket_status_by_id(self, key) -> dict:
+    def get_ticket_status_by_id(self, key: Union[str, int]) -> dict:
         """
         Gets ticket status based on ID or Name.
 
@@ -425,7 +457,7 @@ def get_ticket_status_by_id(self, key) -> dict:
             self.cache['ticket_status'][key] = self.make_call(url_string, 'get')
         return self.cache['ticket_status'][key]
 
-    def search_ticket_status(self, key) -> dict:
+    def search_ticket_status(self, key: str) -> dict:
         """
         Gets ticket status based on name.
 
@@ -465,7 +497,7 @@ def get_all_ticket_priorities(self) -> list:
         url_string = "priorities"
         return self.make_call(url_string, 'get')
 
-    def get_ticket_priority_by_name_id(self, key) -> dict:
+    def get_ticket_priority_by_name_id(self, key: Union[str, int]) -> dict:
         """
         Gets ticket priority based on ID or Name.
 
@@ -497,7 +529,7 @@ def get_all_ticket_urgencies(self) -> list:
         url_string = "urgencies"
         return self.make_call(url_string, 'get')
 
-    def get_ticket_urgency_by_name_id(self, key) -> dict:
+    def get_ticket_urgency_by_name_id(self, key: Union[str, int]) -> dict:
         """
         Gets ticket urgency based on ID or Name.
 
@@ -529,7 +561,7 @@ def get_all_ticket_impacts(self) -> list:
         url_string = "impacts"
         return self.make_call(url_string, 'get')
 
-    def get_ticket_impact_by_name_id(self, key) -> dict:
+    def get_ticket_impact_by_name_id(self, key: Union[str, int]) -> dict:
         """
         Gets ticket impact based on ID or Name
 
@@ -561,7 +593,7 @@ def get_all_ticket_sources(self) -> list:
         url_string = "sources"
         return self.make_call(url_string, 'get')
 
-    def get_ticket_source_by_name_id(self, key) -> dict:
+    def get_ticket_source_by_name_id(self, key: Union[str, int]) -> dict:
         """
         Gets ticket source based on ID or Name
 
@@ -612,7 +644,7 @@ def create_custom_ticket_status(self, name: str, order: float, status_class: str
             status['Description'] = description
         return self.make_call(url_string, "post", status)
 
-    def edit_custom_ticket_status(self, name, changed_attributes) -> dict:
+    def edit_custom_ticket_status(self, name: str, changed_attributes: dict) -> dict:
         """
         Edits a custom ticket status
 
@@ -631,7 +663,7 @@ def edit_custom_ticket_status(self, name, changed_attributes) -> dict:
 
     # #### TICKET TASKS #### #
 
-    def get_all_tasks_by_ticket_id(self, ticket_id: int, is_eligible_predecessor: bool = None) -> list:
+    def get_all_tasks_by_ticket_id(self, ticket_id: Union[str, int], is_eligible_predecessor: bool = None) -> list:
         """
         Gets a list of tasks currently on an open ticket. If the ticket is closed, no tasks will be returned.
 
@@ -648,7 +680,7 @@ def get_all_tasks_by_ticket_id(self, ticket_id: int, is_eligible_predecessor: bo
         url_string = f'{ticket_id}/tasks?isEligiblePredecessor={is_eligible_predecessor}'
         return self.make_call(url_string, 'get')
 
-    def get_ticket_task_by_id(self, ticket_id: int, task_id: int) -> dict:
+    def get_ticket_task_by_id(self, ticket_id: Union[str, int], task_id: Union[str, int]) -> dict:
         """
         Gets ticket task by ID.
 
@@ -663,7 +695,7 @@ def get_ticket_task_by_id(self, ticket_id: int, task_id: int) -> dict:
         url_string = f'{ticket_id}/tasks/{task_id}'
         return self.make_call(url_string, 'get')
 
-    def get_ticket_task_feed(self, ticket_id: int, task_id: int) -> list:
+    def get_ticket_task_feed(self, ticket_id: Union[str, int], task_id: Union[str, int]) -> list:
         """
         Gets all the feed entries from a ticket task.
 
@@ -678,7 +710,7 @@ def get_ticket_task_feed(self, ticket_id: int, task_id: int) -> list:
         url_string = f'{ticket_id}/tasks/{task_id}/feed'
         return self.make_call(url_string, 'get')
 
-    def create_ticket_task(self, ticket_id, task):
+    def create_ticket_task(self, ticket_id: Union[str, int], task: dict) -> dict:
         """
         Adds a ticket task to a ticket.
 
@@ -695,7 +727,7 @@ def create_ticket_task(self, ticket_id, task):
         return self.make_call(url_string, 'post', task)
 
     @staticmethod
-    def validate_ticket_task(task, required=False):
+    def validate_ticket_task(task: dict, required=False):
         editable_task_attributes = [
             'Title', 'Description', 'StartDate', 'EndDate', 'CompleteWithinMinutes', 'EstimatedMinutes',
             'ResponsibleUid', 'ResponsibleGroupID', 'PredecessorID'
@@ -711,7 +743,7 @@ def validate_ticket_task(task, required=False):
                     f"Attribute {k} not a writeable ticket task attribute"
                 )
 
-    def edit_ticket_task(self, ticket_id: int, task, changed_attributes: dict) -> dict:
+    def edit_ticket_task(self, ticket_id: int, task: Union[str, int, dict], changed_attributes: dict) -> dict:
         """
         Edits a ticket task with a set of new values.
 
@@ -728,7 +760,7 @@ def edit_ticket_task(self, ticket_id: int, task, changed_attributes: dict) -> di
         if isinstance(task, str) or isinstance(task, int):
             full_task = self.get_ticket_task_by_id(ticket_id, task)
         else:
-            if not task['ID']:
+            if not isinstance(task, dict) or 'ID' not in task or not task['ID']:
                 raise tdxlib.tdx_api_exceptions.TdxApiObjectTypeError(
                     "The task provided does not contain \'ID\' attribute.")
             else:
@@ -737,7 +769,7 @@ def edit_ticket_task(self, ticket_id: int, task, changed_attributes: dict) -> di
         url_string = f'{ticket_id}/tasks/{full_task["ID"]}'
         return self.make_call(url_string, 'put', full_task)
 
-    def delete_ticket_task(self, ticket_id, task_id):
+    def delete_ticket_task(self, ticket_id: str, task_id: str) -> None:
         """
         Deletes a ticket task by ID
 
@@ -750,7 +782,7 @@ def delete_ticket_task(self, ticket_id, task_id):
         url_string = f'{ticket_id}/tasks/{task_id}'
         self.make_call(url_string, 'delete')
 
-    def reassign_ticket_task(self, ticket_id: int, task, responsible: str, group=False) -> dict:
+    def reassign_ticket_task(self, ticket_id: int, task: Union[str, dict, int], responsible: str, group=False) -> dict:
         """
         Reassigns a ticket task to a person or group
 
@@ -824,7 +856,7 @@ def update_ticket_task(self, ticket_id: int, task_id: int, percent: int, comment
             }
         return self.make_call(url_string, 'post', data)
 
-    def add_asset_to_ticket(self, ticket_id:int, asset_id: int):
+    def add_asset_to_ticket(self, ticket_id: int, asset_id: int) -> dict:
         """
         Attaches an asset to a ticket.
 
@@ -844,7 +876,7 @@ def add_asset_to_ticket(self, ticket_id:int, asset_id: int):
     # #### TEMPLATING TICKETS #### #
 
     @classmethod
-    def get_ticket_classification_id_by_name(cls, name):
+    def get_ticket_classification_id_by_name(cls, name: str):
         """
         Gets ticket classification data by searching by the name of the classification.
 
@@ -859,31 +891,34 @@ def get_ticket_classification_id_by_name(cls, name):
             return TDXTicketIntegration.ticket_classifications[name]
         raise tdxlib.tdx_api_exceptions.TdxApiObjectNotFoundError("No classification found for name " + name)
 
-    def generate_ticket(self, title_template, ticket_type, account, responsible, template_values=None,
-                        body_template=None, attrib_prefix=None, due_date=None, location=None, room=None,
-                        active_days=5, priority="Low", status="New", requestor=None,
-                        classification="Incident", form=None, group=False, asset=None) -> tdxlib.tdx_ticket.TDXTicket:
+    def generate_ticket(self, title_template: str, ticket_type: str, account: str, responsible: str,
+                        template_values: dict = None, body_template: str = None, attrib_prefix: str = None,
+                        due_date: Union[datetime.datetime, str] = None, location: str = None, room: str = None,
+                        active_days: int = 5, priority: str = "Low", status: str = "New", requestor:str = None,
+                        classification: str = "Incident", form: str = None, responsible_is_group: str = False,
+                        custom_attributes: dict = None) -> tdxlib.tdx_ticket.TDXTicket:
         """
         Makes a TdxTicket object based on templates.
 
-        :param title_template: a string with {placeholders} that correspond to keys in ticket parameter (REQUIRED)
-        :param ticket_type: ticket Type (REQUIRED)
-        :param account: Requesting Account for Ticket (REQUIRED)
-        :param responsible: Group/email responsible for ticket (REQUIRED)
-        :param template_values: a dictionary (potentially loaded from google sheet) with substitutions for title/body
-        :param body_template: a string with {placeholders} that correspond to keys in ticket parameter
-        :param attrib_prefix: the string that prefixes all the custom attribute column names in the ticket dict
-        :param due_date: Due Date for ticket, default None (may be included with ticket)
+        :param title_template: a string with {placeholders} that correspond to keys in template_values dict (REQUIRED)
+        :param ticket_type: name of ticket Type (REQUIRED)
+        :param account: name of requesting Account for Ticket (REQUIRED)
+        :param responsible: group or email address to set as responsible for ticket (REQUIRED)
+        :param template_values: a dictionary with substitutions for title/body, using the {placeholders} as keys
+        :param body_template: a string with {placeholders} that correspond to keys in template_values parameter
+        :param attrib_prefix: [DEPRECATED]
+                            the string that prefixes all the custom attribute column names in the template_values dict
+        :param due_date: due date for ticket, default None
         :param location: Building name of location (optional)
-        :param room: Room name of location (optional)
-        :param active_days: number of days before due date to assign start date, default 5
-        :param priority: Priority of ticket, default "Low"
-        :param status: Status for new ticket, default "New"
-        :param requestor: Requester for the ticket, defaults to username of integration (optional)
-        :param classification: Classification name for ticket, default "Incident" (optional)
-        :param form: Name or ID of a form that you'd like to assign to this ticket
-        :param group: Boolean indicating whether or not 'responsible' refers to a group. (Default: False)
-        :param asset: Asset SN or ID to associate with the ticket (Default: None)
+        :param room: Room name of location (optional) Will not set room if location not included.
+        :param active_days: number of days before due date to assign start date, default is 5
+        :param priority: name of priority of ticket, default "Low"
+        :param status: name of status for new ticket, default "New"
+        :param requestor: name or email for requester of the ticket, defaults to username of integration (optional)
+        :param classification: name of classification name for new ticket, default "Incident" (optional)
+        :param form: name or ID of a form to use for the new ticket
+        :param responsible_is_group: Boolean indicating whether or not 'responsible' refers to a group. (Default: False)
+        :param custom_attributes: dict of attribute names and values
 
         :return: TdxTicket object ready to be created via create_ticket()
 
@@ -922,24 +957,24 @@ def generate_ticket(self, title_template, ticket_type, account, responsible, tem
 
         # set up attribute values
         if attrib_prefix:
+            self.logger.warning(f"Deprecated parmeter in generate_ticket(): \"attrib_prefix\". In future versions,"
+                                f" use \"custom_attributes\"")
             data['Attributes'] = []
             # attrib_count = 0
             for key, value in template_values.items():
                 if attrib_prefix in key:
-                    attrib_name = key.replace(attrib_prefix, "")
-                    attrib = self.get_ticket_custom_attribute_by_name(attrib_name)
-                    attrib_value = self.get_custom_attribute_choice_by_name_id(attrib, value)
-                    # if not a set-choice attribute, we just need to set it directly.
-                    new_attrib = dict()
-                    new_attrib['ID'] = attrib['ID']
-                    if not attrib_value or type(attrib_value) is str:
-                        new_attrib['Value'] = value
-                    else:
-                        new_attrib['Value'] = attrib_value['ID']
-                    data['Attributes'].append(new_attrib)
+                    data['Attributes'].append(
+                        self.build_ticket_custom_attribute_value(key.replace(attrib_prefix, ""), value))
+
+        if custom_attributes:
+            for key, value in custom_attributes.items():
+                data['Attributes'].append = self.build_ticket_custom_attribute_value(key, value)
 
         if due_date:
-            target_date = tdxlib.tdx_utils.import_tdx_date(due_date)
+            if isinstance(due_date, datetime.datetime):
+                target_date = due_date
+            else:
+                target_date = tdxlib.tdx_utils.import_tdx_date(due_date)
             start_date = target_date - datetime.timedelta(days=active_days)
             data['StartDate'] = tdxlib.tdx_utils.export_tdx_date(start_date, self.timezone)
             data['EndDate'] = tdxlib.tdx_utils.export_tdx_date(target_date, self.timezone)
@@ -947,16 +982,14 @@ def generate_ticket(self, title_template, ticket_type, account, responsible, tem
         if location:
             building = self.get_location_by_name(location)
             data['LocationID'] = building['ID']
-            data['LocationRoomID'] = self.get_room_by_name(building, room)['ID']
+            if room:
+                data['LocationRoomID'] = self.get_room_by_name(building, room)['ID']
 
-        if group:
+        if responsible_is_group:
             data['ResponsibleGroupID'] = self.get_group_by_name(responsible)['ID']
         else:
             data['ResponsibleUid'] = self.get_person_by_name_email(responsible)['UID']
 
-        if asset:
-            data['']
-
         new_ticket = tdxlib.tdx_ticket.TDXTicket(self, data)
         new_ticket.validate()
         return new_ticket
@@ -1004,7 +1037,7 @@ def generate_ticket_task(self, title: str, est_minutes: int = 30, description: s
 
     # #### CREATING TICKETS #### #
 
-    def create_ticket(self, ticket, silent=True) -> tdxlib.tdx_ticket.TDXTicket:
+    def create_ticket(self, ticket: tdx_ticket.TDXTicket, silent: bool = True) -> tdx_ticket.TDXTicket:
         """
         Creates a ticket in TeamDynamix using a TdxTicket object
 


Test patch:
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/test_tdx_ticket_integration.py b/test/test_tdx_ticket_integration.py
new file mode 100644
index 0000000..e77f056
--- /dev/null
+++ b/test/test_tdx_ticket_integration.py
@@ -0,0 +1,67 @@
+import configparser
+import unittest
+from tdxlib import tdx_integration, tdx_ticket_integration
+from unittest.mock import patch, Mock
+
+
+class MockConfigParser(configparser.ConfigParser):
+
+    def read(self, filename):
+        pass
+
+
+class TdxTesting(unittest.TestCase):
+
+    def setUp(self):
+        config = MockConfigParser()
+        config.read = Mock()
+        config.read_dict({
+            'TDX API Settings': {
+                'fullhost': 'tdx.myuniversity.org',
+                'sandbox': True,
+                'username': '',
+                'password': 'not-a-real-password',
+                'ticketAppId': '',
+                'assetAppId': '',
+                'caching': False,
+                'timezone': '-0500',
+                'logLevel': 'ERROR',
+            }
+        })
+        patcher = patch("configparser.ConfigParser", return_value=config)
+        self.mock_config = patcher.start()
+        self.addCleanup(patcher.stop)
+
+    @patch.object(tdx_integration.TDXIntegration, "auth")
+    def test_api_url(self, config_parser):
+        """Check for extra slashes in API URL"""
+        tdx = tdx_integration.TDXIntegration('../tdxlib.ini')
+        self.assertNotIn('//', tdx.api_url.lstrip('https://'),
+                         f'Extra slash in URL { tdx.api_url }')
+
+    @patch.object(tdx_integration.TDXIntegration, "auth")
+    def test_tdx_ticket_integration_from_dict(self, mock_auth):
+        config = {
+            'TDX API Settings': {
+                "orgname": "myuniversity",
+                "fullhost": "help.uillinois.edu",
+                "sandbox": True,
+                "username": "techsvc-securityapi",
+                "password": "",
+                "ticketAppId": 66,
+                "assetAppId": "",
+                "caching": False,
+                "timezone": "-0500",
+                "logLevel": "ERROR",
+            },
+        }
+        tdx = tdx_ticket_integration.TDXTicketIntegration(config=config)
+        assert not self.mock_config.read.called
+        assert mock_auth.called
+        assert tdx.org_name == config['TDX API Settings']['orgname']
+        assert tdx.username == config['TDX API Settings']['username']
+
+
+if __name__ == "__main__":
+    suite = unittest.TestLoader().loadTestsFromTestCase(TdxTesting)
+    unittest.TextTestRunner(verbosity=2).run(suite)
diff --git a/testing/tdx_asset_testing.py b/testing/tdx_asset_testing.py
index 6aad29e..e3ff85c 100644
--- a/testing/tdx_asset_testing.py
+++ b/testing/tdx_asset_testing.py
@@ -7,9 +7,10 @@
 
 
 class TdxAssetTesting(unittest.TestCase):
-
     # Create TDXIntegration object for testing use. Called before testing methods.
     def setUp(self):
+        # only run admin tests if this is true
+        self.is_admin = False
         testing_vars_file = '../testing_vars.json'
         self.tax = tdx_asset_integration.TDXAssetIntegration('../tdxlib.ini')
         if not self.tax.sandbox:
@@ -26,84 +27,84 @@ def setUp(self):
                   'Any *.json files are ignored by git.')
 
     def test_aaa(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         self.assertGreater(len(self.tax.token), 200)
 
     def test_asset_forms(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         forms = self.tax.get_all_asset_forms()
         self.assertGreater(len(forms), 1)
 
     def test_asset_form_name(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         form = self.tax.get_asset_form_by_name_id(self.testing_vars['asset_form']['Name'])
         self.assertEqual(int(form['ID']), self.testing_vars['asset_form']['ID'])
 
     def test_asset_form_id(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         form = self.tax.get_asset_form_by_name_id(self.testing_vars['asset_form']['ID'])
         self.assertEqual(form['Name'], self.testing_vars['asset_form']['Name'])
 
     def test_asset_statuses(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         statuses = self.tax.get_all_asset_statuses()
         self.assertGreater(len(statuses),3)
 
     def test_asset_status_id(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         status = self.tax.get_asset_status_by_name_id(self.testing_vars['asset_status1']['ID'])
         self.assertEqual(status['Name'], self.testing_vars['asset_status1']['Name'])
 
     def test_asset_status_name(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         status = self.tax.get_asset_status_by_name_id(self.testing_vars['asset_status1']['Name'])
         self.assertEqual(int(status['ID']), self.testing_vars['asset_status1']['ID'])
 
     def test_asset_product_types(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         product_types = self.tax.get_all_product_types()
         self.assertGreater(len(product_types),3)
 
     def test_asset_product_type_id(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         product_type = self.tax.get_product_type_by_name_id(self.testing_vars['product_type']['ID'])
         self.assertEqual(product_type['Name'], self.testing_vars['product_type']['Name'])
 
     def test_asset_product_type_name(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         product_type = self.tax.get_product_type_by_name_id(self.testing_vars['product_type']['Name'])
         self.assertEqual(int(product_type['ID']), self.testing_vars['product_type']['ID'])
         
     def test_asset_product_models(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         product_models = self.tax.get_all_product_models()
         self.assertGreater(len(product_models),3)
 
     def test_asset_product_model_id(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         product_model = self.tax.get_product_model_by_name_id(self.testing_vars['product_model']['ID'])
         self.assertEqual(product_model['Name'], self.testing_vars['product_model']['Name'])
 
     def test_asset_product_model_name(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         product_model = self.tax.get_product_model_by_name_id(self.testing_vars['product_model']['Name'])
         self.assertEqual(int(product_model['ID']), self.testing_vars['product_model']['ID'])
 
     def test_update_product_type(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         product_type = self.tax.get_product_type_by_name_id(self.testing_vars['product_type']['Name'])
         changed_attributes = {'Description': self.timestamp}
@@ -111,7 +112,7 @@ def test_update_product_type(self):
         self.assertEqual(updated_product_type['Description'], self.timestamp)
 
     def test_create_product_type(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         name = 'Temp Product Type ' + self.timestamp
         new_product_type = self.tax.create_product_type(name)
@@ -121,7 +122,7 @@ def test_create_product_type(self):
     # TODO: def test_update_product_model(self):
 
     def test_create_product_model(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         name = 'Temp Product Model ' + self.timestamp
         type = self.tax.get_product_type_by_name_id(self.testing_vars['product_type']['ID'])
@@ -131,13 +132,13 @@ def test_create_product_model(self):
         self.assertEqual(name, new_product_model['Name'])
 
     def test_get_all_vendors(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         vendors = self.tax.get_all_vendors()
         self.assertGreaterEqual(len(vendors), 5)
 
     def test_get_vendor_by_name_id(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         name = self.testing_vars['vendor']['Name']
         vendor = self.tax.get_vendor_by_name_id(name)
@@ -146,7 +147,7 @@ def test_get_vendor_by_name_id(self):
     # TODO: def test_update_vendor(self):
 
     def test_create_vendor(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         name = 'Temp Vendor ' + self.timestamp
         new_vendor = self.tax.create_vendor(name)
@@ -154,44 +155,44 @@ def test_create_vendor(self):
         self.assertEqual(name, new_vendor['Name'])
 
     def test_get_asset_id(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         asset = self.tax.get_asset_by_id(self.testing_vars['asset1']['ID'])
         self.assertEqual(asset['Name'], self.testing_vars['asset1']['Name'])
 
     def test_search_asset(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         assets = self.tax.search_assets(self.testing_vars['asset_search']['Text'])
         self.assertGreaterEqual(len(assets), self.testing_vars['asset_search']['Count'])
 
     def test_find_asset_tag(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         asset = self.tax.find_asset_by_tag(self.testing_vars['asset2']['Tag'])
         self.assertEqual(asset['SerialNumber'], self.testing_vars['asset2']['SerialNumber'])
 
     def test_find_asset_SN(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         asset = self.tax.find_asset_by_sn(self.testing_vars['asset2']['SerialNumber'])
         self.assertEqual(asset['Tag'].lstrip('0'), self.testing_vars['asset2']['Tag'].lstrip('0'))
 
     def test_get_assets_by_location(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         assets = self.tax.get_assets_by_location(self.testing_vars['location1'])
         self.assertGreaterEqual(len(assets), self.testing_vars['location1']['asset_count'])
 
     def test_get_assets_by_locations(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         assets = self.tax.get_assets_by_location([self.testing_vars['location1'],self.testing_vars['location2']])
         self.assertGreaterEqual(len(assets),
                                 self.testing_vars['location1']['asset_count'] + self.testing_vars['location2']['asset_count'])
 
     def test_get_assets_by_room(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         location = self.tax.get_location_by_name(self.testing_vars['location3']['Name'])
         room1 = self.tax.get_room_by_name(location, self.testing_vars['room1']['Name'])
@@ -199,31 +200,31 @@ def test_get_assets_by_room(self):
         self.assertGreaterEqual(len(assets), self.testing_vars['room1']['asset_count'])
 
     def test_get_assets_by_owner(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         assets = self.tax.get_assets_by_owner(self.testing_vars['owner']['PrimaryEmail'], max_results=100, disposed=True)
         self.assertGreaterEqual(len(assets), self.testing_vars['owner']['asset_count'])
 
     def test_get_assets_by_requesting_dept(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         assets = self.tax.get_assets_by_requesting_department(self.testing_vars['department']['Name'], max_results=100, disposed=True)
         self.assertGreaterEqual(len(assets), self.testing_vars['department']['asset_count'])
 
     def test_get_assets_by_product_model(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         assets = self.tax.get_assets_by_product_model(self.testing_vars['product_model']['Name'], max_results=100, retired=True, disposed=True)
         self.assertGreaterEqual(len(assets), self.testing_vars['product_model']['asset_count'])
 
     def test_get_assets_by_product_type(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         assets = self.tax.get_assets_by_product_type(self.testing_vars['product_type']['Name'], max_results=100, retired=True, disposed=True)
         self.assertGreaterEqual(len(assets), self.testing_vars['product_type']['asset_count'])
 
     def test_update_asset_status(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         assets_to_update = self.tax.get_assets_by_owner(self.testing_vars['owner']['PrimaryEmail'], max_results=100, disposed=True)
         new_status = str(self.testing_vars['asset_status1']['ID'])
@@ -236,7 +237,7 @@ def test_update_asset_status(self):
             self.assertEqual(str(updated_assets[i]['StatusID']), new_status)
 
     def test_update_asset_owner(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         asset = self.tax.get_asset_by_id(self.testing_vars['asset1']['ID'])
         new_owner = self.testing_vars['owner']
@@ -247,7 +248,7 @@ def test_update_asset_owner(self):
         self.assertEqual(updated_asset['OwningCustomerName'], new_owner['FullName'])
 
     def test_update_asset_requesting_dept(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         asset = self.tax.get_asset_by_id(self.testing_vars['asset1']['ID'])
         new_dept = self.testing_vars['department']
@@ -257,7 +258,7 @@ def test_update_asset_requesting_dept(self):
         self.assertEqual(updated_asset['RequestingDepartmentName'], new_dept['Name'])
 
     def test_custom_attribute_updating_id(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         asset = self.tax.get_asset_by_id(self.testing_vars['asset1']['ID'])
         new_attributes = self.testing_vars['attributes1']
@@ -276,7 +277,7 @@ def test_custom_attribute_updating_id(self):
                     self.assertEqual(str(desired['Value']), str(existing['Value']))
 
     def test_custom_attribute_updating_name(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         asset = self.tax.get_asset_by_id(self.testing_vars['asset1']['ID'])
         new_attributes = self.testing_vars['attributes1']
@@ -295,7 +296,7 @@ def test_custom_attribute_updating_name(self):
                     self.assertEqual(str(desired['Value']), str(existing['Value']))
 
     def test_add_custom_attributes(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         asset = self.tax.get_asset_by_id(self.testing_vars['asset2']['ID'])
         change = {'Name': asset['Name']+'(Cleared)'}
@@ -318,7 +319,7 @@ def test_add_custom_attributes(self):
                     self.assertEqual(str(desired['Value']), str(existing['Value']))
 
     def test_add_asset_user(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         self.tax.delete_asset_users(self.testing_vars['asset2']['ID'], self.testing_vars['person1']['UID'])
         validate1 = self.tax.get_asset_users(self.testing_vars['asset2']['ID'])
@@ -327,7 +328,7 @@ def test_add_asset_user(self):
         self.assertGreater(len(validate2), len(validate1))
 
     def test_get_asset_users(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         self.tax.add_asset_user(self.testing_vars['asset2']['ID'], self.testing_vars['person1']['UID'])
         validate = self.tax.get_asset_users(self.testing_vars['asset2']['ID'])
@@ -335,7 +336,7 @@ def test_get_asset_users(self):
         self.assertTrue('Name' in validate[0])
 
     def test_delete_asset_users(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         self.tax.add_asset_user(self.testing_vars['asset2']['ID'], self.testing_vars['person1']['UID'])
         self.tax.delete_asset_users(self.testing_vars['asset2']['ID'], self.testing_vars['person1']['UID'])
@@ -344,7 +345,7 @@ def test_delete_asset_users(self):
             self.assertTrue(i['Name'] != self.testing_vars['person1']['FullName'])
 
     def test_change_asset_location(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         new_asset = self.tax.create_asset(
             self.tax.build_asset(f'testing1.{self.timestamp}', f'testing1.{self.timestamp}',
@@ -355,7 +356,7 @@ def test_change_asset_location(self):
         self.assertTrue(validate['LocationName'] == self.testing_vars['location2']['Name'])
 
     def test_clear_asset_custom_attributes(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         new_asset =  self.tax.create_asset(
             self.tax.build_asset(f'testing2.{self.timestamp}', f'testing2.{self.timestamp}',
@@ -366,7 +367,7 @@ def test_clear_asset_custom_attributes(self):
             self.assertFalse(i['Name'] == self.testing_vars['attributes1'][0]['Name'])
 
     def test_get_asset_custom_attribute_value_by_name(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         new_asset = self.tax.create_asset(
             self.tax.build_asset(f'testing3.{self.timestamp}', f'testing3.{self.timestamp}',
@@ -377,7 +378,7 @@ def test_get_asset_custom_attribute_value_by_name(self):
         self.assertTrue(validate == str(self.testing_vars['attributes1'][0]['Value']))
 
     def test_copy_asset_attributes(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         sn1 = f'testing4.{self.timestamp}'
         new_asset1 = self.tax.create_asset(
@@ -394,7 +395,7 @@ def test_copy_asset_attributes(self):
         self.assertTrue(validate1['StatusName'] == self.testing_vars['asset_status2']['Name'])
 
     def test_move_child_assets(self):
-        if not self.timestamp:
+        if not self.tax:
             self.setUp()
         child_asset_id = self.testing_vars['child_asset']['ID']
         parent_asset_id = self.testing_vars['parent_asset']['ID']
diff --git a/testing/tdx_integration_testing.py b/testing/tdx_integration_testing.py
index e966a34..38103df 100644
--- a/testing/tdx_integration_testing.py
+++ b/testing/tdx_integration_testing.py
@@ -1,3 +1,4 @@
+import time
 import unittest
 import json
 from datetime import datetime as dt
@@ -7,9 +8,11 @@
 
 class TdxTesting(unittest.TestCase):
     timestamp = dt.today().strftime("%d-%B-%Y %H:%M:%S")
-
     # Create TDXIntegration object for testing use. Called before testing methods.
+
     def setUp(self):
+        # Will only run non-admin tests
+        self.is_admin = False
         testing_vars_file = '../testing_vars.json'
         self.tdx = tdx_integration.TDXIntegration('../tdxlib.ini')
         right_now = dt.today()
@@ -23,6 +26,7 @@ def setUp(self):
     def test_authentication(self):
         if not self.tdx:
             self.setUp()
+        self.assertIsNotNone(self.tdx.token)
         self.assertGreater(len(self.tdx.token), 200)
 
     def test_check_auth_exp(self):
@@ -238,6 +242,7 @@ def test_create_room(self):
         name = 'Testing Room ' + TdxTesting.timestamp
         description = 'Testing room Description'
         new_room = self.tdx.create_room(location, name, description=description)
+        time.sleep(2)
         test_room = self.tdx.get_room_by_name(location, name)
         self.assertEqual(new_room['Name'], test_room['Name'])
 
diff --git a/testing/tdx_ticket_testing.py b/testing/tdx_ticket_testing.py
index 510af20..3f0cfe4 100644
--- a/testing/tdx_ticket_testing.py
+++ b/testing/tdx_ticket_testing.py
@@ -9,9 +9,10 @@
 
 
 class TdxTicketTesting(unittest.TestCase):
-
     # Create TDXIntegration object for testing use. Called before testing methods.
     def setUp(self):
+        # Only run tests that don't require admin
+        self.is_admin = False
         testing_vars_file = '../testing_vars.json'
         self.tix = tdx_ticket_integration.TDXTicketIntegration('../tdxlib.ini')
         right_now = dt.today()
@@ -24,17 +25,25 @@ def setUp(self):
                   'A sample file is available in testing/sample_ticket_testing_vars. Any *.json files are ignored by git.')
     
     def test_authn(self):
+        if not self.timestamp:
+            self.setUp()
         self.assertGreater(len(self.tix.token), 200)
 
     def test_get_ticket_by_id(self):
+        if not self.timestamp:
+            self.setUp()
         self.assertTrue(self.tix.get_ticket_by_id(self.testing_vars['ticket1']['ID']))  # TDX Test Ticket #5814276
 
     def test_search_tickets(self):
+        if not self.timestamp:
+            self.setUp()
         self.assertTrue(self.tix.search_tickets('test'))
 
     # #### CHANGING TICKETS #### #
 
     def test_edit_tickets(self):
+        if not self.timestamp:
+            self.setUp()
         # Protect production from deleting tasks
         if not self.tix.sandbox:
             return
@@ -53,82 +62,122 @@ def test_edit_tickets(self):
     # #### GETTING TICKET ATTRIBUTES #### #
 
     def test_get_all_ticket_types(self):
+        if not self.timestamp:
+            self.setUp()
         types = self.tix.get_all_ticket_types()
         self.assertGreaterEqual(len(types), 40)
 
     def test_get_ticket_type_by_id(self):
+        if not self.timestamp:
+            self.setUp()
         key = self.testing_vars['ticket_type']['ID'] # IT Internal
         self.assertTrue(self.tix.get_ticket_type_by_name_id(key))
 
     def test_get_ticket_type_by_name(self):
+        if not self.timestamp:
+            self.setUp()
         key = self.testing_vars['ticket_type']['Name']
         self.assertTrue(self.tix.get_ticket_type_by_name_id(key))
 
     def test_get_all_ticket_statuses(self):
+        if not self.timestamp:
+            self.setUp()
         statuses = self.tix.get_all_ticket_statuses()
         self.assertGreaterEqual(len(statuses), 5)
 
     def test_get_ticket_status_by_id(self):
+        if not self.timestamp:
+            self.setUp()
         key = self.testing_vars['ticket_status']['ID'] # Open
         self.assertTrue(self.tix.get_ticket_status_by_id(key))
 
     def test_get_ticket_status_by_name(self):
+        if not self.timestamp:
+            self.setUp()
         key = self.testing_vars['ticket_status']['Name']
         self.assertTrue(self.tix.search_ticket_status(key))
 
     def test_get_all_ticket_priorities(self):
+        if not self.timestamp:
+            self.setUp()
         priorities = self.tix.get_all_ticket_priorities()
         self.assertGreaterEqual(len(priorities), 4)
 
     def test_get_ticket_priority_by_id(self):
+        if not self.timestamp:
+            self.setUp()
         key = self.testing_vars['ticket_priority']['ID']  # Low
         self.assertTrue(self.tix.get_ticket_priority_by_name_id(key))
 
     def test_get_ticket_priority_by_name(self):
+        if not self.timestamp:
+            self.setUp()
         name = self.testing_vars['ticket_priority']['Name']
         self.assertTrue(self.tix.get_ticket_priority_by_name_id(name))
 
     def test_get_ticket_classification_id_by_name(self):
+        if not self.timestamp:
+            self.setUp()
         name = self.testing_vars['ticket_classification']['Name']
         self.assertTrue(self.tix.get_ticket_classification_id_by_name(name))
 
     def test_get_all_ticket_urgencies(self):
+        if not self.timestamp:
+            self.setUp()
         urgencies = self.tix.get_all_ticket_urgencies()
         self.assertGreaterEqual(len(urgencies), 3)
 
     def test_get_ticket_urgency_by_id(self):
+        if not self.timestamp:
+            self.setUp()
         key =  self.testing_vars['ticket_urgency']['ID']
         self.assertTrue(self.tix.get_ticket_urgency_by_name_id(key))
 
     def test_get_ticket_urgency_by_name(self):
+        if not self.timestamp:
+            self.setUp()
         key = self.testing_vars['ticket_urgency']['Name']
         self.assertTrue(self.tix.get_ticket_urgency_by_name_id(key))
 
     def test_get_all_ticket_impacts(self):
+        if not self.timestamp:
+            self.setUp()
         impacts = self.tix.get_all_ticket_impacts()
         self.assertGreaterEqual(len(impacts), 5)
 
     def test_get_ticket_impact_by_id(self):
+        if not self.timestamp:
+            self.setUp()
         key = self.testing_vars['ticket_impact']['ID']
         self.assertTrue(self.tix.get_ticket_impact_by_name_id(key))
 
     def test_get_ticket_impact_by_name(self):
+        if not self.timestamp:
+            self.setUp()
         key = self.testing_vars['ticket_impact']['Name']
         self.assertTrue(self.tix.get_ticket_impact_by_name_id(key))
 
     def test_get_all_ticket_sources(self):
+        if not self.timestamp:
+            self.setUp()
         sources = self.tix.get_all_ticket_sources()
         self.assertGreaterEqual(len(sources), 7)
 
     def test_get_ticket_source_by_id(self):
+        if not self.timestamp:
+            self.setUp()
         key = self.testing_vars['ticket_source']['ID']
         self.assertTrue(self.tix.get_ticket_source_by_name_id(key))
 
     def test_get_ticket_source_by_name(self):
+        if not self.timestamp:
+            self.setUp()
         key = self.testing_vars['ticket_source']['Name']
         self.assertTrue(self.tix.get_ticket_source_by_name_id(key))
 
     def test_create_ticket(self):
+        if not self.timestamp:
+            self.setUp()
         # Protect production from testing tickets
         if not self.tix.sandbox:
             return
@@ -144,15 +193,21 @@ def test_create_ticket(self):
     # #### TICKET TASKS #### #
 
     def test_get_all_tasks_by_ticket_id(self):
+        if not self.timestamp:
+            self.setUp()
         ticket_id = self.testing_vars['ticket2']['ID']
         self.assertTrue(self.tix.get_all_tasks_by_ticket_id(ticket_id))
 
     def test_get_ticket_task_by_id(self):
+        if not self.timestamp:
+            self.setUp()
         ticket_id = self.testing_vars['ticket2']['ID']
         task_id = self.testing_vars['ticket2']['task']['ID']
         self.assertTrue(self.tix.get_ticket_task_by_id(ticket_id, task_id))
 
     def test_create_ticket_task(self):
+        if not self.timestamp:
+            self.setUp()
         # Protect production from testing tasks
         if not self.tix.sandbox:
             return
@@ -165,6 +220,8 @@ def test_create_ticket_task(self):
             self.assertGreater(created_task['ID'], 10000)
 
     def test_edit_ticket_task(self):
+        if not self.timestamp:
+            self.setUp()
         # Protect production from deleting tasks
         if not self.tix.sandbox:
             return
@@ -179,6 +236,8 @@ def test_edit_ticket_task(self):
         self.assertEqual(edited_task['Description'], changed_attributes['Description'])
 
     def test_delete_ticket_task(self):
+        if not self.timestamp:
+            self.setUp()
         if not self.tix.sandbox:
             return
         # Create the task
@@ -195,6 +254,8 @@ def test_delete_ticket_task(self):
         self.assertFalse(all(i['ID'] == created_task['ID'] for i in task_list))
 
     def test_reassign_ticket_task(self):
+        if not self.timestamp:
+            self.setUp()
         if not self.tix.sandbox:
             return
         ticket_id = self.testing_vars['ticket2']['ID']
@@ -208,6 +269,8 @@ def test_reassign_ticket_task(self):
         self.assertEqual(changed_task['ResponsibleUid'], reassign['ResponsibleUid'])
 
     def test_reassign_ticket(self):
+        if not self.timestamp:
+            self.setUp()
         if not self.tix.sandbox:
             return
         ticket_id = self.testing_vars['ticket2']['ID']
@@ -220,6 +283,8 @@ def test_reassign_ticket(self):
         self.assertEqual(changed_ticket.get_attribute('ResponsibleUid'), reassign['UID'])
 
     def test_reschedule_ticket(self):
+        if not self.timestamp:
+            self.setUp()
         if not self.tix.sandbox:
             return
         ticket_id = self.testing_vars['ticket1']['ID']
@@ -229,6 +294,8 @@ def test_reschedule_ticket(self):
         self.assertEqual(changed_ticket.get_attribute('StartDate').day, start.day)
 
     def test_reschedule_ticket_task(self):
+        if not self.timestamp:
+            self.setUp()
         if not self.tix.sandbox:
             return
         ticket_id = self.testing_vars['ticket2']['ID']
@@ -239,6 +306,8 @@ def test_reschedule_ticket_task(self):
         self.assertEqual(changed_ticket_task['StartDate'][0:8], tdx_utils.export_tdx_date(start)[0:8])
 
     def test_generate_ticket_task(self):
+        if not self.timestamp:
+            self.setUp()
         if not self.tix.sandbox:
             return
         task = self.tix.generate_ticket_task("Testing Task", description="this is a test",start=dt.utcnow(),
@@ -246,6 +315,10 @@ def test_generate_ticket_task(self):
         self.assertTrue(task)
 
     def test_create_custom_ticket_status(self):
+        if not self.timestamp:
+            self.setUp()
+        if not self.is_admin:
+            return
         if not self.tix.sandbox:
             return
         status = self.tix.create_custom_ticket_status('Never Gonna Happen', 1, 'Cancelled')
@@ -253,6 +326,10 @@ def test_create_custom_ticket_status(self):
         self.assertTrue(get_status)
 
     def test_edit_custom_ticket_status(self):
+        if not self.timestamp:
+            self.setUp()
+        if not self.is_admin:
+            return
         if not self.tix.sandbox:
             return
         self.test_create_custom_ticket_status()
@@ -261,6 +338,8 @@ def test_edit_custom_ticket_status(self):
         self.assertEqual(to_change['Name'],changed['Name'])
 
     def test_update_ticket_task(self):
+        if not self.timestamp:
+            self.setUp()
         if not self.tix.sandbox:
             return
         ticket_id = self.testing_vars['ticket2']['ID']
@@ -269,6 +348,8 @@ def test_update_ticket_task(self):
         self.assertTrue(update)
 
     def test_update_ticket(self):
+        if not self.timestamp:
+            self.setUp()
         if not self.tix.sandbox:
             return
         ticket_id = self.testing_vars['ticket1']['ID']
@@ -276,6 +357,8 @@ def test_update_ticket(self):
         self.assertTrue(self.timestamp in update['Body'])
 
     def test_upload_attachment(self):
+        if not self.timestamp:
+            self.setUp()
         if not self.tix.sandbox:
             return
         ticket_id = self.testing_vars['ticket1']['ID']
@@ -285,26 +368,33 @@ def test_upload_attachment(self):
         self.assertEqual(filename.split('/')[-1], update['Name'])
 
     def test_upload_attachment_filename(self):
+        if not self.timestamp:
+            self.setUp()
         if not self.tix.sandbox:
             return
-        ticket_id = self.testing_vars['ticket1']['ID']
-        filename = self.testing_vars['attachment']['Name']
-        update = self.tix.upload_attachment(ticket_id, "foobar data data", filename)
+        ticket_id = self.testing_vars['ticket2']['ID']
+        filepath = self.testing_vars['attachment']['Name']
+        filename = filepath.split('/')[-1]
+        with open (filepath, "rb") as file:
+            update = self.tix.upload_attachment(ticket_id, file, filename)
         self.assertEqual(filename, update['Name'])
 
     def test_get_ticket_task_feed(self):
+        if not self.timestamp:
+            self.setUp()
         ticket_id = self.testing_vars['ticket2']['ID']
         task_id = self.testing_vars['ticket2']['task']['ID']
         feed = self.tix.get_ticket_task_feed(ticket_id, task_id)
         self.assertGreater(len(feed), 0)
 
     def test_get_ticket_feed(self):
+        if not self.timestamp:
+            self.setUp()
         ticket_id = self.testing_vars['ticket2']['ID']
         feed = self.tix.get_ticket_feed(ticket_id)
         self.assertGreater(len(feed), 0)
 
 
-
 if __name__ == "__main__":
     suite = unittest.TestLoader().loadTestsFromTestCase(TdxTicketTesting)
     unittest.TextTestRunner(verbosity=2).run(suite)