Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

In 1065 maintenance 09 2024 #272

Merged
merged 2 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
default_language_version:
python: python3.11 # set for project python version
python: python3.12 # set for project python version
repos:
- repo: local
hooks:
Expand Down
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.11.2
3.12
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.11-slim as build
FROM python:3.12-slim as build
WORKDIR /app
COPY . .

Expand Down
52 changes: 33 additions & 19 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,54 +9,64 @@ SHELL=/bin/bash
DATETIME:=$(shell date -u +%Y%m%dT%H%M%SZ)

help: ## Print this message
@awk 'BEGIN { FS = ":.*##"; print "Usage: make <target>\n\nTargets:" } \
/^[-_[:alpha:]]+:.?*##/ { printf " %-15s%s\n", $$1, $$2 }' $(MAKEFILE_LIST)
@awk 'BEGIN { FS = ":.*#"; print "Usage: make <target>\n\nTargets:" } \
/^[-_[:alpha:]]+:.?*#/ { printf " %-15s%s\n", $$1, $$2 }' $(MAKEFILE_LIST)

### Dependency commands ###
#######################
# Dependency commands
#######################

install: ## Install dependencies and CLI app
install: # Install Python dependencies
pipenv install --dev
pipenv run pre-commit install

update: install ## Update all Python dependencies
update: install # Update Python dependencies
pipenv clean
pipenv update --dev

### Test commands ###
######################
# Unit test commands
######################

test: ## Run tests and print a coverage report
test: # Run tests and print a coverage report
pipenv run coverage run --source=ccslips -m pytest -vv
pipenv run coverage report -m

coveralls: test
coveralls: test # Write coverage data to an LCOV report
pipenv run coverage lcov -o ./coverage/lcov.info

### Code quality and safety commands ###
####################################
# Code quality and safety commands
####################################

# linting commands
lint: black mypy ruff safety
lint: black mypy ruff safety # Run linters

black:
black: # Run 'black' linter and print a preview of suggested changes
pipenv run black --check --diff .

mypy:
mypy: # Run 'mypy' linter
pipenv run mypy .

ruff:
ruff: # Run 'ruff' linter and print a preview of errors
pipenv run ruff check .

safety:
safety: # Check for security vulnerabilities and verify Pipfile.lock is up-to-date
pipenv check
pipenv verify

# apply changes to resolve any linting errors
lint-apply: black-apply ruff-apply
lint-apply: # Apply changes with 'black' and resolve 'fixable errors' with 'ruff'
black-apply ruff-apply

black-apply:
black-apply: # Apply changes with 'black'
pipenv run black .

ruff-apply:
ruff-apply: # Resolve 'fixable errors' with 'ruff'
pipenv run ruff check --fix .

#####################################
# Docker build and publish commands
#####################################

### Terraform-generated Developer Deploy Commands for Dev environment ###
dist-dev: ## Build docker container (intended for developer-based manual build)
docker build --platform linux/amd64 \
Expand Down Expand Up @@ -85,5 +95,9 @@ publish-stage: ## Only use in an emergency
docker push $(ECR_URL_STAGE):latest
docker push $(ECR_URL_STAGE):`git describe --always`

################
# Run commands
################

run-dev: # Run in dev against Alma sandbox
aws ecs run-task --cluster alma-integrations-creditcardslips-ecs-dev --task-definition alma-integrations-creditcardslips-ecs-dev --launch-type="FARGATE" --network-configuration '{ "awsvpcConfiguration": {"subnets": ["subnet-0488e4996ddc8365b", "subnet-022e9ea19f5f93e65"],"securityGroups": ["sg-095372030a26c7753"],"assignPublicIp": "DISABLED"}}'
2 changes: 1 addition & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ safety = "*"
types-requests = "*"

[requires]
python_version = "3.11"
python_version = "3.12"

[scripts]
ccslips = "python -c \"from ccslips.cli import main; main()\""
1,020 changes: 543 additions & 477 deletions Pipfile.lock

Large diffs are not rendered by default.

37 changes: 22 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,43 @@

A CLI application to generate and email credit card slips for Alma invoices via the Alma API.

## Description
Credit card slips are generated for items purchased with a credit card (e.g. from vendors like Amazon), which means we prepay rather than receive an invoice after shipment, which is our workflow with most vendors. The credit card slip is in lieu of a vendor-generated invoice, and is used for processing by Acquisition staff.
Credit card slips are generated for items purchased with a credit card (e.g. from vendors like Amazon), which means we prepay rather than receive an invoice after shipment, which is our workflow with most vendors. The credit card slip is in lieu of a vendor-generated invoice and is used for processing by Acquisition staff.

