Initial Commit
2
.coveragerc
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[report]
|
||||||
|
omit="*/venv/*"
|
||||||
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "UI"]
|
||||||
|
path = UI
|
||||||
|
url = https://github.com/QuarterHomes/Quarterforms.git
|
||||||
3
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
8
.idea/ApplicantPortal.iml
generated
Normal 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>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
@ -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
@ -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
@ -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
@ -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
32
app/admin/api.py
Normal 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
@ -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')
|
||||||
5
app/applicant/__init__.py
Normal 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
@ -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
@ -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
@ -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
|
||||||
69
app/applicant/schemas/Address.json
Normal 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"
|
||||||
|
}
|
||||||
200
app/applicant/schemas/Applicant.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/applicant/schemas/Application.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/applicant/schemas/Assets.json
Normal 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"
|
||||||
|
}
|
||||||
37
app/applicant/schemas/Declarations.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/applicant/schemas/Expense.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/applicant/schemas/Job.json
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -0,0 +1 @@
|
|||||||
|
|
||||||
76
app/auth/request_parsers.py
Normal 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
@ -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
@ -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
@ -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
@ -0,0 +1,3 @@
|
|||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
cors = CORS()
|
||||||
77
app/database/__init__.py
Normal 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
@ -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
@ -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
@ -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
@ -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
3
app/model.py
Normal 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
@ -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
@ -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)
|
||||||
0
app/network_admin/__init__.py
Normal file
108
app/network_admin/api.py
Normal 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()
|
||||||
|
|
||||||
0
app/network_admin/views.py
Normal file
18
app/routes.py
Normal 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
BIN
app/static/img/FamilyMovingIn.png
Normal file
|
After Width: | Height: | Size: 818 KiB |
BIN
app/static/img/Quarterhome Logo 300x86 WHITE.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
app/static/img/Quarterhome Logo 300x86.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
app/static/img/Quarterhome Logo 500x144.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
app/static/img/aaron_420w.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
app/static/img/alphatest.webm
Normal file
BIN
app/static/img/animlogo.mp4
Normal file
BIN
app/static/img/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
app/static/img/plus.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
app/static/img/plus_30.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
app/static/img/slide1.jpg
Normal file
|
After Width: | Height: | Size: 245 KiB |
13
app/static/js/helpers.js
Normal 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
6
app/static/js/materialize.min.js
vendored
Normal file
BIN
app/static/video/alphatest.webm
Normal file
0
app/templates/apply/index.html
Normal file
63
app/templates/auth/account.html
Normal 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 %}
|
||||||
99
app/templates/auth/email_confirmation.html
Normal 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 %}
|
||||||
37
app/templates/auth/login_modal.html
Normal 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>
|
||||||
59
app/templates/auth/register.html
Normal 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>
|
||||||
20
app/templates/auth/request_password_reset.html
Normal 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>
|
||||||
35
app/templates/auth/reset_password.html
Normal 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 %}
|
||||||
9
app/templates/auth/unauthorized.html
Normal 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
@ -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 %}
|
||||||
8
app/templates/email/confirm_email.html
Normal 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>
|
||||||
6
app/templates/email/confirm_email.txt
Normal 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) }}
|
||||||
|
|
||||||
4
app/templates/email/forgot_user_id.html
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<p>{{ user.user_id }},</p>
|
||||||
|
<p>
|
||||||
|
Your quarter user ID is "{{ user.user_id }}".
|
||||||
|
</p>
|
||||||
3
app/templates/email/forgot_user_id.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{{ user.user_id }},
|
||||||
|
|
||||||
|
Your quarter user ID is "{{ user.user_id }}".
|
||||||
11
app/templates/email/reset_password.html
Normal 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>
|
||||||
8
app/templates/email/reset_password.txt
Normal 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.
|
||||||
|
|
||||||
18
app/templates/includes/flash_messages.html
Normal 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 %}
|
||||||
67
app/templates/includes/macros.html
Normal 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 %}
|
||||||
|
×
|
||||||
|
{% 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 %}
|
||||||
39
app/templates/includes/navbar.html
Normal 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
@ -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
@ -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)
|
||||||
0
app/util/datatypes/__init__.py
Normal file
27
app/util/datatypes/enum.py
Normal 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
@ -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
@ -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
@ -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
10
app_common/const.py
Normal 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
@ -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
@ -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
@ -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
|
||||||
28
docker/bin/render_docker_templates.sh
Normal 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!"
|
||||||
68
docker/docker-compose.yaml.example
Normal 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
@ -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
@ -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
@ -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
|
||||||
18
docker/quarter_web.Dockerfile
Normal 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"]
|
||||||