Initial Commit

This commit is contained in:
ChristopherDiesch 2025-03-12 20:43:26 -06:00
commit e95bfe9fc4
124 changed files with 5240 additions and 0 deletions

BIN
.coverage Normal file

Binary file not shown.

2
.coveragerc Normal file
View File

@ -0,0 +1,2 @@
[report]
omit="*/venv/*"

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "UI"]
path = UI
url = https://github.com/QuarterHomes/Quarterforms.git

3
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

8
.idea/ApplicantPortal.iml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.13" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ApplicantPortal.iml" filepath="$PROJECT_DIR$/.idea/ApplicantPortal.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

86
app/__init__.py Normal file
View File

@ -0,0 +1,86 @@
from flask import Flask, g, request, flash, render_template
from flask_jwt_extended import current_user
import os
import time
from .auth import blueprint as users_blueprint, jwt
from .routes import blueprint as main_blueprint
from .util.log import set_up_logging
from .api import blueprint as api_blueprint
from .applicant import blueprint as apply_blueprint
from .cors import cors
from app.model import db
from app import config
from flask_mongoengine import MongoEngineSessionInterface
def config_from_environ(app, prefix=None):
if not prefix:
prefix = app.config.get('APP_NAME')
for name, value in os.environ.items():
if name.startswith(prefix):
if isinstance(value, str):
value = value.strip()
if value:
app.config[name[len(prefix) + 1:]] = value
def create_app(app_name=None, config_override={}, config_objs=[], permanent_session_ttl=None, add_ping=True, ping_url='/api/ping'):
if not config_objs:
config_objs = [config]
if not app_name:
app_name = 'QUARTER_WEB'
app_root_path = os.path.abspath(os.path.dirname(__file__))
app = Flask(app_name, template_folder=os.path.join(app_root_path, 'templates'), static_folder=os.path.join(app_root_path, 'static'))
for obj in config_objs:
app.config.from_object(obj)
# configure any environment variables
config_from_environ(app)
for key, value in config_override.items():
app.config[key] = value
# init all the app components
db.init_app(app)
jwt.init_app(app)
cors.init_app(app)
app.session_interface = MongoEngineSessionInterface(db)
app.secret_key = config.SECRET_KEY
app.permanent_session_lifetime = permanent_session_ttl if permanent_session_ttl else app.config.get('PERMANENT_SESSION_TTL')
app.register_blueprint(api_blueprint, url_prefix='/api')
for blueprint in [main_blueprint, users_blueprint, apply_blueprint]:
app.register_blueprint(blueprint)
set_up_logging(app, app_name)
@app.errorhandler(401)
def unauthorized(e):
flash('You are not authorized to access this page, please log in')
return render_template('auth/unauthorized.html')
@app.before_request
def before_request():
g.start = time.time()
@app.after_request
def after_request(response):
request_time = time.time() - g.start
app.logger.info(f'HTTP request completed (method={request.method}, path={request.path}, status_code={response.status_code}, request_time={request_time}s, user={current_user.user_id if current_user else "None"})')
return response
if add_ping:
@app.route(ping_url)
def ping():
return {'ready': True}, 200
return app

0
app/admin/__init__.py Normal file
View File

32
app/admin/api.py Normal file
View File

@ -0,0 +1,32 @@
from flask import jsonify, make_response
from flask_jwt_extended import create_access_token, get_jti
from flask_restx import Resource, Namespace
from flask_restx.reqparse import RequestParser
from datetime import datetime
from app.auth import role_required
from app.auth.request_parsers import user_id_parser
from app import model
namespace = Namespace('admin', description='API endpoint for admin users.', decorators=[role_required([model.Roles.ADMIN])])
delete_access_token_parser = RequestParser()
delete_access_token_parser.add_argument('access_token', type=str, required=True, help='The access token to revoke.')
@namespace.route('/ajax/access_token')
@namespace.doc(params={'user_id': 'The ID of the user to get an access token for'})
class AccessToken(Resource):
@namespace.doc(descrption='Gets an access token for a user')
@namespace.expect(user_id_parser)
def get(self):
args = user_id_parser.parse_args()
user = model.User.objects(user_id=args.user_id).first_or_404()
return make_response(jsonify(access_token=create_access_token(user)), 200)
@namespace.doc('Revokes the given access token.')
@namespace.expect(delete_access_token_parser)
def delete(self):
args = delete_access_token_parser.parse_args()
jti = get_jti(args.access_token)
to_blacklist = model.BlackListedJWT(jti=jti, created_at=datetime.utcnow())
to_blacklist.save()

22
app/api.py Normal file
View File

@ -0,0 +1,22 @@
from flask import Blueprint
from flask_restx import Api
from .auth.api import auth as auth_namespace
from .applicant.api import applicant_namespace
from .network_admin.api import namespace as network_admin_namespace
from .admin.api import namespace as admin_namespace
authorizations = {'Bearer': {'type': 'apiKey', 'in': 'header', 'name': 'Auth-Header'}}
blueprint = Blueprint('api', __name__)
api = Api(
blueprint,
doc='/doc/',
title='Quarter backend API documentation',
description='Welcome to the API documentation for the Quarter backend!',
authorizations=authorizations
)
api.add_namespace(auth_namespace, '/auth')
api.add_namespace(applicant_namespace, '')
api.add_namespace(network_admin_namespace, '/network_admin')
api.add_namespace(admin_namespace, '/admin')

View File

@ -0,0 +1,5 @@
from flask import Blueprint
blueprint = Blueprint('apply', __name__, url_prefix='/apply')
from app.applicant import routes

95
app/applicant/api.py Normal file
View File

@ -0,0 +1,95 @@
import os
import json
from flask import jsonify, request, make_response, current_app
from jsonschema import ValidationError
from flask_restx import Resource, Namespace
from flask_jwt_extended import jwt_required, current_user
from app import model
from app.util import emtpy_string_to_none, dict_to_snake
from app.util.validators import validate_schema
applicant_namespace = Namespace('applicant', description='The API endpoints for applicants', path='/')
@applicant_namespace.route('/ajax/application/<finalize>')
@applicant_namespace.route('/ajax/application')
class SubmitOccupantApplication(Resource):
@jwt_required()
@applicant_namespace.doc(responses={
200: 'The Application was retrieved successfully.',
401: 'The user is not authorized to access the application.'
})
def get(self, finalize='False'):
if not current_user:
return make_response(jsonify({'message': f'No user found'}), 404)
application = model.Application.objects(applicants__match={'email': current_user.email}).first()
user = model.User.objects(user_id=current_user.user_id).first()
# if there is no application, create one
if not application:
if model.Roles.APPLICANT not in current_user.roles:
current_user.roles.append(model.Roles.APPLICANT)
current_user.save()
application = model.Application(user_id=current_user.user_id,
applicants=[model.Applicant(user=user, user_id=current_user.user_id)],
property_info=model.Property(),
deposit_held_by=None)
application.save()
return make_response(jsonify(application.to_camel_case()), 200)
@jwt_required()
@applicant_namespace.doc(responses={
200: 'The application was saved to the user successfully.',
402: 'The application was finalized and the application is no longer allowed to be edited.',
401: 'The user is not authorized to access the application.',
403: 'The given user the co-applicant, and it only able to view the application, not modify it'
},
params={
'finalize': '"true" if the application should be finalized, "false" otherwise.'
})
def post(self, finalize='False'):
data = request.get_data()
if isinstance(data, (str, bytes)):
data = json.loads(data)
data = emtpy_string_to_none(data)
try:
validate_schema(data, os.path.join(os.path.abspath(os.path.dirname(__file__)), 'schemas', 'Application.json'))
current_app.logger.info(f'Data: {data}')
except ValidationError as err:
field = ".".join([f'[{p}]' if isinstance(p, int) else str(p) for p in err.path])
return make_response(jsonify(message='The given request does not follow the correct schema', error=f'Field "{field}": {err.message}'), 400)
# should the application be finalized?
finalize = finalize.lower() == 'true'
application = model.Application.objects(applicants__match={'email': current_user.email}).first()
# if this user is NOT the "main" applicant
if application and not application.user_id == current_user.user_id:
return make_response(jsonify({'message': 'The given application cannot be modified by this user'}), 403)
# if an application exists for the user
if application:
# was that application finalized?
if application.finalized:
return make_response(jsonify({'message': f'The application for the user "{current_user.user_id}" is finalized.'}), 402)
application.applicants = [model.Applicant.from_camel_case(a) for a in data.get('applicants', [])]
prop_info = data.get('propertyInfo')
if prop_info:
application.property_info = model.Property.from_camel_case(prop_info)
kwargs = dict_to_snake(data)
for ignore in application.ignore_from_json() + ['applicants', 'property_info']:
kwargs.pop(ignore, None)
application.update(**kwargs)
else:
application = model.Application.from_camel_case(data)
application.finalized = finalize
application.save()
return make_response(jsonify({'message': 'Occupant application saved successfully'}), 200)

296
app/applicant/model.py Normal file
View File