The application runs daily and retrieves purchase order (PO) lines from the Alma REST API with the following criteria:
The app retrieves purchase order (PO) lines from the Alma REST API with the following criteria:
* `status=ACTIVE`
* `acquisition_method=PURCHASE_NOLETTER` (`Credit card` in the Alma UI)
* A note that begins with `CC-`

The application is scheduled to run each day as an Elastic Container Service (ECS) task. By default, it retrieves PO lines from 2 days before the date the application is run. Originally, it was set for 1 day before the application is run but a bug was discovered in August 2023 that required a change to 2 days before in order to get the expected output.
**Note:** By default, it retrieves PO lines from two (2) days before the date the application is run. Originally, it was set for one (1) day before the application is run, but a bug was discovered in August 2023 that required the change in order to get the expected output.

The application fills in a template with the PO line data and the resulting file is emailed as an attachment to the necessary stakeholders. Acquisitions staff print out the attachment, mark it up, and complete recording the payment in Alma.
Data is extracted from the PO lines and used to fill in a template, and the resulting file is emailed as an attachment to the necessary stakeholders. Acquisitions staff print out the attachment, mark it up, and complete recording the payment in Alma.

This Python CLI application is run on a schedule as an Elastic Container Service (ECS) task in AWS via EventBridge rules.

## Development

- To preview a list of available Makefile commands: `make help`
- To install with dev dependencies: `make install`
- To update dependencies: `make update`
- To run unit tests: `make test`
- To lint the repo: `make lint`
- To run the app: `pipenv run ccslips --help`

## Required ENV Variables
## Environment Variables

### Required

- `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.
```shell
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.
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.
```

## Optional ENV Variables
### Optional

- `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.
- `SES_RECIPIENT_EMAIL`: Email address(es) for recipient(s) who should receive the credit card slips email. Multiple email addresses should be separated by a space, e.g. `SES_RECIPIENT_EMAIL=recipient1@example.com recipient2@example.com`. This value can either be set in ENV or passed directly to the command line as an option.
- `SES_SEND_FROM_EMAIL`: Verified email address for sending emails via SES. This value can either be set in ENV or passed directly to the command as an option.
```shell
ALMA_API_TIMEOUT=### Request timeout for Alma API calls. Defaults to 30 seconds.
SES_RECIPIENT_EMAIL=### Email addresses for recipients of the the credit card slips email. Multiple email addresses should be separated by a space, e.g. 'recipient1@example.com recipient2@example.com'. This value can also be passed directly to the CLI command via the -r/--recipient-email option.
SES_SEND_FROM_EMAIL=### Verified email address for sending emails via SES. This value can also be passed directly to the CLI command via the -s/--source-email option.
```
21 changes: 13 additions & 8 deletions ccslips/alma.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import requests

from ccslips.config import load_alma_config
from ccslips.config import Config

logger = logging.getLogger(__name__)

