Compare commits

...

15 Commits

Author SHA1 Message Date
7d35f85c1b Updated README
Some checks failed
test-clients / pyinvoiceninja (push) Failing after 53s
2024-11-06 10:11:24 -05:00
3eaa187019 Fixed gitea action syntax error
Some checks failed
test-clients / pyinvoiceninja (push) Failing after 1s
2024-11-05 14:47:53 -05:00
d274bdad49 Testing gitea actions 2024-11-05 14:44:40 -05:00
ebc55fbf8d ADDED dotenv file for pytests 2024-10-22 16:12:28 -04:00
aefb0dc2ab CHANGED nix shell is more reproducible 2024-10-22 15:04:02 -04:00
249cf62fcc FIXED InvoiceNinjaClient ping function now obeys debugging flag 2024-10-22 15:02:24 -04:00
8e70ce3c26 Added pytest for testing, Added pytest and loguru packages to nix shell 2024-09-30 13:01:41 -04:00
23d2999baa Added changelog file 2024-09-30 12:57:23 -04:00
05c0ccb398 Changed updated readme file 2024-09-30 12:56:57 -04:00
deacda0cfa Changed renamed source directory to pyinvoiceninja 2024-09-30 12:54:44 -04:00
cc34db9ed1 Added editorconfig to project 2024-09-30 12:51:50 -04:00
713613e2c6 FIXED headers are built and returned properly 2024-02-10 11:06:25 -05:00
a9b38d8515 ADDED markdown viewer and python-dotenv to shell 2024-02-10 11:04:52 -05:00
79b4dfd4eb UPDATED README.md to include new 'model' projects section 2024-02-10 11:03:40 -05:00
d8af2d0287 added gitignore 2023-12-17 10:36:55 -05:00
29 changed files with 1459 additions and 305 deletions

33
.editorconfig Normal file
View File

@ -0,0 +1,33 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,py}]
charset = utf-8
# Python indentation
[*.py]
indent_style = space
indent_size = 4
# Nix indentation
[*.nix]
indent_style = space
indent_size = 2
# Markdown
[*.md]
indent_style = space
indent_size = 4
# Tab indentation (no size specified)
[Makefile]
indent_style = tab

View File

@ -0,0 +1,10 @@
name: test-clients
run-name: ${{ gitea.actor }} is testing pyinvoiceninja
on: push
jobs:
pyinvoiceninja:
runs-on: invoiceninja
steps:
- run: echo "Hello world!"

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__

View File

@ -0,0 +1,12 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- This CHANGELOG.md!

View File