@ -0,0 +1,296 @@
from datetime import datetime
from dateutil.relativedelta import relativedelta
from app.util.datatypes.enum import BaseEnum
from bson import json_util
from app.auth.model import User, UserIDField
from ..database import db, EmbeddedDocumentBase, DocumentBase
from app.database.fields import QuarterDateField, QuarterYearField
class MarriageStatus(BaseEnum):
SINGLE = 'single'
MARRIED = 'married'
DIVORCED = 'divorced'
WIDOWED = 'windowed'
SEPARATED = 'separated'
class OwnOrRent(BaseEnum):
OWN = 'own'
RENT = 'rent'
class FilingTypes(BaseEnum):
SINGLE = 'single'
JOINT = 'joint'
class Property(EmbeddedDocumentBase):
original_cost = db.FloatField(required=False)
home_price = db.FloatField(required=False)
address1 = db.StringField(required=False)
address2 = db.StringField(required=False)
city = db.StringField(required=False)
state = db.StringField(required=False)
zip = db.StringField(required=False)
number_of_units = db.IntField(required=False)
funding_purpose = db.StringField(required=False, default='purchase')
property_purpose = db.StringField(required=False)
year_acquired = QuarterYearField(required=False)
year_built = QuarterYearField(required=False)
existing_liens = db.EmbeddedDocumentListField('AssetExpense', required=False)
title_holders = db.StringField(required=False)
tax_amount = db.FloatField(required=False)
hoa_amount = db.FloatField(required=False)
downpayment_source = db.StringField(required=False)
property_type = db.StringField(required=False)
class Job(EmbeddedDocumentBase):
employer_info = db.StringField(required=False)
self_employed = db.BooleanField(required=False)
date_from = QuarterDateField(required=False)
date_to = QuarterDateField(required=False, default=datetime.today())
monthly_income = db.FloatField(required=False)
position_title = db.StringField(required=False)
bus_phone = db.StringField(required=False)
naics = db.StringField(required=False)
def _get_date_delta_on_job(self, time_scale='years'):
if self.date_to and self.date_from:
date_from = QuarterDateField.make_datetime(self.date_to)
date_to = QuarterDateField.make_datetime(self.date_from)
return getattr(relativedelta(date_from, date_to), time_scale)
return 0
@classmethod
def ignore_from_json(cls):
result = super().ignore_from_json()
result.append('years_moths_on_job')
return result
@property
def years_on_job(self):
return self._get_date_delta_on_job('years')
@property
def months_on_job(self):
return self._get_date_delta_on_job('months')
@property
def years_months_on_job(self):
years = self.years_on_job
extra_months = self.months_on_job
return f'{years} Year(s), {extra_months} Month(s)'
@classmethod
def additional_fields(cls):
res = super().additional_fields()
res.append('years_months_on_job')
return res
class PrevAddress(EmbeddedDocumentBase):
address1 = db.StringField(required=False)
address2 = db.StringField(required=False)
city = db.StringField(required=False)
state = db.StringField(required=False)
zip = db.StringField(required=False)
own_or_rent = db.EnumField(OwnOrRent, required=False)
years = db.IntField(required=False)
class AssetBase(EmbeddedDocumentBase):
meta = {'allow_inheritance': True}
value = db.FloatField(required=False, default=0)
class AssetExpense(AssetBase):
meta = {'allow_inheritance': True}
description = db.StringField(required=False, default='')
class CheckingSavings(AssetBase):
bank_sl_cu = db.StringField(required=False)
acct_number = db.StringField(required=False)
class LifeInsurance(AssetBase):
face_amount = db.FloatField(required=False, default=None)
class Expense(EmbeddedDocumentBase):
base_emp_inc = db.FloatField(required=False)
over_time = db.FloatField(required=False)
bonuses = db.FloatField(required=False)
commissions = db.FloatField(required=False)
dividends_interest = db.FloatField(required=False)
net_rental_income = db.FloatField(required=False)
other = db.EmbeddedDocumentField('AssetExpense', required=False)
class ScheduleOfRealEstate(EmbeddedDocumentBase):
address1 = db.StringField(required=False)
address2 = db.StringField(required=False)
city = db.StringField(required=False)
state = db.StringField(required=False)
zip = db.StringField(required=False)
status = db.StringField(required=False)
type = db.StringField(required=False)
present_market_value = db.FloatField(required=False)
amount_of_mortgages = db.FloatField(required=False)
gross_rental_income = db.FloatField(required=False)
mortgage_payments = db.FloatField(required=False)
insurance_main_taxes_misc = db.FloatField(required=False)
net_rental_income = db.FloatField(required=False)
class Declarations(EmbeddedDocumentBase):
any_judgements = db.BooleanField(required=False)
declared_bankruptcy = db.BooleanField(required=False)
property_foreclosed = db.BooleanField(required=False)
party_to_lawsuit = db.BooleanField(required=False)
obligated_loan_foreclosure = db.BooleanField(required=False)
delinquent_or_default = db.BooleanField(required=False)
alimony_child_support_maintenance = db.BooleanField(required=False)
down_payment_borrowed = db.BooleanField(required=False)
comaker_or_endorser = db.BooleanField(required=False)
intend_primary_residence = db.BooleanField(Required=False)
class AcknowledgeAgree(EmbeddedDocumentBase):
signature = db.StringField(required=False)
date = QuarterDateField(required=False)
class GovernmentInfo(EmbeddedDocumentBase):
will_not_furnish = db.BooleanField(required=False)
ethnicity = db.BooleanField(required=False)
sex = db.StringField(required=False)
race = db.StringField(required=False)
class GeneralAssets(AssetExpense):
deposit_held_by = db.StringField(required=False, default='')
class Assets(EmbeddedDocumentBase):
assets = db.EmbeddedDocumentListField('GeneralAssets', required=False, default=[GeneralAssets()])
checking_savings = db.EmbeddedDocumentListField('CheckingSavings', required=False, default=[])
stocks_bonds = db.EmbeddedDocumentListField('AssetExpense', required=False, default=[])
life_insurance = db.EmbeddedDocumentListField('LifeInsurance', required=False, default=[])
retirement_funds = db.EmbeddedDocumentListField('AssetExpense', required=False, default=[])
vehicles_owned = db.EmbeddedDocumentListField('AssetExpense', required=False, default=[])
property_owned = db.EmbeddedDocumentListField('ScheduleOfRealEstate', required=False, default=[])
owned_real_estate_value = db.FloatField(required=False, default=None)
vested_interest_retirement_fund = db.FloatField(required=False, default=None)
net_worth_bus_owned = db.FloatField(required=False, default=None)
autos_owned_value = db.FloatField(required=False, default=None)
other_assets = db.StringField(required=False, default='')
class Applicant(EmbeddedDocumentBase):
def __init__(self, *args, **kwargs):
user = None
if 'email' in kwargs:
user = User.objects(email=kwargs['email']).first()
elif 'user_id' in kwargs:
user = User.objects(user_id=kwargs['user_id']).first()
if user:
kwargs['user'] = user
kwargs['user_id'] = user.user_id
user.date_of_birth = kwargs.get('date_of_birth') or user.date_of_birth
user.first_name = kwargs.get('first_name') or user.first_name
user.middle_init_or_name = kwargs.get('middle_init_or_name') or user.middle_init_or_name
user.last_name = kwargs.get('last_name') or user.last_name
user.address1 = kwargs.get('address1') or user.address1
user.address2 = kwargs.get('address2') or user.address2
user.city = kwargs.get('city') or user.city
user.state = kwargs.get('state') or user.state
user.zip = kwargs.get('zip') or user.zip
user.phone_number = kwargs.get('phone_number') or user.phone_number
user.save()
if 'user' in kwargs:
kwargs['email'] = kwargs['user'].email
kwargs['first_name'] = kwargs['user'].first_name
kwargs['middle_init_or_name'] = kwargs['user'].middle_init_or_name
kwargs['last_name'] = kwargs['user'].last_name
kwargs['address1'] = kwargs['user'].address1
kwargs['address2'] = kwargs['user'].address2
kwargs['city'] = kwargs['user'].city
kwargs['state'] = kwargs['user'].state
kwargs['zip'] = kwargs['user'].zip
kwargs['phone_number'] = kwargs['user'].phone_number
kwargs['date_of_birth'] = kwargs['user'].date_of_birth
kwargs.pop('user')
super().__init__(*args, **kwargs)
user = db.ReferenceField('User', required=False)
user_id = UserIDField(required=False)
# these properties will be loaded from the user if there is one, if there isn't one, they will be saved here
email = db.StringField(required=False)
first_name = db.StringField(required=False)
middle_init_or_name = db.StringField(required=False, db_field='middle_initial')
last_name = db.StringField(required=False)
address1 = db.StringField(required=False)
address2 = db.StringField(required=False)
city = db.StringField(required=False)
state = db.StringField(required=False)
zip = db.StringField(required=False)
date_of_birth = QuarterDateField(required=False)
phone_number = db.StringField(required=False, db_field='home_phone')
# the applicant specific fields
role = db.StringField(required=False)
pin = db.StringField(required=False)
social_security_number = db.StringField(required=False)
years_school = db.IntField(required=False)
degree_earned = db.StringField(required=False)
school_name = db.StringField(required=False)
marriage_status = db.EnumField(MarriageStatus, required=False, default=MarriageStatus.SINGLE)
number_dependents = db.IntField(required=False, default=0)
ages_dependents = db.ListField(db.IntField(required=False))
previous_jobs = db.EmbeddedDocumentListField('Job', required=False)
previous_addresses = db.EmbeddedDocumentListField('PrevAddress', required=False)
expense_info = db.EmbeddedDocumentField('Expense', required=False)
other_incomes = db.EmbeddedDocumentListField('AssetExpense', required=False)
assets = db.EmbeddedDocumentField('Assets', required=False, default=Assets())
declarations = db.EmbeddedDocumentField('Declarations', required=False)
liabilities = db.EmbeddedDocumentListField('AssetExpense', required=False)
acknowledge_and_agree = db.EmbeddedDocumentField('AcknowledgeAgree', required=False)
auth_borrower = db.EmbeddedDocumentField('AcknowledgeAgree', required=False)
auth_fcra = db.EmbeddedDocumentField('AcknowledgeAgree', required=False)
government_info = db.EmbeddedDocumentField('GovernmentInfo')
@classmethod
def ignore_to_json(cls):
result = super().ignore_to_json()
result.append('user')
result.append('user_id')
return result
@classmethod
def ignore_from_json(cls):
result = super().ignore_from_json()
result.append('user_name')
return result
class Application(DocumentBase):
filing_type = db.EnumField(FilingTypes, required=False, default=FilingTypes.SINGLE)
user_id = UserIDField(required=True)
applicants = db.EmbeddedDocumentListField('Applicant', required=True)
deposit_held_by = db.StringField(required=False)
property_info = db.EmbeddedDocumentField('Property', required=False)
finalized = db.BooleanField(required=True, default=False)
def to_json(self, *args, **kwargs):
use_db_field = kwargs.pop('use_db_field', True)
result = super().to_mongo(use_db_field=use_db_field)
for applicant in result.get('applicants', [{}]):
applicant.pop('user', None)
return json_util.dumps(result, *args, **kwargs)

9
app/applicant/routes.py Normal file
View File

@ -0,0 +1,9 @@
import os
from flask import render_template, current_app
from . import blueprint
@blueprint.route('/home')
def apply_home():
rendered = render_template('apply/index.html')
return rendered

View File

@ -0,0 +1,69 @@
{
"$defs": {
"PropertyBase": {
"type": "object",
"properties": {
"address1": {
"type": "string"
},
"address2": {
"type": "string"
},
"city": {
"type": "string"
},
"state": {
"type": "string"
},
"zip": {
"type": "string"
}
}
},
"PreviousAddress": {
"type": "object",
"allOf": [{"$ref": "#/$defs/PropertyBase"}],
"properties": {
"ownOrRent": {
"type": "string",
"enum": ["own", "rent"]
},
"years": {
"type": "number"
}
}
},
"ScheduleOfRealEstate": {
"title": "ScheduleOfRealEstate",
"type": "object",
"allOf": [{"$ref": "#/$defs/PropertyBase"}],
"properties": {
"status": {
"type": "string"
},
"type": {
"type": "string"
},
"presentMarketValue": {
"type": "number"
},
"amountOfMortgages": {
"type": "number"
},
"grossRentalIncome": {
"type": "number"
},
"mortgagePayments": {
"type": "number"
},
"insuranceMainTaxesMisc": {
"type": "number"
},
"netRentalIncome": {
"type": "number"
}
}
}
},
"$schema": "https://json-schema.org/draft-07/schema"
}

View File

@ -0,0 +1,200 @@
{
"$defs": {
"AcknowledgeAgree": {
"title": "AcknowledgeAgree",
"type": "object",
"properties": {
"signature": {
"type": "string"
},
"date": {
"type": "string"
}
}
},
"GovernmentInfo": {
"title": "GovernmentInfo",
"type": "object",
"properties": {
"willNotFurnish": {
"type": "boolean"
},
"ethnicity": {
"type": "boolean"
},
"sex": {
"type": "string"
},
"race": {
"type": "string"
}
}
},
"Assets": {
"title": "Assets",
"type": "object",
"properties": {
"assets": {
"type": "array",
"items": {
"allOf": [{
"$ref": "Assets.json#/$defs/AssetExpense"
}],
"type": "object",
"properties": {
"depositHeldBy": {
"type": "string"
}
}
}
},
"checkingSavings": {
"type": "array",
"items": {
"$ref": "Assets.json#/$defs/CheckingSavings"
}
},
"stocksBonds": {
"type": "array",
"items": {
"$ref": "Assets.json#/$defs/AssetExpense"
}
},
"lifeInsurance": {
"type": "array",
"items": {
"$ref": "Assets.json#/$defs/LifeInsurance"
}
},
"retirementFunds": {
"type": "array",
"items": {
"$ref": "Assets.json#/$defs/AssetExpense"
}
},
"vehiclesOwned": {
"type": "array",
"items": {
"$ref": "Assets.json#/$defs/AssetExpense"
}
},
"propertyOwned": {
"type": "array",
"items": {
"$ref": "Address.json#/$defs/ScheduleOfRealEstate"
}
},
"liabilities": {
"type": "array",
"items": {
"$ref": "Assets.json#/$defs/AssetExpense"
}
}
}
}
},
"$schema": "https://json-schema.org/draft-07/schema",
"Applicant": {
"title": "Applicant",
"type": "object",
"required": [
"email"
],
"properties": {
"pin": {
"type": "string"
},
"firstName": {
"type": "string"
},
"middleInitial": {
"type": "string"
},
"email": {
"type": "string"
},
"lastName": {
"type": "string"
},
"socialSecurityNumber": {
"type": "string"
},
"currentAddress": {
"type": "string"
},
"homePhone": {
"type": "string"
},
"dateOfBirth": {
"type": "string"
},
"yearsSchool": {
"type": "integer"
},
"degreeEarned": {
"type": "string"
},
"schoolName": {
"type": "string"
},
"marriageStatus": {
"type": "string",
"enum": [
"single",
"married",
"divorced",
"widowed",
"separated"
]
},
"numberDependants": {
"type": "integer"
},
"agesDependants": {
"type": "array",
"items": {
"type": "integer"
}
},
"previousJobs": {
"type": "array",
"items": {
"$ref": "Job.json"
}
},
"previousAddresses": {
"type": "array",
"items": {
"$ref": "Address.json#/$defs/PreviousAddress"
}
},
"expenseInfo": {
"$ref": "Expense.json"
},
"otherIncomes": {
"type": "array",
"items": {
"$ref": "Assets.json#/$defs/AssetExpense"
}
},
"assets": {
"$ref": "#/$defs/Assets"
},
"declarations": {
"$ref": "Declarations.json"
},
"acknowledgeAndAgree": {
"$ref": "#/$defs/AcknowledgeAgree"
},
"authBorrower": {
"$ref": "#/$defs/AcknowledgeAgree"
},
"AuthFcra": {
"$ref": "#/$defs/AcknowledgeAgree"
},
"governmentInfo": {
"$ref": "#/$defs/GovernmentInfo"
}
}
}
}

View File

@ -0,0 +1,91 @@
{
"$defs": {
"PropertyInfo": {
"title": "PropertyInfo",
"type": "object",
"properties": {
"homePrice": {
"type": "number"
},
"address1": {
"type": "string"
},
"address2": {
"type": "string"
},
"city": {
"type": "string"
},
"state": {
"type": "string"
},
"zip": {
"type": "string"
},
"numberOfUnits": {
"type": "integer"
},
"fundingPurpose": {
"type": "string"
},
"propertyPurpose": {
"type": "string"
},
"yearAcquired": {
"type": "string"
},
"yearBuilt": {
"type": "string"
},
"originalCost": {
"type": "number"
},
"existingLiens": {
"type": "array",
"items": {
"$ref": "Assets.json#/$defs/AssetExpense"
}
},
"titleHolders": {
"type": "string"
},
"taxAmount": {
"type": "number"
},
"hoaAmount": {
"type": "number"
},
"downPaymentSource": {
"type": "string"
}
}
}
},
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Application",
"type": "object",
"properties": {
"userID": {
"type": "string"
},
"applicants": {
"type": "array",
"items": {
"$ref": "Applicant.json#Applicant"
}
},
"filingType": {
"type": "string",
"enum": [
"single",
"joint"
]
},
"propertyInfo": {
"$ref": "#/$defs/PropertyInfo"
},
"depositHeldBy": {
"type": "string"
}
}
}

View File

