Coverage for app / __init__.py: 99%

78 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-06 04:49 +0000

1""" 

2Flask application factory module responsible for creating and configuring the application  

3instance. 

4 

5This module initializes core Flask extensions, sets up security, configures logging, and manages  

6db connections. It handles user authentication, admin interface setup, and asset bundling for  

7the application. The module provides a create_app() factory function that returns a fully  

8configured Flask application instance ready for deployment. 

9""" 

10import logging 

11import os 

12from datetime import datetime, timezone 

13 

14from flask import Flask 

15from flask_admin import Admin 

16from flask_assets import Bundle, Environment 

17from flask_caching import Cache 

18from flask_limiter.util import get_remote_address 

19from flask_login import user_logged_out 

20from flask_mailman import Mail 

21from flask_security import SQLAlchemyUserDatastore, Security, hash_password 

22from flask_sqlalchemy import SQLAlchemy 

23from flask_talisman import Talisman 

24from werkzeug.middleware.proxy_fix import ProxyFix 

25 

26from app.config import configure_app_logging, PROJECT_ROOT 

27from app.helpers import register_globals 

28from app.limiter import limiter, add_limits_to_views 

29 

30# Initial admin user. Only create if db contains no admins 

31INITIAL_USER_PASSWORD = "example1" # nosec B105, noqa: dodgy:password 

32INITIAL_USER_EMAIL = "admin@example.com" 

33 

34db = SQLAlchemy() 

35user_datastore = None # pylint: disable=invalid-name 

36admin = None # pylint: disable=invalid-name 

37cache = None # pylint: disable=invalid-name 

38talisman = None # pylint: disable=invalid-name 

39 

40 

41# pylint: disable=too-many-locals,too-many-statements 

42def create_app(): 

43 """ 

44 Creates and configures the Flask application instance. This function is 

45 responsible for creating the application, registering the necessary routes, 

46 and returning the configured Flask app, ready to be run. 

47 

48 :return: Flask application instance. 

49 :rtype: Flask 

50 """ 

51 

52 app = Flask(__name__, template_folder="templates", static_folder="static") 

53 

54 # Load config by environment 

55 env = os.getenv("FLASK_ENV", "development") # Default to "development" 

56 app.config.from_object(f"app.config.{env.capitalize()}Config") 

57 

58 # Set up logging for the environment 

59 configure_app_logging(env) 

60 

61 # Set up Talisman 

62 csp = { 

63 'default-src': "'self'", 

64 'script-src': "'self' https://beacon.helpscout.net https://cdnjs.cloudflare.com " + 

65 "https://cdn.jsdelivr.net https://beacon-v2.helpscout.net", 

66 'style-src': "'self' 'unsafe-inline' " + 

67 "https://cdnjs.cloudflare.com https://cdn.jsdelivr.net " + 

68 "https://fonts.googleapis.com", 

69 'style-src-elem': "'self' 'unsafe-inline' https://cdnjs.cloudflare.com " + 

70 "https://cdn.jsdelivr.net https://fonts.googleapis.com", 

71 'style-src-attr': "'self'", 

72 'font-src': "'self' https://cdnjs.cloudflare.com https://fonts.gstatic.com", 

73 'img-src': "'self' data: https://beacon.helpscout.net https://m.media-amazon.com " + 

74 "*", # allow images from anywhere, support book cover images 

75 'connect-src': "'self' https://beacon-v2.helpscout.net " + 

76 "https://d3hb14vkzrxvla.cloudfront.net " + 

77 "https://beaconapi.helpscout.net", 

78 } 

79 global talisman # pylint: disable=global-statement 

80 talisman = Talisman( 

81 app, 

82 force_https=True, # be sure to add --cert=adhoc to start up options for dev/test 

83 

84 frame_options='DENY', 

85 frame_options_allow_from='None', 

86 

87 strict_transport_security=False, # defer https://github.com/dandoug/readinglist/issues/61 

88 strict_transport_security_include_subdomains=False, 

89 

90 content_security_policy=csp, 

91 content_security_policy_report_only=False, # switch to True to get reports but not blocked 

92 content_security_policy_report_uri='/csp-report', 

93 content_security_policy_nonce_in=['script-src', 'style-src'], 

94 

95 referrer_policy='origin-when-cross-origin', # used to return to add the book launch page 

96 

97 session_cookie_secure=True, 

98 session_cookie_http_only=True, 

99 

100 force_file_save=True, 

101 

102 feature_policy={ 

103 'geolocation': '\'none\'' 

104 } 

105 ) 

106 

107 # handle being behind a reverse proxy, like in AWS Elastic Beanstalk 