@ -1,5 +1,25 @@
# Python Invoice Ninja SDK # pyinvoiceninja
Inspired by the [official PHP SDK](https://github.com/invoiceninja/sdk-php), a Inspired by the [official PHP SDK](https://github.com/invoiceninja/sdk-php), a
Python wrapper for Invoice Ninja's REST API. Python wrapper for Invoice Ninja's REST API.
## Model Projects
Projects to look to for guidance:
* [official PHP SDK](https://github.com/invoiceninja/sdk-php)
- official Invoice Ninja SDK
* [python-vaultwarden](https://github.com/numberly/python-vaultwarden)
- simple python wrapper for the bitwarden/vaultwarden
## How to use
Let see some basic usage:
```
>>> # Import client from module
>>> from pyinvoiceninja import InvoiceNinjaClient
>>> #
>>> client = InvoiceNinjaClient(base_url='https://ninja.instance/api/v1')
```

View File

@ -1,74 +0,0 @@
import requests
class InvoiceNinja(object):
API_V1 = 'api/v1'
ENDPOINT_URLS = {
'clients': 'clients',
'products': 'products',
'invoices': 'invoices',
'recurring invoices': 'recurring_invoices',
'payments': 'payments',
'quotes': 'quotes',
'credits': 'credits',
'reports': 'reports',
'activities': 'activities',
'charts': 'charts',
'companies': 'companies',
'documents': 'documents',
'emails': 'emails',
'expense': 'expenses',
'export': 'export',
'import': 'import_json',
'ping': 'ping',
'health check': 'health_check',
'users': 'users'
}
def __init__(self,
endpoint_url: str = 'https://invoicing.co',
api_token: str = str()):
self.endpoint_url = '{}/{}'.format(endpoint_url, self.API_V1)
self.api_token = api_token
self.headers = dict()
def _get_url_for(self, endpoint: str = 'ping'):
'''
Get complete URL for an endpoint.
Endpoint URLs are appended to the Invoice Ninja base URL
and returned.
'''
if endpoint in self.ENDPOINT_URLS:
return '{}/{}'.format(self.endpoint_url,
self.ENDPOINT_URLS[endpoint])
else:
raise KeyError('Endpoint URL not found')
#def _get_headers(self, headers: dict = dict()):
def build_headers(self):
'''
Build Invoice Ninja API headers for request.
A header dictionary with the API token is returned by default.
'''
headers = {
'X-API-TOKEN': self.api_token,
'X-Requested-With': 'XMLHttpRequest'}
return self.headers.update(headers)
def ping(self):
'''
Ping Invoice Ninja instance.
'''
server_response = requests.get(url=self._get_url_for(),
headers=self.build_headers())
if server_response.ok:
return True
else:
return False

View File

@ -1,30 +0,0 @@
from invoice_ninja import InvoiceNinja
class BaseEndpoint(InvoiceNinja):
def bulk(self, action: str):
pass
def archive(self):
pass
def delete(self):
pass
def restore(self):
pass
def all(self):
pass
def get(self):
pass
def update(self):
pass
def create(self):
pass
def download(self):
pass

View File

@ -1,92 +0,0 @@
from invoice_ninja.endpoints.base_endpoint import BaseEndpoint
from invoice_ninja.types.client import Client
import requests
class Clients(BaseEndpoint):
uri = '/api/v1/clients'
def __init__(self, base_url: str = str(), api_token: str = str()):
super().__init__(base_url, api_token)
self.url = super()._get_url_for('clients')
def __build_sort_params(self, sort: dict):
sort_params = {'sort': str()}
is_first_entry = True
for option in sort.keys():
if is_first_entry:
sort_params['sort'] += '{}|{}'.format(option, sort[option])
is_first_entry = False
else:
sort_params['sort'] += ' {}|{}'.format(option, sort[option])
return sort_params
def __client_from_dict(self, client: dict):
return Client(client_id=client['id'],
name=client['name'],
address=client['address1'],
city=client['city'],
state=client['state'],
postal_code=client['postal_code'],
phone=client['phone'],
email=client['contacts'][0]['email'],
pets=client['custom_value1'])
def __clients_from_response(self, response: requests.Response):
clients = list()
for client_dict in response.json()['data']:
clients.append(self.__client_from_dict(client_dict))
return clients
def show_client(self, client_id: str = None):
"""
Get client based on client id.
"""
if client_id:
response = requests.get(url=self.url,
headers=super()._get_headers())
if response.ok:
return self.__client_from_dict(response.json()['data'])
return None
def list_clients(self, include: str = 'activities',
sort: dict = dict(), status: str = 'active',
name: str = None):
"""
Get list of clients.
"""
request_params = dict()
# Add sort parameters to request
if len(sort) > 0:
request_params.update(self.__build_sort_params(sort))
# Add include parameters to request
request_params.update({'include': include})
# Add status parameters to request
request_params.update({'status': status})
# Add name parameters to request
if name:
request_params.update({'name': name})
# Check is request should be sent with parameters
if len(request_params) > 0:
response = requests.get(url=self.url,
params=request_params,
headers=super()._get_headers())
else:
response = requests.get(url=self.url,
headers=super()._get_headers())
if response.ok:
return self.__clients_from_response(response)

View File

@ -1,27 +0,0 @@
class Client(object):
def __init__(self, client_id: int = None, name: str = None,
address: str = None, city: str = None, state: str = None,
postal_code: str = None, phone: str = None,
email: str = None, pets: str = None):
self.id = client_id
self.name = name
self.address = address
self.city = city
self.state = state
self.postal_code = postal_code
self.phone = phone
self.email = email
self.pets = pets
def __str__(self):
return 'Client({}, {}, {}, {}, {}, {}, {}, {}, {})'.format(
self.id,
self.name,
self.address,
self.city,
self.state,
self.postal_code,
self.phone,
self.email,
self.pets)

View File

@ -1,36 +0,0 @@
class ClientContact(object):
def __init__(self, first_name: str = '',
last_name: str = '',
email: str = '',
phone: str = '',
send_email: bool = True,
custom_value1: str = '',
custom_value2: str = '',
custom_value3: str = '',
custom_value4: str = ''):
self.first_name = first_name
self.last_name = last_name
self.email = email
self.phone = phone
# Flag for whether the contact will receive emails.
self.send_email = send_email
# Custom values
self.custom_value1 = custom_value1
self.custom_value2 = custom_value2
self.custom_value3 = custom_value3
self.custom_value4 = custom_value4
def __str__(self):
return 'ClientContact({}, {}, {}, {}, {}, {}, {}, {}, {})'.format(
self.first_name,
self.last_name,
self.email,
self.phone,
self.send_email,
self.custom_value1,
self.custom_value2,
self.custom_value3,
self.custom_value4)

View File

@ -1,38 +0,0 @@
class ClientSettings(object):
def __init__(self, language_id: str = None,
currency_id: str = None,
payment_terms: str = None,
valid_until: str = None,
default_task_rate: float = 0,
send_reminders: bool = None):
# The language ID for the client, for the full list of languages,
# please see this resource - optional
#
# https://invoiceninja.github.io/docs/statics/#languages
self.language_id = language_id
# The currency ID - optional
# See this resource for full list:
#
# https://invoiceninja.github.io/docs/statics/#currencies
self.currency_id = currency_id
# The payment terms - in days - optional
self.payment_terms = payment_terms
# The quote terms - optional
#
# How many days the quote will be valid for.
self.valid_until = valid_until
# The task rate for this client - optional
#
# A value of 0 equates to disabled.
self.default_task_rate = default_task_rate
# Whether the client will receive reminders - optional
#
# When left unset, this setting will rely on the company
# settings as the override/default
self.send_reminders = send_reminders

View File

@ -0,0 +1,4 @@
from .client import InvoiceNinjaClient
__all__ = ['InvoiceNinjaClient']

146
pyinvoiceninja/client.py Normal file
View File

@ -0,0 +1,146 @@
from typing import Any
from typing import Dict
# HTTP client
import requests
# Logger
from loguru import logger
# Local library imports
from .endpoints.clients import ClientsAPI
from .endpoints.documents import DocumentsAPI
class InvoiceNinjaClient(object):
"""
A client for interacting with the Invoice Ninja API.
Attributes
----------
base_url : str
URL to Invoice Ninja instance.
api_token : str
Token for API request access.
clients : ClientsAPI
An instance of the ClientsAPI class for client-related operations.
documents : DocumentsAPI
An instance of the DocumentsAPI class for document-related operations.
debug : bool
Flag to enable or disable debug logging.
"""
def __init__(self,
base_url: str = 'https://invoicing.co/api/v1',
api_token: str = '',
debug: bool = False):
"""
Constructs all the necessary attributes for the InvoiceNinjaClient object
Parameters
----------
base_url : str, optional
The base URL of the Invoice Ninja API (default is 'https://invoicing.co/api/v1').
api_token : str, optional
The API token for authentication (default is an empty string).
debug : bool, optional
Flag to enable or disable debug logging (default is False).
"""
# Check parameters for correct types
if not isinstance(base_url, str):
raise ValueError('Base URL must be a string.')
if not isinstance(api_token, str):
raise ValueError('API token must be a string.')
if not isinstance(debug, bool):
raise ValueError('Debug flag must be a boolean.')
# Initialize paramater attributes
self._base_url = base_url
self._api_token = api_token
self._debug = debug
# Initialize API client attributes
self._documents = DocumentsAPI(self)
self._clients = ClientsAPI(self)
@property
def base_url(self) -> str:
"""Gets the base URL of the Invoice Ninja API."""
return self._base_url
@base_url.setter
def base_url(self, base_url: str):
"""Sets the base URL of the Invoice Ninja API."""
if not isinstance(base_url, str):
raise ValueError('Base URL must be a string.')
self._base_url = base_url
@property
def api_token(self) -> str:
"""Gets the API token."""
return self._api_token
@api_token.setter
def api_token(self, api_token: str):
"""Sets the API token."""
if not isinstance(api_token, str):
raise ValueError('API token must be a string.')
self._api_token = api_token
@property
def clients(self) -> ClientsAPI:
return self._clients
@property
def documents(self) -> DocumentsAPI:
return self._documents
@property
def debug(self):
return self._debug
@debug.setter
def debug(self, debug: bool) -> None:
"""Sets the debug flag."""
if not isinstance(debug, bool):
raise ValueError('Debug flag must be a boolean.')
self._debug = debug
if self._debug:
logger.enable(__name__)
else:
logger.disable(__name__)
@property
def headers(self) -> Dict[str, Any]:
"""Gets the headers for API requests."""
return {'X-API-TOKEN': self._api_token,
'X-Requested-With': 'XMLHttpRequest'}
def _log_debug(self, message: str) -> None:
"""
Logs a debug message if debugging is enabled.
Parameters
----------
message : str
The debug message to log.
"""
if self._debug:
print(f'DEBUG: {message}')
def ping(self):
"""
Ping the Invoice Ninja instance to check if it's reachable.
Returns
-------
bool
True if the server response is OK, False otherwise.
"""
self._log_debug(f'Pinging {self.base_url}/ping with headers {self.headers}')
server_response = requests.get(url=f'{self.base_url}/ping',
headers=self.headers)
self._log_debug(f'Server response: {server_response.status_code}, {server_response.text}')
return server_response.ok

View File

@ -0,0 +1,5 @@
from .clients import ClientsAPI
from .documents import DocumentsAPI
__all__ = ['ClientsAPI', 'DocumentsAPI']

View File

@ -0,0 +1,176 @@
# HTTP client
import requests
# Logger
from loguru import logger
from typing import Dict
from typing import List
from typing import Optional
from typing import Union
from pyinvoiceninja.models import Client
class ClientsAPI:
"""
A client for interacting with the Invoice Ninja clients API.
Attributes
----------
client : InvoiceNinjaClient
An instance of the InvoiceNinjaClient class.
clients_url : str
The URL for accessing the clients endpoint.
"""
VALID_SORT_BY: List[str] = [
'id', 'name', 'balance', 'paid_to_date', 'payment_balance',
'credit_balance', 'archived_at', 'updated_at', 'created_at',
'display_name', 'address', 'city', 'state', 'postal_code',
'custom_value1', 'custom_value2', 'custom_value3', 'custom_value4'
]
VALID_SORT_ORDER: List[str] = ['asc', 'desc']
VALID_INCLUDES: List[str] = ['activities', 'ledger', 'system_logs']
VALID_CLIENT_STATUSES: List[str] = ['active', 'archived', 'deleted']
VALID_BALANCE_OPERATORS: List[str] = ['lt', 'lte', 'gt', 'gte', 'eq']
def __init__(self, client: 'InvoiceNinjaClient') -> None:
"""
Constructs all the necessary attributes for the ClientsAPI object.
Parameters
----------
client : InvoiceNinjaClient
An instance of the InvoiceNinjaClient class.
"""
self.client = client
self.clients_url = f'{self.client.base_url}/clients'
def get_clients(self,
includes: Optional[List[str]] = None,
sort: Optional[Dict[str, str]] = None,
statuses: Optional[List[str]] = None,
name: Optional[str] = None,
email: Optional[str] = None,
balance: Optional[Dict[str, Union[str, int]]] = None,
is_deleted: Optional[bool] = None,
filter_deleted_clients: Optional[bool] = None) -> List[Client]:
"""
Retrieves a list of clients from the Invoice Ninja API.
Parameters
----------
includes : Optional[List[str]]
The child relationships to include in the response.
sort : Optional[Dict[str, str]]
A dictionary containing the field by which to sort the clients and the order.
statuses : Optional[List[str]]
The statuses to filter the clients.
name : Optional[str]
The name to filter the clients.
email : Optional[str]
The email to filter the clients.
balance : Optional[Dict[str, Union[str, int]]]
The balance to filter the clients.
is_deleted : Optional[bool]
Whether to include deleted clients.
filter_deleted_clients : Optional[bool]
Whether to filter out deleted clients.
Returns
-------
List[Client]
A list of Client objects if the request is successful.
Raises
------
ValueError
If any of the parameters are invalid.
"""
params = {}
if includes:
if isinstance(includes, list):
for item in includes:
if item not in self.VALID_INCLUDES:
raise ValueError(f'Invalid include option: {item}. Valid options: {self.VALID_INCLUDES}')
params['include'] = ','.join(includes)
else:
raise ValueError('Includes must be a list of valid client child relationship strings.')
if sort:
if isinstance(sort, dict):
if 'sort_by' not in sort or sort['sort_by'] not in self.VALID_SORT_BY:
raise ValueError(f'Invalid sort by option: {sort["sort_by"]}. Valid options: {self.VALID_SORT_BY}')
if 'sort_order' not in sort or sort['sort_order'] not in self.VALID_SORT_ORDER:
raise ValueError(f'Invalid sort order option: {sort["sort_order"]}. Valid options: {self.VALID_SORT_ORDER}')
params['sort'] = f'{sort["sort_by"]}|{sort["sort_order"]}'
else:
raise ValueError('Sort must be a dictionary with "sort_by" and "sort_order" keys.')
if statuses:
if isinstance(statuses, list):
for item in statuses:
if item not in self.VALID_CLIENT_STATUSES:
raise ValueError(f'Invalid status: {item}. Valid options: {self.VALID_CLIENT_STATUSES}')
params['status'] = ','.join(statuses)
else:
raise ValueError('Statuses must be a list of valid client statuses.')
if name:
if isinstance(name, str):
params['name'] = name
else:
raise ValueError('Name must be a string.')
if email:
if isinstance(email, str):
params['email'] = email
else:
raise ValueError('Email parameter must be a string value.')
# FIXME: This doesn't seem to be working properly.
if balance:
if isinstance(balance, dict):
if 'operator' in balance and 'value' in balance:
if balance['operator'] not in self.VALID_BALANCE_OPERATORS:
raise ValueError(f'Invalid balance operator: {balance["operator"]}. Valid options: {self.VALID_BALANCE_OPERATORS}')
if not isinstance(balance['value'], int):
raise ValueError('Balance value must be an integer.')
params['balance'] = f'{balance["operator"]}:{balance["value"]}'
else:
raise ValueError('Balance must be a dictionary with "operator" and "value" keys.')
else:
raise ValueError('Balance must be a dictionary with "operator" and "value" keys.')
if is_deleted is not None:
if isinstance(is_deleted, bool):
params['is_deleted'] = is_deleted
else:
raise ValueError('Is deleted must be a boolean value.')
if filter_deleted_clients is not None:
if isinstance(filter_deleted_clients, bool):
params['filter_deleted_clients'] = filter_deleted_clients
else:
raise ValueError('Filter deleted clients must be a boolean value.')
request = requests.Request('GET', self.clients_url, headers=self.client.headers, params=params)
prepared_request = request.prepare()
logger.debug(f'Fetching clients from server: {prepared_request.url}')
session = requests.Session()
response = session.send(prepared_request)
logger.debug(f'Server response status: {response.status_code}')
if response.ok:
clients = [Client.from_json(client_dict) for client_dict in response.json().get('data', [])]
if clients:
logger.success('Fetched client list from server.')
return clients
else:
logger.error('Search yielded no clients.')
return []
else:
logger.error(f'Error fetching clients: {response.text}')
response.raise_for_status()

View File

@ -0,0 +1,64 @@
import requests
from pyinvoiceninja.models import Client
from pyinvoiceninja.models import Document
class DocumentsAPI(object):
"""
A client for interacting with the Invoice Ninja documents API.
Attributes
----------
client : InvoiceNinjaClient
An instance of the InvoiceNinjaClient class.
documents_url : str
The URL for accessing the clients endpoint.
"""
def __init__(self, client):
"""
Constructs all the necessary attributes for the DocumentsAPI object.
Parameters
----------
client : InvoiceNinjaClient
An instance of the InvoiceNinjaClient class.
"""
self.client = client
self.documents_url = f'{self.client.base_url}/documents'
def __get_documents_from_response(self, response: requests.Response):
"""
Parses the response from the API and converts it to a list of Client objects.
Parameters
----------
response : requests.Response
The response object from the API request.
Returns
-------
list of Client
A list of Client objects parsed from the response.
"""
documents = list()
for document_dict in response.json()['data']:
documents.append(Document.from_json(document_dict))
return documents
def get_documents(self):
"""
Retrieves a list of documents from the Invoice Ninja API.
Returns
-------
list of Documents
A list of Document objects if the request is successful.
"""
self.client._log_debug(f'Fetching documents from {self.documents_url}')
response = requests.get(self.documents_url, headers=self.client.headers)
self.client._log_debug(f'Server response: {response.status_code}, {response.text}')
if response.ok:
return self.__get_documents_from_response(response)

View File

@ -0,0 +1,7 @@
from .client import Client
from .clientContact import ClientContact
from .clientSettings import ClientSettings
from .document import Document
__all__ = ['Client', 'ClientContact', 'ClientSettings', 'Document']

View File

@ -0,0 +1,402 @@
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Type
from typing import Union
# JSON serializer/deserializer
import json
# Local library imports
from pyinvoiceninja.models.clientContact import ClientContact
from pyinvoiceninja.models.clientInvoice import ClientInvoice
from pyinvoiceninja.models.clientPayment import ClientPayment
from pyinvoiceninja.models.clientSettings import ClientSettings
from pyinvoiceninja.models.document import Document
class Client:
"""
A class to represent a client.
Attributes:
id (int): The ID of the client.
name (str): The name of the client.
private_notes (str): Private notes about the client.
public_notes (str): Public notes about the client.
balance (int): The balance of the client.
paid_to_date (int): The amount paid to date by the client.
payment_balance (int): The payment balance of the client.
credit_balance (int): The credit balance of the client.
settings (dict): Client-specific settings.
is_deleted (bool): Indicates if the client is deleted.
updated_at (int): The timestamp when the client was last updated.
archived_at (int): The timestamp when the client was archived.
created_at (int): The timestamp when the client was created.
display_name (str): The display name of the client.
contacts (list): A list of contacts associated with the client.
documents (list): A list of documents associated with the client.
address (str): The address of the client.
city (str): The city of the client.
state (str): The state of the client.
postal_code (str): The postal code of the client.
phone (str): The phone number of the client.
custom_value1 (str): Custom field 1.
custom_value2 (str): Custom field 2.
custom_value3 (str): Custom field 3.
custom_value4 (str): Custom field 4.
"""
def __init__(self, client_id: int = 0,
name: str = '',
private_notes: str = '',
public_notes: str = '',
balance: int = 0,
paid_to_date: int = 0,
payment_balance: int = 0,
credit_balance: int = 0,
settings: Optional[ClientSettings] = None,
is_deleted: bool = False,
updated_at: int = 0,
archived_at: int = 0,
created_at: int = 0,
display_name: str = '',
contacts: Optional[List[ClientContact]] = None,
documents: Optional[List[Document]] = None,
address: str = '',
city: str = '',
state: str = '',
postal_code: str = '',
phone: str = '',
custom_value1: str = '',
custom_value2: str = '',
custom_value3: str = '',
custom_value4: str = '',
ledger: Optional[List[Union[ClientPayment, ClientInvoice]]] = None):
self._id = client_id
self._name = name
self._private_notes = private_notes
self._public_notes = public_notes
self._balance = balance
self._paid_to_date = paid_to_date
self._payment_balance = payment_balance
self._credit_balance = credit_balance
self._settings = settings or {}
self._is_deleted = is_deleted
self._updated_at = updated_at
self._archived_at = archived_at
self._created_at = created_at
self._display_name = display_name
self._contacts = contacts or []
self._documents = documents or []
self._address = address
self._city = city
self._state = state
self._postal_code = postal_code
self._phone = phone
self._custom_value1 = custom_value1
self._custom_value2 = custom_value2
self._custom_value3 = custom_value3
self._custom_value4 = custom_value4
self._ledger = ledger
@property
def id(self):
return self._id
@property
def name(self):
return self._name
@name.setter
def name(self, name):
self._name = name
@name.deleter
def name(self):
self._name = ''
@property
def private_notes(self):
return self._private_notes
@private_notes.setter
def private_notes(self, note):
self._private_notes = note
@private_notes.deleter
def private_notes(self):
self._private_notes = ''
@property
def public_notes(self):
return self._public_notes
@public_notes.setter
def public_notes(self, note):
self._public_notes = note
@public_notes.deleter
def public_notes(self):
self._public_notes = ''
@property
def balance(self):
return self._balance
@property
def paid_to_date(self):
return self._paid_to_date
@property
def payment_balance(self):
return self._payment_balance
@property
def credit_balance(self):
return self._credit_balance
@property
def settings(self):
return self._settings
@settings.setter
def settings(self, settings):
self._settings = settings
@settings.deleter
def settings(self):
self._settings = {}
@property
def is_deleted(self):
return self._is_deleted
@property
def updated_at(self):
return self._updated_at
@property
def archived_at(self):
return self._archived_at
@property
def created_at(self):
return self._created_at
@property
def display_name(self):
return self._display_name
@display_name.setter
def display_name(self, name):
self._display_name = name
@display_name.deleter
def display_name(self):
self._display_name = ''
@property
def contacts(self):
return self._contacts
@contacts.setter
def contacts(self, contacts):
self._contacts = contacts
@contacts.deleter
def contacts(self):
self._contacts = []
@property
def documents(self):
return self._documents
@documents.setter
def documents(self, documents):
self._documents = documents
@documents.deleter
def documents(self):
self._documents = []
@property
def address(self):
return self._address
@address.setter
def address(self, address):
self._address = address
@address.deleter
def address(self):
self._address = ''
@property
def city(self):
return self._city
@city.setter
def city(self, city):
self._city = city
@city.deleter
def city(self):
self._city = ''
@property
def state(self):
return self._state
@state.setter
def state(self, state):
self._state = state
@state.deleter
def state(self):
self._state = ''
@property
def postal_code(self):
return self._postal_code
@postal_code.setter
def postal_code(self, postal_code):
self._postal_code = postal_code
@postal_code.deleter
def postal_code(self):
self._postal_code = ''
@property
def custom_value1(self):
return self._custom_value1
@custom_value1.setter
def custom_value1(self, value):
self._custom_value1 = value
@custom_value1.deleter
def custom_value1(self):
self._custom_value1 = ''
@property
def custom_value2(self):
return self._custom_value2
@custom_value2.setter
def custom_value2(self, value):
self._custom_value2 = value
@custom_value2.deleter
def custom_value2(self):
self._custom_value2 = ''
@property
def custom_value3(self):
return self._custom_value3
@custom_value3.setter
def custom_value3(self, value):
self._custom_value3 = value
@custom_value3.deleter
def custom_value3(self):
self._custom_value3 = ''
@property
def custom_value4(self):
return self._custom_value4
@custom_value4.setter
def custom_value4(self, value):
self._custom_value4 = value
@custom_value4.deleter
def custom_value4(self):
self._custom_value4 = ''
@property
def ledger(self):
return self._ledger
@ledger.setter
def ledger(self, ledger: List[Dict[str, Any]]):
self._ledger = ledger
@classmethod
def from_json(cls, client: dict):
"""
Create a Client instance from a JSON-like dictionary.
"""
contacts = [ClientContact.from_json(contact) for contact in client.get('contacts', [])]
documents = [Document.from_json(document) for document in client.get('documents', [])]
ledger_processed = []
for index, item in enumerate(client.get('ledger', [])):
if index % 2 == 0:
# Processing ClientInvoice
ledger_processed = ClientInvoice.from_json(item)
else:
# Processing ClientPayment
ledger_processed = ClientPayment.from_json(item)
return cls(client_id=client['id'],
name=client['name'],
private_notes=client['private_notes'],
public_notes=client['public_notes'],
balance=client['balance'],
paid_to_date=client['paid_to_date'],
payment_balance=client['payment_balance'],
credit_balance=client['credit_balance'],
settings=ClientSettings.from_json(client['settings']),
is_deleted=client['is_deleted'],
updated_at=client['updated_at'],
archived_at=client['archived_at'],
created_at=client['created_at'],
display_name=client['display_name'],
contacts=contacts,
documents=documents,
address=client['address1'],
city=client['city'],
state=client['state'],
postal_code=client['postal_code'],
phone=client['phone'],
custom_value1=client['custom_value1'],
custom_value2=client['custom_value2'],
custom_value3=client['custom_value3'],
custom_value4=client['custom_value4'],
ledger=ledger_processed)
def to_json(self):
"""
Convert the Client instance to a JSON-like dictionary.
"""
return json.dumps({'id': self._id,
'name': self._name,
'private_notes': self._private_notes,
'public_notes': self._public_notes,
'balance': self._balance,
'paid_to_date': self._paid_to_date,
'payment_balance': self._payment_balance,
'credit_balance': self._credit_balance,
'settings': self._settings,
'is_deleted': self._is_deleted,
'updated_at': self._updated_at,
'archived_at': self._archived_at,
'created_at': self._created_at,
'display_name': self._display_name,
'contacts': [contact.to_json() for contact in self._contacts],
'documents': [document.to_json() for document in self._documents],
'address1': self._address,
'city': self._city,
'state': self._state,
'postal_code': self._postal_code,
'phone': self._phone,
'custom_value1': self._custom_value1,
'custom_value2': self._custom_value2,
'custom_value3': self._custom_value3,
'custom_value4': self._custom_value4,
'ledger': self._ledger})

View File

@ -0,0 +1,254 @@
from typing import Any
from typing import Dict
from typing import List
class ClientContact(object):
def __init__(self, contact_id: str = '',
first_name: str = '',
last_name: str = '',
email: str = '',
phone: str = '',
password: str = '',
created_at: int = 0,
updated_at: int = 0,
archived_at: int = 0,
is_primary: bool = False,
is_locked: bool = False,
send_email: bool = False,
contact_key: str = '',
last_login: int = 0,
portal_link: str = '',
custom_value1: str = '',
custom_value2: str = '',
custom_value3: str = '',
custom_value4: str = '') -> None:
self._contact_id = contact_id
self._first_name = first_name
self._last_name = last_name
self._email = email
self._phone = phone
self._password = password
self._created_at = created_at
self._updated_at = updated_at
self._archived_at = archived_at
self._is_primary = is_primary
self._is_locked = is_locked
self._contact_key = contact_key
self._last_login = last_login
self._send_email = send_email
self._portal_link = portal_link
self._custom_value1 = custom_value1
self._custom_value2 = custom_value2
self._custom_value3 = custom_value3
self._custom_value4 = custom_value4
@property
def contact_id(self) -> str:
return self._contact_id
@property
def first_name(self) -> str:
return self._first_name
@first_name.setter
def first_name(self, first_name: str) -> None:
self._first_name = first_name
@first_name.deleter
def first_name(self) -> None:
self._first_name = ''
@property
def last_name(self) -> str:
return self._last_name
@last_name.setter
def last_name(self, last_name: str) -> None:
self._last_name = last_name
@last_name.deleter
def last_name(self) -> None:
self._last_name = ''
@property
def email(self) -> str:
return self._email
@email.setter
def email(self, email: str) -> None:
self._email = email
@email.deleter
def email(self) -> None:
self._email = ''
@property
def phone(self) -> str:
return self._phone
@phone.setter
def phone(self, phone: str) -> None:
self._phone = phone
@phone.deleter
def phone(self) -> None:
self._phone = ''
@property
def password(self) -> str:
return self._password
@password.setter
def password(self, password: str) -> None:
self._password = password
@password.deleter
def password(self) -> None:
self._password = ''
@property
def created_at(self) -> int:
return self._created_at
@property
def updated_at(self) -> int:
return self._updated_at
@property
def archived_at(self) -> int:
return self._archived_at
@property
def is_primary(self) -> bool:
return self._is_primary
@is_primary.setter
def is_primary(self, is_primary: bool) -> None:
self._is_primary = is_primary
@property
def is_locked(self) -> bool:
return self._is_locked
@is_locked.setter
def is_locked(self, is_locked: bool) -> None:
self._is_locked = is_locked
@property
def contact_key(self) -> str:
return self._contact_key
@property
def last_login(self) -> int:
return self._last_login
@property
def send_email(self) -> bool:
return self._send_email
@send_email.setter
def send_email(self, send_email: bool) -> None:
self._send_email = send_email
@property
def portal_link(self) -> str:
return self._portal_link
@property
def custom_value1(self) -> str:
return self._custom_value1
@custom_value1.setter
def custom_value1(self, value: str) -> None:
self._custom_value1 = value
@custom_value1.deleter
def custom_value1(self) -> None:
self._custom_value1 = ''
@property
def custom_value2(self) -> str:
return self._custom_value2
@custom_value2.setter
def custom_value2(self, value: str) -> None:
self._custom_value2 = value
@custom_value2.deleter
def custom_value2(self) -> None:
self._custom_value2 = ''
@property
def custom_value3(self) -> str:
return self._custom_value3
@custom_value3.setter
def custom_value3(self, value: str) -> None:
self._custom_value3 = value
@custom_value3.deleter
def custom_value3(self) -> None:
self._custom_value3 = ''
@property
def custom_value4(self) -> str:
return self._custom_value4
@custom_value4.setter
def custom_value4(self, value: str) -> None:
self._custom_value4 = value
@custom_value4.deleter
def custom_value4(self) -> None:
self._custom_value4 = ''
@classmethod
def from_json(cls, contact: Dict[str, Any]) -> 'ClientContact':
return cls(contact_id=contact['id'],
first_name=contact['first_name'],
last_name=contact['last_name'],
email=contact['email'],
created_at=contact['created_at'],
updated_at=contact['updated_at'],
archived_at=contact['archived_at'],
is_primary=contact['is_primary'],
is_locked=contact['is_locked'],
phone=contact['phone'],
custom_value1=contact['custom_value1'],
custom_value2=contact['custom_value2'],
custom_value3=contact['custom_value3'],
custom_value4=contact['custom_value4'],
contact_key=contact['contact_key'],
send_email=contact['send_email'],
last_login=contact['last_login'],
password=contact['password'],
portal_link=contact['link'])
@classmethod
def from_json_list(cls, contacts: List[Dict[str, Any]]) -> List['ClientContact']:
return [cls.from_json(contact) for contact in contacts]
def to_json(self) -> str:
import json
contact = {
"contact_id": self._contact_id,
"first_name": self._first_name,
"last_name": self._last_name,
"email": self._email,
"phone": self._phone,
"created_at": self._created_at,
"updated_at": self._updated_at,
"archived_at": self._archived_at,
"is_primary": self._is_primary,
"is_locked": self._is_locked,
"send_email": self._send_email,
"contact_key": self._contact_key,
"last_login": self._last_login,
"portal_link": self._portal_link,
"custom_value1": self._custom_value1,
"custom_value2": self._custom_value2,
"custom_value3": self._custom_value3,
"custom_value4": self._custom_value4
}
return json.dumps(contact)

View File

@ -0,0 +1,48 @@
from typing import Any
from typing import Dict
from typing import Optional
from typing import Type
import json
class ClientInvoice:
def __init__(self, invoice_id: Optional[str] = None,
notes: Optional[str] = None,
balance: Optional[int] = None,
adjustment: Optional[int] = None,
activity_id: Optional[int] = None,
created_at: Optional[int] = None,
updated_at: Optional[int] = None,
archived_at: Optional[int] = None):
self.invoice_id = invoice_id
self.notes = notes
self.balance = balance
self.adjustment = adjustment
self.activity_id = activity_id
self.created_at = created_at
self.updated_at = updated_at
self.archived_at = archived_at
@classmethod
def from_json(cls: Type['ClientInvoice'], invoice: Dict[str, Any]) -> 'ClientInvoice':
return cls(invoice_id=invoice.get('invoice_id', None),
notes=invoice.get('notes', None),
balance=invoice.get('balance', None),
adjustment=invoice.get('adjustment', None),
activity_id=invoice.get('activity_id', None),
created_at=invoice.get('created_at', None),
updated_at=invoice.get('updated_at', None),
archived_at=invoice.get('archived_at', None))
def __str__(self):
return f'Invoice: {self.invoice_id}, {self.balance}'
def to_json(self) -> str:
return json.dumps({'invoice_id': self.invoice_id,
'notes': self.notes,
'balance': self.balance,
'adjustment': self.adjustment,
'activity_id': self.activity_id,
'created_at': self.created_at,
'archive_at': self.archived_at})

View File

@ -0,0 +1,48 @@
from typing import Any
from typing import Dict
from typing import Optional
from typing import Type
import json
class ClientPayment:
def __init__(self, payment_id: Optional[str] = None,
notes: Optional[str] = None,
balance: Optional[int] = None,
adjustment: Optional[int] = None,
activity_id: Optional[int] = None,
created_at: Optional[int] = None,
updated_at: Optional[int] = None,
archived_at: Optional[int] = None):
self.payment_id = payment_id
self.notes = notes
self.balance = balance
self.adjustment = adjustment
self.activity_id = activity_id
self.created_at = created_at
self.updated_at = updated_at
self.archived_at = archived_at
@classmethod
def from_json(cls: Type['ClientLedger'], ledger: Dict[str, Any]) -> 'ClientLedger':
return cls(payment_id=ledger.get('payment_id', None),
notes=ledger.get('notes', None),
balance=ledger.get('balance', None),
adjustment=ledger.get('adjustment', None),
activity_id=ledger.get('activity_id', None),
created_at=ledger.get('created_at', None),
updated_at=ledger.get('updated_at', None),
archived_at=ledger.get('archived_at', None))
def __str__(self):
return f'Ledger: {self.payment_id}, {self.balance}'
def to_json(self) -> str:
return json.dumps({'payment_id': self.payment_id,
'notes': self.notes,
'balance': self.balance,
'adjustment': self.adjustment,
'activity_id': self.activity_id,
'created_at': self.created_at,
'archive_at': self.archived_at})

View File

@ -0,0 +1,42 @@
from typing import Any
from typing import Dict
from typing import Optional
from typing import Type
import json
class ClientSettings:
def __init__(self, currency_id: Optional[str] = None,
language_id: Optional[str] = None,
payment_terms: Optional[int] = None,
default_task_rate: Optional[float] = None,
send_reminders: Optional[bool] = None,
valid_until: Optional[str] = None,
enable_client_portal_password: Optional[bool] = None):
self.currency_id = currency_id
self.language_id = language_id
self.payment_terms = payment_terms
self.default_task_rate = default_task_rate
self.send_reminders = send_reminders
self.valid_until = valid_until
self.enable_client_portal_password = enable_client_portal_password
@classmethod
def from_json(cls: Type['ClientSettings'], settings: Dict[str, Any]) -> 'ClientSettings':
return cls(currency_id=settings.get('currency_id', None),
language_id=settings.get('language_id', None),
payment_terms=settings.get('payment_terms', None),
default_task_rate=settings.get('default_task_rate', None),
send_reminders=settings.get('send_reminders', None),
valid_until=settings.get('valid_until', None),
enable_client_portal_password=settings.get('enable_client_portal_password', None))
def to_json(self) -> str:
return json.dumps({'currency_id': self.currency_id,
'language_id': self.language_id,
'payment_terms': self.payment_terms,
'default_task_rate': self.default_task_rate,
'send_reminders': self.send_reminders,
'valid_until': self.valid_until,
'enable_client_portal_password': self.enable_client_portal_password})

View File

@ -0,0 +1,147 @@
from typing import Any
from typing import Dict
from typing import List
import json
class Document:
def __init__(self, document_id: str = '',
user_id: str = '',
assigned_user_id: str = '',
project_id: str = '',
vendor_id: str = '',
name: str = '',
url: str = '',
preview: str = '',
document_type: str = '',
disk: str = '',
document_hash: str = '',
is_deleted: bool = False,
is_default: bool = False,
created_at: int = 0,
updated_at: int = 0,
deleted_at: int = 0):
self._document_id = document_id
self._user_id = user_id
self._assigned_user_id = assigned_user_id
self._project_id = project_id
self._vendor_id = vendor_id
self._name = name
self._url = url
self._preview = preview
self._document_type = document_type
self._disk = disk
self._document_hash = document_hash
self._is_deleted = is_deleted
self._is_default = is_default
self._created_at = created_at
self._updated_at = updated_at
self._deleted_at = deleted_at
@property
def document_id(self) -> str:
return self._document_id
@property
def user_id(self) -> str:
return self._user_id
@property
def assigned_user_id(self) -> str:
return self._assigned_user_id
@property
def project_id(self) -> str:
return self._project_id
@property
def vendor_id(self) -> str:
return self._vendor_id
@property
def name(self) -> str:
return self._name
@property
def url(self) -> str:
return self._url
@property
def preview(self) -> str:
return self._preview
@property
def document_type(self) -> str:
return self._document_type
@property
def disk(self) -> str:
return self._disk
@property
def document_hash(self) -> str:
return self._document_hash
@property
def is_deleted(self) -> bool:
return self._is_deleted
@property
def is_default(self) -> bool:
return self._is_default
@property
def created_at(self) -> int:
return self._created_at
@property
def updated_at(self) -> int:
return self._updated_at
@property
def deleted_at(self) -> int:
return self._deleted_at
@classmethod
def from_json(cls, document: Dict[str, Any]) -> 'Document':
return cls(document_id=document.get('id', ''),
user_id=document.get('user_id', ''),
assigned_user_id=document.get('assigned_user_id', ''),
project_id=document.get('project_id', ''),
vendor_id=document.get('vendor_id', ''),
name=document.get('name', ''),
url=document.get('url', ''),
preview=document.get('preview', ''),
document_type=document.get('type', ''),
disk=document.get('disk', ''),
document_hash=document.get('hash', ''),
is_deleted=document.get('is_deleted', False),
is_default=document.get('is_default', False),
created_at=document.get('created_at', 0),
updated_at=document.get('updated_at', 0),
deleted_at=document.get('deleted_at', 0))
@classmethod
def from_json_list(cls, documents: List[Dict[str, Any]]) -> List['Document']:
return [cls.from_json(document) for document in documents]
def to_json(self) -> str:
return json.dumps({'document_id': self._document_id,
'user_id': self._user_id,
'assigned_user_id': self._assigned_user_id,
'project_id': self._project_id,
'vendor_id': self._vendor_id,
'name': self._name,
'url': self._url,
'preview': self._preview,
'document_type': self._document_type,
'disk': self._disk,
'document_hash': self._document_hash,
'is_deleted': self._is_deleted,
'is_default': self._is_default,
'created_at': self._created_at,
'updated_at': self._updated_at,
'deleted_at': self._deleted_at})
def __str__(self):
return f'Document({self._document_id}, {self._name})'

View File

@ -1,13 +1,25 @@
{ pkgs ? import <nixpkgs> {} }: { pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz") {} }:
with pkgs; with pkgs;
pkgs.mkShell { pkgs.mkShell {
nativeBuildInputs = [ nativeBuildInputs = [
# Python development environment inlyne # markdown viewer
(python3.withPackages(ps: with ps; [
requests # Python development environment
])) (python3.withPackages(ps: with ps; [
]; # For SDK configuration
python-dotenv
# Logging
loguru
# HTTP client
requests
# Testing
pytest
]))
];
} }

14
tests/conftest.py Normal file
View File

@ -0,0 +1,14 @@
# Library imports
import pytest
####
# Global fixtures
####
# App client for testing
@pytest.fixture(scope='module')
def client():
app = create_app(config_class='tests/testing_config.py')
with app.app_context():
yield app

6
tests/test.env Normal file
View File

@ -0,0 +1,6 @@
# Base URL of an Invoice Ninja instance
BASE_URL=
# API token for an Invoice Ninja instance
API_TOKEN=