@ -0,0 +1,44 @@
{
"$defs": {
"AssetExpenseBase": {
"type": "object",
"properties": {
"value": {
"type": "number"
}
}
},
"AssetExpense": {
"title": "AssetExpense",
"type": "object",
"allOf": [{"$ref": "#/$defs/AssetExpenseBase"}],
"properties": {
"description": {
"type": "string"
}
}
},
"CheckingSavings": {
"type": "object",
"allOf": [{"$ref": "#/$defs/AssetExpenseBase"}],
"properties": {
"bankSlCu": {
"type": "string"
},
"acctNumber": {
"type": "string"
}
}
},
"LifeInsurance": {
"type": "object",
"allOf": [{"$ref": "#/$defs/AssetExpenseBase"}],
"properties": {
"faceAmount": {
"type": "number"
}
}
}
},
"$schema": "https://json-schema.org/draft-07/schema"
}

View File

@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Declarations",
"type": "object",
"properties": {
"anyJudgements": {
"type": "boolean"
},
"declaredBankruptcy": {
"type": "boolean"
},
"propertyForeclosed": {
"type": "boolean"
},
"partyToLawSuit": {
"type": "boolean"
},
"obligatedLoanForeclosure": {
"type": "boolean"
},
"delinquentOrDefault": {
"type": "boolean"
},
"alimonyChildSupportMaintenance": {
"type": "boolean"
},
"downPaymentBorrowed": {
"type": "boolean"
},
"comakerOrEndorser": {
"type": "boolean"
},
"intendedPrimaryResidence": {
"type": "boolean"
}
}
}

View File

@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Expense",
"type": "object",
"properties": {
"baseEmpInc": {
"type": "number"
},
"overTime": {
"type": "number"
},
"bonuses": {
"type": "number"
},
"commissions": {
"type": "number"
},
"dividendsInterest": {
"type": "number"
},
"netRentalIncome": {
"type": "number"
},
"other": {
"$ref": "Assets.json#/$defs/AssetExpense"
}
}
}

View File

@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Job",
"type": "object",
"properties": {
"employerInfo": {
"type": "string"
},
"selfEmployed": {
"type": "boolean"
},
"yearsOnJob": {
"type": "integer"
},
"dateFrom": {
"type": "string"
},
"dateTo": {
"type": "string"
},
"monthlyIncome": {
"type": "number"
},
"positionTitle": {
"type": "string"
},
"busPhone": {
"type": "string"
}
}
}

80
app/auth/__init__.py Normal file
View File

@ -0,0 +1,80 @@
from flask import Blueprint, jsonify, current_app, make_response
from flask_restx import abort
from flask_jwt_extended import JWTManager, verify_jwt_in_request, get_jwt
from functools import wraps
from app_common.const import USER_NOT_ACTIVE_MESSAGE, USER_NOT_ACTIVE_STATUS_CODE
from .model import Roles, BlackListedJWT
jwt = JWTManager()
blueprint = Blueprint('auth', __name__)
from app.auth import routes
from app.auth.model import User, BlackListedJWT
NO_USER_FOUND_MESSAGE = 'No user found'
@jwt.user_identity_loader
def _user_identity_lookup(user):
return user.user_id
@jwt.additional_claims_loader
def add_claims_to_token(identity):
return {'roles': ','.join([str(r) for r in identity.roles])}
@jwt.user_lookup_loader
def _user_lookup_callback(_jwt_header, jwt_data):
user_id = jwt_data['sub']
return User.objects(user_id=user_id, active=True).first()
@jwt.user_lookup_error_loader
def _user_lookup_error_callback(_jwt_header, jwt_data):
user_id = jwt_data['sub']
user = User.objects(user_id=user_id).first()
if not user:
return abort(404, NO_USER_FOUND_MESSAGE)
elif not user.active:
return abort(USER_NOT_ACTIVE_STATUS_CODE, USER_NOT_ACTIVE_MESSAGE)
else:
return make_response(jsonify(message='An error occurred trying to load the given user.', user_id=user_id), 400)
@jwt.token_in_blocklist_loader
def _is_token_revoked(_jwt_header, jwt_payload):
return BlackListedJWT.is_blacklisted(jwt_payload['jti'])
@jwt.expired_token_loader
def _expired_token_callback(jwt_header, jwt_payload):
return jsonify(message='The given JWT token has expired'), 405
def role_required(roles):
"""A wrapper for an endpoint that will require the calling user has any of the given roles (implies `jwt_required()`).
Arguments:
roles (`list(Roles)`): The allowed role for this endpoint.
"""
def wrapper(func):
@wraps(func)
def decorator(*args, **kwargs):
for role in roles:
if role not in Roles:
msg = f'The given role is not a valid (role="{role}", valid_roles="{Roles.values()}").'
current_app.logger.warn(msg)
return abort(401, msg)
verify_jwt_in_request()
claims = get_jwt()
user_has_role = any([role in roles for role in claims['roles'].split(',')])
if user_has_role:
return func(*args, **kwargs)
return abort(401, f'The endpoint requires a user with the following roles "{roles}".')
return decorator
return wrapper

226
app/auth/api.py Normal file
View File

@ -0,0 +1,226 @@
from datetime import datetime
from flask import jsonify, current_app, make_response, request
from flask_restx import Resource, Namespace, abort
from mongoengine.errors import NotUniqueError, ValidationError
from flask_jwt_extended import create_access_token, jwt_required, get_jwt, create_refresh_token, current_user
from app_common.const import NOT_UNIQUE, MALFORMED_DATA, USER_NOT_ACTIVE_MESSAGE, USER_NOT_ACTIVE_STATUS_CODE
from .model import User, BlackListedJWT, Roles, UserPrefs
from .email import send_password_reset_email, send_activate_account_email, send_forgot_user_id_email
from .request_parsers import post_user_login_schema, create_user_parser, reset_password_parser, update_password_parser, request_password_reset_parser, activate_account_parser, \
resend_activation_email
from . import role_required
auth = Namespace('auth', description='The API endpoints for user authentication.')
def _get_auth_payload(user, include_refresh_token=True):
kwargs = user.to_dict()
kwargs['access_token'] = create_access_token(user)
if include_refresh_token:
kwargs['refresh_token'] = create_refresh_token(user)
return jsonify(**kwargs)
@auth.route('/ajax/register')
class Register(Resource):
@auth.expect(create_user_parser)
@jwt_required(optional=True)
def post(self):
current_app.logger.info(f'\n\nRequest Headers: {request.headers}\nType:{type(request.headers)}\n')
if current_user:
return abort(401, f'Already logged in as {current_user.user_id}')
redirect_url = ''
if request.headers.get('Referer'):
redirect_url = request.headers.get('Referer')
current_app.logger.info(f'Redirect URL: {redirect_url}')
redirect_url = f'{redirect_url}#home?activated=true'
current_app.logger.info(f'Redirect URL: {redirect_url}')
args = create_user_parser.parse_args()
try:
user = User(user_id=args.user_id,
email=args.email,
first_name=args.first_name,
middle_init_or_name=args.middle_init_or_name,
last_name=args.last_name,
last_login=datetime.utcnow(),
join_date=datetime.utcnow(),
date_of_birth=args.date_of_birth,
phone_number=args.phone_number,
roles=[Roles.APPLICANT])
user.set_password(args.password)
# save the new user
user.save()
except NotUniqueError:
bad_fields = []
unique_fields = ['user_id', 'email']
for field in unique_fields:
if User.objects(**{field: args.get(field)}).first():
bad_fields.append(field)
return abort(400, 'A user already exists with the following fields', fields=bad_fields, error_type=NOT_UNIQUE)
except ValidationError as ex:
return abort(400, 'Invalid data given', error=str(ex), error_type=MALFORMED_DATA)
# send the account activation email
token = user.get_activation_token(expire_secs=current_app.config['CONFIRM_EMAIL_EXP_SEC'], secret_key=current_app.config['SECRET_KEY'], redirect_url=redirect_url)
send_activate_account_email(user, token)
return {'message': 'Account activation email sent'}, 200
@auth.route('/ajax/activate')
@auth.doc(description='Activates a user from a JWT token')
@auth.response(401, 'The activation token has expired')
@auth.response(402, 'The user has already activated their account')
class ActivateUser(Resource):
@auth.expect(activate_account_parser)
def post(self):
args = activate_account_parser.parse_args()
user, _ = User.from_activation_token(args.token, current_app.config['SECRET_KEY'])
if not user:
return {'message': 'The account activation token has expired.'}, 401
if user.active:
return {'message': 'The user has already activated their account.'}, 402
user.active = True
user.save()
@auth.route('/ajax/send_confirm_email')
@auth.response(404, 'No user with the given email address was found.')
@auth.doc(description='Sends the account activation email to a user.')
class SendAccountActivation(Resource):
@auth.expect(resend_activation_email)
def post(self):
args = resend_activation_email.parse_args()
user = User.objects(email=args.email).first_or_404(message=f'No user with email {args.email} found')
token = user.get_activation_token(expire_secs=current_app.config['CONFIRM_EMAIL_EXP_SEC'], secret_key=current_app.config['SECRET_KEY'])
send_activate_account_email(user, token)
@auth.route('/ajax/login')
class AjaxLogin(Resource):
@auth.expect(post_user_login_schema)
@jwt_required(optional=True)
def post(self):
if current_user:
return abort(401, f'A user is already logged in {current_user.user_id}')
args = post_user_login_schema.parse_args()
user = User.objects(user_id=args.user_id).first_or_404(message=f'No user "{args.user_id}" exists.')
if not user.active:
return make_response(jsonify(message=USER_NOT_ACTIVE_MESSAGE), USER_NOT_ACTIVE_STATUS_CODE)
if user.check_password(args.password):
user.last_login = datetime.utcnow()
user.save()
return _get_auth_payload(user, include_refresh_token=args.remember)
return abort(401, 'Incorrect Password')
@auth.route('/ajax/refresh_access_token')
class RefreshAccess(Resource):
@jwt_required(refresh=True)
def get(self):
return _get_auth_payload(current_user, include_refresh_token=False)
@auth.route('/ajax/test_access_token')
class TestAccessToken(Resource):
@jwt_required()
def post(self):
return {'user_id': current_user.user_id}, 200
@auth.route('/ajax/logout')
class AjaxLogout(Resource):
@jwt_required()
@role_required([Roles.APPLICANT, Roles.OCCUPANT])
def post(self):
to_blacklist = BlackListedJWT(jti=get_jwt()['jti'], created_at=datetime.utcnow())
to_blacklist.save()
return jsonify(message='Access token revoked')
@auth.route('/ajax/update_password')
class UpdatePassword(Resource):
@jwt_required()
@auth.expect(update_password_parser)
def post(self):
args = update_password_parser.parse_args()
if not current_user.check_password(args.old_password):
return abort(401, 'The password is incorrect')
current_user.set_password(args.new_password)
current_user.save()
@auth.route('/ajax/request_password_reset')
@auth.response(404, 'No user was found')
class RequestPasswordReset(Resource):
@auth.expect(request_password_reset_parser)
def post(self):
args = request_password_reset_parser.parse_args()
# were we given an email?
if args.email:
user = User.objects(email=args.email).first_or_404()
# were we given a user id?
elif args.user_id:
user = User.objects(user_id=args.user_id).first_or_404()
# were we not given either?
else:
return abort(400, 'Either a user ID or email MUST be provided.', error_type=MALFORMED_DATA)
token = user.get_password_reset_token(expire_secs=current_app.config['PASSWORD_RESET_EXP_SEC'], secret_key=current_app.config['SECRET_KEY'])
send_password_reset_email(user, token)
return {'message': 'Password reset email sent successfully'}, 200
@auth.route('/ajax/reset_password')
class ResetPassword(Resource):
@auth.expect(reset_password_parser)
def post(self):
args = reset_password_parser.parse_args()
user = User.from_password_reset_token(args.token, secret_key=current_app.config['SECRET_KEY'])
# if the user is none, the password reset token has expired
if not user:
return abort(401, 'The password reset token has expired')
if user.check_password(args.new_password):
token = user.get_password_reset_token(secret_key=current_app.config['SECRET_KEY'], expire_secs=current_app.config['PASSWORD_RESET_EXP_SEC'])
return {'password_reset_token': token, 'error_type': MALFORMED_DATA}, 400
user.set_password(args.new_password)
user.save()
return jsonify()
@auth.route('/ajax/forgot_user_id')
class ForgotUsername(Resource):
@auth.expect(resend_activation_email)
def post(self):
args = resend_activation_email.parse_args()
user = User.objects(email=args.email).first_or_404()
send_forgot_user_id_email(user)
@auth.route('/ajax/user_prefs/<user_id>')
class GetUserPrefs(Resource):
def get(self, user_id):
user = User.objects(user_id=user_id).first_or_404()
return jsonify(user.prefs)
@auth.route('/ajax/user_prefs')
class PostUserPrefs(Resource):
@jwt_required()
def post(self):
current_user.prefs = UserPrefs(**request.args)
current_user.save()

27
app/auth/email.py Normal file
View File

