diff --git a/section7/__main__.py b/section7/__main__.py new file mode 100644 index 0000000..b1b3e6f --- /dev/null +++ b/section7/__main__.py @@ -0,0 +1,32 @@ +"""Development server stub entrypoint + +Flask comes with a built-in development server. This entrypoint allows ``section7`` +to be run directly to run the development server and expose some simple config options +for ease of access. Run the below command to start the server: + +:: + + python -m section7 + +.. warning:: As the development server will tell you on startup, do not use this for + production deployments. +""" +import argparse + +from section7.application import APPLICATION + + +def main(): + """Run the werkzeug development server""" + parser = argparse.ArgumentParser() + parser.add_argument("-b", "--bind", help="Host to listen on", default="127.0.0.1") + parser.add_argument( + "-p", "--port", help="Port to listen on", default=5000, type=int + ) + parser.add_argument("-d", "--debug", help="Run in debug mode", action="store_true") + args = parser.parse_args() + APPLICATION.run(host=args.bind, port=args.port, debug=args.debug, load_dotenv=True) + + +if __name__ == "__main__": + main() diff --git a/section7/application.py b/section7/application.py new file mode 100644 index 0000000..9250e47 --- /dev/null +++ b/section7/application.py @@ -0,0 +1,33 @@ +import flask +import flask_assets + +from section7 import configuration +from section7 import database +from section7 import filters +from section7 import router + + +class Section7Flask(flask.Flask): + """Stub class to typehint the ``section7`` attribute with the application config""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.section7 = configuration.load() + + +APPLICATION = Section7Flask(__name__) +assets = flask_assets.Environment(APPLICATION) + +# Register the global application handler +APPLICATION.register_error_handler(Exception, router.handle_error) + +# Initialize the database connection +APPLICATION.before_first_request(database.initialize) + +# Inject the custom jinja2 filters so that they're available in the templates +for custom_filter in filters.FILTERS: + APPLICATION.jinja_env.filters[custom_filter.__name__] = custom_filter + +# Attach the application route endpoints +for route in router.ROUTES: + APPLICATION.route(route.route, methods=list(route.methods))(route.entrypoint) diff --git a/section7/filters.py b/section7/filters.py new file mode 100644 index 0000000..5c9eba8 --- /dev/null +++ b/section7/filters.py @@ -0,0 +1,16 @@ +from typing import Callable +from typing import Optional +from typing import Tuple + +import flask + + +def url_for(value: str, filename: Optional[str] = None) -> str: + """Wrapper around :func:`flask.url_for` to expose the functionality in a template""" + + if filename: + return flask.url_for(value, filename=filename) + return flask.url_for(value) + + +FILTERS: Tuple[Callable, ...] = (url_for,) diff --git a/section7/router/__init__.py b/section7/router/__init__.py new file mode 100644 index 0000000..951c216 --- /dev/null +++ b/section7/router/__init__.py @@ -0,0 +1,23 @@ +from typing import Callable +from typing import NamedTuple +from typing import Sequence +from typing import Tuple + +from section7.router.error_handler import handle_error +from section7.router.home import display_home + + +class Routable(NamedTuple): + """Structure for storing details for a given route + + :param route: String indicating the Flask URL rule to apply to the route entry + :param entrypoint: The callable that Flask should invoke when the route is accessed + :param methods: Sequence of HTTP method verbs that should be accepted by the route + """ + + route: str + entrypoint: Callable + methods: Sequence[str] = ("GET",) + + +ROUTES: Tuple[Routable, ...] = (Routable(route="/", entrypoint=display_home),) diff --git a/section7/router/error_handler.py b/section7/router/error_handler.py new file mode 100644 index 0000000..4423d8b --- /dev/null +++ b/section7/router/error_handler.py @@ -0,0 +1,122 @@ +"""Custom error handler and error page rendering endpoint""" +import enum +import logging +import uuid +from dataclasses import dataclass +from dataclasses import field +from typing import Tuple + +import flask +import werkzeug + + +class ErrorColor(enum.Enum): + """Enum of possible colors for the error page to be rendered with + + Colors can be used to indicate the severity of the problem to a user. Generally, red is reserved + for fatal errors (errors that the user cannot fix themselves), orange for user input or + temporary errors (errors that the user can potentially fix themselves), and blue for + informational errors that don't necessarily indicate a problem, but still needs to block user + access. + + Names are the intended usage of the color while values are the CSS classes that the page + uses for the corresponding color. + """ + + WARNING = "warning" + ERROR = "error" + INFORMATIONAL = "informational" + + +class ErrorIcon(enum.Enum): + """Enum of possible icons for the error page + + Names are the intended usage of the icon while values are the Font Awesome CSS class structure + for the corresponding icon. + """ + + EXCLAMATION = "fas fa-exclamation-triangle" + INFORMATION = "fas fa-exclamation-circle" + + +@dataclass +class ErrorDetails: + """Container of details for an error that can be used to render an error page + + :param code: The HTTP response code for the error + :param title: Page title/short description of the error + :param description: Long description of the error. Ideally should include an action the user + can take to debug or fix the problem. + :param icon: The icon that should be added to the page + :param color: The color of the page highlights + """ + + code: int = 500 + title: str = "Internal Server Error" + description: str = "The application encountered an unhandled error while trying to fulfill the request." + icon: ErrorIcon = ErrorIcon.EXCLAMATION + color: ErrorColor = ErrorColor.ERROR + event_id: str = field(default_factory=lambda: uuid.uuid4().hex) + + +def handle_error(error: Exception) -> Tuple[str, int]: + """Handle an exception raised elsewhere in the application + + :returns: Rendered template of an error page for the raised exception + """ + logger = logging.getLogger(__name__) + # If the application is running in debug mode then we re-raise the exception so that the + # developer gets the helpful browser-rendered stack trace werkzeug provides + if flask.current_app.config["DEBUG"]: + logger.warning( + "Application is running in debug mode, stack trace will be returned to client" + ) + raise error + + details = ErrorDetails() + + if isinstance(error, NotImplementedError): + details.title = "Not Implemented" + details.description = "The functionality for this resource has not yet been implemented. The resource name is reserved for for future use" + details.color = ErrorColor.INFORMATIONAL + details.icon = ErrorIcon.INFORMATION + + elif isinstance(error, werkzeug.exceptions.HTTPException): + details.code = error.code + details.title = error.name + details.description = error.description + if 400 <= error.code < 500: + details.color = ErrorColor.WARNING + details.icon = ErrorIcon.INFORMATION + + else: + logger.exception( + f"Event ID {details.event_id}: unhandled application error at '{flask.request.full_path}'" + ) + + logger.error( + f"Event ID {details.event_id}: Error {details.code} {details.description}" + ) + + def _render_template( + title: str, + error: ErrorDetails, + ) -> str: + """Stub function to type, enumerate, and document the template parameters + + :param title: Value of the page title header + :param error: Container of error details about the error to render on the page + """ + return flask.render_template( + "error.html.j2", + title=title, + error=error, + ) + + return ( + _render_template( + title=f"{details.title} ({details.code})", + error=details, + ), + details.code, + ) diff --git a/section7/router/home.py b/section7/router/home.py new file mode 100644 index 0000000..ff944ba --- /dev/null +++ b/section7/router/home.py @@ -0,0 +1,24 @@ +"""Endpoint for displaying the main homepage of the application""" +import flask + + +def display_home() -> str: + """Display the home page + + :returns: A response rendering the home page template + """ + + def _render_template() -> str: + """Stub function to type, enumerate, and document the template parameters + + :param version: Semantic version of the currently running application + :param common_urls: Container of common application URLs used in mutliple templates + :param testers: List of tester names that the logged in user can read to link to for + easy access search access + :param messages: Optional list of Flask flash messages to render as popup notifications + """ + return flask.render_template( + "home.html.j2", + ) + + return _render_template() diff --git a/section7/templates/base.html.j2 b/section7/templates/base.html.j2 new file mode 100644 index 0000000..21a7789 --- /dev/null +++ b/section7/templates/base.html.j2 @@ -0,0 +1,14 @@ + + + + {% block header %}{% endblock %} + + + +
+ +
+ {% block content %}{% endblock %} +
+ + diff --git a/section7/templates/error.html.j2 b/section7/templates/error.html.j2 new file mode 100644 index 0000000..bb6905a --- /dev/null +++ b/section7/templates/error.html.j2 @@ -0,0 +1,10 @@ +{% extends "base.html.j2" %} +{% from "macros.html.j2" import make_header %} + +{% block header %} +{{ make_header(title) }} +{% endblock %} + +{% block content %} +{{ error.title }} +{% endblock %} diff --git a/section7/templates/home.html.j2 b/section7/templates/home.html.j2 new file mode 100644 index 0000000..54ed712 --- /dev/null +++ b/section7/templates/home.html.j2 @@ -0,0 +1,10 @@ +{% extends "base.html.j2" %} +{% from "macros.html.j2" import make_header %} + +{% block header %} +{{ make_header('Disclose My Pay') }} +{% endblock %} + +{% block content %} +Hello world +{% endblock %} diff --git a/section7/templates/macros.html.j2 b/section7/templates/macros.html.j2 new file mode 100644 index 0000000..44e19d4 --- /dev/null +++ b/section7/templates/macros.html.j2 @@ -0,0 +1,56 @@ +{% macro make_header(title, js_bundle=none, css_bundle=none) %} + + + + + + + + + + + + + + + + {{ title }} + + + + + + + + + + + + {% if js_bundle is not none %}{% assets js_bundle %} + + {% endassets %}{% endif %} + + {% if css_bundle is not none %}{% assets css_bundle %} + + {% endassets %}{% endif %} +{% endmacro %}