Compare commits
10 Commits
04ccc4e9ae
...
fbcc48b5af
| Author | SHA1 | Date | |
|---|---|---|---|
| fbcc48b5af | |||
| 5cc90be8b6 | |||
| 13e5d326ea | |||
| 43aaddedaa | |||
| 0dc6e84727 | |||
| d8b5b01bcc | |||
| 5b0c70024a | |||
| 22da3b77ce | |||
| 441c7c6c57 | |||
| 4859c43b2a |
39
README.md
39
README.md
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user