Compare commits

...

10 Commits

6 changed files with 99 additions and 63 deletions

View File

@@ -1,25 +1,21 @@
# AVT Fresh! # AVT Fresh!
This is a wrapper of [the roly-poly, not 100% ergonomic Freshbooks web API](https://www.freshbooks.com/api/start). It is far from comprehensive: It was created for the specific client- and invoice-related needs of [Averbach Transcription](https://avtranscription.com). This is a wrapper of [the Freshbooks web API](https://www.freshbooks.com/api/start). It is far from comprehensive: It was created for the specific client- and invoice-related needs of [Averbach Transcription](https://avtranscription.com). However, there are "band-aids" here to work around some of the API's shortcomings. For example, you don't have to deal with pagination at all. 🎉
There are "band-aids" here to work around some of the API's shortcomings. For example, you don't have to deal with pagination at all. 🎉 Install it with `pip install avt-fresh`.
# Installation Here's how you use it:
```
pip install avt-fresh
```
# Usage
Instantiate the avt_fresh `Client` like so, and you're off to the races:
```python ```python
client = Client(client_secret="...", client_id="...", redirect_uri="https://...", account_id="...") from avt_fresh import ApiClient
client = ApiClient(client_secret="...", client_id="...", redirect_uri="https://...", account_id="...")
monster_invoices = client.get_all_invoices_for_org_name("Monsters Inc") monster_invoices = client.get_all_invoices_for_org_name("Monsters Inc")
client.get_one_invoice(12345)
``` ```
You can get and set the required arguments to `Client` [here](https://my.freshbooks.com/#/developer). Well, all of them except `FRESHBOOKS_ACCOUNT_ID`, which you can see (there's got to be another way??) by clicking on one of your invoices and grabbing the substring here: `https://my.freshbooks.com/#/invoice/<THIS THING>-1234567`. You can get and set the required arguments to `ApiClient` [here](https://my.freshbooks.com/#/developer). Well, all of them except `FRESHBOOKS_ACCOUNT_ID`, which you can see (there's got to be another way??) by clicking on one of your invoices and grabbing the substring here: `https://my.freshbooks.com/#/invoice/<THIS THING>-1234567`.
Don't tell anyone but `redirect_uri` can be pretty much anything! See `Initializing` below 👇. Don't tell anyone but `redirect_uri` can be pretty much anything! See `Initializing` below 👇.
@@ -134,11 +130,26 @@ Finally, once you have an app on that developer page, click into it and click "g
You should only have to do this once in each environment you use this library in. You should only have to do this once in each environment you use this library in.
# OAuth Token Stores
By default this library stores OAuth tokens on disk in whatever working directory its methods are called from. As an alternative you can use Redis via the `avt_fresh.token.TokenStoreOnRedis` at instantiation of an `ApiClient` like so:
```python
client = Client(
client_secret="...",
client_id="...",
redirect_uri="https://...",
account_id="...",
token_store=avt_fresh.token.TokenStoreOnRedis,
connection_string="redis://..." ,
)
```
As a further alternative, feel free to implement and inject your own! See `avt_fresh.token.TokenStore` for the API, but tl;dr simply inherit from `TokenStore` and implement `get()` and `set()` methods, the former of which should return an instance of `avt_fresh.token.TokenTup`.
# Hardcoded Stuff / TODOs # Hardcoded Stuff / TODOs
Here are some quirks and TODOs. PRs are welcome!: Here are some quirks and TODOs. PRs are welcome!:
OAuth tokens are currently saved in the ever-so-insecure path of `~/freshbooks_oauth_token.json`. TODO: don't do this anymore. ¯\_(ツ)_/¯
Only Python 3.10 is supported at the moment. Only Python 3.10 is supported at the moment.
When it comes to invoice statuses, we're only using `v3_status` strings, not the numbers. What's more, when you create an invoice we're only supporting two possible statuses: "draft" and "paid". When it comes to invoice statuses, we're only using `v3_status` strings, not the numbers. What's more, when you create an invoice we're only supporting two possible statuses: "draft" and "paid".

View File

@@ -30,7 +30,7 @@ from avt_fresh.payments import (
get_default_payment_options, get_default_payment_options,
add_payment_option_to_invoice, add_payment_option_to_invoice,
) )
from avt_fresh.token import TokenStoreOnDisk, NoToken, Token from avt_fresh.token import TokenStoreOnDisk, NoToken, TokenStore, TokenTup
BASE_URL = "https://api.freshbooks.com" BASE_URL = "https://api.freshbooks.com"
@@ -48,8 +48,13 @@ class AvtFreshException(Exception):
class ApiClient: class ApiClient:
def __init__( def __init__(
self, client_secret: str, client_id: str, redirect_uri: str, account_id: str, self,
token_store: Token = TokenStoreOnDisk, connection_string: str | None = None, client_secret: str,
client_id: str,
redirect_uri: str,
account_id: str,
token_store: TokenStore = TokenStoreOnDisk,
connection_string: str | None = None,
): ):
self.client_secret = client_secret self.client_secret = client_secret
self.client_id = client_id self.client_id = client_id
@@ -319,7 +324,7 @@ def _get_code_from_user() -> str:
) )
def _is_expired(token: Token) -> bool: def _is_expired(token: TokenTup) -> bool:
return dt.datetime.now().timestamp() > token.created_at + token.expires_in return dt.datetime.now().timestamp() > token.created_at + token.expires_in