Expand All @@ -24,16 +24,21 @@ class AlmaClient:
{"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']}",
@property
def base_url(self) -> str:
return Config().ALMA_API_URL

@property
def headers(self) -> dict:
return {
"Authorization": f"apikey {Config().ALMA_API_READ_KEY}",
"Accept": "application/json",
"Content-Type": "application/json",
}
self.timeout = float(alma_config["TIMEOUT"])

@property
def timeout(self) -> float:
return float(Config().ALMA_API_TIMEOUT)

def get_paged(
self,
Expand Down
50 changes: 26 additions & 24 deletions ccslips/cli.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import datetime
import logging
import os
from time import perf_counter

import click

from ccslips.config import configure_logger, configure_sentry
from ccslips.config import Config, configure_logger, configure_sentry
from ccslips.email import Email
from ccslips.polines import generate_credit_card_slips_html, process_po_lines

logger = logging.getLogger(__name__)

CONFIG = Config()


@click.command()
@click.option(
Expand All @@ -36,48 +37,51 @@
"--date",
help=(
"Optional date of exports to process, in 'YYYY-MM-DD' format. Defaults to "
"yesterday's date if not provided."
"two (2) days before the date the application is run."
jonavellecuerdo marked this conversation as resolved.
Show resolved Hide resolved
),
)
@click.option(
"-l",
"--log-level",
envvar="LOG_LEVEL",
help="Case-insensitive Python log level to use, e.g. debug or warning. Defaults to "
"INFO if not provided or found in ENV.",
"-v",
"--verbose",
is_flag=True,
help="Pass to set log level to DEBUG. Defaults to INFO.",
)
@click.pass_context
def main(
ctx: click.Context,
source_email: str,
recipient_email: list[str],
date: str | None,
log_level: str | None,
*,
verbose: bool,
) -> None:
start_time = perf_counter()
log_level = log_level or "INFO"
root_logger = logging.getLogger()
logger.info(configure_logger(root_logger, log_level))
logger.info(configure_logger(root_logger, verbose=verbose))
logger.info(configure_sentry())
logger.debug("Command called with options: %s", ctx.params)
CONFIG.check_required_env_vars()

logger.debug("Command called with options: %s", ctx.params)
logger.info("Starting credit card slips process")
date = date or (

# creation date of retrieved PO lines
created_date = date or (
datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(days=2)
).strftime("%Y-%m-%d")
credit_card_slips_data = process_po_lines(date)

credit_card_slips_data = process_po_lines(created_date)

email_content = generate_credit_card_slips_html(credit_card_slips_data)
email = Email()
env = os.environ["WORKSPACE"]
subject_prefix = f"{env.upper()} " if env != "prod" else ""
subject_prefix = f"{CONFIG.WORKSPACE.upper()} " if CONFIG.WORKSPACE != "prod" else ""
email.populate(
from_address=source_email,
to_addresses=",".join(recipient_email),
subject=f"{subject_prefix}Credit card slips {date}",
subject=f"{subject_prefix}Credit card slips {created_date}",
attachments=[
{
"content": email_content,
"filename": f"{date}_credit_card_slips.htm",
"filename": f"{created_date}_credit_card_slips.htm",
}
],
)
Expand All @@ -86,10 +90,8 @@ def main(

elapsed_time = perf_counter() - start_time
logger.info(
"Credit card slips processing complete for date %s. Email sent to recipient(s) "
"%s with SES message ID '%s'. Total time to complete process: %s",
date,
recipient_email,
response["MessageId"],
str(datetime.timedelta(seconds=elapsed_time)),
f"Credit card slips processing complete for date {created_date}. "
f"Email sent to recipient(s) {recipient_email} "
f"with SES message ID {response["MessageId"]}. "
f"Total time to complete process: {datetime.timedelta(seconds=elapsed_time)}"
)
45 changes: 29 additions & 16 deletions ccslips/config.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
import logging
import os
from typing import Any

import sentry_sdk


def configure_logger(logger: logging.Logger, log_level_string: str) -> str:
if log_level_string.upper() not in logging.getLevelNamesMapping():
message = f"'{log_level_string}' is not a valid Python logging level"
raise ValueError(message)
log_level = logging.getLevelName(log_level_string.upper())
if log_level < 20: # noqa: PLR2004
class Config:
jonavellecuerdo marked this conversation as resolved.
Show resolved Hide resolved
REQUIRED_ENV_VARS = ("ALMA_API_URL", "ALMA_API_READ_KEY", "SENTRY_DSN", "WORKSPACE")

OPTIONAL_ENV_VARS = (
"ALMA_API_TIMEOUT",
"SES_RECIPIENT_EMAIL",
"SES_SEND_FROM_EMAIL",
)

def check_required_env_vars(self) -> None:
"""Method to raise exception if required env vars not set."""
missing_vars = [var for var in self.REQUIRED_ENV_VARS if not os.getenv(var)]
if missing_vars:
message = f"Missing required environment variables: {', '.join(missing_vars)}"
raise OSError(message)

def __getattr__(self, name: str) -> Any: # noqa: ANN401
"""Provide dot notation access to configurations and env vars on this class."""
if name in self.REQUIRED_ENV_VARS or name in self.OPTIONAL_ENV_VARS:
return os.getenv(name)
message = f"'{name}' not a valid configuration variable"
raise AttributeError(message)


def configure_logger(logger: logging.Logger, *, verbose: bool) -> str:
if verbose:
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s.%(funcName)s() line %(lineno)d: "
"%(message)s"
)
logger.setLevel(log_level)
logger.setLevel(logging.DEBUG)
for handler in logging.root.handlers:
handler.addFilter(logging.Filter("ccslips"))
else:
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s.%(funcName)s(): %(message)s"
)
logger.setLevel(log_level)
logger.setLevel(logging.INFO)
return (
f"Logger '{logger.name}' configured with level="
f"{logging.getLevelName(logger.getEffectiveLevel())}"
Expand All @@ -35,11 +56,3 @@ def configure_sentry() -> str:
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"),
}
Loading