Skip to content

Commit

Permalink
Add Alma API client
Browse files Browse the repository at this point in the history
Why these changes are being introduced:
Initial functionality for this app involves interacting with the Alma
Acquisitions API.

How this addresses that need:
* Adds alma module with AlmaClient class, including methods to retrieve
  the necessary data for credit card slips processing.
* Adds Alma configuration function to config module.
* Adds tests and fixtures to reflect changes.
* Updates README to include new required and optional ENV variables.

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/IN-715
  • Loading branch information
hakbailey committed Feb 23, 2023
1 parent bb01a4f commit 9b59aba
Show file tree
Hide file tree
Showing 10 changed files with 538 additions and 38 deletions.
3 changes: 3 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ name = "pypi"

[packages]
click = "*"
requests = "*"
sentry-sdk = "*"

[dev-packages]
Expand All @@ -16,6 +17,8 @@ freezegun = "*"
mypy = "*"
pylama = {extras = ["all"], version = "*"}
pytest = "*"
requests-mock = "*"
types-requests = "*"

[requires]
python_version = "3.11"
Expand Down
194 changes: 163 additions & 31 deletions Pipfile.lock

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ A CLI application to generate and email credit card slips for Alma invoices via
- To lint the repo: `make lint`
- To run the app: `pipenv run ccslips --help`

## ENV Variables
## Required ENV Variables

- `LOG_LEVEL` = Optional, set to a valid Python logging level (e.g. `DEBUG`, case-insensitive) if desired. Can also be passed as an option directly to the ccslips command. Defaults to `INFO` if not set or passed to the command.
- `SENTRY_DSN` = If set to a valid Sentry DSN, enables Sentry exception monitoring. This is not needed for local development.
- `WORKSPACE` = Set to `dev` for local development, this will be set to `stage` and `prod` in those environments by Terraform.
- `ALMA_API_URL`: Base URL for the Alma API.
- `ALMA_API_READ_KEY`: Read-only key for the appropriate Alma instance (sandbox or prod) Acquisitions API.
- `WORKSPACE`: Set to `dev` for local development, this will be set to `stage` and `prod` in those environments by Terraform.

## Optional ENV Variables

- `ALMA_API_TIMEOUT`: Request timeout for Alma API calls. Defaults to 30 seconds if not set.
- `LOG_LEVEL`: Set to a valid Python logging level (e.g. `DEBUG`, case-insensitive) if desired. Can also be passed as an option directly to the ccslips command. Defaults to `INFO` if not set or passed to the command.
- `SENTRY_DSN`: If set to a valid Sentry DSN enables Sentry exception monitoring. This is not needed for local development.
148 changes: 148 additions & 0 deletions ccslips/alma.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import logging
import time
from typing import Generator, Optional
from urllib.parse import urljoin

import requests

from ccslips.config import load_alma_config

logger = logging.getLogger(__name__)


class AlmaClient:
"""AlmaClient class.
An Alma API client with specific functionality necessary for credit card slips
processing.
Notes:
- All requests to the Alma API include a 0.1 second wait to ensure we don't
exceed the API rate limit.
- If no records are found for a given endpoint with the provided parameters,
Alma will still return a 200 success response with a json object of
{"total_record_count": 0} and these methods will return that object.
"""

def __init__(self) -> None:
"""Initialize AlmaClient instance."""
alma_config = load_alma_config()
self.base_url = alma_config["BASE_URL"]
self.headers = {
"Authorization": f"apikey {alma_config['API_KEY']}",
"Accept": "application/json",
"Content-Type": "application/json",
}
self.timeout = float(alma_config["TIMEOUT"])

def get_paged(
self,
endpoint: str,
record_type: str,
params: Optional[dict] = None,
limit: int = 100,
_offset: int = 0,
_records_retrieved: int = 0,
) -> Generator[dict, None, None]:
"""Retrieve paginated results from the Alma API for a given endpoint.
Args:
endpoint: The paged Alma API endpoint to call, e.g. "acq/invoices".
record_type: The type of record returned by the Alma API for the specified
endpoint, e.g. "invoice" record_type returned by the "acq/invoices"
endpoint. See <https://developers.exlibrisgroup.com/alma/apis/docs/xsd/
rest_invoice.xsd/?tags=POST#invoice> for example.
params: Any endpoint-specific params to supply to the GET request.
limit: The maximum number of records to retrieve per page. Valid values are
0-100.
_offset: The offset value to supply to paged request. Should only be used
internally by this method's recursion.
_records_retrieved: The number of records retrieved so far for a given
paged endpoint. Should only be used internally by this method's
recursion.
"""
params = params or {}
params["limit"] = str(limit)
params["offset"] = str(_offset)
response = requests.get(
url=urljoin(self.base_url, endpoint),
params=params,
headers=self.headers,
timeout=self.timeout,
)
response.raise_for_status()
time.sleep(0.1)
total_record_count = int(response.json()["total_record_count"])
records = response.json().get(record_type, [])
records_retrieved = _records_retrieved + len(records)
for record in records:
yield record
if records_retrieved < total_record_count:
yield from self.get_paged(
endpoint,
record_type,
params=params,
limit=limit,
_offset=_offset + limit,
_records_retrieved=records_retrieved,
)

def get_brief_po_lines(
self, acquisition_method: Optional[str] = None
) -> Generator[dict, None, None]:
"""
Get brief PO line records, optionally filtered by acquisition_method.
The PO line records retrieved from this endpoint do not contain all of the PO
line data and users may wish to retrieve the full PO line records with the
get_full_po_lines method.
"""
po_line_params = {
"status": "ACTIVE",
"acquisition_method": acquisition_method,
}
return self.get_paged(
endpoint="acq/po-lines", record_type="po_line", params=po_line_params
)