@ -0,0 +1,27 @@
from flask import render_template, current_app
from app.email import send_email
def send_email_to_user(user, subject, txt_body, html_body):
send_email(subject, sender=current_app.config['MAIL_USERNAME'], recipient=user.email, txt_body=txt_body, html_body=html_body)
def send_password_reset_email(user, token):
send_email_to_user(user,
'Quarter Password Reset',
txt_body=render_template('email/reset_password.txt', user=user, token=token, exp_mins=int(current_app.config['PASSWORD_RESET_EXP_SEC'] / 60)),
html_body=render_template('email/reset_password.html', user=user, token=token, exp_mins=int(current_app.config['PASSWORD_RESET_EXP_SEC'] / 60)))
def send_activate_account_email(user, token):
send_email_to_user(user,
'Confirm Quarter Email',
txt_body=render_template('email/confirm_email.txt', user=user, token=token, exp_mins=int(current_app.config['CONFIRM_EMAIL_EXP_SEC'] / 60)),
html_body=render_template('email/confirm_email.html', user=user, token=token, exp_mins=int(current_app.config['CONFIRM_EMAIL_EXP_SEC'] / 60)))
def send_forgot_user_id_email(user):
send_email_to_user(user,
'Forgot Quarter Username',
txt_body=render_template('email/forgot_user_id.txt', user=user),
html_body=render_template('email/forgot_user_id.html', user=user))

93
app/auth/forms.py Normal file
View File

@ -0,0 +1,93 @@
from wtforms.form import Form
from wtforms.fields import StringField, PasswordField, BooleanField, DateField
from wtforms import validators as wtf_validators
from wtforms import ValidationError
from datetime import datetime
from .model import User
from app.util import validators
class LoginForm(Form):
user_id = StringField('Username', [wtf_validators.required()])
password = PasswordField('Password', [wtf_validators.required(), wtf_validators.length(min=8, message='Password MUST be at least 8 characters long.')])
remember_me = BooleanField('Remember Me?')
@staticmethod
def validate_user_id(form, field):
user = User.objects(user_id=field.data).first()
if not user:
raise ValidationError('The given user does not exist')
@staticmethod
def validate_password(form, field):
user = User.objects(user_id=form.user_id.data).first()
if user:
if not user.check_password(field.data):
raise ValidationError('The given password is incorrect')
class ResetPasswordForm(Form):
password = PasswordField('Password', [wtf_validators.required()])
confirm_password = PasswordField('Confirm Password', [wtf_validators.required(), wtf_validators.equal_to('password', message='Passwords are not equal.')])
class RequestPasswordResetForm(Form):
email = StringField('Email', [wtf_validators.email()])
user_id = StringField('Username')
@staticmethod
def validate_email(form, field):
if not field.data:
if not form.user_id.data:
raise ValidationError('Either a username or email MUST be provided.')
else:
user = User.objects(email=field.data).first()
if not user:
raise ValidationError('The given email is not associated with any account.')
@staticmethod
def validate_user_id(form, field):
if not field.data:
if not form.email.data:
raise ValidationError('Either a username or email MUST be provided.')
else:
user = User.objects(user_id=field.data).first()
if not user:
raise ValidationError('There is no account with the given username')
class RegisterForm(Form):
user_id = StringField('Username', [wtf_validators.required()])
email = StringField('Email', [wtf_validators.email(), wtf_validators.required()])
confirm_email = StringField('Confirm Email', [wtf_validators.email(), wtf_validators.equal_to('email')])
phone_number = StringField('Phone Number', [wtf_validators.required(), validators.phone_number()])
password = PasswordField('Password', [wtf_validators.required(), wtf_validators.length(min=8, message='Password MUST be at least 8 characters long.')])
confirm_pass = PasswordField('Confirm Password', [wtf_validators.required(), wtf_validators.equal_to('password', message='Passwords are not equal.')])
first_name = StringField('First Name', [wtf_validators.required()])
last_name = StringField('Last Name', [wtf_validators.required()])
date_of_birth = StringField('Date of Birth', [validators.date_of_birth()])
is_applicant = BooleanField('Applying to be a homeowner?'),
@staticmethod
def validate_user_id(form, field):
user = User.objects(user_id=field.data).first()
if user:
raise ValidationError('The given username is taken, please pick a new one')
@staticmethod
def validate_email(form, field):
user = User.objects(email=field.data).first()
if user:
raise ValidationError('The given email is already in use. Please choose a different one.')
@property
def get_date_of_birth(self):
return datetime.strptime(self.date_of_birth.raw_data[0], '%b %d, %Y')
class SendConfirmEmailFrom(Form):
"""A form for sending a confirmation email to activate a user account."""
email = StringField('Email', [wtf_validators.required(), wtf_validators.email()])
"""The email address for the user account to activate."""

180
app/auth/model.py Normal file
View File

@ -0,0 +1,180 @@
import jwt
from werkzeug.security import generate_password_hash, check_password_hash
from time import time
from datetime import datetime
from uuid import uuid4
from mongoengine.fields import StringField
from app.database import db, DocumentBase, EmbeddedDocumentBase
from app.database.fields import QuarterDateField
from app.util.datatypes import enum
from app_common.const import USER_ID_REGEX
class UserIDField(StringField):
def validate(self, value):
value = value.lower()
if not USER_ID_REGEX.fullmatch(value):
self.error(f'The given user ID "{value}" is invalid, can only contain alphanumeric characters and "_, -".')
if value in Roles:
self.error(f'A user ID cannot be a user role (invalid values: {Roles})')
class Roles(enum.BaseEnum):
APPLICANT = 'applicant'
OCCUPANT = 'occupant'
INVESTOR = 'investor'
IMPACT_INVESTOR = 'impact_investor'
NETWORK_ADMIN = 'network_admin'
ADMIN = 'admin'
class BlackListedJWT(DocumentBase):
"""A mongo document to represent expired JWT access tokens."""
jti = db.StringField(required=True)
created_at = db.DateTimeField(required=True)
@staticmethod
def blacklist_jwt(jti):
to_blacklist = BlackListedJWT(jti=jti, created_at=datetime.utcnow())
to_blacklist.save()
@staticmethod
def is_blacklisted(jti):
return BlackListedJWT.objects(jti=jti).first() is not None
class UserPrefs(EmbeddedDocumentBase):
data = db.DictField(required=False)
class User(DocumentBase):
"""A mongo document to represent users."""
user_id = UserIDField(required=True, unique=True)
"""The user's ID (unique, required)"""
email = db.EmailField(required=True, unique=True)
"""The user's email (unique, required)."""
password_hash = db.StringField(required=False)
"""The hashed password for the user."""
first_name = db.StringField(required=True)
"""The user's first name (required)"""
middle_init_or_name = db.StringField(required=False, default='')
"""The middle initial or middle name for the user."""
last_name = db.StringField(required=True)
"""The user's last name (required)."""
last_login = db.DateTimeField(required=False)
"""The last time the user logged in."""
address1 = db.StringField(required=False)
address2 = db.StringField(required=False)
city = db.StringField(required=False)
state = db.StringField(required=False)
zip = db.StringField(required=False)
"""The user's address."""
join_date = QuarterDateField(required=True)
"""THe date the user joined (required)."""
date_of_birth = QuarterDateField(required=False)
"""The user's date of birth."""
phone_number = db.StringField(requied=False, sparse=True)
"""The user's phone number (unique but not required)."""
roles = db.ListField(db.EnumField(Roles), default=[Roles.APPLICANT])
"""The roles the user has."""
active = db.BooleanField(required=True, default=False)
"""Weather or not the user account is active"""
prefs = db.EmbeddedDocumentField('UserPrefs')
misc_data = db.DictField(required=False)
@classmethod
def ignore_to_json(cls):
result = super().ignore_to_json()
result.append('password_hash')
result.append('active')
return result
@classmethod
def from_request_args(cls, **kwargs):
password = kwargs.pop('password')
kwargs['join_date'] = datetime.utcnow().date()
if isinstance(kwargs.get('roles'), str):
kwargs['roles'] = kwargs['roles'].split(',')
user = User(**kwargs)
user.set_password(password)
user.save()
def set_password(self, password):
"""Hashes and stores the password for the user.
Arguments:
password (`str`): The password to store the hash of for the user.
"""
self.password_hash = generate_password_hash(password, salt_length=15)
def check_password(self, password):
"""Checks if the password is correct for the user.
Arguments:
password (`str`): The password to check against the user's stored password hash.
Returns:
`bool`: `True` if the password is correct, `False` otherwise.
"""
return check_password_hash(self.password_hash, password)
def get_password_reset_token(self, expire_secs=600, secret_key=None):
"""Generates a JWT token allowing the user to reset their password that will expire after the given time.
Arguments:
expire_secs (`int`): The number of seconds the password reset token should be valid for.
secret_key (`str`): The secret key to use when generating the JWT token.
Returns:
`str`: A JWT token which allows the user to reset their password.
"""
payload = {
'exp': time() + expire_secs,
'reset_password': self.user_id,
'jti': str(uuid4())
}
return jwt.encode(payload, secret_key, algorithm='HS256')
def get_activation_token(self, expire_secs=1200, secret_key=None, redirect_url=''):
payload = {
'exp': time() + expire_secs,
'activate': self.user_id,
'redirect_url': redirect_url
}
return jwt.encode(payload, secret_key, algorithm='HS256')
@staticmethod
def from_activation_token(token, secret_key=None):
try:
jwt_data = jwt.decode(token, secret_key, algorithms='HS256')
user_id = jwt_data['activate']
redirect_url = jwt_data['redirect_url']
except jwt.ExpiredSignatureError:
return None
return User.objects(user_id=user_id).first(), redirect_url
@staticmethod
def from_password_reset_token(token, secret_key=None):
"""Gets a `User` from the given JWT token that allows them to reset their password.
Arguments:
token (`str`): The encoded JWT token to load the user from.
secret_key (`str`): The secret key used to generate the JWT token.
Returns:
`User|None`: The user object encoded if the token is still valid, `None` if the token has expired.
"""
try:
jwt_data = jwt.decode(token, secret_key, algorithms=['HS256'])
# Has this token been blacklisted?
if BlackListedJWT.is_blacklisted(jwt_data['jti']):
return None
# get the user id
user_id = jwt_data['reset_password']
# black list this token after it's been used
BlackListedJWT.blacklist_jwt(jwt_data['jti'])
except jwt.ExpiredSignatureError:
return None
# get the user
return User.objects(user_id=user_id).first()

1
app/auth/oauth.py Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,76 @@
from flask_restx import inputs
from .model import Roles
from app_common.parser import QuarterRequestParser as RequestParser
from app_common.inputs import quarter_date_time, email, user_id
def roles(value):
if isinstance(value, str):
value = value.split(',')
def invalid_role_filter(v):
return v not in Roles
if isinstance(value, list):
invalid = list(filter(invalid_role_filter, value))
if invalid:
raise ValueError(f'The given role(s) are invalid: {invalid}')
return value
post_user_login_schema = RequestParser()
post_user_login_schema.add_argument('user_id', required=True, type=user_id(), help='The ID of the user to log in.')
post_user_login_schema.add_argument('password', required=True, type=str, help='The password to use when logging the user in.')
post_user_login_schema.add_argument('remember', required=False, type=inputs.boolean, default=False,
help='If a refresh token should be included when logging in.')
def _get_create_user_parser(require_user_id=True):
result = RequestParser()
result.add_argument('user_id', required=require_user_id, type=user_id(), help='The user ID to create an account for.')
result.add_argument('password', required=True, type=str, help='The password to create for the user account.')
result.add_argument('email', required=True, type=email(), help='The user\'s email address.')
result.add_argument('first_name', required=True, type=str, help='The user\'s first name.')
result.add_argument('middle_init_or_name', required=False, type=str, help='The user\'s middle intital or middle name')
result.add_argument('last_name', required=True, type=str, help='The user\'s last name.')
result.add_argument('date_of_birth', required=False, type=quarter_date_time(), help='The user\'s date of birth (expected format: %b %d, %Y ex: "Apr 17, 1993").')
result.add_argument('phone_number', required=False, type=str, help='The user\'s phone number.')
result.add_argument('roles', required=False, type=roles, default=str(Roles.APPLICANT), help=f'A comma-separated list of the user\'s roles (valid roles are: {Roles.values()}).')
return result
create_user_parser = _get_create_user_parser()
admin_crate_user_parser = _get_create_user_parser(False)
user_id_parser = RequestParser()
user_id_parser.add_argument('user_id', required=True, type=user_id(), help='The user ID of the user to operate this request on.')
edit_user_parser = RequestParser()
edit_user_parser.add_argument('email', required=False, type=email(), help='The new email address to set for the user.')
edit_user_parser.add_argument('first_name', required=False, type=str, help='The new first name for the user.')
edit_user_parser.add_argument('middle_init_or_name', required=False, type=str, help='The new middle initial or middle name for the user.')
edit_user_parser.add_argument('last_name', required=False, type=str, help='The new last name for the user.')
edit_user_parser.add_argument('date_of_birth', required=False, type=quarter_date_time(), help='The new date of birth for the user (expected format: %b %d, %Y ex: "Apr 17, 1993").')
edit_user_parser.add_argument('phone_number', required=False, type=str, help='The new phone number for the user.')
create_user_parser.add_argument('roles', required=False, type=roles, default=str(Roles.APPLICANT),
help=f'A comma-separated list of the user\'s roles (valid roles are: {Roles.values()}).')
update_password_parser = RequestParser()
update_password_parser.add_argument('old_password', required=True)
update_password_parser.add_argument('new_password', required=True)
reset_password_parser = RequestParser()
reset_password_parser.add_argument('new_password', required=True, type=str, help='The new password to set for the user.')
reset_password_parser.add_argument('token', required=True, type=str, help='The JWT password reset token used to allow a password to be reset.')
activate_account_parser = RequestParser()
activate_account_parser.add_argument('token', required=True, type=str, help='The JWT account activation token used to activate the account.')
resend_activation_email = RequestParser()
resend_activation_email.add_argument('email', required=True, type=email(), help='The email address to resend the account activation link to.')
request_password_reset_parser = RequestParser()
request_password_reset_parser.add_argument('email', required=False, type=email(), help='The email address of the user to reset the password for.')
request_password_reset_parser.add_argument('user_id', required=False, type=user_id(), help='The User ID of the user to reset the password for.')