View File

@@ -20,6 +20,11 @@ class FreshbooksContact(typing.NamedTuple):
"fname": self.first_name, "fname": self.first_name,
"lname": self.last_name, "lname": self.last_name,
} }
def __rich_repr__(self):
yield "contact_id", self.contact_id
yield "first_name", self.first_name
yield "last_name", self.last_name
yield "email", self.email
class FreshbooksClient(typing.NamedTuple): class FreshbooksClient(typing.NamedTuple):
@@ -53,6 +58,14 @@ class FreshbooksClient(typing.NamedTuple):
email_contact_id_lookup=email_contact_id_lookup, email_contact_id_lookup=email_contact_id_lookup,
) )
def __rich_repr__(self):
yield "client_id", self.client_id
yield "email", self.email
yield "organization", self.organization
yield "first_name", self.first_name
yield "last_name", self.last_name
yield "contacts", self.contacts
class NoResult(Exception): class NoResult(Exception):
pass pass

View File

@@ -1,8 +1,9 @@
import datetime as dt import datetime as dt
from decimal import Decimal import decimal
from functools import partial, lru_cache import functools
import typing import typing
WHAT = "invoice" WHAT = "invoice"
@@ -27,22 +28,22 @@ class FreshbooksLine(typing.NamedTuple):
client_id: int client_id: int
description: str description: str
name: str name: str
rate: Decimal rate: decimal.Decimal
line_id: int line_id: int
quantity: Decimal quantity: decimal.Decimal
amount: Decimal amount: decimal.Decimal
@classmethod @classmethod
def from_api(cls, **kwargs): def from_api(cls, **kwargs):
return cls( return cls(
invoice_id=kwargs["invoice_id"], invoice_id=kwargs["invoice_id"],
client_id=kwargs["client_id"], client_id=kwargs["client_id"],
rate=Decimal(kwargs["unit_cost"]["amount"]), rate=decimal.Decimal(kwargs["unit_cost"]["amount"]),
description=kwargs["description"], description=kwargs["description"],
name=kwargs["name"], name=kwargs["name"],
quantity=Decimal(kwargs["qty"]), quantity=decimal.Decimal(kwargs["qty"]),
line_id=kwargs["lineid"], line_id=kwargs["lineid"],
amount=Decimal(kwargs["amount"]["amount"]), amount=decimal.Decimal(kwargs["amount"]["amount"]),
) )
@property @property
@@ -54,6 +55,14 @@ class FreshbooksLine(typing.NamedTuple):
"quantity": str(self.quantity), "quantity": str(self.quantity),
} }
def __rich_repr__(self):
yield "line_id", self.line_id
yield "description", self.description
yield "amount", self.amount
yield "quantity", self.quantity
yield "rate", self.rate
yield "name", self.name
class FreshbooksInvoice(typing.NamedTuple): class FreshbooksInvoice(typing.NamedTuple):
lines: list[FreshbooksLine] lines: list[FreshbooksLine]
@@ -63,9 +72,9 @@ class FreshbooksInvoice(typing.NamedTuple):
invoice_id: int invoice_id: int
number: str number: str
organization: str organization: str
amount: Decimal amount: decimal.Decimal
status: str status: str
amount_outstanding: Decimal amount_outstanding: decimal.Decimal
po_number: str po_number: str
line_id_line_dict: dict line_id_line_dict: dict
line_description_line_dict: dict line_description_line_dict: dict
@@ -101,12 +110,22 @@ class FreshbooksInvoice(typing.NamedTuple):
number=kwargs["invoice_number"], number=kwargs["invoice_number"],
organization=kwargs["organization"], organization=kwargs["organization"],
allowed_gateways=kwargs["allowed_gateways"], allowed_gateways=kwargs["allowed_gateways"],
amount=Decimal(kwargs["amount"]["amount"]), amount=decimal.Decimal(kwargs["amount"]["amount"]),
amount_outstanding=Decimal(kwargs["outstanding"]["amount"]), amount_outstanding=decimal.Decimal(kwargs["outstanding"]["amount"]),
contacts={contact["email"]: contact for contact in kwargs["contacts"]}, contacts={contact["email"]: contact for contact in kwargs["contacts"]},
status=kwargs["v3_status"], status=kwargs["v3_status"],
) )
def __rich_repr__(self):
yield "invoice_id", self.invoice_id
yield "invoice_number", self.number
yield "organization", self.organization
yield "date", self.date
yield "status", self.status
yield "amount", self.amount
yield "lines", self.lines
yield "contacts", self.contacts
def get_all_draft_invoices(*, get_func: typing.Callable) -> list[FreshbooksInvoice]: def get_all_draft_invoices(*, get_func: typing.Callable) -> list[FreshbooksInvoice]:
return _get(get_func=get_func, status="draft") return _get(get_func=get_func, status="draft")
@@ -115,10 +134,11 @@ def get_all_draft_invoices(*, get_func: typing.Callable) -> list[FreshbooksInvoi
def get_all_invoices_for_org_name( def get_all_invoices_for_org_name(
*, get_func: typing.Callable, org_name: str *, get_func: typing.Callable, org_name: str
) -> list[FreshbooksInvoice]: ) -> list[FreshbooksInvoice]:
return _get(get_func=get_func, org_name=org_name) from avt_fresh.client import get_freshbooks_client_from_org_name
client_id = get_freshbooks_client_from_org_name(get_func=get_func, org_name=org_name).client_id
return get_all_invoices_for_client_id(get_func=get_func, client_id=client_id)
@lru_cache
def get_all_invoices_for_client_id( def get_all_invoices_for_client_id(
*, get_func: typing.Callable, client_id: int *, get_func: typing.Callable, client_id: int
) -> list[FreshbooksInvoice]: ) -> list[FreshbooksInvoice]:
@@ -147,7 +167,7 @@ def _get(
org_name=None, org_name=None,
status=None, status=None,
) -> list[FreshbooksInvoice]: ) -> list[FreshbooksInvoice]:
get_func = partial(get_func, what=WHAT) get_func = functools.partial(get_func, what=WHAT)
if client_id is not None and org_name is not None: if client_id is not None and org_name is not None:
raise ArgumentError("Please provide either client_id or org_name") raise ArgumentError("Please provide either client_id or org_name")

