Skip to content
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
7 changes: 5 additions & 2 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Image for a NYU Lab development environment
FROM rofrano/nyu-devops-base:sp25
##################################################
# Image for a Python 3 development environment
##################################################
# cSpell: disable
FROM quay.io/rofrano/nyu-devops-base:su26

# Set up the Python development environment
WORKDIR /app
Expand Down
1 change: 1 addition & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"psycopg",
"pytest",
"onupdate",
"ondelete",
"testdb"
],
"[python]": {
Expand Down
18 changes: 14 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
############################################################
# Continuous Integration Tests
############################################################
# spell: disable
name: CI Build
on:
push:
Expand All @@ -16,7 +20,7 @@ jobs:
build:
runs-on: ubuntu-latest
# use a known build environment
container: python:3.11-slim
container: python:3.12-slim

# Required services
services:
Expand Down Expand Up @@ -53,10 +57,16 @@ jobs:

- name: Run unit tests with nose
run: |
pytest --pspec --cov=service --cov-fail-under=95
pytest --pspec --cov=service --cov-fail-under=95 --disable-warnings --cov-report=xml
env:
FLASK_APP: wsgi:app
DATABASE_URI: "postgresql+psycopg://postgres:pgs3cr3t@postgres:5432/testdb"

- name: Upload code coverage
uses: codecov/codecov-action@v3.1.4
- name: Install packages for Codecov to work
run: apt update && apt install -y git curl gpg

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: nyu-devops/sample-accounts
31 changes: 18 additions & 13 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,29 @@ verify_ssl = true
name = "pypi"

[packages]
flask = "==3.1.0"
flask = "==3.1.3"
flask-sqlalchemy = "==3.1.1"
psycopg = {extras = ["binary"], version = "==3.2.4"}
retry2 = "==0.9.5"
python-dotenv = "==1.0.1"
gunicorn = "==23.0.0"
psycopg = {extras = ["binary"], version = "==3.3.4"}
retry2 = "~=0.9.5"
gunicorn = "~=25.0.0"
python-dotenv = "~=1.2.2"

[dev-packages]
honcho = "~=2.0.0"
pylint = "~=3.3.4"
flake8 = "~=7.1.1"
black = "~=25.1.0"
pytest = "~=8.3.4"
# Code Quality
pylint = "~=4.0.5"
flake8 = "~=7.3.0"
black = "~=26.5.1"

# Test-Driven Development
pytest = "~=9.0.3"
pytest-pspec = "~=0.0.4"
pytest-cov = "~=6.0.0"
pytest-cov = "~=7.1.0"
factory-boy = "~=3.3.3"
coverage = "~=7.6.12"
coverage = "~=7.14.1"

# Utility
honcho = "~=2.0.0"
httpie = "~=3.2.4"

[requires]
python_version = "3.11"
python_version = "3.12"
1,577 changes: 890 additions & 687 deletions Pipfile.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ The test cases have 95% test coverage and can be run with `pytest`

## License

Copyright (c) 2016, 2025 [John Rofrano](https://www.linkedin.com/in/JohnRofrano/). All rights reserved.
Copyright (c) 2016, 2026 [John Rofrano](https://www.linkedin.com/in/JohnRofrano/). All rights reserved.

Licensed under the Apache License. See [LICENSE](LICENSE)

Expand Down
6 changes: 2 additions & 4 deletions service/models/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class Account(db.Model, PersistentBase):
def __repr__(self):
return f"<Account {self.name} id=[{self.id}]>"

def serialize(self):
def serialize(self) -> dict:
"""Converts an Account into a dictionary"""
account = {
"id": self.id,
Expand All @@ -61,7 +61,7 @@ def serialize(self):
account["addresses"].append(address.serialize())
return account

def deserialize(self, data):
def deserialize(self, data: dict) -> None:
"""
Populates an Account from a dictionary

Expand Down Expand Up @@ -92,8 +92,6 @@ def deserialize(self, data):
+ str(error)
) from error

return self

@classmethod
def find_by_name(cls, name):
"""Returns all Accounts with the given name
Expand Down
2 changes: 0 additions & 2 deletions service/models/address.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,3 @@ def deserialize(self, data: dict) -> None:
"Invalid Address: body of request contained bad or no data "
+ str(error)
) from error

return self
24 changes: 11 additions & 13 deletions service/routes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
######################################################################
# Copyright 2016, 2024 John J. Rofrano. All Rights Reserved.
# Copyright 2016, 2026 John J. Rofrano. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -19,6 +19,7 @@

This microservice handles the lifecycle of Accounts
"""

from flask import jsonify, request, url_for, abort
from flask import current_app as app # Import Flask application
from service.models import Account, Address
Expand Down Expand Up @@ -69,7 +70,7 @@ def list_accounts():
# Return as an array of dictionaries
results = [account.serialize() for account in accounts]

return jsonify(results), status.HTTP_200_OK
return results, status.HTTP_200_OK


######################################################################
Expand All @@ -92,7 +93,7 @@ def get_accounts(account_id):
f"Account with id '{account_id}' could not be found.",
)

return jsonify(account.serialize()), status.HTTP_200_OK
return account.serialize(), status.HTTP_200_OK


######################################################################
Expand All @@ -116,7 +117,7 @@ def create_accounts():
message = account.serialize()
location_url = url_for("get_accounts", account_id=account.id, _external=True)

return jsonify(message), status.HTTP_201_CREATED, {"Location": location_url}
return message, status.HTTP_201_CREATED, {"Location": location_url}


######################################################################
Expand Down Expand Up @@ -144,7 +145,7 @@ def update_accounts(account_id):
account.id = account_id
account.update()

return jsonify(account.serialize()), status.HTTP_200_OK
return account.serialize(), status.HTTP_200_OK


######################################################################
Expand Down Expand Up @@ -191,7 +192,7 @@ def list_addresses(account_id):
# Get the addresses for the account
results = [address.serialize() for address in account.addresses]

return jsonify(results), status.HTTP_200_OK
return results, status.HTTP_200_OK


######################################################################
Expand Down Expand Up @@ -228,12 +229,9 @@ def create_addresses(account_id):

# Send the location to GET the new item
location_url = url_for(
"get_addresses",
account_id=account.id,
address_id=address.id,
_external=True
"get_addresses", account_id=account.id, address_id=address.id, _external=True
)
return jsonify(message), status.HTTP_201_CREATED, {"Location": location_url}
return message, status.HTTP_201_CREATED, {"Location": location_url}


######################################################################
Expand All @@ -258,7 +256,7 @@ def get_addresses(account_id, address_id):
f"Account with id '{address_id}' could not be found.",
)

return jsonify(address.serialize()), status.HTTP_200_OK
return address.serialize(), status.HTTP_200_OK


######################################################################
Expand Down Expand Up @@ -289,7 +287,7 @@ def update_addresses(account_id, address_id):
address.id = address_id
address.update()

return jsonify(address.serialize()), status.HTTP_200_OK
return address.serialize(), status.HTTP_200_OK


######################################################################
Expand Down
3 changes: 2 additions & 1 deletion tests/test_account.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
######################################################################
# Copyright 2016, 2024 John J. Rofrano. All Rights Reserved.
# Copyright 2016, 2026 John J. Rofrano. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -69,6 +69,7 @@ def tearDown(self):

def test_create_an_account(self):
"""It should Create an Account and assert that it exists"""
# Use the factory to generate data but test using manual parameters
fake_account = AccountFactory()
# pylint: disable=unexpected-keyword-arg
account = Account(
Expand Down
4 changes: 3 additions & 1 deletion tests/test_address.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
######################################################################
# Copyright 2016, 2024 John J. Rofrano. All Rights Reserved.
# Copyright 2016, 2026 John J. Rofrano. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -94,9 +94,11 @@ def test_update_account_address(self):
accounts = Account.all()
self.assertEqual(accounts, [])

# Create an account from which to create an address
account = AccountFactory()
address = AddressFactory(account=account)
account.create()

# Assert that it was assigned an id and shows up in the database
self.assertIsNotNone(account.id)
accounts = Account.all()
Expand Down
Loading
Loading