def get_full_po_line(self, po_line_id: str) -> dict:
"""Get a single full PO line record using the PO line ID."""
response = requests.get(
url=str(urljoin(self.base_url, f"acq/po-lines/{po_line_id}")),
headers=self.headers,
timeout=self.timeout,
)
response.raise_for_status()
time.sleep(0.1)
return response.json()

def get_full_po_lines(
self,
acquisition_method: Optional[str] = None,
date: Optional[str] = None,
) -> Generator[dict, None, None]:
"""Get full PO line records, optionally filtered by acquisition_method/date."""
for line in self.get_brief_po_lines(acquisition_method):
number = line["number"]
if date is None:
yield self.get_full_po_line(number)
elif line.get("created_date") == f"{date}Z":
yield self.get_full_po_line(number)

def get_fund_by_code(self, fund_code: str) -> dict:
"""Get fund details using the fund code.
Note: this technically returns a list of funds as the request uses a search
query rather than getting a single fund directly, which is not supported by the
API. Theoretically the result could include multiple funds, however in practice
we expect there to only be one.
"""
response = requests.get(
urljoin(self.base_url, "acq/funds"),
headers=self.headers,
params={"q": f"fund_code~{fund_code}", "view": "full"},
timeout=self.timeout,
)
response.raise_for_status()
time.sleep(0.1)
return response.json()
10 changes: 9 additions & 1 deletion ccslips/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,17 @@ def configure_logger(logger: logging.Logger, log_level_string: str) -> str:


def configure_sentry() -> str:
env = os.getenv("WORKSPACE")
env = os.environ["WORKSPACE"]
sentry_dsn = os.getenv("SENTRY_DSN")
if sentry_dsn and sentry_dsn.lower() != "none":
sentry_sdk.init(sentry_dsn, environment=env)
return f"Sentry DSN found, exceptions will be sent to Sentry with env={env}"
return "No Sentry DSN found, exceptions will not be sent to Sentry"


def load_alma_config() -> dict[str, str]:
return {
"API_KEY": os.environ["ALMA_API_READ_KEY"],
"BASE_URL": os.environ["ALMA_API_URL"],
"TIMEOUT": os.getenv("ALMA_API_TIMEOUT", "30"),
}
97 changes: 96 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,110 @@
import json
import os

import pytest
import requests_mock
from click.testing import CliRunner

from ccslips.alma import AlmaClient


# Env fixture
@pytest.fixture(autouse=True)
def test_env():
os.environ = {"SENTRY_DSN": None, "WORKSPACE": "test"}
os.environ = {
"ALMA_API_URL": "https://example.com",
"ALMA_API_READ_KEY": "just-for-testing",
"ALMA_API_TIMEOUT": "10",
"SENTRY_DSN": "None",
"WORKSPACE": "test",
}
yield


# CLI fixture
@pytest.fixture()
def runner():
return CliRunner()


# Record fixtures
@pytest.fixture(name="fund_records", scope="session")
def fund_records_fixture():
with open("tests/fixtures/fund_records.json", encoding="utf-8") as funds_file:
return json.load(funds_file)


@pytest.fixture(name="po_line_records", scope="session")
def po_line_records_fixture():
with open("tests/fixtures/po_line_records.json", encoding="utf-8") as po_lines_file:
return json.load(po_lines_file)


# API fixtures
@pytest.fixture()
def alma_client():
return AlmaClient()


@pytest.fixture(autouse=True)
def mocked_alma(fund_records, po_line_records):
with requests_mock.Mocker() as mocker:
# Generic paged endpoints
mocker.get(
"https://example.com/paged?limit=10&offset=0",
complete_qs=True,
json={
"fake_records": [{"record_number": i} for i in range(10)],
"total_record_count": 15,
},
)
mocker.get(
"https://example.com/paged?limit=10&offset=10",
complete_qs=True,
json={
"fake_records": [{"record_number": i} for i in range(10, 15)],
"total_record_count": 15,
},
)

# Fund endpoints
mocker.get(
"https://example.com/acq/funds?q=fund_code~FUND-abc",
json={"fund": [fund_records["abc"]], "total_record_count": 1},
)

# PO Line endpoints
mocker.get(
"https://example.com/acq/po-lines?status=ACTIVE",
json={
"po_line": [po_line_records["other_acq_method"]],
"total_record_count": 1,
},
)
mocker.get(
(
"https://example.com/acq/po-lines?status=ACTIVE&"
"acquisition_method=PURCHASE_NOLETTER"
),
json={
"po_line": [
po_line_records["all_fields"],
po_line_records["wrong_date"],
],
"total_record_count": 2,
},
)
mocker.get(
"https://example.com/acq/po-lines/POL-all-fields",
json=po_line_records["all_fields"],
)
mocker.get(
"https://example.com/acq/po-lines/POL-other-acq-method",
json=po_line_records["other_acq_method"],
)
mocker.get(
"https://example.com/acq/po-lines/POL-wrong-date",
json=po_line_records["wrong_date"],
)

yield mocker
5 changes: 5 additions & 0 deletions tests/fixtures/fund_records.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"abc": {
"code": "FUND-abc"
}
}
23 changes: 23 additions & 0 deletions tests/fixtures/po_line_records.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"all_fields": {
"acquisition_method": {
"desc": "Credit Card"
},
"created_date": "2023-01-02Z",
"number": "POL-all-fields"
},
"other_acq_method": {
"acquisition_method": {
"desc": "Something else"
},
"created_date": "2023-01-02Z",
"number": "POL-other-acq-method"
},
"wrong_date": {
"acquisition_method": {
"desc": "Credit Card"
},
"created_date": "2023-12-11Z",
"number": "POL-wrong-date"
}
}

0 comments on commit 9b59aba

Please sign in to comment.