126
app/auth/routes.py Normal file
View File

@ -0,0 +1,126 @@
from flask import request, url_for, render_template, redirect, flash, jsonify, make_response, current_app
from flask_jwt_extended import current_user, jwt_required, create_access_token, get_jwt, create_refresh_token, set_access_cookies, unset_jwt_cookies
from datetime import datetime
from . import blueprint
from .model import User, BlackListedJWT
from .forms import LoginForm, RegisterForm, RequestPasswordResetForm, ResetPasswordForm, SendConfirmEmailFrom
from app.auth.email import send_password_reset_email
@blueprint.route('/login', methods=['GET', 'POST'])
@jwt_required(optional=True, locations=['cookies'])
def login():
if current_user:
flash(f'Already logged in as {current_user.user_id}. Incorrect? <a href="{url_for("auth.logout")}" class="toast-action" onclick="close_toast(this)">logout</a>')
form = LoginForm(request.form)
if request.method == 'POST':
if form.validate():
user = User.objects(user_id=form.user_id.data).first()
auth_token = create_access_token(user)
user.last_login = datetime.utcnow()
user.save()
response = make_response(jsonify(redirect=url_for('main.index')))
set_access_cookies(response, auth_token)
return response
return render_template('auth/login_modal.html', form=form)
@blueprint.route('/register', methods=['POST', 'GET'])
@jwt_required(optional=True)
def register():
if current_user:
flash(f'Already logged in as {current_user.user_id}. Incorrect? <a href="{url_for("auth.logout")}" class="toast-action" onclick="close_toast(this)">logout</a>')
return redirect(url_for('main.index'))
form = RegisterForm(request.form)
if request.method == 'POST':
if form.validate():
user = User(user_id=form.user_id.data,
email=form.email.data,
first_name=form.first_name.data,
last_name=form.last_name.data,
last_login=datetime.now(),
join_date=datetime.now().date(),
date_of_birth=form.get_date_of_birth,
phone_number=form.phone_number.data,
type='Occupant')
user.set_password(form.password.data)
user.last_login = datetime.utcnow()
user.save()
auth_token = create_access_token(user)
response = make_response(jsonify(redirect=url_for('main.index')))
set_access_cookies(response, auth_token)
return response
return render_template('auth/register.html', form=form)
@blueprint.route('/logout')
@jwt_required(locations=['cookies'])
def logout():
to_blacklist = BlackListedJWT(jti=get_jwt()['jti'], created_at=datetime.utcnow())
to_blacklist.save()
response = redirect(url_for('main.index'))
flash('Logged out successfully!')
unset_jwt_cookies(response)
return redirect(url_for('main.index'))
@blueprint.route('/request_password_reset', methods=['GET', 'POST'])
@jwt_required(optional=True)
def request_password_reset():
if current_user:
flash(f'You are already logged in as {current_user.user_id}. Incorrect? <a href="{url_for("auth.logout")}" class="toast-action" onclick="close_toast(this)">logout</a>')
return redirect(url_for('main.index'))
form = RequestPasswordResetForm(request.form)
if request.method == 'POST':
if form.validate():
if form.user_id.data:
user = User.objects(user_id=form.user_id.data).first()
else:
user = User.objects(email=form.email.data).first()
send_password_reset_email(user)
flash('Check your email for the password reset link')
return jsonify(redirect=url_for('main.index'))
return render_template('auth/request_password_reset.html', form=form)
@blueprint.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user:
flash(f'You are already logged in as {current_user.user_id}. Incorrect? <a href="{url_for("auth.logout")}" class="toast-action" onclick="close_toast(this)">logout</a>')
return redirect(url_for('main.index'))
user = User.from_password_reset_token(token, secret_key=current_app.config['SECRET_KEY'])
if not user:
flash(f'The password reset link may have expired. Please reset your password again.')
return redirect(url_for('main.index'))
form = ResetPasswordForm(request.form)
if request.method == 'POST':
if form.validate():
flash('Password reset successfully!')
user.set_password(form.password.data)
return redirect(url_for('main.index'))
return render_template('auth/reset_password.html', form=form)
@blueprint.route('/account')
@jwt_required()
def account():
return render_template('auth/account.html', current_user=current_user)
@blueprint.route('/confirm_email/<token>')
def confirm_email(token):
form = SendConfirmEmailFrom()
user, redirect_url = User.from_activation_token(token, secret_key=current_app.config['SECRET_KEY'])
return render_template('auth/email_confirmation.html', token=token, form=form, redirect_url=redirect_url)

6
app/auth/util.py Normal file
View File

@ -0,0 +1,6 @@
import os
from base64 import b64encode
def secure_random_string(num_bytes=24):
return b64encode(os.urandom(num_bytes)).decode('utf-8')

64
app/config.py Normal file
View File

@ -0,0 +1,64 @@
import logging
import datetime
import os
# General app configuration options
APP_NAME = 'QUARTER_WEB'
VERSION = '0.1.0'
LOCAL_HOSTNAME = 'localhost'
# TODO: This should be retrieved from the AWS instance name
PUBLIC_HOSTNAME = 'localhost'
SECRET_KEY = os.environ.get('SECRET_KEY', 'ThisIsASecret')
# Mongo configuration options
MONGODB_DB = os.environ.get('MONGODB_DATABASE', 'quarter')
MONGODB_HOST = os.environ.get('MONGODB_HOST', 'quarter_mongodb')
MONGODB_PORT = int(os.environ.get('MONGODB_PORT', 27017))
MONGODB_USERNAME = os.environ.get('MONGODB_USERNAME', 'quarter')
MONGODB_PASSWORD = os.environ.get('MONGODB_PASSWORD', 'quarter').replace('"', '')
MONGODB_CONNECT = False
# Flask-Static-Digest config
FLASK_STATIC_DIGEST_URL = 'apply/'
# JWT configuration options
JWT_SECRET_KEY = SECRET_KEY
JWT_ACCESS_TOKEN_EXPIRES = datetime.timedelta(hours=1)
JWT_REFRESH_TOKEN_EXPIRES = datetime.timedelta(days=1)
JWT_TOKEN_LOCATION = ['headers', 'cookies']
JWT_HEADER_NAME = 'Auth-Token'
JWT_COOKIE_CSRF_PROTECT = False
JWT_CLEANUP_MULE_SLEEP_SECS = datetime.timedelta(minutes=30).seconds
# CORS configuration options
CORS_SUPPORTS_CREDENTIALS = True
CORS_ORIGINS = [
'http://127.0.0.1:5000',
'http://127.0.0.1',
'http://localhost:5000',
'http://localhost',
'https://www.apply.quarterhomes.com',
'https://apply.qaurterhomes.com',
'http://apply.qaurterhomes.com',
'http://www.quarterhomes.com',
'https://www.quarterhomes.com',
'http://qhome2.strassner.com'
]
CORS_RESOURCES = r'/api/*'
# Swagger config options
SWAGGER_UI_DOC_EXPANSION = 'list'
RESTX_MASK_SWAGGER = False
# logging configuration options
LOG_LEVEL = logging.INFO
LOG_PATH = os.path.join('/var/log/www/quarter.log')
# Mail configuration options
MAIL_USERNAME = os.environ.get('MAIL_USERNAME', 'noreply@quarterhomes.com')
# Password reset config options
PASSWORD_RESET_EXP_SEC = datetime.timedelta(minutes=10).seconds
# Email confirmation options
CONFIRM_EMAIL_EXP_SEC = datetime.timedelta(minutes=15).seconds

3
app/cors.py Normal file
View File

@ -0,0 +1,3 @@
from flask_cors import CORS
cors = CORS()

77
app/database/__init__.py Normal file
View File

@ -0,0 +1,77 @@
import json
from bson import json_util
from flask_mongoengine import MongoEngine
from mongoengine.document import BaseDocument
from mongoengine.fields import ListField, EmbeddedDocumentField
from app.util import dict_to_camel, dict_to_snake, camel_to_snake
db = MongoEngine()
class QuarterDocumentBase(BaseDocument):
meta = {'abstract': True}
@classmethod
def ignore_to_json(cls):
return ['_id', '_cls']
@classmethod
def ignore_from_json(cls):
return ['_cls']
@classmethod
def json_mapped_fields(cls):
return {}
@classmethod
def additional_fields(cls):
return []
def to_dict(self):
result = json_util.loads(self.to_json())
for ignore in self.ignore_to_json():
result.pop(ignore, None)
for additional in self.additional_fields():
result[additional] = getattr(self, additional, None)
for prop_name in result:
if hasattr(getattr(self, prop_name, None), 'to_dict'):
result[prop_name] = getattr(self, prop_name).to_dict()
elif isinstance(getattr(self, prop_name, None), list):
result[prop_name] = [k.to_dict() if hasattr(k, 'to_dict') else k for k in getattr(self, prop_name)]
for json_name, prop_name in self.json_mapped_fields().items():
result[json_name] = result.pop(prop_name, None)
return result
def to_camel_case(self):
return dict_to_camel(self.to_dict())
@classmethod
def from_camel_case(cls, json_data):
if isinstance(json_data, str):
json_data = json_util.loads(json_data)
kwargs = dict_to_snake(json_data)
result = cls._from_son(kwargs)
return result
@classmethod
def _from_son(cls, son, _auto_dereference=True, created=False):
for ignore in cls.ignore_from_json():
son.pop(ignore, None)
for json_name, prop_name in cls.json_mapped_fields().items():
son[prop_name] = son.pop(json_name, None)
return super()._from_son(son, _auto_dereference=_auto_dereference, created=created)
class DocumentBase(db.Document, QuarterDocumentBase):
meta = {'abstract': True}
class EmbeddedDocumentBase(db.EmbeddedDocument, QuarterDocumentBase):
meta = {'abstract': True}

60
app/database/fields.py Normal file
View File

@ -0,0 +1,60 @@
from mongoengine.fields import DateField
from datetime import datetime
DEFAULT_DATE_FORMAT = '%b %d, %Y'
class QuarterDateField(DateField):
def __init__(self, *args, date_format='%b %d, %Y', **kwargs):
self.date_format = date_format
super().__init__(*args, **kwargs)
def validate(self, value):
if isinstance(value, str):
try:
datetime.strptime(value, self.date_format)
except Exception as ex:
self.error(f'Date time is not the correct format: (expected {self.date_format}). Error: {str(ex)}')
def to_mongo(self, value):
value = super().to_mongo(value)
if isinstance(value, datetime):
value = value.strftime(self.date_format)
return value
def to_python(self, value):
value = super().to_python(value)
if isinstance(value, datetime):
value = value.strftime(self.date_format)
return value
def get_dt_format(self):
return self.date_format
@staticmethod
def make_datetime(value, dt_format='%b %d, %Y'):
if isinstance(value, str):
return datetime.strptime(value, dt_format)
return value
class QuarterYearField(QuarterDateField):
def __init__(self, *args, **kwargs):
kwargs['date_format'] = '%Y'
super().__init__(*args, **kwargs)
def validate(self, value):
if isinstance(value, int):
value = str(value)
return super().validate(value)
def to_mongo(self, value):
if isinstance(value, int):
value = str(value)
return super().to_mongo(value)
def to_python(self, value):
if isinstance(value, int):
value = str(value)
return super().to_python(value)

56
app/database/utils.py Normal file
View File

@ -0,0 +1,56 @@
# from mongoengine import fields
# from app.util.schema import load_json_schema
# from . import DocumentBase
#
# def obj_from_schema(schema_file, base_classes=(DocumentBase, )):
# schema = load_json_schema(schema_file)
# type = schema['type']
# title = schema['title']
# required = schema.get('required', [])
# unique = schema.get('unique', [])
#
# obj_fields = {}
#
# def load_properties(obj_schema):
# for name, _schema in obj_schema['properties']:
# _required = name in required
# _unique = name in unique
# _sparse = not _required and _unique
# field_class = _get_field_class(_schema)
#
# # is this a list field?
# if field_class == fields.ListField:
# list_class = _get_field_class(_schema['items'])
# # is this an embedded document field?
# elif field_class == fields.EmbeddedDocumentField:
#
#
# print(schema)
#
#
# def _get_field_class(field_schema):
# _type = field_schema.get('type')
# if _type == 'string':
# str_format = field_schema.get('format', '').lower()
# if str_format == 'date':
# return fields.DateField
# if str_format == 'datetime':
# return fields.DateTimeField
# elif str_format == 'email':
# return fields.EmailField
# elif str_format == 'multiline':
# return fields.MultiLineStringField
# return fields.StringField
#
# elif _type == 'float':
# return fields.FloatField
# elif _type == 'integer':
# return fields.IntField
# elif _type == 'boolean':
# return fields.BooleanField
# elif _type == 'array':
# return fields.ListField
# elif _type == 'object':
# return fields.EmbeddedDocumentField

3
app/docker-entrypoint.sh Normal file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
uwsgi --ini /var/www/docker/quarter-web.ini --touch-reload /var/www/uwsgi.reload >> /var/log/www/quarter.log 2>&1

43
app/email/__init__.py Normal file
View File

