Compare commits

...

10 Commits

6 changed files with 99 additions and 63 deletions

View File

@@ -1,25 +1,21 @@
# 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
```
pip install avt-fresh
```
# Usage
Instantiate the avt_fresh `Client` like so, and you're off to the races:
Here's how you use it:
```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")
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 👇.
@@ -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.
# 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
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.
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,
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"
@@ -48,8 +48,13 @@ class AvtFreshException(Exception):
class ApiClient:
def __init__(
self, client_secret: str, client_id: str, redirect_uri: str, account_id: str,
token_store: Token = TokenStoreOnDisk, connection_string: str | None = None,
self,
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_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

View File

@@ -20,6 +20,11 @@ class FreshbooksContact(typing.NamedTuple):
"fname": self.first_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):
@@ -53,6 +58,14 @@ class FreshbooksClient(typing.NamedTuple):
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):
pass

View File

@@ -1,8 +1,9 @@
import datetime as dt
from decimal import Decimal
from functools import partial, lru_cache
import decimal
import functools
import typing
WHAT = "invoice"
@@ -27,22 +28,22 @@ class FreshbooksLine(typing.NamedTuple):
client_id: int
description: str
name: str
rate: Decimal
rate: decimal.Decimal
line_id: int
quantity: Decimal
amount: Decimal
quantity: decimal.Decimal
amount: decimal.Decimal
@classmethod
def from_api(cls, **kwargs):
return cls(
invoice_id=kwargs["invoice_id"],
client_id=kwargs["client_id"],
rate=Decimal(kwargs["unit_cost"]["amount"]),
rate=decimal.Decimal(kwargs["unit_cost"]["amount"]),
description=kwargs["description"],
name=kwargs["name"],
quantity=Decimal(kwargs["qty"]),
quantity=decimal.Decimal(kwargs["qty"]),
line_id=kwargs["lineid"],
amount=Decimal(kwargs["amount"]["amount"]),
amount=decimal.Decimal(kwargs["amount"]["amount"]),
)
@property
@@ -54,6 +55,14 @@ class FreshbooksLine(typing.NamedTuple):
"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):
lines: list[FreshbooksLine]
@@ -63,9 +72,9 @@ class FreshbooksInvoice(typing.NamedTuple):
invoice_id: int
number: str
organization: str
amount: Decimal
amount: decimal.Decimal
status: str
amount_outstanding: Decimal
amount_outstanding: decimal.Decimal
po_number: str
line_id_line_dict: dict
line_description_line_dict: dict
@@ -101,12 +110,22 @@ class FreshbooksInvoice(typing.NamedTuple):
number=kwargs["invoice_number"],
organization=kwargs["organization"],
allowed_gateways=kwargs["allowed_gateways"],
amount=Decimal(kwargs["amount"]["amount"]),
amount_outstanding=Decimal(kwargs["outstanding"]["amount"]),
amount=decimal.Decimal(kwargs["amount"]["amount"]),
amount_outstanding=decimal.Decimal(kwargs["outstanding"]["amount"]),
contacts={contact["email"]: contact for contact in kwargs["contacts"]},
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]:
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(
*, get_func: typing.Callable, org_name: str
) -> 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(
*, get_func: typing.Callable, client_id: int
) -> list[FreshbooksInvoice]:
@@ -147,7 +167,7 @@ def _get(
org_name=None,
status=None,
) -> 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:
raise ArgumentError("Please provide either client_id or org_name")

View File

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

View File

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