Coverage for app / __init__.py: 99%
78 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-06 04:49 +0000
« 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.
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
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
26from app.config import configure_app_logging, PROJECT_ROOT
27from app.helpers import register_globals
28from app.limiter import limiter, add_limits_to_views
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"
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
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.
48 :return: Flask application instance.
49 :rtype: Flask
50 """
52 app = Flask(__name__, template_folder="templates", static_folder="static")
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")
58 # Set up logging for the environment
59 configure_app_logging(env)
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
84 frame_options='DENY',
85 frame_options_allow_from='None',
87 strict_transport_security=False, # defer https://github.com/dandoug/readinglist/issues/61
88 strict_transport_security_include_subdomains=False,
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'],
95 referrer_policy='origin-when-cross-origin', # used to return to add the book launch page
97 session_cookie_secure=True,
98 session_cookie_http_only=True,
100 force_file_save=True,
102 feature_policy={
103 'geolocation': '\'none\''
104 }
105 )
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 )
115 # Bind limiter to app
116 limiter.init_app(app)
118 # Set up email handling using Flask-Mailman using context info
119 mail = Mail(app)
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)
127 db.init_app(app)
128 mail.init_app(app)
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"))
136 # Lazily init data models with their relationships
137 from app.models import ReadingStatus, Feedback, Book # pylint: disable=import-outside-toplevel,unused-import
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
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')
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
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()
163 # Import routes
164 with app.app_context():
165 register_globals(app)
167 global cache # pylint: disable=global-statement
168 cache = Cache(app)
170 # noinspection PyUnresolvedReferences
171 from app import routes # pylint: disable=import-outside-toplevel,unused-import
173 from app.security import registration_bp # pylint: disable=import-outside-toplevel
174 app.register_blueprint(registration_bp)
176 from app.security import register_admin_views # pylint: disable=import-outside-toplevel
177 register_admin_views(db, admin)
179 from app.helpers import render_icon # pylint: disable=import-outside-toplevel
180 app.jinja_env.filters['render_icon'] = render_icon
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"])
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 )
199 assets = Environment(app)
200 assets.register('badge-color', scss)
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
210 return app