@ -0,0 +1,43 @@
from __future__ import print_function
from flask import current_app
from googleapiclient.discovery import build
from apiclient import errors
import os
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import base64
from google.oauth2 import service_account
def login():
scopes = ['https://www.googleapis.com/auth/gmail.send']
service_account_file = os.path.abspath(os.path.join(os.path.dirname(__file__), 'service-key.json'))
creds = service_account.Credentials.from_service_account_file(service_account_file, scopes=scopes)
delegated_creds = creds.with_subject(current_app.config['MAIL_USERNAME'])
service = build('gmail', 'v1', credentials=delegated_creds)
return service
def create_message(subject, sender, recipient, txt_body, html_body):
message = MIMEMultipart('alternative')
message['To'] = recipient
message['From'] = sender
message['Subject'] = subject
message.attach(MIMEText(txt_body, 'plain'))
message.attach(MIMEText(html_body, 'html'))
return {'raw': base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')}
def send_email(subject, sender, recipient, txt_body, html_body):
service = login()
message = create_message(subject, sender, recipient, txt_body, html_body)
try:
msg = (service.users().messages().send(userId='me', body=message).execute())
print(f'Message ID: {msg["id"]}')
return msg
except errors.HttpError as error:
print(f'Failed to send message: {str(error)}')

BIN
app/email/service-key.json Normal file

Binary file not shown.

3
app/model.py Normal file
View File

@ -0,0 +1,3 @@
from .database import db
from .auth.model import User, Roles, BlackListedJWT
from .applicant.model import Application, Applicant, Property, LifeInsurance, Assets, Job

28
app/mules/JWTCleanMule.py Normal file
View File

@ -0,0 +1,28 @@
from app import create_app
from app import model
from datetime import datetime
from app.mules.mule import MuleBase
class JWTCleanupMule(MuleBase):
def __init__(self):
app = create_app()
self.jwt_exp_time = app.config.get('JWT_ACCESS_TOKEN_EXPIRES')
super().__init__('JWTCleanupMule', app.logger, app.config.get('JWT_CLEANUP_MULE_SLEEP_SECS', 10))
def loop(self):
oldest_allowed = datetime.utcnow() - self.jwt_exp_time
self.logger.info(f'Searching for JWT access tokens revoked at or before {oldest_allowed.strftime("%Y %m %d %H:%M:%S")}...')
to_remove = model.BlackListedJWT.objects(created_at__lt=oldest_allowed).all()
self.logger.info(f'Found {len(to_remove)} expired JWT access tokens to remove')
for idx, jwt in enumerate(to_remove):
self.logger.debug(f'Removing expired JWT {idx + 1}/{len(to_remove)}')
try:
jwt.delete()
except Exception as ex:
self.logger.error(f'Failed to remove JWT (exception={str(ex)}).')
if __name__ == '__main__':
mule = JWTCleanupMule()
mule()

24
app/mules/mule.py Normal file
View File

@ -0,0 +1,24 @@
from time import sleep
import traceback
from abc import abstractmethod, ABC
class MuleBase(ABC):
def __init__(self, mule_name, logger, sleep_secs):
self.name = mule_name
self.logger = logger
self.sleep_secs = sleep_secs
@abstractmethod
def loop(self):
pass
def __call__(self):
while True:
try:
self.loop()
self.logger.info(f'[{self.name}] Completed work, sleeping (sleep_secs={self.sleep_secs}).')
except Exception as ex:
self.logger.error(f'[{self.name}] Task failed, sleeping before restarting (sleep_secs={self.sleep_secs}, exception={str(ex)}).\n{traceback.format_exc()}')
finally:
sleep(self.sleep_secs)

View File

108
app/network_admin/api.py Normal file
View File

@ -0,0 +1,108 @@
from app.auth import role_required, NO_USER_FOUND_MESSAGE
from app import model
from app_common.const import BAD_USER_ROLES
from app_common.parser import QuarterRequestParser
from app.auth.request_parsers import edit_user_parser, admin_crate_user_parser, roles
from flask_restx import Namespace, Resource, abort, inputs
from flask import jsonify, make_response
namespace = Namespace('network_admin', description='API endpoints available for network admins', decorators=[role_required([model.Roles.ADMIN, model.Roles.NETWORK_ADMIN])])
@namespace.route('/ajax/user/<user_id>')
@namespace.response(404, NO_USER_FOUND_MESSAGE)
class User(Resource):
@namespace.doc(description='Gets the data for a user.')
def get(self, user_id):
user = model.User.objects(user_id=user_id).first_or_404()
return make_response(jsonify(user.to_dict()))
@namespace.expect(admin_crate_user_parser)
@namespace.doc(description='Crates a new user.')
def put(self, user_id):
args = admin_crate_user_parser.parse_args()
args.user_id = user_id
model.User.from_request_args(**dict(args))
@namespace.expect(edit_user_parser)
@namespace.doc(description='Update the data for a user.')
def post(self, user_id):
args = edit_user_parser.parse_args()
args.user_id = user_id
user = model.User.objects(user_id=args.user_id).first_or_404()
# try to update the user
for key in dict(args):
value = getattr(args, key)
if value:
setattr(user, key, value)
user.save()
@namespace.doc(description='Deletes a user.')
def delete(self, user_id):
user = model.User.objects(user_id=user_id).first_or_404()
user.delete()
# see if there is an application to delete as well...
application = model.Application.objects(user_id=user_id).first()
if application:
application.delete()
get_users_parser = QuarterRequestParser()
get_users_parser.add_argument('roles', required=False, type=roles, help='A comma separated list of user roles to return.', default=None)
@namespace.route('/users')
class GetUsers(Resource):
@namespace.expect(get_users_parser)
def get(self):
args = get_users_parser.parse_args()
if args.roles:
users = model.User.objects(roles__in=args.roles).all()
else:
users = model.User.objects()
return jsonify([u.to_dict() for u in users])
@namespace.route('/ajax/user/<user_id>/application')
class UserApplication(Resource):
@namespace.response(404, 'No user was found or no application was found for the given user')
def get(self, user_id):
user = model.User.objects(user_id=user_id).first_or_404(message='No user found')
application = model.Application.objects(applicants__match={'email': user.email}).first_or_404(message='No application found for user.')
return make_response(jsonify(user=user.to_dict(), application=application.to_dict()))
user_roles_parser = QuarterRequestParser()
user_roles_parser.add_argument('roles', required=True, default='', type=roles, help='A comma separated list of roles to assign to/delete from a user')
user_roles_parser.add_argument('force_match', required=False, default=False, type=inputs.boolean, help='Weather or not to force a users roles to exactly match what is given.')
@namespace.route('/ajax/user/<user_id>/roles')
class UserRoles(Resource):
@namespace.response(404, 'No user found with the given ID')
def get(self, user_id):
user = model.User.objects(user_id=user_id).first_or_404(message='No user found')
return jsonify(user.roles)
@namespace.expect(user_roles_parser)
@namespace.response(404, 'No user found with the given ID')
def post(self, user_id):
args = user_roles_parser.parse_args()
user = model.User.objects(user_id=user_id).first_or_404(message='No user found')
# if we are supposed to force the user roles to match what is given, clear out the existing roles
if args.force_match:
user.roles = []
# add the new roles
user.roles.extend(args.roles)
user.save()
@namespace.expect(user_roles_parser)
@namespace.response(404, 'No user found with the given ID')
def delete(self, user_id):
args = user_roles_parser.parse_args()
user = model.User.objects(user_id=user_id).first_or_404(message='No user found')
user.roles = [r for r in user.roles if r not in args.roles]
user.save()

View File

18
app/routes.py Normal file
View File

@ -0,0 +1,18 @@
from flask import Blueprint, render_template
from flask_jwt_extended import current_user, jwt_required, get_jwt_identity
blueprint = Blueprint('main', __name__)
@blueprint.route('/')
@blueprint.route('/index')
@jwt_required(optional=True)
def index():
try:
user_id = get_jwt_identity()
except:
user_id = None
if current_user:
user_id = current_user.user_id
return render_template('index.html', user_id=user_id)

13
app/static/css/materialize.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

BIN
app/static/img/animlogo.mp4 Normal file

Binary file not shown.

BIN
app/static/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
app/static/img/plus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
app/static/img/plus_30.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
app/static/img/slide1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

13
app/static/js/helpers.js Normal file
View File

@ -0,0 +1,13 @@
function request_with_jwt(url, method, callback, current_jwt) {
$.ajax({
url: url,
type: method,
headers: {
'Auth-Token': current_jwt
},
success: callback
});
}

2
app/static/js/jquery-3.5.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
app/static/js/materialize.min.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

View File

@ -0,0 +1,63 @@
{% extends "base.html" %}
{% set title = current_user.id %}
{% block content %}
<div class="wrapper">
<div id="profile-page-header" class="card">
<div class="card-image waves-effect waves-block waves-light"></div>
<div class="card-content">
<div class="row">
<div class="col s3 left offset-s2">
<h4 class="card-title grey-text text-darken-4">{{ current_user.first_name }} {{ current_user.last_name }}</h4>
<p class="medium grey-text">{{ current_user.type }}</p>
</div>
<div class="col s2 center-align">
<h4 class="grey-text text-darken-4">Member since</h4>
<p class="medium grey-text">{{ current_user.join_date }}</p>
</div>
</div>
</div>
</div>
<div id="profile-page-content" class="row">
<div class="col s12 m8">
<div id="home-info" class="row">
<ul class="tabs z-depth-1 light-blue">
<li class="tab col s3 active" style="width: 33.3333%;">
<a class="waves-effect waves-light white-text active" href="#general">
<i class="material-icons prefix">home</i>
Home Information
</a>
</li>
<li class="tab col s3" style="width: 33.3333%">
<a class="waves-effect waves-light white-text" href="#ownership-info">
<i class="material-icons prefix">info_outline</i>
Ownership Information
</a>
</li>
<li class="tab col s3" style="width: 33.3333%">
<a class="waves-effect waves-light white-text" href="#payment-info">
<i class="material-icons prefix">attach_money</i>
Payment Information
</a>
</li>
</ul>
<div id="general" class="tab-item col s12 grey lighten-4">
<div class="row">
<h5 class="title">Address</h5>
<p class="medium">123 Example St, Boulder CO, 80303</p>
</div>
</div>
<div id="payment-info" class="tab-item col s12 grey lighten-4">
<h5 class="title">Monthly payment</h5>
<p class="medium">$1,000.00</p>
</div>
<div id="ownership-info" class="tab-item col s12 grey lighten-4">
<h5 class="title">Current Ownership</h5>
<p class="medium">10%</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,99 @@
{% extends "base.html" %}
{% import "includes/macros.html" as macros %}
{% block page_js %}
<script>
$(document).ready(function(){
$('#confirm-email-modal').modal('open');
$.ajax({
url: "{{ url_for('api.auth_activate_user') }}",
data: { "token": "{{token}}" },
dateType: 'json',
type: 'POST',
timeout: 2000,
success: function(data, status) {
$('#email-confirmation-status').html('<h5>Email address confirmed!</h5>');
// redirect to the application home page
window.location = "{{ redirect_url }}"
}
}).fail(function(xhr, status, errorThrown) {
// if the call failed, change the open modal to the error modal
$('#confirm-email-modal').modal('close');
$('#confirmation-error-modal').modal('open');
let error_message = errorThrown.toString();
// get the error message if there was ont
if ('message' in xhr.responseJSON) {
error_message = xhr.responseJSON.message;
}
// display the error message
$('#confirmation-error-msg').html('<h5>Unable to confirm email</h5><p>' + error_message);
});
// allow the user to resend their confirmation email
$('#btn-send-confirmation-email').on('click', function() {
$.ajax({
url: "{{ url_for('api.auth_send_account_activation') }}",
data: $('#resend-confirm-email-form').serialize(),
dataType: 'json',
type: 'POST',
success: function(data, status) {
$('#resend-confirm-email-errors').html(`{{ macros.make_toast("Confirmation email sent!", None, None) }}`);
}
}).fail(function(xhr, status, errorThrown) {
let error_message = errorThrown.toString();
if ('message' in xhr.responseJSON) {
error_message = xhr.responseJSON.message;
}
$('#resend-confirm-email-errors').html(`{{ macros.make_toast("Failed to resend confirmation email", None, None) }}`)
});
});
});
</script>
{% endblock %}
{% block content %}
<div class="modal fade-in open" id="confirm-email-modal">
<div class="row blue white-text modal-ux-header">
<h4 class="left-align">Email Confirmation</h4>
</div>
<div class="modal-content modal-ux" id="confirm-email-modal-content">
<div class="center-align" id="email-confirmation-status">
<div class="left-align">
<h5>Confirming Email...</h5>
</div>
<div class="progress">
<div class="indeterminate"></div>
</div>
</div>
</div>
</div>
<div class="modal fade-in" id="resend-confirmation-email-modal">
<div class="row blue white-text modal-ux-header">
<h4 class="left-align">Resend Email Confirmation</h4>
</div>
<div class="modal-content modal-ux">
<form id="resend-confirm-email-form">
<div class="input-field">
{{ macros.render_field(form.email, 'email') }}
</div>
</form>
</div>
<div class="row" id="resend-confirm-email-errors"></div>
<div class="row">
<div class="modal-footer">
<button id="btn-send-confirmation-email" class="btn btn-flat waves-effect waves-light right" type="button">
Submit<i class="material-icons right">send</i>
</button>
</div>
</div>
</div>
<div class="modal fade-in" id="confirmation-error-modal">
<div class="modal-ux-header row blue white-text">
<h4 class="left-align">Error Confirming Email</h4>
</div>
<div class="modal-content" id="confirmation-error-msg"></div>
<div class="modal-footer">
{{ macros.link_modal('resend-confirm-email-link', 'btn btn-flat modal-close', 'Resend Confirmation Email', 'resend-confirmation-email-modal') }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,37 @@
{% import "includes/macros.html" as macros %}
<form>
<div class="row">
<div class="input-field col s6">
{{ macros.render_field(form.user_id, 'account_circle') }}
</div>
<div class="col s6">
<div class="input-field">
{{ macros.render_field(form.password, 'lock') }}
</div>
<div class="label {% if not form.password.errors %}hide{% endif %}" id="forgot-password-link">
Forgot your password?
{{ macros.link_modal('requset-pwd-reset-link', 'btn btn-flat modal-close', 'Reset Password', 'requset-pwd-reset-modal') }}
</div>
</div>
</div>
<div class="row" style="padding-bottom: 5px;">
<div class="input-field col s12">
<label for="remember_me">
{{ form.remember_me(class_='indeterminate-checkbox checkbox') }}
<span>{{ form.remember_me.label }}</span>
</label>
</div>
</div>
<div class="row">
<div class="modal-footer">
<button id="btn-login-submit" class="btn waves-effect waves-light left" type="button" name="submit">
Login<i class="material-icons right">send</i>
</button>
<div class="label">
New to Quarter?
{{ macros.link_modal('register-from-login-modal-link', 'btn btn-flat modal-close', 'Register', 'register-modal') }}
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,59 @@
{% import "includes/macros.html" as macros %}
<form>
<div class="row">
<div class="input-field col" style="width: 100%;">
{{ macros.render_field(form.user_id, 'account_circle') }}
</div>
</div>
<div class="row">
<div class="input-field col" style="width: 50%;">
{{ macros.render_field(form.email, 'email')}}
</div>
<div class="input-field col" style="width: 50%;">
{{ macros.render_field(form.confirm_email) }}
</div>
</div>
<div class="row">
<div class="input-field col">
{{ macros.render_field(form.phone_number, 'phone_android') }}
</div>
</div>
<div class="row">
<div class="input-field col" style="width: 40%;">
{{ macros.render_field(form.first_name, 'person') }}
</div>
<div class="input-field col" style="width: 40%;">
{{ macros.render_field(form.last_name) }}
</div>
<div class="input-field col" style="width: 20%">
{% if not form.date_of_birth.errors %}
{{ form.date_of_birth(class='datepicker') }}
{{ form.date_of_birth.label }}
{% else %}
{{ form.date_of_birth(class='datepicker invalid data') }}
{% for err in form.date_of_birth.errors %}
<span class="helper-text" data-error="{{ err }}"></span>
{% endfor %}
{{ form.date_of_birth.label(class='active') }}
{% endif %}
</div>
</div>
<div class="row">
<div class="input-field col" style="width: 50%;">
{{ macros.render_field(form.password, 'lock') }}
</div>
<div class="input-field col" style="width: 50%;">
{{ macros.render_field(form.confirm_pass) }}
</div>
</div>
<div class="modal-footer">
<button id="btn-register-submit" class="btn waves-effect waves-light" type="button" name="submit">Register<i class="material-icons right">send</i></button>
</div>
</form>
<script>
$(document).ready(function() {
$('.datepicker').datepicker();
})
</script>

View File

@ -0,0 +1,20 @@
{% import "includes/macros.html" as macros %}
<form>
<div class="row">
<div class="input-field col" style="width: 100%;">
{{ macros.render_field(form.email, 'email')}}
</div>
</div>
<div class="row">
<h4 class="center">OR</h4>
</div>
<div class="row">
<div class="input-field" style="width: 100%;">
{{ macros.render_field(form.user_id, 'account_circle') }}
</div>
</div>
<div class="modal-footer">
<button id="btn-request-reset-pwd-submit" class="btn waves-effect waves-light right-align" type="button">Reset Password</button>
</div>
</form>

View File

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% import "includes/macros.html" as macros %}
{% set title="Reset password" %}
{% block page_js %}
<script>
$(document).ready(function() {
$('#reset-password-modal').modal('open');
});
</script>
{% endblock %}
{% block content %}
<div class="modal fade-in" id="reset-password-modal">
<div class="row blue white-text modal-ux-header">
<h4 class="left-align">Reset Password</h4>
</div>
<div class="modal-content model-ux">
<form method="POST">
<div class="row">
{{ macros.render_field(form.password, 'lock') }}
</div>
<div class="row">
{{ macros.render_field(form.confirm_password, 'lock') }}
</div>
<div class="modal-footer">
<button id="btn-reset-pwd-submit" class="btn waves-effect waves-light right-align" type="submit">Reset Password</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block page_js %}
<script>
$(document).ready(function() {
$('#register-modal').addClass('open');
})
</script>
{% endblock %}