View File

@@ -2,7 +2,7 @@ import abc
from dataclasses import dataclass from dataclasses import dataclass
import json import json
from pathlib import Path from pathlib import Path
from uuid import uuid4 import typing
import redis import redis
@@ -18,9 +18,7 @@ class OnlyOneToken(Exception):
pass pass
@dataclass class TokenTup(typing.NamedTuple):
class Token:
id: int
access_token: str access_token: str
token_type: str token_type: str
expires_in: int expires_in: int
@@ -28,33 +26,25 @@ class Token:
scope: str scope: str
created_at: int created_at: int
def __init__(self, *args):
@dataclass
class TokenStore(metaclass=abc.ABCMeta):
@abc.abstractmethod
def get(self) -> TokenTup:
... ...
@abc.abstractmethod @abc.abstractmethod
def get(cls) -> "Token": def set(self, token_dict: dict) -> None:
...
@abc.abstractmethod
def delete(self) -> None:
...
@abc.abstractmethod
def set(cls, token_dict: dict) -> None:
... ...
class TokenStoreOnDisk(Token): class TokenStoreOnDisk(TokenStore):
def __init__(self, *args):
super().__init__(*args)
@classmethod @classmethod
def get(cls) -> Token: def get(cls) -> TokenTup:
if not TOKEN_PATH.exists(): if not TOKEN_PATH.exists():
raise NoToken raise NoToken
with TOKEN_PATH.open(encoding="utf-8") as fin: with TOKEN_PATH.open(encoding="utf-8") as fin:
return Token(**json.load(fin)) return TokenTup(**json.load(fin))
@classmethod @classmethod
def set(cls, token_dict: dict) -> None: def set(cls, token_dict: dict) -> None:
@@ -65,18 +55,15 @@ class TokenStoreOnDisk(Token):
json.dump(token_dict, fout) json.dump(token_dict, fout)
class TokenStoreOnRedis(Token): class TokenStoreOnRedis(TokenStore):
def __init__(self, redis_url, redis_db_num: int = 0):
self.redis_client = redis.from_url(redis_url, db=redis_db_num)
def __init__(self, *args): def get(self) -> TokenTup:
super().__init__(*args)
redis_url = args[0]
self.redis_client = redis.from_url(redis_url)
def get(self):
result = self.redis_client.get(TOKEN_KEY) result = self.redis_client.get(TOKEN_KEY)
if result is None: if result is None:
raise NoToken raise NoToken
return Token(**json.loads(result)) return TokenTup(**json.loads(result))
def set(self, token_dict: dict) -> None: def set(self, token_dict: dict) -> None:
self.redis_client.set(TOKEN_KEY, json.dumps(token_dict)) self.redis_client.set(TOKEN_KEY, json.dumps(token_dict))

View File

@@ -4,7 +4,7 @@ setup(
name="avt_fresh", name="avt_fresh",
author="Zev Averbach", author="Zev Averbach",
author_email="zev@averba.ch", author_email="zev@averba.ch",
version="0.0.14", version="0.0.16",
license="MIT", license="MIT",
python_requires=">3.10.0", python_requires=">3.10.0",
keywords="freshbooks API", keywords="freshbooks API",