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"]
|
||||