55
app/templates/base.html Normal file
View File

@ -0,0 +1,55 @@
{% import "includes/macros.html" as macros %}
{% block head %}
<title>Quarter Homes</title>
{% endblock %}
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="{{ url_for('static', filename='css/materialize.min.css') }}">
<script type="text/javascript" src="https://code.jquery.com/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/materialize.min.js') }}"></script>
<script type="text/javascript" src="{{url_for('static', filename='js/helpers.js')}}"></script>
<script>
$(document).ready(function() {
$('.dropdown-trigger').dropdown();
$('.tabs').tabs();
$('.modal').modal({
onOpenStart: function() {
let modal = $('#' + this.id);
let modal_form_id = '#' + modal.data('form-id');
$.ajax({
url: modal.data('body-url'),
type: 'GET',
success: function (data, status) {
$(modal_form_id).html(data);
}
});
}
});
});
</script>
{% block page_js %}
{% endblock %}
<body style="height: 100%">
{{ macros.make_modal_form('register', 'Register', url_for('auth.register'), 'register-form', 'btn-register-submit') }}
{{ macros.make_modal_form('login', 'Login', url_for('auth.login'), 'login-form', 'btn-login-submit') }}
{{ macros.make_modal_form('requset-pwd-reset', 'Reset Password', url_for('auth.request_password_reset'), 'request-pwd-reset-form', 'btn-request-reset-pwd-submit') }}
{% block flash_messages %}
{% include "includes/flash_messages.html" %}
{% endblock %}
{% block body_content %}
{% include "includes/navbar.html" %}
{% block content %}
{% endblock %}
{% endblock %}
</body>
{% block footer %}
{% endblock %}

View File

@ -0,0 +1,8 @@
<p>{{ user.user_id }},</p>
<p>
To confirm your email address <a href="{{ url_for('auth.confirm_email', token=token, _external=True) }}">click here</a>.
</p>
<p>
Alternatively you can paste the following link into your browser's address bar:
</p>
<p>{{ url_for('auth.confirm_email', token=token, _external=True) }}</p>

View File

@ -0,0 +1,6 @@
{{user.user_id}},
Please confirm your email address with the following link (expires in {{ exp_mins }} minutes):
{{ url_for('auth.confirm_email', token=token, _external=True) }}

View File

@ -0,0 +1,4 @@
<p>{{ user.user_id }},</p>
<p>
Your quarter user ID is "{{ user.user_id }}".
</p>

View File

@ -0,0 +1,3 @@
{{ user.user_id }},
Your quarter user ID is "{{ user.user_id }}".

View File

@ -0,0 +1,11 @@
<p>{{ user.user_id }},</p>
<p>
To reset your password <a href="{{ url_for('auth.reset_password', token=token, _external=True) }}">click here</a>.
</p>
<p>
Alternatively you can paste the following link into your browser's address bar:
</p>
<p>{{ url_for('auth.reset_password', token=token, _external=True) }}</p>
<p>
If you have not requested a password reset, please ignore this email.
</p>

View File

@ -0,0 +1,8 @@
{{user.user_id}},
Reset your password with the following link (expires in {{ exp_mins }} minutes):
{{ url_for('auth.reset_password', token=token, _external=True) }}
If you have not requested a password reset, ignore this message.

View File

@ -0,0 +1,18 @@
{% import "includes/macros.html" as macros %}
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
<div id="toast-container" class="pin-top center-align">
{% for category, message in messages %}
{% if category == 'error' %}
{{ macros.make_toast(message, "color: rgba(255, 0, 0, 0.5)", None) }}
{% elif category == 'success' %}
{{ macros.make_toast(message, "color: rgba(0, 255, 0, 0.5)", None) }}
{% elif category == 'warning' %}
{{ macros.make_toast(message, "color: rgba(220, 200, 0, 0.5)", None) }}
{% else %}
{{ macros.make_toast(message, None, None) }}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}

View File

@ -0,0 +1,67 @@
{% macro render_field(field, icon_prefix=None) %}
{% if icon_prefix %}
<i class="material-icons prefix">{{ icon_prefix }}</i>
{% endif %}
{% if field.errors %}
{{ field(class='invalid data') }}
{{ field.label(class='active') }}
{% for err in field.errors %}
<span class="helper-text" data-error="{{ err }}"></span>
{% endfor %}
{% else %}
{{ field }}
{{ field.label }}
{% endif %}
{% endmacro %}
{% macro make_toast(message, style, close_btn_msg) %}
<div class="toast" {% if style %}style="{{ style }}"{% endif %}>
<span>{{ message|safe -}}</span>
<button class="btn-flat toast-action" onclick="this.parentElement.remove();">
{% if close_btn_msg %}
{{ close_btn_msg|safe }}
{% else %}
&times;
{% endif %}
</button>
</div>
{% endmacro %}
{% macro make_modal_form(id, title, form_url, form_id, form_submit_btn_id) %}
<div class="modal fade-in" id="{{ id }}-modal" data-body-url="{{ form_url }}" data-form-id="{{ form_id }}" data-form-submit-btn="{{ form_submit_btn_id }}">
<div class="row blue white-text modal-ux-header">
<h4 class="left-align">{{ title }}</h4>
</div>
<div class="modal-content model-ux" id="{{ form_id }}"></div>
</div>
<script type="text/javascript">
$('#{{ form_id}}').on('click', '#{{ form_submit_btn_id }}', function() {
$.ajax({
url: '{{ form_url }}',
type: 'POST',
data: $('#{{ form_id}}').find('form').serialize(),
success: function(data, status) {
if ('redirect' in data) {
let req = new XMLHttpRequest();
req.open('GET', data.redirect);
// get the access token if there is one and put it in the request header
if ('access_token' in data) {
req.setRequestHeader('Auth-Token', `Bearer ${data.access_token}`);
}
// make the request
window.location.replace(data.redirect);
}
else {
$('#{{ form_id }}').html(data);
}
}
});
});
</script>
{% endmacro %}
{% macro link_modal(id, class, content, modal_id) %}
<a class="{{ class }} modal-trigger waves-effect waves-light" id="{{ id }}" href="#{{ modal_id }}">{{ content | safe }}</a>
{% endmacro %}

View File

@ -0,0 +1,39 @@
{% import "includes/macros.html" as macros %}
<ul id="account-dropdown" class="dropdown-content">
<li><a class="waves-effect waves-light" href="{{ url_for('auth.account') }}">Info</a></li>
<li class="divider"></li>
<li class="medium"><a href="{{ url_for('auth.logout') }}">Logout</a></li>
</ul>
<script>
$(document).ready(function() {
$('.dropdown-trigger').dropdown();
});
</script>
<nav class="navbar-fixed light-blue darken-3 white-text">
<div class="nav-wrapper">
<a href="{{ url_for('main.index') }}" class="brand-logo left waves-effect waves-light">
<img
src="{{ url_for('static', filename='img/Quarterhome Logo 300x86 WHITE.png') }}"
height="65"
>
</a>
<ul class="right hide-on-med-and-down">
<li>
<a class="waves-effect waves-light" href="{{ url_for('main.index') }}">Home</a>
</li>
{% if current_user %}
<li>
<a class="dropdown-trigger waves-effect waves-light" href="#" data-target="account-dropdown ">
Account <i class="material-icons right">arrow_drop_down</i>
</a>
</li>
{% else %}
<li>
{{ macros.link_modal('login-link', '', 'Login', 'login-modal') }}
</li>
{% endif %}
</ul>
</div>
</nav>

