├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── alembic.ini ├── app.py ├── aws ├── __init__.py └── services │ ├── __init__.py │ └── dynamo_db │ ├── __init__.py │ └── logs.py ├── codecov.yml ├── crud ├── __init__.py ├── charge.py ├── company.py ├── contact.py └── message.py ├── database.py ├── dependencies ├── __init__.py └── dependencies.py ├── email_api ├── __init__.py └── email.py ├── env-example ├── hubspot_api ├── __init__.py └── utils.py ├── logger ├── __init__.py └── log.py ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ ├── 3f4f812d4ca6_add_created_at_and_modified_at_to_.py │ ├── 479a101db0bb_rename_created_and_modified_field_for_.py │ ├── 573a2f55e475_add_created_at_and_modified_at_to_.py │ ├── 5bf098d393cd_remove_unique_constraint.py │ ├── 60809261b623_create_organization_table.py │ ├── 64c38ea8a78f_add_contact_table.py │ ├── 6d11f960f1a5_add_active_field_to_contact_table.py │ ├── 71886cd76b25_alter_organization_model.py │ ├── 72ca14fa8b9e_alter_charge_id_nullable_to_false.py │ ├── 873f4fb17cc8_add_organization_foreign_key_to_contact.py │ ├── 913be17e323f_add_message_table.py │ ├── 9b9c2855cad1_add_unique_constraint_for_charge_type_.py │ ├── a980495e9ab1_modify_relationship.py │ ├── a9e00646e1d7_modify_unique_constraint.py │ ├── ad9ebc89187d_add_unique_constraint_to_charge.py │ ├── d15215db454c_modify_organization_backref.py │ ├── e0da39a6bed1_create_payment_table.py │ └── f5bac25e3c22_create_charge_table.py ├── models ├── __init__.py ├── contact.py ├── message.py └── payment.py ├── readme.md ├── requirements.txt ├── routes ├── __init__.py └── v1 │ ├── __init__.py │ ├── charge.py │ ├── company.py │ ├── contact.py │ └── email.py ├── schemas ├── __init__.py └── schema.py ├── settings.py ├── stripe_api ├── __init__.py └── payment.py ├── task_checklist.md ├── templates ├── charge.html └── status.html └── tests ├── __init__.py ├── conftest.py ├── data ├── __init_-.py ├── dynamo_db_response.json └── email_data.json ├── integrations ├── __init__.py ├── common.py ├── test_dynamo_db.py ├── test_hubspot.py ├── test_sendgrid.py └── test_stripe.py ├── routes ├── __init__.py ├── test_charge.py ├── test_company.py ├── test_contact.py └── test_email.py └── test_database.py /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: CodeCov 2 | on: [push, pull_request] 3 | jobs: 4 | run: 5 | runs-on: ubuntu-latest 6 | env: 7 | OS: ubuntu-latest 8 | PYTHON: '3.9' 9 | steps: 10 | - uses: checkout@v2 11 | with: 12 | fetch-depth: ‘2’ 13 | 14 | - name: Setup Python 15 | uses: actions/setup-python@master 16 | with: 17 | python-version: 3.7 18 | - name: Generate Report 19 | run: | 20 | pip install coverage 21 | pip install pytest-cov 22 | coverage run -m pytest 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .idea 3 | __pycache__/ 4 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to migrations/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" below. 39 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 44 | # Valid values for version_path_separator are: 45 | # 46 | # version_path_separator = : 47 | # version_path_separator = ; 48 | # version_path_separator = space 49 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 50 | 51 | # the output encoding used when revision files 52 | # are written from script.py.mako 53 | # output_encoding = utf-8 54 | 55 | sqlalchemy.url = '' 56 | 57 | 58 | [post_write_hooks] 59 | # post_write_hooks defines scripts or Python functions that are run 60 | # on newly generated revision scripts. See the documentation for further 61 | # detail and examples 62 | 63 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 64 | # hooks = black 65 | # black.type = console_scripts 66 | # black.entrypoint = black 67 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 68 | 69 | # Logging configuration 70 | [loggers] 71 | keys = root,sqlalchemy,alembic 72 | 73 | [handlers] 74 | keys = console 75 | 76 | [formatters] 77 | keys = generic 78 | 79 | [logger_root] 80 | level = WARN 81 | handlers = console 82 | qualname = 83 | 84 | [logger_sqlalchemy] 85 | level = WARN 86 | handlers = 87 | qualname = sqlalchemy.engine 88 | 89 | [logger_alembic] 90 | level = INFO 91 | handlers = 92 | qualname = alembic 93 | 94 | [handler_console] 95 | class = StreamHandler 96 | args = (sys.stderr,) 97 | level = NOTSET 98 | formatter = generic 99 | 100 | [formatter_generic] 101 | format = %(levelname)-5.5s [%(name)s] %(message)s 102 | datefmt = %H:%M:%S 103 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi import Request 3 | from fastapi.templating import Jinja2Templates 4 | 5 | from routes.v1 import email 6 | from routes.v1 import contact 7 | from routes.v1 import company 8 | from routes.v1 import charge 9 | 10 | 11 | app = FastAPI() 12 | app.include_router(email.router) 13 | app.include_router(contact.router) 14 | app.include_router(company.router) 15 | app.include_router(charge.router) 16 | 17 | 18 | templates = Jinja2Templates(directory="templates") 19 | 20 | 21 | @app.get("/") 22 | def home(request: Request): 23 | return templates.TemplateResponse( 24 | "charge.html", {"request": request, "message": "Welcome to Property Management API"} 25 | ) 26 | 27 | -------------------------------------------------------------------------------- /aws/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/aws/__init__.py -------------------------------------------------------------------------------- /aws/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/aws/services/__init__.py -------------------------------------------------------------------------------- /aws/services/dynamo_db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/aws/services/dynamo_db/__init__.py -------------------------------------------------------------------------------- /aws/services/dynamo_db/logs.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import boto3 4 | import botocore 5 | 6 | import settings 7 | 8 | dynamodb = boto3.resource( 9 | "dynamodb", 10 | aws_access_key_id=settings.AWS_SECRET_KEY_ID, 11 | aws_secret_access_key=settings.AWS_SECRET_KEY, 12 | region_name=settings.AWS_REGION, 13 | ) 14 | 15 | 16 | class BotoClientException(Exception): 17 | pass 18 | 19 | 20 | async def create_log(data): 21 | try: 22 | table = dynamodb.Table("Log") 23 | trans = { 24 | "log_id": data["log_id"], 25 | "request_ip": data["request_ip"], 26 | "message": json.dumps(data["message"]), 27 | } 28 | return table.put_item(Item=trans) 29 | except botocore.exceptions.ClientError as e: 30 | raise BotoClientException(e) 31 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "*/tests/*" -------------------------------------------------------------------------------- /crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/crud/__init__.py -------------------------------------------------------------------------------- /crud/charge.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy.exc 2 | from sqlalchemy.orm import Session 3 | 4 | from models import payment 5 | from models.payment import Organization 6 | 7 | 8 | class ChargeExistException(Exception): 9 | pass 10 | 11 | 12 | async def add_charge(db: Session, charge, org_id: int): 13 | try: 14 | charge_dict = charge.dict() 15 | del charge_dict["company_name"] 16 | charge_dict["org_id"] = org_id 17 | db_item = payment.Charge(**charge_dict) 18 | db.add(db_item) 19 | db.commit() 20 | db.refresh(db_item) 21 | except sqlalchemy.exc.IntegrityError as exc: 22 | raise ChargeExistException(exc) 23 | return db_item 24 | -------------------------------------------------------------------------------- /crud/company.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy.exc 2 | from sqlalchemy.orm import Session 3 | 4 | from models.payment import Organization 5 | from schemas.schema import CreateCompany 6 | 7 | 8 | class CompanyExistException(Exception): 9 | pass 10 | 11 | 12 | def create_company(db: Session, company: CreateCompany): 13 | db_item = Organization(**company.dict()) 14 | try: 15 | db.add(db_item) 16 | db.commit() 17 | except sqlalchemy.exc.IntegrityError as exc: 18 | raise CompanyExistException(exc.__cause__) 19 | db.refresh(db_item) 20 | return db_item 21 | 22 | 23 | def delete_company(db: Session, organization_id: int): 24 | db_organization = db.query(Organization).get(organization_id) 25 | if db_organization: 26 | db.delete(db_organization) 27 | db.commit() 28 | return True 29 | else: 30 | return None 31 | 32 | 33 | def filter_company_by_name(db: Session, company_name: str): 34 | company = db.query(Organization).filter_by(name=company_name) 35 | return company 36 | -------------------------------------------------------------------------------- /crud/contact.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy.exc 2 | from sqlalchemy.orm import Session 3 | 4 | from models import contact 5 | from schemas.schema import CreateContact 6 | 7 | 8 | class ContactExistException(Exception): 9 | pass 10 | 11 | 12 | def create_contact(db: Session, contact_data: CreateContact, org_id): 13 | contact_dict = contact_data.dict() 14 | del contact_dict["company_name"] 15 | contact_dict["org_id"] = org_id 16 | db_item = contact.Contact(**contact_dict) 17 | try: 18 | db.add(db_item) 19 | db.commit() 20 | except sqlalchemy.exc.IntegrityError as exc: 21 | raise ContactExistException(exc.__cause__) 22 | db.refresh(db_item) 23 | return db_item 24 | 25 | 26 | def delete_contact(db: Session, contact_id): 27 | db_contact = db.query(contact.Contact).get(contact_id) 28 | if db_contact: 29 | db.delete(db_contact) 30 | db.commit() 31 | return True 32 | else: 33 | return None 34 | -------------------------------------------------------------------------------- /crud/message.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy.exc 2 | from sqlalchemy.orm import Session 3 | 4 | from models import message 5 | 6 | 7 | class MessageExistException(Exception): 8 | pass 9 | 10 | 11 | async def save_message(db: Session, message_data): 12 | db_item = message.Message(**message_data) 13 | try: 14 | db.add(db_item) 15 | db.commit() 16 | except sqlalchemy.exc.IntegrityError as exc: 17 | raise MessageExistException(exc.__cause__) 18 | db.refresh(db_item) 19 | return db_item 20 | -------------------------------------------------------------------------------- /database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | 5 | from settings import DATABASE 6 | from settings import DATABASE_USER 7 | from settings import DATABASE_PASSWORD 8 | from settings import DATABASE_DRIVER 9 | from settings import DATABASE_HOST 10 | 11 | SQLALCHEMY_DATABASE_URL = f"{DATABASE_DRIVER}://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}/{DATABASE}" 12 | 13 | engine = create_engine(SQLALCHEMY_DATABASE_URL) 14 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 15 | 16 | Base = declarative_base() 17 | -------------------------------------------------------------------------------- /dependencies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/dependencies/__init__.py -------------------------------------------------------------------------------- /dependencies/dependencies.py: -------------------------------------------------------------------------------- 1 | from models import payment 2 | from database import SessionLocal 3 | from database import engine 4 | 5 | payment.Base.metadata.create_all(bind=engine) 6 | 7 | 8 | def get_db(): 9 | db = SessionLocal() 10 | try: 11 | yield db 12 | finally: 13 | db.close() 14 | -------------------------------------------------------------------------------- /email_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/email_api/__init__.py -------------------------------------------------------------------------------- /email_api/email.py: -------------------------------------------------------------------------------- 1 | import python_http_client 2 | from sendgrid import SendGridAPIClient 3 | from sendgrid.helpers.mail import To 4 | from sendgrid.helpers.mail.mail import Mail 5 | 6 | import settings 7 | 8 | 9 | class SendgridException(Exception): 10 | pass 11 | 12 | 13 | class UnauthorizedException(SendgridException): 14 | pass 15 | 16 | 17 | class BadRequestException(SendgridException): 18 | pass 19 | 20 | 21 | def generate_message(data): 22 | templates = { 23 | "welcome_email": settings.WELCOME_MESSAGE_TEMPLATE_ID, 24 | "payment_email": settings.PAYMENT_CONFIRMATION_TEMPLATE_ID, 25 | } 26 | recipient_message = [] 27 | recipients = data["recipients"] 28 | for recipient in recipients: 29 | recipient_message.append( 30 | To( 31 | email=recipient["recipient_email"], 32 | name=recipient["recipient_name"], 33 | dynamic_template_data=data["template_content"], 34 | ), 35 | ) 36 | message = Mail( 37 | from_email=data["sender"], 38 | to_emails=recipient_message, 39 | subject=data["subject"], 40 | ) 41 | email_type = data["email_type"] 42 | message.template_id = templates[email_type] 43 | return message 44 | 45 | 46 | async def send_email(data): 47 | message = generate_message(data) 48 | try: 49 | sendgrid_client = SendGridAPIClient(settings.SENDGRID_API_KEY) 50 | response = sendgrid_client.send(message) 51 | except python_http_client.exceptions.UnauthorizedError as e: 52 | raise UnauthorizedException(e) 53 | except python_http_client.exceptions.BadRequestsError as e: 54 | raise BadRequestException(e) 55 | return response 56 | -------------------------------------------------------------------------------- /env-example: -------------------------------------------------------------------------------- 1 | TEST_DATABASE_NAME = str 2 | DATABASE_NAME = str 3 | DATABASE_HOST = str 4 | DATABASE_USER = str 5 | DATABASE_PASSWORD = str 6 | DATABASE_DRIVER = str 7 | STRIPE_PUBLISHABLE_KEY = str 8 | STRIPE_SECRET_KEY = str 9 | HUBSPOT_API_KEY = str 10 | SENDGRID_API_KEY = str 11 | WELCOME_MESSAGE_TEMPLATE_ID=str 12 | PAYMENT_CONFIRMATION_TEMPLATE_ID=str 13 | AWS_SECRET_KEY_ID = str 14 | AWS_SECRET_KEY = str 15 | AWS_REGION = str -------------------------------------------------------------------------------- /hubspot_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/hubspot_api/__init__.py -------------------------------------------------------------------------------- /hubspot_api/utils.py: -------------------------------------------------------------------------------- 1 | from hubspot import HubSpot 2 | from hubspot.crm.contacts import SimplePublicObjectInput 3 | from hubspot.crm import contacts 4 | from hubspot.crm import companies 5 | 6 | from settings import HUBSPOT_API_KEY 7 | 8 | api_client = HubSpot(api_key=HUBSPOT_API_KEY) 9 | 10 | 11 | class HubSpotException(Exception): 12 | pass 13 | 14 | 15 | class ContactException(HubSpotException): 16 | pass 17 | 18 | 19 | class CompanyException(HubSpotException): 20 | pass 21 | 22 | 23 | class ContactAssociationOrganizationException(HubSpotException): 24 | pass 25 | 26 | 27 | def create_contact(data): 28 | try: 29 | company_name = data["company_name"] 30 | del data["company_name"] 31 | contact_map = { 32 | "first_name": "firstname", 33 | "last_name": "lastname", 34 | "email": "email", 35 | "phone": "phone", 36 | } 37 | data = {contact_map[k]: v for k, v in data.items()} 38 | simple_public_object_input = SimplePublicObjectInput(properties=data) 39 | contact = api_client.crm.contacts.basic_api.create( 40 | simple_public_object_input=simple_public_object_input 41 | ) 42 | contact_id = contact.id 43 | company = get_company_by_name(company_name) 44 | company_id = company["id"] 45 | associate_contact_to_organization(contact_id, company_id) 46 | return {"contact_id": contact.id} 47 | except contacts.exceptions.ApiException as e: 48 | raise ContactException(e) 49 | 50 | 51 | def create_company(data): 52 | # company == organization 53 | try: 54 | company_map = { 55 | "org_id": "companynumber", 56 | "name": "name", 57 | } 58 | 59 | data = {company_map[k]: v for k, v in data.items()} 60 | simple_public_object_input = SimplePublicObjectInput(properties=data) 61 | company = api_client.crm.companies.basic_api.create( 62 | simple_public_object_input=simple_public_object_input 63 | ) 64 | return {"company_id": company.id} 65 | except companies.exceptions.ApiException as e: 66 | raise CompanyException(e) 67 | 68 | 69 | def get_company_by_name(name): 70 | email_filter = companies.Filter(property_name="name", operator="EQ", value=name) 71 | filter_group = companies.FilterGroup(filters=[email_filter]) 72 | request = contacts.PublicObjectSearchRequest( 73 | filter_groups=[ 74 | filter_group, 75 | ], 76 | properties=[ 77 | "id", 78 | ], 79 | ) 80 | try: 81 | company = api_client.crm.companies.search_api.do_search(request) 82 | if company.results: 83 | company_detail = company.results[0] 84 | return { 85 | "id": company_detail.id, 86 | } 87 | else: 88 | return {} 89 | except companies.exceptions.ApiException as e: 90 | raise CompanyException(e) 91 | 92 | 93 | def get_contact_by_email(email): 94 | email_filter = contacts.Filter(property_name="email", operator="EQ", value=email) 95 | filter_group = contacts.FilterGroup(filters=[email_filter]) 96 | request = contacts.PublicObjectSearchRequest( 97 | filter_groups=[ 98 | filter_group, 99 | ], 100 | ) 101 | try: 102 | contact = api_client.crm.contacts.search_api.do_search(request) 103 | if contact.results: 104 | contact_detail = contact.results[0] 105 | contact_properties = contact_detail.properties 106 | return { 107 | "id": contact_detail.id, 108 | "email": contact_properties["email"], 109 | "firstname": contact_properties["firstname"], 110 | "lastname": contact_properties["lastname"], 111 | } 112 | else: 113 | return {} 114 | except contacts.exceptions.ApiException as e: 115 | raise ContactException(e) 116 | 117 | 118 | def associate_contact_to_organization(contact_id, company_id): 119 | try: 120 | api_client.crm.contacts.associations_api.create( 121 | contact_id=contact_id, 122 | to_object_type="company", 123 | to_object_id=company_id, 124 | association_type="contact_to_company", 125 | ) 126 | except contacts.exceptions.ApiException as e: 127 | raise ContactAssociationOrganizationException(e) 128 | return True 129 | -------------------------------------------------------------------------------- /logger/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/logger/__init__.py -------------------------------------------------------------------------------- /logger/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import uuid 4 | from logging.handlers import RotatingFileHandler 5 | from functools import wraps 6 | 7 | from aws.services.dynamo_db.logs import create_log 8 | from settings import LOG_FILE_DIR 9 | from settings import LOG_FILE_NAME 10 | 11 | LOG_FILE = LOG_FILE_DIR.joinpath(LOG_FILE_NAME) 12 | 13 | 14 | def create_log_dir(): 15 | if not os.path.exists("logs"): 16 | os.makedirs("logs") 17 | 18 | 19 | def create_log_file(): 20 | try: 21 | open(LOG_FILE, "x") 22 | except FileExistsError: 23 | pass 24 | 25 | 26 | create_log_dir() 27 | create_log_file() 28 | 29 | logger = logging.getLogger(__name__) 30 | logger.setLevel(logging.DEBUG) 31 | formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s") 32 | fh = RotatingFileHandler(LOG_FILE) 33 | fh.setLevel(logging.DEBUG) 34 | fh.setFormatter(formatter) 35 | logger.addHandler(fh) 36 | 37 | 38 | def save_log(func): 39 | @wraps(func) 40 | async def wrapper(*args, **kwargs): 41 | try: 42 | request = kwargs.get("request") 43 | url = request.url 44 | client = request.client.host 45 | method = request.method 46 | message = dict( 47 | url=str(url), 48 | method=method, 49 | ) 50 | if hasattr(request, "data"): 51 | data = await request.data() 52 | message["data"] = [data] 53 | if hasattr(request, "json"): 54 | json_data = await request.json() 55 | message["data"] = [json_data] 56 | logger.debug(message) 57 | data = dict( 58 | log_id=str(uuid.uuid4()), 59 | request_ip=client, 60 | message=message 61 | ) 62 | await create_log(data) 63 | except Exception as exc: 64 | logger.exception(f"Exception occurred {exc}") 65 | pass 66 | return await func(*args, **kwargs) 67 | 68 | return wrapper 69 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy import engine_from_config 5 | from sqlalchemy import pool 6 | 7 | from alembic import context 8 | 9 | from settings import DATABASE 10 | from settings import DATABASE_USER 11 | from settings import DATABASE_PASSWORD 12 | from settings import DATABASE_HOST 13 | from settings import DATABASE_DRIVER 14 | from database import Base 15 | 16 | # this is the Alembic Config object, which provides 17 | # access to the values within the .ini file in use. 18 | config = context.config 19 | 20 | # Interpret the config file for Python logging. 21 | # This line sets up loggers basically. 22 | if config.config_file_name is not None: 23 | fileConfig(config.config_file_name) 24 | 25 | # add your model's MetaData object here 26 | # for 'autogenerate' support 27 | from models.payment import Charge 28 | from models import contact 29 | from models import message 30 | 31 | target_metadata = Base.metadata 32 | 33 | 34 | # other values from the config, defined by the needs of env.py, 35 | # can be acquired: 36 | # my_important_option = config.get_main_option("my_important_option") 37 | # ... etc. 38 | 39 | 40 | def get_url(): 41 | return f"{DATABASE_DRIVER}://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}/{DATABASE}" 42 | 43 | 44 | def run_migrations_offline(): 45 | """Run migrations in 'offline' mode. 46 | 47 | This configures the context with just a URL 48 | and not an Engine, though an Engine is acceptable 49 | here as well. By skipping the Engine creation 50 | we don't even need a DBAPI to be available. 51 | 52 | Calls to context.execute() here emit the given string to the 53 | script output. 54 | 55 | """ 56 | context.configure( 57 | url=get_url(), 58 | target_metadata=target_metadata, 59 | literal_binds=True, 60 | dialect_opts={"paramstyle": "named"}, 61 | ) 62 | 63 | with context.begin_transaction(): 64 | context.run_migrations() 65 | 66 | 67 | def run_migrations_online(): 68 | """Run migrations in 'online' mode. 69 | 70 | In this scenario we need to create an Engine 71 | and associate a connection with the context. 72 | 73 | """ 74 | alembic_config = config.get_section(config.config_ini_section) 75 | alembic_config['sqlalchemy.url'] = get_url() 76 | connectable = engine_from_config( 77 | alembic_config, 78 | prefix='sqlalchemy.', 79 | poolclass=pool.NullPool) 80 | 81 | with connectable.connect() as connection: 82 | context.configure( 83 | connection=connection, target_metadata=target_metadata 84 | ) 85 | 86 | with context.begin_transaction(): 87 | context.run_migrations() 88 | 89 | 90 | if context.is_offline_mode(): 91 | run_migrations_offline() 92 | else: 93 | run_migrations_online() 94 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/3f4f812d4ca6_add_created_at_and_modified_at_to_.py: -------------------------------------------------------------------------------- 1 | """add created_at and modified_at to message table 2 | 3 | Revision ID: 3f4f812d4ca6 4 | Revises: 913be17e323f 5 | Create Date: 2022-05-21 14:55:23.504250 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '3f4f812d4ca6' 14 | down_revision = '913be17e323f' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('message', sa.Column('created_at', sa.DateTime(), nullable=True)) 22 | op.add_column('message', sa.Column('modified_at', sa.DateTime(), nullable=True)) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column('message', 'modified_at') 29 | op.drop_column('message', 'created_at') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/versions/479a101db0bb_rename_created_and_modified_field_for_.py: -------------------------------------------------------------------------------- 1 | """rename created and modified field for charge model 2 | 3 | Revision ID: 479a101db0bb 4 | Revises: a9e00646e1d7 5 | Create Date: 2022-05-27 12:12:42.945570 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '479a101db0bb' 14 | down_revision = 'a9e00646e1d7' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('charge', sa.Column('created', sa.DateTime(), nullable=True)) 22 | op.add_column('charge', sa.Column('modified', sa.DateTime(), nullable=True)) 23 | op.drop_column('charge', 'created_at') 24 | op.drop_column('charge', 'modified_at') 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.add_column('charge', sa.Column('modified_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) 31 | op.add_column('charge', sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) 32 | op.drop_column('charge', 'modified') 33 | op.drop_column('charge', 'created') 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /migrations/versions/573a2f55e475_add_created_at_and_modified_at_to_.py: -------------------------------------------------------------------------------- 1 | """add created_at and modified_at to charge table 2 | 3 | Revision ID: 573a2f55e475 4 | Revises: 3f4f812d4ca6 5 | Create Date: 2022-05-26 19:50:38.717366 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '573a2f55e475' 14 | down_revision = '3f4f812d4ca6' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('charge', sa.Column('created_at', sa.DateTime(), nullable=True)) 22 | op.add_column('charge', sa.Column('modified_at', sa.DateTime(), nullable=True)) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column('charge', 'modified_at') 29 | op.drop_column('charge', 'created_at') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/versions/5bf098d393cd_remove_unique_constraint.py: -------------------------------------------------------------------------------- 1 | """remove unique constraint 2 | 3 | Revision ID: 5bf098d393cd 4 | Revises: 9b9c2855cad1 5 | Create Date: 2022-05-26 20:04:41.897338 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '5bf098d393cd' 14 | down_revision = '9b9c2855cad1' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/60809261b623_create_organization_table.py: -------------------------------------------------------------------------------- 1 | """create organization table 2 | 3 | Revision ID: 60809261b623 4 | Revises: 5 | Create Date: 2022-05-17 11:19:08.492308 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '60809261b623' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('organization', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('org_id', sa.String(), nullable=True), 24 | sa.Column('name', sa.String(), nullable=True), 25 | sa.PrimaryKeyConstraint('id') 26 | ) 27 | op.create_index(op.f('ix_organization_id'), 'organization', ['id'], unique=False) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_index(op.f('ix_organization_id'), table_name='organization') 34 | op.drop_table('organization') 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /migrations/versions/64c38ea8a78f_add_contact_table.py: -------------------------------------------------------------------------------- 1 | """add contact table 2 | 3 | Revision ID: 64c38ea8a78f 4 | Revises: 72ca14fa8b9e 5 | Create Date: 2022-05-17 11:47:19.826927 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '64c38ea8a78f' 14 | down_revision = '72ca14fa8b9e' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('contact', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('first_name', sa.String(), nullable=False), 24 | sa.Column('last_name', sa.String(), nullable=False), 25 | sa.Column('email', sa.String(), nullable=False), 26 | sa.Column('phone', sa.String(), nullable=False), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.create_index(op.f('ix_contact_id'), 'contact', ['id'], unique=False) 30 | op.create_index(op.f('ix_contact_phone'), 'contact', ['phone'], unique=True) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_index(op.f('ix_contact_phone'), table_name='contact') 37 | op.drop_index(op.f('ix_contact_id'), table_name='contact') 38 | op.drop_table('contact') 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /migrations/versions/6d11f960f1a5_add_active_field_to_contact_table.py: -------------------------------------------------------------------------------- 1 | """add active field to contact table 2 | 3 | Revision ID: 6d11f960f1a5 4 | Revises: 64c38ea8a78f 5 | Create Date: 2022-05-17 11:51:50.109560 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '6d11f960f1a5' 14 | down_revision = '64c38ea8a78f' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('contact', sa.Column('active', sa.Boolean(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('contact', 'active') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/71886cd76b25_alter_organization_model.py: -------------------------------------------------------------------------------- 1 | """alter organization model 2 | 3 | Revision ID: 71886cd76b25 4 | Revises: d15215db454c 5 | Create Date: 2022-05-19 12:44:56.918818 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '71886cd76b25' 14 | down_revision = 'd15215db454c' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column('organization', 'org_id', 22 | existing_type=sa.VARCHAR(), 23 | nullable=False) 24 | op.alter_column('organization', 'name', 25 | existing_type=sa.VARCHAR(), 26 | nullable=False) 27 | op.create_unique_constraint(None, 'organization', ['name']) 28 | op.create_unique_constraint(None, 'organization', ['org_id']) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_constraint(None, 'organization', type_='unique') 35 | op.drop_constraint(None, 'organization', type_='unique') 36 | op.alter_column('organization', 'name', 37 | existing_type=sa.VARCHAR(), 38 | nullable=True) 39 | op.alter_column('organization', 'org_id', 40 | existing_type=sa.VARCHAR(), 41 | nullable=True) 42 | # ### end Alembic commands ### 43 | -------------------------------------------------------------------------------- /migrations/versions/72ca14fa8b9e_alter_charge_id_nullable_to_false.py: -------------------------------------------------------------------------------- 1 | """alter charge_id nullable to false 2 | 3 | Revision ID: 72ca14fa8b9e 4 | Revises: e0da39a6bed1 5 | Create Date: 2022-05-17 11:21:49.740043 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '72ca14fa8b9e' 14 | down_revision = 'e0da39a6bed1' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column('Payment', 'charge_id', 22 | existing_type=sa.INTEGER(), 23 | nullable=False) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.alter_column('Payment', 'charge_id', 30 | existing_type=sa.INTEGER(), 31 | nullable=True) 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/873f4fb17cc8_add_organization_foreign_key_to_contact.py: -------------------------------------------------------------------------------- 1 | """add organization foreign key to contact 2 | 3 | Revision ID: 873f4fb17cc8 4 | Revises: 6d11f960f1a5 5 | Create Date: 2022-05-17 11:57:19.389214 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '873f4fb17cc8' 14 | down_revision = '6d11f960f1a5' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('contact', sa.Column('org_id', sa.Integer(), nullable=False)) 22 | op.create_unique_constraint(None, 'contact', ['email']) 23 | op.create_foreign_key(None, 'contact', 'organization', ['org_id'], ['id']) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_constraint(None, 'contact', type_='foreignkey') 30 | op.drop_constraint(None, 'contact', type_='unique') 31 | op.drop_column('contact', 'org_id') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/913be17e323f_add_message_table.py: -------------------------------------------------------------------------------- 1 | """add_message_table 2 | 3 | Revision ID: 913be17e323f 4 | Revises: 71886cd76b25 5 | Create Date: 2022-05-21 11:42:37.026628 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '913be17e323f' 14 | down_revision = '71886cd76b25' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('message', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('message_id', sa.String(), nullable=False), 24 | sa.Column('message_type', sa.Enum('EMAIL', 'SMS', name='messagetype'), nullable=False), 25 | sa.Column('carrier', sa.Enum('SENDGRID', 'TWILIO', name='carrier'), nullable=False), 26 | sa.Column('status_code', sa.String(), nullable=False), 27 | sa.Column('response', sa.String(), nullable=True), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | op.create_index(op.f('ix_message_id'), 'message', ['id'], unique=False) 31 | op.create_index(op.f('ix_message_message_id'), 'message', ['message_id'], unique=True) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_index(op.f('ix_message_message_id'), table_name='message') 38 | op.drop_index(op.f('ix_message_id'), table_name='message') 39 | op.drop_table('message') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /migrations/versions/9b9c2855cad1_add_unique_constraint_for_charge_type_.py: -------------------------------------------------------------------------------- 1 | """add unique constraint for charge type and org_id for charge table 2 | 3 | Revision ID: 9b9c2855cad1 4 | Revises: 573a2f55e475 5 | Create Date: 2022-05-26 19:53:14.981004 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '9b9c2855cad1' 14 | down_revision = '573a2f55e475' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/a980495e9ab1_modify_relationship.py: -------------------------------------------------------------------------------- 1 | """modify relationship 2 | 3 | Revision ID: a980495e9ab1 4 | Revises: 873f4fb17cc8 5 | Create Date: 2022-05-19 10:31:01.704712 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'a980495e9ab1' 14 | down_revision = '873f4fb17cc8' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/a9e00646e1d7_modify_unique_constraint.py: -------------------------------------------------------------------------------- 1 | """modify unique constraint 2 | 3 | Revision ID: a9e00646e1d7 4 | Revises: ad9ebc89187d 5 | Create Date: 2022-05-26 23:23:53.573992 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'a9e00646e1d7' 14 | down_revision = 'ad9ebc89187d' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_unique_constraint(None, 'charge', ['org_id', 'charge_type']) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_constraint(None, 'charge', type_='unique') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/ad9ebc89187d_add_unique_constraint_to_charge.py: -------------------------------------------------------------------------------- 1 | """add unique constraint to charge 2 | 3 | Revision ID: ad9ebc89187d 4 | Revises: 5bf098d393cd 5 | Create Date: 2022-05-26 20:05:35.891406 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ad9ebc89187d' 14 | down_revision = '5bf098d393cd' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/d15215db454c_modify_organization_backref.py: -------------------------------------------------------------------------------- 1 | """modify organization backref 2 | 3 | Revision ID: d15215db454c 4 | Revises: a980495e9ab1 5 | Create Date: 2022-05-19 11:57:34.656839 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'd15215db454c' 14 | down_revision = 'a980495e9ab1' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/e0da39a6bed1_create_payment_table.py: -------------------------------------------------------------------------------- 1 | """create payment table 2 | 3 | Revision ID: e0da39a6bed1 4 | Revises: f5bac25e3c22 5 | Create Date: 2022-05-17 11:20:29.475825 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'e0da39a6bed1' 14 | down_revision = 'f5bac25e3c22' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('Payment', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('transaction_id', sa.String(), nullable=True), 24 | sa.Column('charge_id', sa.Integer(), nullable=True), 25 | sa.Column('response', sa.String(), nullable=True), 26 | sa.ForeignKeyConstraint(['charge_id'], ['charge.id'], ), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.create_index(op.f('ix_Payment_id'), 'Payment', ['id'], unique=False) 30 | op.create_index(op.f('ix_Payment_transaction_id'), 'Payment', ['transaction_id'], unique=True) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_index(op.f('ix_Payment_transaction_id'), table_name='Payment') 37 | op.drop_index(op.f('ix_Payment_id'), table_name='Payment') 38 | op.drop_table('Payment') 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /migrations/versions/f5bac25e3c22_create_charge_table.py: -------------------------------------------------------------------------------- 1 | """create charge table 2 | 3 | Revision ID: f5bac25e3c22 4 | Revises: 60809261b623 5 | Create Date: 2022-05-17 11:20:01.803699 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f5bac25e3c22' 14 | down_revision = '60809261b623' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('charge', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('currency', sa.Enum('USD', 'GBP', 'KES', 'INR', name='currency'), nullable=False), 24 | sa.Column('amount', sa.Integer(), nullable=False), 25 | sa.Column('charge_type', sa.Enum('ONBOARDING', 'MAINTENANCE', name='chargetype'), nullable=False), 26 | sa.Column('org_id', sa.Integer(), nullable=True), 27 | sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | op.create_index(op.f('ix_charge_id'), 'charge', ['id'], unique=False) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_index(op.f('ix_charge_id'), table_name='charge') 37 | op.drop_table('charge') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/models/__init__.py -------------------------------------------------------------------------------- /models/contact.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, UniqueConstraint 2 | from sqlalchemy import Integer 3 | from sqlalchemy import String 4 | from sqlalchemy import Boolean 5 | from sqlalchemy import ForeignKey 6 | from sqlalchemy.orm import relationship 7 | 8 | from database import Base 9 | 10 | 11 | class Contact(Base): 12 | __tablename__ = "contact" 13 | 14 | id = Column(Integer, primary_key=True, index=True) 15 | first_name = Column(String, nullable=False) 16 | last_name = Column(String, nullable=False) 17 | email = Column(String, nullable=False, unique=True) 18 | phone = Column(String, nullable=False, unique=True, index=True) 19 | active = Column(Boolean, default=False) 20 | org_id = Column(Integer, ForeignKey("organization.id"), nullable=False) 21 | organization = relationship("Organization", backref="contact") 22 | UniqueConstraint("org_id", "email", name="contact_email_organization") 23 | -------------------------------------------------------------------------------- /models/message.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import datetime 3 | from sqlalchemy import Column 4 | from sqlalchemy import Integer 5 | from sqlalchemy import String 6 | from sqlalchemy import Enum 7 | from sqlalchemy import DateTime 8 | 9 | from database import Base 10 | 11 | 12 | class Carrier(enum.Enum): 13 | SENDGRID = "SENDGRID" 14 | TWILIO = "TWILIO" 15 | 16 | 17 | class MessageType(enum.Enum): 18 | EMAIL = "EMAIL" 19 | SMS = "SMS" 20 | 21 | 22 | class Message(Base): 23 | __tablename__ = "message" 24 | 25 | id = Column(Integer, primary_key=True, index=True) 26 | message_id = Column(String, nullable=False, unique=True, index=True) 27 | message_type = Column(Enum(MessageType), nullable=False) 28 | carrier = Column(Enum(Carrier), nullable=False) 29 | status_code = Column(String, nullable=False) 30 | response = Column(String, nullable=True) 31 | created_at = Column(DateTime, default=datetime.datetime.now) 32 | modified_at = Column(DateTime, default=datetime.datetime.now) 33 | -------------------------------------------------------------------------------- /models/payment.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import datetime 3 | 4 | from sqlalchemy import Column 5 | from sqlalchemy import Integer 6 | from sqlalchemy import String 7 | from sqlalchemy import Enum 8 | from sqlalchemy import ForeignKey 9 | from sqlalchemy import DateTime 10 | from sqlalchemy import UniqueConstraint 11 | from sqlalchemy.orm import relationship 12 | 13 | from database import Base 14 | 15 | 16 | class ChargeType(enum.Enum): 17 | ONBOARDING = "onboarding" 18 | MAINTENANCE = "maintenance" 19 | 20 | 21 | class Currency(enum.Enum): 22 | USD = "USD" 23 | GBP = "GBP" 24 | KES = "KES" 25 | INR = "INR" 26 | 27 | 28 | class Organization(Base): 29 | __tablename__ = "organization" 30 | 31 | id = Column(Integer, primary_key=True, index=True) 32 | org_id = Column(String, nullable=False, unique=True) 33 | name = Column(String, nullable=False, unique=True) 34 | 35 | 36 | class Charge(Base): 37 | __tablename__ = "charge" 38 | __table_args__ = (UniqueConstraint("org_id", "charge_type"),) 39 | id = Column(Integer, primary_key=True, index=True) 40 | currency = Column(Enum(Currency), nullable=False, default=Currency.USD) 41 | amount = Column(Integer, nullable=False) 42 | charge_type = Column(Enum(ChargeType), nullable=False) 43 | org_id = Column(Integer, ForeignKey("organization.id")) 44 | organization = relationship("Organization", backref="charges") 45 | created = Column(DateTime, default=datetime.datetime.now) 46 | modified = Column(DateTime, default=datetime.datetime.now) 47 | 48 | 49 | class Payment(Base): 50 | __tablename__ = "Payment" 51 | 52 | id = Column(Integer, primary_key=True, index=True) 53 | transaction_id = Column(String, unique=True, index=True) 54 | charge_id = Column(Integer, ForeignKey("charge.id"), nullable=False) 55 | charge = relationship("Charge", backref="payments") 56 | response = Column(String, nullable=True) 57 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Use case 2 | 3 | An organization called Test Org comes to a property management company. The property management 4 | investigates the property and determines a rate and sends the organization a quotation. 5 | Upon agreement, the property management Co sends a payment link for the organization to make the 6 | payment. The Organization makes the payment and a payment record is saved in the Database 7 | 8 | # Data Store 9 | 10 | - Organization/Company record is saved in a postgres db(for internal datastore) and Hubspot for business to use. 11 | 12 | # Key points to consider 13 | 14 | - Since data is supposed to reside in two places namely postgresql database and Hubspot CRM. 15 | Data consistency is key and hence the synchronisation of data is a big part of the design. 16 | Currently, to keep things simple, the process of creating the record is done sequentially. 17 | However, the plan is to use a event based architecture to decouple the logic. 18 | 19 | # Task checklist Link 20 | -task_checklist.md 21 | 22 | # Quick Set up 23 | - pip install -r requirements.txt 24 | - create .env file and add the keys for env-example 25 | - alembic upgrade head (to migrate) 26 | - uvicorn app:app --reload (to run server) 27 | - /docs - to view available endpoints 28 | 29 | # Endpoints - v1 (Version 1) 30 | 31 | Contact 32 | - /api/v1/contacts/ - POST - Create a Contact 33 | - /api/v1/contact// - GET email by contact 34 | 35 | Company 36 | - /api/v1/companies/ - POST - Create a Company/Organization 37 | - /api/v1/company// - GET - Get company by Name 38 | 39 | Email 40 | - /api/v1/email/send/ - POST - Send an email 41 | 42 | 43 | # External Integrations 44 | 45 | - Hubspot 46 | - Sendgrid 47 | - Stripe 48 | - AWS DynamoDB 49 | 50 | # Logs 51 | 52 | - Logs are saved in a dir(logs/app.json) 53 | 54 | 55 | # Running tests 56 | - pytest -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.7.7 2 | anyio==3.6.1 3 | black==22.3.0 4 | boto3==1.23.5 5 | certifi==2021.10.8 6 | coverage==6.3.3 7 | charset-normalizer==2.0.12 8 | click==8.1.3 9 | fastapi==0.78.0 10 | greenlet==1.1.2 11 | idna==3.3 12 | importlib-metadata==4.11.3 13 | importlib-resources==5.7.1 14 | Jinja2==3.1.2 15 | Mako==1.2.0 16 | MarkupSafe==2.1.1 17 | mypy-extensions==0.4.3 18 | mixer==7.2.1 19 | pytest==5.3.5 20 | pathspec==0.9.0 21 | platformdirs==2.5.2 22 | psycopg2-binary==2.9.3 23 | pydantic==1.9.0 24 | python_http_client==3.3.7 25 | python-decouple==3.6 26 | hubspot-api-client==5.0.0 27 | requests==2.27.1 28 | sniffio==1.2.0 29 | SQLAlchemy==1.4.36 30 | sendgrid==6.9.7 31 | starlette==0.19.1 32 | stripe==3.0.0 33 | tomli==2.0.1 34 | typing_extensions==4.2.0 35 | urllib3==1.26.9 36 | zipp==3.8.0 37 | sqlalchemy-utils==0.38.2 38 | pytest-asyncio==0.18.3 39 | -------------------------------------------------------------------------------- /routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/routes/__init__.py -------------------------------------------------------------------------------- /routes/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/routes/v1/__init__.py -------------------------------------------------------------------------------- /routes/v1/charge.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from fastapi import Depends 3 | from fastapi import HTTPException 4 | from fastapi import Request 5 | from fastapi import status 6 | from sqlalchemy.orm import Session 7 | 8 | from crud.charge import ChargeExistException 9 | from crud.company import filter_company_by_name 10 | from logger.log import save_log 11 | from dependencies.dependencies import get_db 12 | from crud import charge as _charge 13 | from schemas.schema import CreateCharge 14 | 15 | router = APIRouter( 16 | prefix="/api/v1", 17 | tags=["charge"], 18 | responses={404: {"description": "Not found"}}, 19 | ) 20 | 21 | 22 | @router.post("/charges/", status_code=status.HTTP_201_CREATED) 23 | @save_log 24 | async def create_charge(request: Request, charge: CreateCharge, db: Session = Depends(get_db)): 25 | try: 26 | company_name = charge.company_name 27 | company = filter_company_by_name(db, company_name).all()[0] 28 | response = await _charge.add_charge(db, charge, company.id) 29 | except ChargeExistException as exc: 30 | raise HTTPException(status_code=200, detail=str(exc)) 31 | return response 32 | -------------------------------------------------------------------------------- /routes/v1/company.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from fastapi import Depends 3 | from fastapi import HTTPException 4 | from fastapi import Request 5 | from sqlalchemy.orm import Session 6 | 7 | from crud.company import CompanyExistException 8 | from logger.log import save_log 9 | from schemas import schema 10 | from hubspot_api import utils 11 | from dependencies.dependencies import get_db 12 | from crud import company as _company 13 | 14 | router = APIRouter( 15 | prefix="/api/v1", 16 | tags=["company"], 17 | responses={404: {"description": "Not found"}}, 18 | ) 19 | 20 | 21 | @router.post("/companies/") 22 | @save_log 23 | async def create_company( 24 | request: Request, company: schema.CreateCompany, db: Session = Depends(get_db) 25 | ): 26 | db_company = None 27 | try: 28 | db_company = _company.create_company(db, company) 29 | company = utils.create_company(data=company.dict()) 30 | except (utils.CompanyException, CompanyExistException) as exc: 31 | if isinstance(exc, utils.CompanyException) and db_company: 32 | _company.delete_company(db, db_company.id) 33 | raise HTTPException(status_code=200, detail=str(exc)) 34 | return company 35 | 36 | 37 | @router.get("/company/{company_name}/") 38 | @save_log 39 | async def get_company(request: Request, company_name): 40 | try: 41 | company = utils.get_company_by_name(company_name) 42 | except utils.CompanyException as exc: 43 | raise HTTPException(status_code=200, detail=str(exc)) 44 | return company 45 | -------------------------------------------------------------------------------- /routes/v1/contact.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from fastapi import Depends 3 | from fastapi import HTTPException 4 | from fastapi import Request 5 | from sqlalchemy.orm import Session 6 | 7 | from crud.contact import ContactExistException 8 | from crud import contact as _contact 9 | from logger.log import save_log 10 | from schemas import schema 11 | from hubspot_api import utils 12 | from dependencies.dependencies import get_db 13 | from crud import company as _company 14 | 15 | router = APIRouter( 16 | prefix="/api/v1", 17 | tags=["contact"], 18 | responses={404: {"description": "Not found"}}, 19 | ) 20 | 21 | 22 | @router.post("/contacts/") 23 | @save_log 24 | async def create_contact( 25 | request: Request, contact: schema.CreateContact, db: Session = Depends(get_db) 26 | ): 27 | db_contact = None 28 | try: 29 | org_name = contact.company_name 30 | db_org = _company.filter_company_by_name(db, org_name).all()[0] 31 | db_contact = _contact.create_contact(db, contact, db_org.id) 32 | hubspot_contact = utils.create_contact(data=contact.dict()) 33 | except (utils.ContactException, ContactExistException) as exc: 34 | if isinstance(exc, utils.ContactException) and db_contact: 35 | _contact.delete_contact(db, db_contact.id) 36 | raise HTTPException(status_code=200, detail=str(exc)) 37 | return hubspot_contact 38 | 39 | 40 | @router.get("/contact/{email}/") 41 | @save_log 42 | async def get_contact(request: Request, email): 43 | try: 44 | contact = utils.get_contact_by_email(email) 45 | except utils.ContactException as exc: 46 | raise HTTPException(status_code=200, detail=str(exc)) 47 | return contact 48 | -------------------------------------------------------------------------------- /routes/v1/email.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from fastapi import Depends 3 | from fastapi import HTTPException 4 | from fastapi import Request 5 | 6 | from crud.message import save_message 7 | from crud.message import MessageExistException 8 | from email_api import email 9 | from email_api.email import UnauthorizedException 10 | from email_api.email import BadRequestException 11 | from logger.log import save_log 12 | from models.message import MessageType 13 | from models.message import Carrier 14 | from sqlalchemy.orm import Session 15 | 16 | from dependencies.dependencies import get_db 17 | 18 | router = APIRouter( 19 | prefix="/api/v1", 20 | tags=["email"], 21 | responses={404: {"description": "Not found"}}, 22 | ) 23 | 24 | 25 | @router.post("/email/send/") 26 | @save_log 27 | async def send_email(request: Request, db: Session = Depends(get_db)): 28 | try: 29 | data = await request.json() 30 | response = await email.send_email(data) 31 | message = dict( 32 | message_id=response.headers["x-message-id"], 33 | status_code=response.status_code, 34 | message_type=MessageType.EMAIL.value, 35 | carrier=Carrier.SENDGRID.value, 36 | ) 37 | await save_message(db, message) 38 | except (UnauthorizedException, BadRequestException, MessageExistException) as exc: 39 | raise HTTPException(status_code=200, detail=str(exc)) 40 | return response 41 | -------------------------------------------------------------------------------- /schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/schemas/__init__.py -------------------------------------------------------------------------------- /schemas/schema.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from pydantic import validator 3 | 4 | 5 | class CompanyBase(BaseModel): 6 | org_id: str 7 | name: str 8 | 9 | 10 | class ContactBase(BaseModel): 11 | first_name: str 12 | last_name: str 13 | email: str 14 | phone: str 15 | 16 | 17 | class CreateContact(ContactBase): 18 | company_name: str 19 | 20 | 21 | class CreateCompany(CompanyBase): 22 | pass 23 | 24 | 25 | class ChargeBase(BaseModel): 26 | currency: str 27 | amount: int 28 | charge_type: str 29 | company_name: str 30 | 31 | 32 | class CreateCharge(ChargeBase): 33 | pass 34 | 35 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from decouple import config 4 | 5 | BASE_DIR = Path(__file__).resolve(strict=True).parent 6 | 7 | DATABASE_HOST = config("DATABASE_HOST") 8 | DATABASE = config("DATABASE_NAME") 9 | DATABASE_USER = config("DATABASE_USER") 10 | DATABASE_PASSWORD = config("DATABASE_PASSWORD") 11 | DATABASE_DRIVER = config("DATABASE_DRIVER") 12 | TEST_DATABASE_NAME = config("TEST_DATABASE_NAME") 13 | 14 | STRIPE_PUBLISHABLE_KEY = config("STRIPE_PUBLISHABLE_KEY") 15 | STRIPE_SECRET_KEY = config("STRIPE_SECRET_KEY") 16 | 17 | HUBSPOT_API_KEY = config("HUBSPOT_API_KEY") 18 | 19 | SENDGRID_API_KEY = config("SENDGRID_API_KEY") 20 | WELCOME_MESSAGE_TEMPLATE_ID = config("WELCOME_MESSAGE_TEMPLATE_ID") 21 | PAYMENT_CONFIRMATION_TEMPLATE_ID = config("WELCOME_MESSAGE_TEMPLATE_ID") 22 | 23 | TEST_DATA_DIR = BASE_DIR.joinpath("tests/data") 24 | LOG_FILE_DIR = BASE_DIR.joinpath("logs") 25 | LOG_FILE_NAME = "app.json" 26 | 27 | AWS_SECRET_KEY_ID = config('AWS_SECRET_KEY_ID') 28 | AWS_SECRET_KEY = config('AWS_SECRET_KEY') 29 | AWS_REGION = config('AWS_REGION') 30 | -------------------------------------------------------------------------------- /stripe_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/stripe_api/__init__.py -------------------------------------------------------------------------------- /stripe_api/payment.py: -------------------------------------------------------------------------------- 1 | import stripe 2 | 3 | from settings import STRIPE_SECRET_KEY 4 | 5 | stripe.api_key = STRIPE_SECRET_KEY 6 | 7 | 8 | class StripeException(Exception): 9 | pass 10 | 11 | 12 | class PaymentIntentException(StripeException): 13 | pass 14 | 15 | 16 | def create_payment_intent( 17 | amount: int, currency_iso: str, organization_id: str, charge_type: str 18 | ): 19 | try: 20 | return stripe.PaymentIntent.create( 21 | amount=amount, 22 | currency=currency_iso, 23 | automatic_payment_methods={"enabled": True}, 24 | metadata={"organization_id": organization_id, "charge_type": charge_type}, 25 | ) 26 | except stripe.error.InvalidRequestError as e: 27 | raise PaymentIntentException(e) 28 | 29 | 30 | def get_payment_intent(payment_intent_id): 31 | payment_intent = stripe.PaymentIntent.retrieve( 32 | payment_intent_id, 33 | ) 34 | return payment_intent 35 | -------------------------------------------------------------------------------- /task_checklist.md: -------------------------------------------------------------------------------- 1 | # Task 1 (Done) 2 | 3 | Onboard an organization/company into our system 4 | - Save organization details in database 5 | - Save organization details to Hubspot 6 | 7 | -Rollback 8 | if organization creation succeeds in our database but fails in Hubspot 9 | then the record is deleted from our database to maintain consistency. 10 | 11 | # Task 2 (Done) 12 | 13 | - Onboard a contact into the system 14 | - Save Contact details in database 15 | - Associate contact with the organization 16 | - Save Contact details in Hubspot 17 | 18 | # Task 3 (DONE) 19 | 20 | - Create a Charge into our system for an organization 21 | - Save Charge details in database 22 | - Save Charge details in Hubspot 23 | 24 | # Task 4 (TODO) 25 | 26 | - Render stripe charge form in charge.html 27 | - Generate a payment link for that organization 28 | - Charge a client using Stripe Payment API 29 | - Use status.html to display payment status to client 30 | - Save Payment details in the DB for that charge 31 | - Save Payment details in Hubspot 32 | 33 | # Task 5 (DONE) 34 | - Email API endpoint for sending emails 35 | - Message Model for saving message record 36 | - Integrate with Sendgrid API for emailing 37 | - Save message response in DB 38 | 39 | # Task 6 (TODO) 40 | 41 | - Booking appointment for property 42 | - Record relevant information for appointment 43 | - Save in Database 44 | - Save in Hubspot 45 | 46 | 47 | # Task 7 (IN PROGRESS) 48 | 49 | - Integrate pytest 50 | - Add tests for the API endpoints 51 | - Add tests for the stripe api functionality 52 | - Add tests for hubspot functions 53 | 54 | # Task 7 (DONE) 55 | 56 | - Integrate boto3 57 | - Push request logs to AWS Dynamo DB 58 | - Add tests -------------------------------------------------------------------------------- /templates/charge.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | Hello {{ message }} 5 |

6 | 7 | -------------------------------------------------------------------------------- /templates/status.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/templates/status.html -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy_utils import database_exists 5 | from sqlalchemy_utils import create_database 6 | from fastapi.testclient import TestClient 7 | from sqlalchemy.orm import Session 8 | 9 | import schemas.schema 10 | from crud.charge import add_charge 11 | from crud.company import create_company 12 | from crud.contact import create_contact 13 | from crud.message import save_message 14 | from models.message import MessageType 15 | from models.message import Carrier 16 | from models.payment import Organization 17 | from tests.test_database import SQLALCHEMY_DATABASE_URL 18 | 19 | from dependencies.dependencies import get_db 20 | from app import app 21 | from database import Base 22 | 23 | 24 | @pytest.fixture(scope="session") 25 | def db_engine(): 26 | engine = create_engine(SQLALCHEMY_DATABASE_URL) 27 | if not database_exists: 28 | create_database(engine.url) 29 | 30 | Base.metadata.create_all(bind=engine) 31 | yield engine 32 | 33 | 34 | @pytest.fixture(scope="function") 35 | def db(db_engine): 36 | connection = db_engine.connect() 37 | # begin a non-ORM transaction 38 | connection.begin() 39 | # bind an individual Session to the connection 40 | db = Session(bind=connection) 41 | yield db 42 | db.rollback() 43 | connection.close() 44 | 45 | 46 | @pytest.fixture(scope="function") 47 | def client(db): 48 | app.dependency_overrides[get_db] = lambda: db 49 | with TestClient(app) as c: 50 | yield c 51 | 52 | 53 | @pytest.fixture 54 | def organization(db): 55 | yield create_company( 56 | db, schemas.schema.CreateCompany(name="Test org", org_id=12345) 57 | ) 58 | 59 | 60 | @pytest.fixture 61 | @pytest.mark.asyncio 62 | async def charge_onboarding(db, organization): 63 | await add_charge( 64 | db, 65 | schemas.schema.CreateCharge( 66 | company_name="Test org", 67 | currency="USD", 68 | charge_type="ONBOARDING", 69 | amount=200, 70 | ), 71 | organization.id, 72 | ) 73 | 74 | 75 | @pytest.fixture 76 | @pytest.mark.asyncio 77 | async def message(db): 78 | message_data = dict( 79 | message_id="123657ab", 80 | status_code=202, 81 | message_type=MessageType.EMAIL.value, 82 | carrier=Carrier.SENDGRID.value, 83 | ) 84 | await save_message(db, message_data) 85 | 86 | 87 | @pytest.fixture 88 | def contact(db): 89 | organization = db.query(Organization).filter_by(name="Test org").all()[0] 90 | create_contact( 91 | db, 92 | schemas.schema.CreateContact( 93 | first_name="Test", 94 | last_name="User", 95 | email="testuser@example.com", 96 | phone="2547120202002", 97 | company_name=organization.name, 98 | ), 99 | org_id=organization.id, 100 | ) 101 | -------------------------------------------------------------------------------- /tests/data/__init_-.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/tests/data/__init_-.py -------------------------------------------------------------------------------- /tests/data/dynamo_db_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "ResponseMetadata": { 3 | "RequestId": "PFK7OPBR2L9FLV3N9OFCHO3VJNVV4KQNSO5A", 4 | "HTTPStatusCode": 200, 5 | "HTTPHeaders": { 6 | "server": "Server", 7 | "date": "Sat, 28 May 2022 18:32:28 GMT", 8 | "content-type": "application/x-amz-json-1.0", 9 | "content-length": "2", 10 | "connection": "keep-alive", 11 | "x-amzn-requestid": "PFK7OPBR2L9FLV3N9OFCHO3V", 12 | "x-amz-crc32": "274561" 13 | }, 14 | "RetryAttempts": 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/data/email_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "recipients": [ 3 | { 4 | "recipient_email": "dccomics@example.com", 5 | "recipient_name": "DC" 6 | }, 7 | { 8 | "recipient_email": "baryallen@example.com", 9 | "recipient_name": "Flash" 10 | } 11 | ], 12 | "template_content": { 13 | "content": "Hey there DC please continue with the Flash seasons" 14 | }, 15 | "sender": "humblefool@example.com", 16 | "subject": "Flash Fan", 17 | "email_type": "welcome_email" 18 | } -------------------------------------------------------------------------------- /tests/integrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/tests/integrations/__init__.py -------------------------------------------------------------------------------- /tests/integrations/common.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from settings import TEST_DATA_DIR 4 | 5 | 6 | def read_json(filename): 7 | with open(TEST_DATA_DIR.joinpath(filename)) as resp_file: 8 | return json.load(resp_file) 9 | -------------------------------------------------------------------------------- /tests/integrations/test_dynamo_db.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest import mock 3 | 4 | from aws.services.dynamo_db.logs import create_log, BotoClientException 5 | from tests.integrations.common import read_json 6 | 7 | 8 | class MockDynamoDbTable: 9 | def put_item(self, **kwargs): 10 | response = read_json("dynamo_db_response.json") 11 | return response 12 | 13 | 14 | @mock.patch("aws.services.dynamo_db.logs.dynamodb.Table") 15 | @mock.patch("aws.services.dynamo_db.logs.boto3.resource") 16 | @pytest.mark.asyncio 17 | async def test_dynamo_db_log(mock_boto3_connect, mock_dynamo_db_table): 18 | mock_boto3_connect.return_value = "success" 19 | mock_dynamo_db_table.return_value = MockDynamoDbTable() 20 | data = { 21 | "log_id": 12345, 22 | "request_ip": "localhost", 23 | "message": { 24 | "url": "http://127.0.0.1:8000/api/v1/companies/", 25 | "method": "POST", 26 | "data": [{"org_id": 1234, "name": "New test"}], 27 | }, 28 | } 29 | response = await create_log(data) 30 | assert response == read_json("dynamo_db_response.json") 31 | 32 | 33 | @mock.patch("aws.services.dynamo_db.logs.dynamodb.Table") 34 | @mock.patch("aws.services.dynamo_db.logs.boto3.resource") 35 | @pytest.mark.asyncio 36 | async def test_dynamo_db_log_raises_client_error( 37 | mock_boto3_connect, mock_dynamo_db_table 38 | ): 39 | mock_boto3_connect.return_value = "success" 40 | mock_dynamo_db_table.side_effect = BotoClientException("Something went wrong") 41 | data = { 42 | "log_id": 12345, 43 | "request_ip": "localhost", 44 | "message": { 45 | "url": "http://127.0.0.1:8000/api/v1/companies/", 46 | "method": "POST", 47 | "data": [{"org_id": 1234, "name": "New test"}], 48 | }, 49 | } 50 | with pytest.raises(BotoClientException) as exc: 51 | await create_log(data) 52 | assert isinstance(exc.value, BotoClientException) 53 | assert exc.value.args[0] == "Something went wrong" 54 | -------------------------------------------------------------------------------- /tests/integrations/test_hubspot.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from hubspot_api.utils import CompanyException 6 | from hubspot_api.utils import ContactAssociationOrganizationException 7 | from hubspot_api.utils import ContactException 8 | from hubspot_api.utils import create_contact 9 | from hubspot_api.utils import create_company 10 | from hubspot_api.utils import get_contact_by_email 11 | from hubspot_api.utils import get_company_by_name 12 | from hubspot_api.utils import associate_contact_to_organization 13 | 14 | 15 | class MockHubspotContact: 16 | def __init__(self, id, properties=None): 17 | self.id = id 18 | self.properties = properties 19 | 20 | 21 | class MockHubspotCompany: 22 | def __init__(self, id): 23 | self.id = id 24 | 25 | 26 | class MockHubspotCompanySearch: 27 | def __init__(self, results=None): 28 | self.results = results 29 | 30 | 31 | class MockHubspotContactSearch: 32 | def __init__(self, results): 33 | self.results = results 34 | 35 | 36 | @mock.patch("hubspot_api.utils.associate_contact_to_organization") 37 | @mock.patch("hubspot_api.utils.get_company_by_name") 38 | @mock.patch("hubspot_api.utils.api_client") 39 | def test_create_contact( 40 | mock_api_client, 41 | mock_get_company_by_name, 42 | mock_associate_contact_to_organization, 43 | ): 44 | mock_api_client.crm.contacts.basic_api.create.return_value = MockHubspotContact( 45 | 1234 46 | ) 47 | mock_get_company_by_name.return_value = {"id": 1234} 48 | mock_associate_contact_to_organization.return_value = "ok" 49 | contact = create_contact( 50 | dict( 51 | company_name="Test org", 52 | first_name="Test", 53 | last_name="User", 54 | email="testuser@example.com", 55 | phone="111111111", 56 | ) 57 | ) 58 | assert contact == {"contact_id": 1234} 59 | 60 | 61 | @mock.patch("hubspot_api.utils.api_client") 62 | def test_create_contact_raises_contact_exception(mock_api_client): 63 | mock_api_client.crm.contacts.basic_api.create.side_effect = ContactException( 64 | "Contact Already exists" 65 | ) 66 | 67 | with pytest.raises(ContactException) as exc: 68 | create_contact( 69 | dict( 70 | company_name="Test org", 71 | first_name="Test", 72 | last_name="User", 73 | email="testuser@example.com", 74 | phone="111111111", 75 | ) 76 | ) 77 | assert isinstance(exc.value, ContactException) 78 | assert exc.value.args[0] == "Contact Already exists" 79 | 80 | 81 | @mock.patch("hubspot_api.utils.api_client") 82 | def test_create_company(mock_api_client): 83 | mock_api_client.crm.companies.basic_api.create.return_value = MockHubspotCompany( 84 | 777 85 | ) 86 | company = create_company( 87 | dict( 88 | name="Test org", 89 | org_id="999", 90 | ) 91 | ) 92 | assert company == {"company_id": 777} 93 | 94 | 95 | @mock.patch("hubspot_api.utils.api_client") 96 | def test_create_company_raises_company_exception(mock_api_client): 97 | mock_api_client.crm.companies.basic_api.create.side_effect = CompanyException( 98 | "Company already exists" 99 | ) 100 | with pytest.raises(CompanyException) as exc: 101 | create_company( 102 | dict( 103 | name="Test org", 104 | org_id="999", 105 | ) 106 | ) 107 | 108 | assert isinstance(exc.value, CompanyException) 109 | assert exc.value.args[0] == "Company already exists" 110 | 111 | 112 | @mock.patch("hubspot_api.utils.api_client") 113 | def test_get_company_by_name(mock_api_client): 114 | mock_hubspot_company = MockHubspotCompany(id=1234) 115 | mock_api_client.crm.companies.search_api.do_search.return_value = ( 116 | MockHubspotCompanySearch(results=[mock_hubspot_company]) 117 | ) 118 | company = get_company_by_name("Test org") 119 | assert company == {"id": 1234} 120 | 121 | 122 | @mock.patch("hubspot_api.utils.api_client") 123 | def test_get_company_by_name_with_empty_results(mock_api_client): 124 | mock_api_client.crm.companies.search_api.do_search.return_value = ( 125 | MockHubspotCompanySearch() 126 | ) 127 | company = get_company_by_name("Test org1") 128 | assert company == {} 129 | 130 | 131 | @mock.patch("hubspot_api.utils.api_client") 132 | def test_get_company_by_name_raises_company_exception(mock_api_client): 133 | mock_api_client.crm.companies.search_api.do_search.side_effect = CompanyException( 134 | "Something went wrong" 135 | ) 136 | with pytest.raises(CompanyException) as exc: 137 | get_company_by_name("Test org2") 138 | 139 | assert isinstance(exc.value, CompanyException) 140 | assert exc.value.args[0] == "Something went wrong" 141 | 142 | 143 | @mock.patch("hubspot_api.utils.api_client") 144 | def test_get_contact_by_email(mock_api_client): 145 | mock_api_client.crm.contacts.search_api.do_search.return_value = ( 146 | MockHubspotContactSearch( 147 | results=[ 148 | MockHubspotContact( 149 | 1234, 150 | properties={ 151 | "email": "abc@example.com", 152 | "firstname": "test", 153 | "lastname": "user", 154 | }, 155 | ) 156 | ], 157 | ) 158 | ) 159 | contact = get_contact_by_email("abc@example.com") 160 | assert contact == { 161 | "id": 1234, 162 | "email": "abc@example.com", 163 | "firstname": "test", 164 | "lastname": "user", 165 | } 166 | 167 | 168 | @mock.patch("hubspot_api.utils.api_client") 169 | def test_get_contact_by_email_for_no_result(mock_api_client): 170 | mock_api_client.crm.contacts.search_api.do_search.return_value = ( 171 | MockHubspotContactSearch(results=None) 172 | ) 173 | contact = get_contact_by_email("abc1@example.com") 174 | assert contact == {} 175 | 176 | 177 | @mock.patch("hubspot_api.utils.api_client") 178 | def test_get_contact_by_email_raises_contact_exception(mock_api_client): 179 | mock_api_client.crm.contacts.search_api.do_search.side_effect = ContactException( 180 | "Something went wrong" 181 | ) 182 | with pytest.raises(ContactException) as exc: 183 | get_contact_by_email("abc1@example.com") 184 | assert isinstance(exc.value, ContactException) 185 | assert exc.value.args[0] == "Something went wrong" 186 | 187 | 188 | @mock.patch("hubspot_api.utils.api_client") 189 | def test_associate_contact_to_organization(mock_api_client): 190 | mock_api_client.crm.contacts.associations_api.create.return_value = True 191 | contact_association = associate_contact_to_organization( 192 | contact_id=1234, company_id=777 193 | ) 194 | assert contact_association is True 195 | 196 | 197 | @mock.patch("hubspot_api.utils.api_client") 198 | def test_associate_contact_to_organization_raises_company_organization_association_exception( 199 | mock_api_client, 200 | ): 201 | mock_api_client.crm.contacts.associations_api.create.side_effect = ( 202 | ContactAssociationOrganizationException( 203 | "Something went wrong in linking contact to company" 204 | ) 205 | ) 206 | with pytest.raises(ContactAssociationOrganizationException) as exc: 207 | associate_contact_to_organization(contact_id=123, company_id=999) 208 | assert isinstance(exc.value, ContactAssociationOrganizationException) 209 | assert exc.value.args[0] == "Something went wrong in linking contact to company" 210 | -------------------------------------------------------------------------------- /tests/integrations/test_sendgrid.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest import mock 3 | 4 | from email_api.email import send_email 5 | from email_api.email import UnauthorizedException 6 | from email_api.email import BadRequestException 7 | from tests.integrations.common import read_json 8 | 9 | 10 | @mock.patch("email_api.email.SendGridAPIClient.send") 11 | @pytest.mark.asyncio 12 | async def test_send_email(mock_email_client): 13 | mock_email_client.return_value = {"status_code": 202} 14 | response = await send_email(data=read_json("email_data.json")) 15 | assert response["status_code"] == 202 16 | 17 | 18 | @mock.patch("email_api.email.SendGridAPIClient.send") 19 | @pytest.mark.asyncio 20 | async def test_send_email_raises_unauthorized_exception(mock_email_client): 21 | mock_email_client.side_effect = UnauthorizedException("Invalid api key") 22 | with pytest.raises(UnauthorizedException) as exc: 23 | await send_email(data=read_json("email_data.json")) 24 | assert isinstance(exc.value, UnauthorizedException) 25 | assert exc.value.args[0] == "Invalid api key" 26 | 27 | 28 | @mock.patch("email_api.email.SendGridAPIClient.send") 29 | @pytest.mark.asyncio 30 | async def test_send_email_raises_bad_request_exception(mock_email_client): 31 | mock_email_client.side_effect = BadRequestException("Bad request") 32 | with pytest.raises(BadRequestException) as exc: 33 | await send_email(data=read_json("email_data.json")) 34 | assert isinstance(exc.value, BadRequestException) 35 | assert exc.value.args[0] == "Bad request" 36 | -------------------------------------------------------------------------------- /tests/integrations/test_stripe.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/tests/integrations/test_stripe.py -------------------------------------------------------------------------------- /tests/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surajit003/PropertyAppWithFastAPI/69016a550f9e2874230ad3996d75d3dca20c4b08/tests/routes/__init__.py -------------------------------------------------------------------------------- /tests/routes/test_charge.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from unittest import mock 4 | 5 | 6 | @mock.patch("logger.log.logger.debug") 7 | @pytest.mark.asyncio 8 | async def test_create_charge(mock_logger_debug, organization, client): 9 | mock_logger_debug.return_value = "Ok" 10 | response = client.post( 11 | "/api/v1/charges/", 12 | json={ 13 | "currency": "USD", 14 | "charge_type": "MAINTENANCE", 15 | "amount": 200, 16 | "company_name": "Test org", 17 | }, 18 | ) 19 | assert response.status_code == 201 20 | assert response.json()["charge_type"] == "maintenance" 21 | 22 | 23 | @mock.patch("logger.log.logger.debug") 24 | @pytest.mark.asyncio 25 | async def test_create_charge_for_duplicate_charge( 26 | mock_logger_debug, charge_onboarding, client 27 | ): 28 | mock_logger_debug.return_value = "Ok" 29 | response = client.post( 30 | "/api/v1/charges/", 31 | json={ 32 | "currency": "USD", 33 | "charge_type": "ONBOARDING", 34 | "amount": 200, 35 | "company_name": "Test org", 36 | }, 37 | ) 38 | assert response.status_code == 200 39 | -------------------------------------------------------------------------------- /tests/routes/test_company.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from hubspot_api.utils import CompanyException 4 | from models.payment import Organization 5 | 6 | 7 | @mock.patch("routes.v1.company.utils.get_company_by_name") 8 | @mock.patch("logger.log.logger.debug") 9 | def test_company(mock_logger_debug, mock_get_company_by_name, client): 10 | mock_logger_debug.return_value = "Ok" 11 | mock_get_company_by_name.return_value = {"id": 12345} 12 | response = client.get("/api/v1/company/test-company/") 13 | assert response.status_code == 200 14 | assert response.json() == {"id": 12345} 15 | 16 | 17 | @mock.patch("routes.v1.company.utils.get_company_by_name") 18 | @mock.patch("logger.log.logger.debug") 19 | def test_company_for_hubspot_company_exception( 20 | mock_logger_debug, mock_get_company_by_name, client 21 | ): 22 | mock_logger_debug.return_value = "Ok" 23 | mock_get_company_by_name.side_effect = CompanyException( 24 | "Something went wrong in fetching company from Hubspot" 25 | ) 26 | response = client.get("/api/v1/company/test-company/") 27 | assert response.status_code == 200 28 | assert response.json() == { 29 | "detail": "Something went wrong in fetching company from Hubspot" 30 | } 31 | 32 | 33 | @mock.patch("routes.v1.company.utils.create_company") 34 | @mock.patch("logger.log.logger.debug") 35 | def test_create_company(mock_logger_debug, mock_create_company, client): 36 | mock_logger_debug.return_value = "Ok" 37 | mock_create_company.return_value = {"company_id": 1111} 38 | response = client.post( 39 | "/api/v1/companies/", json={"org_id": 123, "name": "Warner Bros co"} 40 | ) 41 | assert response.status_code == 200 42 | assert response.json() == {"company_id": 1111} 43 | 44 | 45 | @mock.patch("routes.v1.company.utils.create_company") 46 | @mock.patch("logger.log.logger.debug") 47 | def test_create_company_with_hubspot_company_exception( 48 | mock_logger_debug, mock_create_company, client, db 49 | ): 50 | mock_logger_debug.return_value = "Ok" 51 | mock_create_company.side_effect = CompanyException( 52 | "Something went wrong in creating company in Hubspot" 53 | ) 54 | response = client.post( 55 | "/api/v1/companies/", json={"org_id": 123, "name": "Warner Bros co"} 56 | ) 57 | company = db.query(Organization).filter_by(name="Warner Bros co").all() 58 | assert response.status_code == 200 59 | assert response.json() == { 60 | "detail": "Something went wrong in creating company in Hubspot" 61 | } 62 | assert company == [] 63 | 64 | 65 | @mock.patch("logger.log.logger.debug") 66 | def test_create_company_with_duplicate_organization_org_id( 67 | mock_logger_debug, client, organization 68 | ): 69 | mock_logger_debug.return_value = "Ok" 70 | response = client.post( 71 | "/api/v1/companies/", json={"org_id": 12345, "name": "UWISO"} 72 | ) 73 | assert response.status_code == 200 74 | assert response.json() == { 75 | "detail": "duplicate key value violates unique constraint " 76 | '"organization_org_id_key"\nDETAIL: Key (org_id)=(' 77 | "12345) already exists.\n" 78 | } 79 | 80 | 81 | @mock.patch("logger.log.logger.debug") 82 | def test_create_company_with_duplicate_organization_name( 83 | mock_logger_debug, client, organization 84 | ): 85 | mock_logger_debug.return_value = "Ok" 86 | response = client.post( 87 | "/api/v1/companies/", json={"org_id": 1232245, "name": "Test org"} 88 | ) 89 | assert response.status_code == 200 90 | assert response.json() == { 91 | "detail": "duplicate key value violates unique constraint " 92 | '"organization_name_key"\nDETAIL: Key (name)=(' 93 | "Test org) already exists.\n" 94 | } 95 | -------------------------------------------------------------------------------- /tests/routes/test_contact.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from hubspot_api.utils import ContactException 4 | from models.contact import Contact 5 | 6 | 7 | @mock.patch("routes.v1.contact.utils.get_contact_by_email") 8 | @mock.patch("logger.log.logger.debug") 9 | def test_get_contact_by_email(mock_logger_debug, mock_get_contact_by_email, client): 10 | mock_logger_debug.return_value = "Ok" 11 | mock_get_contact_by_email.return_value = { 12 | "id": "1234", 13 | "email": "test@example.com", 14 | "firstname": "test", 15 | "lastname": "user", 16 | } 17 | response = client.get("/api/v1/contact/abc@example.com") 18 | assert response.status_code == 200 19 | assert response.json() == { 20 | "id": "1234", 21 | "email": "test@example.com", 22 | "firstname": "test", 23 | "lastname": "user", 24 | } 25 | 26 | 27 | @mock.patch("routes.v1.contact.utils.create_contact") 28 | @mock.patch("routes.v1.contact._contact.create_contact") 29 | @mock.patch("logger.log.logger.debug") 30 | def test_create_contact( 31 | mock_logger_debug, 32 | mock_db_create_contact, 33 | mock_hubspot_create_contact, 34 | organization, 35 | client, 36 | ): 37 | mock_logger_debug.return_value = "Ok" 38 | mock_db_create_contact.return_value = { 39 | "first_name": "raaj", 40 | "last_name": "das", 41 | "email": "raj@example.com", 42 | "phone": "+254720323309", 43 | } 44 | mock_hubspot_create_contact.return_value = {"contact_id": 12356} 45 | response = client.post( 46 | "/api/v1/contacts/", 47 | json={ 48 | "first_name": "raaj", 49 | "last_name": "das", 50 | "email": "test@example.com", 51 | "phone": "+255720323309", 52 | "company_name": "Test org", 53 | }, 54 | ) 55 | assert response.status_code == 200 56 | assert response.json() == {"contact_id": 12356} 57 | 58 | 59 | @mock.patch("logger.log.logger.debug") 60 | def test_create_contact_returns_duplicate_email_error( 61 | mock_logger_debug, organization, contact, client 62 | ): 63 | mock_logger_debug.return_value = "Ok" 64 | response = client.post( 65 | "/api/v1/contacts/", 66 | json={ 67 | "first_name": "raaj", 68 | "last_name": "das", 69 | "email": "testuser@example.com", 70 | "phone": "2547120202003", 71 | "company_name": "Test org", 72 | }, 73 | ) 74 | assert response.status_code == 200 75 | assert response.json() == { 76 | "detail": "duplicate key value violates unique constraint " 77 | '"contact_email_key"\nDETAIL: Key (email)=(' 78 | "testuser@example.com) already exists.\n" 79 | } 80 | 81 | 82 | @mock.patch("routes.v1.contact._contact.delete_contact") 83 | @mock.patch("routes.v1.contact.utils.create_contact") 84 | @mock.patch("logger.log.logger.debug") 85 | def test_create_contact_raises_hubspot_contactexception( 86 | mock_logger_debug, mock_create_contact, mock_delete_contact, organization, client 87 | ): 88 | mock_logger_debug.return_value = "Ok" 89 | mock_create_contact.side_effect = ContactException( 90 | "Email with this contact exists in Hubspot" 91 | ) 92 | mock_delete_contact.return_value = True 93 | response = client.post( 94 | "/api/v1/contacts/", 95 | json={ 96 | "first_name": "test", 97 | "last_name": "user", 98 | "email": "testuser1@example.com", 99 | "phone": "2547120202002", 100 | "company_name": "Test org", 101 | }, 102 | ) 103 | assert response.status_code == 200 104 | assert response.json() == {"detail": "Email with this contact exists in Hubspot"} 105 | 106 | 107 | @mock.patch("routes.v1.contact.utils.create_contact") 108 | @mock.patch("logger.log.logger.debug") 109 | def test_create_contact_rollbacks_db_contact_for_hubspot_exception( 110 | mock_logger_debug, mock_create_contact, db, organization, client 111 | ): 112 | mock_logger_debug.return_value = "Ok" 113 | mock_create_contact.side_effect = ContactException( 114 | "Email with this contact exists " "in Hubspot" 115 | ) 116 | response = client.post( 117 | "/api/v1/contacts/", 118 | json={ 119 | "first_name": "test", 120 | "last_name": "user", 121 | "email": "testuser2@example.com", 122 | "phone": "2547120202002", 123 | "company_name": "Test org", 124 | }, 125 | ) 126 | contact = db.query(Contact).filter_by(email="testuser2@example.com").all() 127 | assert response.status_code == 200 128 | assert response.json() == {"detail": "Email with this contact exists in Hubspot"} 129 | assert contact == [] 130 | -------------------------------------------------------------------------------- /tests/routes/test_email.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import mock 3 | 4 | from email_api.email import UnauthorizedException 5 | from email_api.email import BadRequestException 6 | from models.message import Message 7 | from settings import TEST_DATA_DIR 8 | 9 | 10 | def read_json(filename): 11 | with open(TEST_DATA_DIR.joinpath(filename)) as resp_file: 12 | return json.load(resp_file) 13 | 14 | 15 | class SendGridResponseMock: 16 | def __init__(self, headers, status_code): 17 | self.headers = headers 18 | self.status_code = status_code 19 | 20 | 21 | @mock.patch("routes.v1.email.email.send_email") 22 | @mock.patch("logger.log.logger.debug") 23 | def test_email_send(mock_logger_debug, mock_send_email, client, db): 24 | mock_logger_debug.return_value = "Ok" 25 | sendgrid_response_mock = SendGridResponseMock( 26 | headers={"x-message-id": "xvis10203sn"}, status_code=202 27 | ) 28 | mock_send_email.return_value = sendgrid_response_mock 29 | response = client.post("/api/v1/email/send/", json=read_json("email_data.json")) 30 | message = db.query(Message).filter_by(message_id="xvis10203sn").all()[0] 31 | assert response.status_code == 200 32 | assert message.message_id == "xvis10203sn" 33 | assert message.message_type.value == "EMAIL" 34 | assert message.status_code == "202" 35 | assert message.carrier.value == "SENDGRID" 36 | 37 | 38 | @mock.patch("routes.v1.email.email.send_email") 39 | @mock.patch("logger.log.logger.debug") 40 | def test_email_send_with_sendgrid_unauthorizedexception( 41 | mock_logger_debug, mock_send_email, client 42 | ): 43 | mock_logger_debug.return_value = "Ok" 44 | mock_send_email.side_effect = UnauthorizedException("Not allowed to access the API") 45 | response = client.post("/api/v1/email/send/", json=read_json("email_data.json")) 46 | assert response.status_code == 200 47 | assert response.json() == {"detail": "Not allowed to access the API"} 48 | 49 | 50 | @mock.patch("routes.v1.email.email.send_email") 51 | @mock.patch("logger.log.logger.debug") 52 | def test_email_send_with_sendgrid_bad_request_exception( 53 | mock_logger_debug, mock_send_email, client 54 | ): 55 | mock_logger_debug.return_value = "Ok" 56 | mock_send_email.side_effect = BadRequestException("Bad request") 57 | response = client.post("/api/v1/email/send/", json=read_json("email_data.json")) 58 | assert response.status_code == 200 59 | assert response.json() == {"detail": "Bad request"} 60 | 61 | 62 | @mock.patch("routes.v1.email.email.send_email") 63 | @mock.patch("logger.log.logger.debug") 64 | def test_email_send_with_db_duplicate_message_id( 65 | mock_logger_debug, mock_send_email, client, message 66 | ): 67 | mock_logger_debug.return_value = "Ok" 68 | sendgrid_response_mock = SendGridResponseMock( 69 | headers={"x-message-id": "123657ab"}, status_code=202 70 | ) 71 | mock_send_email.return_value = sendgrid_response_mock 72 | response = client.post("/api/v1/email/send/", json=read_json("email_data.json")) 73 | assert response.status_code == 200 74 | assert response.json() == { 75 | "detail": "duplicate key value violates unique constraint " 76 | '"ix_message_message_id"\nDETAIL: Key (message_id)=(' 77 | "123657ab) already exists.\n" 78 | } 79 | -------------------------------------------------------------------------------- /tests/test_database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import sessionmaker 3 | 4 | from dependencies.dependencies import get_db 5 | from app import app 6 | from settings import DATABASE_DRIVER 7 | from settings import DATABASE_USER 8 | from settings import DATABASE_PASSWORD 9 | from settings import DATABASE_HOST 10 | from settings import TEST_DATABASE_NAME 11 | 12 | SQLALCHEMY_DATABASE_URL = f"{DATABASE_DRIVER}://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}/{TEST_DATABASE_NAME}" 13 | 14 | engine = create_engine(SQLALCHEMY_DATABASE_URL) 15 | TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 16 | 17 | 18 | def override_get_db(): 19 | try: 20 | db = TestingSessionLocal() 21 | yield db 22 | finally: 23 | db.close() 24 | 25 | 26 | app.dependency_overrides[get_db] = override_get_db 27 | --------------------------------------------------------------------------------