108 app.wsgi_app = ProxyFix( 

109 app.wsgi_app, 

110 x_proto=1, # Number of values to trust for X-Forwarded-Proto 

111 x_for=1, # Number of values to trust for X-Forwarded-For 

112 x_host=1 # Number of values to trust for X-Forwarded-Host 

113 ) 

114 

115 # Bind limiter to app 

116 limiter.init_app(app) 

117 

118 # Set up email handling using Flask-Mailman using context info 

119 mail = Mail(app) 

120 

121 # Setup Flask-Security 

122 from app.security import User, Role # pylint: disable=import-outside-toplevel 

123 global user_datastore # pylint: disable=global-statement 

124 user_datastore = SQLAlchemyUserDatastore(db, User, Role) 

125 security = Security(app, user_datastore) 

126 

127 db.init_app(app) 

128 mail.init_app(app) 

129 

130 # Initialize the Admin object 

131 from app.security import SecureAdminIndexView # pylint: disable=import-outside-toplevel 

132 global admin # pylint: disable=global-statement 

133 admin = Admin(app, name='Book List Administration', template_mode='bootstrap4', 

134 index_view=SecureAdminIndexView(name="Admin")) 

135 

136 # Lazily init data models with their relationships 

137 from app.models import ReadingStatus, Feedback, Book # pylint: disable=import-outside-toplevel,unused-import 

138 

139 # override the user loader for login manager to reduce database loads of user/roles 

140 from app.security import custom_user_loader, on_logout # pylint: disable=import-outside-toplevel 

141 security.login_manager.user_loader(custom_user_loader) 

142 user_logged_out.connect(on_logout, app) # invalidate cache entry on logout 

143 

144 # setup of users and roles will bootstrap the database with basic requirements if necessary 

145 with app.app_context(): 

146 security.datastore.find_or_create_role(name='admin', description='Administrator') 

147 security.datastore.find_or_create_role(name='editor', description='Editor') 

148 

149 # Count the number of users with the 'admin' role 

150 admin_role = security.datastore.find_role('admin') 

151 admin_count = len(admin_role.users.all()) if admin_role else 0 

152 

153 # Create an initial user if there are no other admins 

154 if admin_count == 0: 154 ↛ 161line 154 didn't jump to line 161 because the condition on line 154 was always true

155 logging.warning("Creating default admin user, %s", INITIAL_USER_EMAIL) 

156 security.datastore.create_user( 

157 email=INITIAL_USER_EMAIL, 

158 confirmed_at=datetime.now().astimezone(timezone.utc), # skip confirmation 

159 password=hash_password(INITIAL_USER_PASSWORD), 

160 roles=["admin"]) 

161 db.session.commit() 

162 

163 # Import routes 

164 with app.app_context(): 

165 register_globals(app) 

166 

167 global cache # pylint: disable=global-statement 

168 cache = Cache(app) 

169 

170 # noinspection PyUnresolvedReferences 

171 from app import routes # pylint: disable=import-outside-toplevel,unused-import 

172 

173 from app.security import registration_bp # pylint: disable=import-outside-toplevel 

174 app.register_blueprint(registration_bp) 

175 

176 from app.security import register_admin_views # pylint: disable=import-outside-toplevel 

177 register_admin_views(db, admin) 

178 

179 from app.helpers import render_icon # pylint: disable=import-outside-toplevel 

180 app.jinja_env.filters['render_icon'] = render_icon 

181 

182 # Set route-specific limits we don't control directly not that they have all been defined 

183 add_limits_to_views(app) 

184 # Add limits specifically to the Flask-Login `/login` route 

185 limiter.limit("10 per minute", key_func=get_remote_address)(app.view_functions["security.login"]) 

186 # Apply rate limiting to all routes in Flask-Admin and other security roles 

187 limiter.limit("30 per minute", key_func=get_remote_address)(app.blueprints["admin"]) 

188 limiter.limit("30 per minute", key_func=get_remote_address)(app.blueprints["security"]) 

189 

190 # Define your SCSS file bundle 

191 output_dir = PROJECT_ROOT / "app" / "static" / "gen" / "css" 

192 os.makedirs(output_dir, exist_ok=True) 

193 scss = Bundle( 

194 'scss/badge-color.scss', 

195 filters='libsass', 

196 output='gen/css/badge-color.css' # Compiled CSS file location 

197 ) 

198 

199 assets = Environment(app) 

200 assets.register('badge-color', scss) 

201 

202 # Build assets during app initialization 

203 # Programmatically build assets during application startup 

204 with app.app_context(): 

205 # noinspection PyProtectedMember 

206 for name, bundle in assets._named_bundles.items(): # pylint: disable=protected-access 

207 logging.info("Building asset bundle: %s", name) 

208 bundle.build(force=True) # The `force=True` ensures the bundle rebuilds every time 

209 

210 return app