51
app/templates/index.html Normal file
View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block page_js %}
<script>
{#let base_url = 'http://localhost';#}
let base_url = 'http://ec2-18-222-43-88.us-east-2.compute.amazonaws.com/';
$(document).ready(function() {
$('.carousel.carousel-slider').carousel({
fullWidth: true,
indicators: true,
});
})
</script>
{% endblock %}
{% block content %}
<div class="row valign-wrapper" style="height: 80%">
<div class="col s10 m6">
<div class="container center-align">
<img class="center" src="{{ url_for('static', filename='img/Quarterhome Logo 500x144.png') }}">
<blockquote class="flow-text left-align" style="border-left-color: #9c9c9c">Catchy Slogan</blockquote>
</div>
</div>
<div class="col s12 m6">
<div class="carousel carousel-slider center white-text blue darken-3 card" data-indicators="true">
<div class="container">
<div class="carousel-item" href="#one!">
<h2>Title 1</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque tempus lobortis tellus quis iaculis.
Sed et quam condimentum, suscipit elit vitae, cursus turpis. Mauris eget metus magna. Quisque at nibh ac lacus
vulputate ornare nec et risus. Nulla malesuada est gravida risus hendrerit porta. Class aptent taciti sociosqu
ad litora torquent per conubia nostra, per inceptos himenaeos. Integer ac ornare tellus, sit amet condimentum
ante.
</p>
</div>
<div class="carousel-item" href="#two!">
<h2 class="">Title 2</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque tempus lobortis tellus quis iaculis.
Sed et quam condimentum, suscipit elit vitae, cursus turpis. Mauris eget metus magna. Quisque at nibh ac lacus
vulputate ornare nec et risus. Nulla malesuada est gravida risus hendrerit porta. Class aptent taciti sociosqu
ad litora torquent per conubia nostra, per inceptos himenaeos. Integer ac ornare tellus, sit amet condimentum
ante.
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

62
app/util/__init__.py Normal file
View File

@ -0,0 +1,62 @@
def camel_to_snake(camel_case):
camel_case = camel_case.replace('ID', 'Id')
res = camel_case[0].lower()
for c in camel_case[1:]:
if c.isupper():
res += f'_{c.lower()}'
continue
res += c
return res
def snake_to_camel(snake_case):
res = snake_case.split('_')[0]
res += ''.join(w.title() if w != 'id' else w.upper() for w in snake_case.split('_')[1:])
return res
def convert_dict_names(rename_func, to_convert):
if isinstance(to_convert, dict):
new = to_convert.__class__()
for key, v in to_convert.items():
new[rename_func(key)] = convert_dict_names(rename_func, v)
elif isinstance(to_convert, (list, tuple, set)):
new = to_convert.__class__([convert_dict_names(rename_func, item) for item in to_convert])
else:
return to_convert
return new
def remove_dict_values(a_dict, map_to_none_fn):
if isinstance(a_dict, dict):
result = a_dict.__class__()
for k, v in a_dict.items():
should_ignore = map_to_none_fn(v)
if not should_ignore:
result[k] = remove_dict_values(v, map_to_none_fn)
return result
elif isinstance(a_dict, list):
return [remove_dict_values(v, map_to_none_fn) for v in a_dict]
elif not map_to_none_fn(a_dict):
return a_dict
def emtpy_string_to_none(some_dict):
def default_to_none(value):
if not isinstance(value, bool):
if not value:
return True
return False
return remove_dict_values(some_dict, default_to_none)
def dict_to_camel(value):
return convert_dict_names(snake_to_camel, value)
def dict_to_snake(value):
return convert_dict_names(camel_to_snake, value)

View File

View File

@ -0,0 +1,27 @@
from enum import Enum, EnumMeta
class MetaEnum(EnumMeta):
def __contains__(cls, item):
try:
cls(item)
except ValueError:
return False
return True
class BaseEnum(Enum, metaclass=MetaEnum):
def __eq__(self, other):
if isinstance(other, MetaEnum):
return self == other
return self.value == other
def __hash__(self):
return hash(self.value)
def __str__(self):
return str(self.value)
@classmethod
def values(cls):
return list(map(lambda c: c.value, cls))

37
app/util/log.py Normal file
View File

@ -0,0 +1,37 @@
import logging
LOG_FORMAT = '%(asctime)s %(app_name)s v%(app_version)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]'
class ContextFilter(logging.Filter):
def __init__(self, app_name, app_version):
self.app_name = app_name.upper()
self.app_version = app_version
super().__init__()
def filter(self, record):
record.app_name = self.app_name
record.app_version = self.app_version
return True
def set_up_logging(app, app_name):
log = logging.getLogger('werkzeug')
log.setLevel(logging.WARN)
logger = app.logger
for handler in logger.handlers:
logger.removeHandler(handler)
log_formatter = logging.Formatter(LOG_FORMAT)
# set up the new handlers
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(log_formatter)
stream_handler.setLevel(logging.INFO)
logger.addFilter(ContextFilter(app_name, app.config['VERSION']))
logger.addHandler(stream_handler)
app.logger.setLevel(logging.INFO)

10
app/util/schema.py Normal file
View File

@ -0,0 +1,10 @@
import os
import jsonref
def load_json_schema(filename):
base_path = os.path.dirname(filename)
base_url = f'file://{base_path}/'
with open(filename) as schema_file:
return jsonref.loads(schema_file.read(), base_uri=base_url, jsonschema=True)

62
app/util/validators.py Normal file
View File

@ -0,0 +1,62 @@
import re
import threading
from wtforms import ValidationError
from datetime import datetime
from jsonschema import validate
from jsonschema.exceptions import ValidationError as JSONValidationError
from .schema import load_json_schema
_thread_local = threading.local()
def validate_schema(data, schema_file):
schema_dict = getattr(_thread_local, 'schema_dict', None)
if schema_dict is None:
_thread_local.schema_dict = {}
if schema_file not in _thread_local.schema_dict:
_thread_local.schema_dict[schema_file] = load_json_schema(schema_file)
schema = _thread_local.schema_dict[schema_file]
# try:
validate(instance=data, schema=schema)
# except JSONValidationError as err:
# print(err)
# raise err
def _regex_validator(regex_str, err_msg='Value does not match expected format'):
def validate(form, field):
if re.fullmatch(regex_str, field.data):
return field.data
raise ValidationError(err_msg)
return validate
def social_security_number():
ssn_re = '[0-9]{3}[\s.-]?[0-9]{2}[\s.-]?[0-9]{4}'
return _regex_validator(ssn_re, 'The given SSN is not in the correct format (expected "XXX-XX-XXXX").')
def phone_number():
phone_number_re = '(\+?\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}'
return _regex_validator(phone_number_re, 'The given phone number is invalid.')
def date_of_birth():
def validate(form, field):
if not field.raw_data or not field.raw_data[0]:
raise ValidationError('This field is required.')
dob = datetime.strptime(field.raw_data[0], '%b %d, %Y')
if dob.date() > datetime.now().date():
raise ValidationError('You cannot have been born in the future!')
return validate

0
app_common/__init__.py Normal file
View File

10
app_common/const.py Normal file
View File

@ -0,0 +1,10 @@
import re
USER_NOT_ACTIVE_STATUS_CODE = 406
USER_NOT_ACTIVE_MESSAGE = 'The given user is not active.'
NOT_UNIQUE = 'NOT_UNIQUE'
MALFORMED_DATA = 'MALFORMED_DATA'
BAD_USER_ROLES = 'BAD_ROLES'
USER_ID_REGEX = re.compile('[a-z0-9_-]*')

41
app_common/inputs.py Normal file
View File

@ -0,0 +1,41 @@
import re
from datetime import datetime
from app_common.const import USER_ID_REGEX
def quarter_date_time(dt_format='%b %d, %Y'):
def validate(value):
try:
value = datetime.strptime(value, dt_format)
except ValueError:
raise ValueError(f'The given string "{value}" does not conform to the date time format string "{dt_format}" '
f'(see: https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior).')
return value
return validate
def regex_str(regex, error_msg=None):
def validate(value):
if not isinstance(value, str):
raise ValueError('The given value is not a string.')
if re.search(regex, value):
return value
exp_msg = error_msg or f'The given string does not match the expected regex (value="{value}", regex="{regex}").'
raise ValueError(exp_msg)
return validate
def email():
email_regex = '^(\w|\.|\_|\-)+[@](\w|\_|\-|\.)+[.]\w{2,3}$'
return regex_str(email_regex, error_msg='The given value is not an email address.')
def user_id():
def validate(value):
if USER_ID_REGEX.fullmatch(value):
return value
raise ValueError(f'The given value is not a valid username (value="{value}").')
return validate

15
app_common/parser.py Normal file
View File

@ -0,0 +1,15 @@
from flask_restx.reqparse import RequestParser
from werkzeug.exceptions import BadRequest
from app_common.const import MALFORMED_DATA, BAD_USER_ROLES
class QuarterRequestParser(RequestParser):
def parse_args(self, req=None, strict=False):
try:
return super().parse_args(req=req, strict=strict)
except BadRequest as err:
error_type = MALFORMED_DATA
if 'roles' in err.data.get('errors', {}):
error_type = BAD_USER_ROLES
err.data.update({'error_type': error_type})
raise err

51
build.sh Normal file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
show_help() {
echo "Build
A script to build the web app's docker images.
Usage: build.sh [OPTIONS]
Options:
-r|--render-templates: Run the render_docker_templates script before building the images.
-h|--help: Shows this help message.
"
}
cur_dir="$(cd "$( dirname "$0")" && pwd)"
docker_compose_file="$cur_dir/docker/docker-compose.yaml"
render_template_script="$cur_dir/docker/bin/render_docker_templates.sh"
venv_dir="$cur_dir/venv/bin/activate"
render_templates=false
while [[ "$#" -gt 0 ]]; do
case "$1" in
-h|--help)
show_help
exit
;;
-r|--render-templates)
render_templates=true
shift 1
;;
*)
echo "Invalid argument given ($1)"
exit 1
;;
esac
done
source "$venv_dir"
# if we need to render the docker templates, then we should do that
if "$render_templates"; then
$render_template_script
fi
if [[ ! -f "$docker_compose_file" ]]; then
echo "The docker-compose file does not exist (expected at $docker_compose_file)."
echo "Run with --render-templates to render the docker-compose file."
exit 1
fi
sudo docker-compose -f "$docker_compose_file" build

View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
line_sep="==============================="
proj_root_dir="$(cd "$(dirname "$0")/../.." && pwd)"
render_tmpl_script="$proj_root_dir/scripts/render_templates.py"
docker_root_dir="$proj_root_dir/docker"
mongo_pass="$(uuidgen | tr -d '-')"
flask_secret_key="$(uuidgen | tr -d '-')"
kwargs="app_root_dir=$proj_root_dir,frontend_build_dir=$proj_root_dir/quarterformsdist,quarter_log_dir=${QUARTER_LOG_DIR:-"$proj_root_dir/quarter_logs"},mongo_pwd=$mongo_pass,secret_key=$flask_secret_key"
if [[ -z "${IS_DEV}" ]]; then
kwargs="$kwargs,expose_port=27017"
fi
echo "Calling render_templates.py...
$line_sep
"
set -e
"$render_tmpl_script" --overwrite --kwargs "$kwargs" "$docker_root_dir/templates" "$docker_root_dir"
set +e
echo "
$line_sep
Templates rendered successfully!"

View File

@ -0,0 +1,68 @@
version: '3.3'
services:
quarter_web:
build:
context: ..
dockerfile: docker/quarter_web.Dockerfile
container_name: quarter_web
restart: unless-stopped
env_file:
- env
environment:
APP_DEBUG: "False"
MONGODB_DATABASE: quarter
MONGODB_USERNAME: quarter
MONGODB_HOSTNAME: quarter_mongodb
volumes:
- ~/quarter_logs:/var/log/www/
depends_on:
- mongodb
networks:
- frontend
- backend
mongodb:
image: mongo:4.4.4
container_name: quarter_mongodb
restart: unless-stopped
command: [--auth]
env_file:
- env
environment:
MONGO_INITDB_ROOT_USERNAME: "admin"
MONGO_INITDB_ROOT_PASSWORD: "ijfijfijf++"
MONGO_INITDB_DATABASE: "quarter"
MONDODB_LOG_DIR: /dev/null
networks:
- backend
volumes:
- ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
- ./mongo/mongo-data:/data/db/
webserver:
build:
context: ..
dockerfile: docker/nginx.Dockerfile
image: digitalocean.com/webserver:latest
container_name: webserver
restart: unless-stopped
env_file:
- env
environment:
APP_NAME: "quarter_nginx"
APP_DEBUG: "true"
SERVICE_NAME: "quarter_nginx"
ports:
- "80:80"
- "443:443"
depends_on:
- quarter_web
networks:
- frontend
networks:
frontend:
driver: bridge
backend:
driver: bridge

9
docker/nginx.Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM nginx:1.19.7
LABEL MAINTAINER="Chris Diesch <chris@quarterhomes.com>"
RUN rm /etc/nginx/conf.d/default.conf
RUN apt-get update
COPY docker/nginx.conf /etc/nginx/conf.d/quarter_web.conf

15
docker/nginx.conf Normal file
View File

@ -0,0 +1,15 @@
server {
listen 80;
server_name quarter_web;
client_max_body_size 264M;
charset utf-8;
location / {
include uwsgi_params;
uwsgi_param request_rev_msec $msec;
uwsgi_pass quarter_web:5000;
uwsgi_send_timeout 300;
uwsgi_read_timeout 300;
}
}

16
docker/quarter-web.ini Normal file
View File

@ -0,0 +1,16 @@
[uwsgi]
socket = 0.0.0.0:5000
wsgi-file = /var/www/quarter_web.wsgi
master = true
processes = 5
buffer-size = 40960
plugins = python
disable-logging=True
log-5xx=True
mule = /var/www/app/mules/JWTCleanMule.py

View File

@ -0,0 +1,18 @@
FROM python:3.9.2
LABEL MAINTAINER="Chris Diesch <chris@quarterhomes.com>"
WORKDIR /var/www
RUN apt-get update
ADD ./app/docker-entrypoint.sh /var/docker-entrypoint.sh
ADD requirements.txt /var/www/requirements.txt
RUN chmod +x /var/docker-entrypoint.sh
RUN pip install -Ur /var/www/requirements.txt
EXPOSE 5000
ENTRYPOINT ["/var/docker-entrypoint.sh"]

Some files were not shown because too many files have changed in this diff Show More