import logging from typing import Any, List, Optional import sqlalchemy.exc from flask import (Blueprint, Response, current_app, flash, redirect, render_template, url_for) from flask.typing import ResponseReturnValue from flask_wtf import FlaskForm from sqlalchemy import exc from wtforms import (BooleanField, FileField, SelectField, StringField, SubmitField) from wtforms.validators import DataRequired from app.brm.static import create_static_origin from app.models.base import Group from app.models.cloud import CloudAccount, CloudProvider from app.models.mirrors import Origin, StaticOrigin from app.portal.util import response_404, view_lifecycle bp = Blueprint("static", __name__) class StaticOriginForm(FlaskForm): # type: ignore description = StringField( 'Description', validators=[DataRequired()], description='Enter a brief description of the static website that you are creating in this field. This is ' 'also a required field.' ) group = SelectField( 'Group', validators=[DataRequired()], description='Select the group that you want the origin to belong to from the drop-down menu in this field. ' 'This is a required field.' ) storage_cloud_account = SelectField( 'Storage Cloud Account', validators=[DataRequired()], description='Select the cloud account that you want the origin to be deployed to from the drop-down menu in ' 'this field. This is a required field.' ) source_cloud_account = SelectField( 'Source Cloud Account', validators=[DataRequired()], description='Select the cloud account that will be used to modify the source repository for the web content ' 'for this static origin. This is a required field.' ) source_project = StringField( 'Source Project', validators=[DataRequired()], description='GitLab project path.' ) auto_rotate = BooleanField( 'Auto-Rotate', default=True, description='Select this field if you want to enable auto-rotation for the mirror. This means that the mirror ' 'will automatically redeploy with a new domain name if it is detected to be blocked. This field ' 'is optional and is enabled by default.' ) matrix_homeserver = SelectField( 'Matrix Homeserver', description='Select the Matrix homeserver from the drop-down box to enable Keanu Convene on mirrors of this ' 'static origin.' ) keanu_convene_path = StringField( 'Keanu Convene Path', default='talk', description='Enter the subdirectory to present the Keanu Convene application at on the mirror. This defaults ' 'to "talk".' ) keanu_convene_logo = FileField( 'Keanu Convene Logo', description='Logo to use for Keanu Convene' ) keanu_convene_color = StringField( 'Keanu Convene Accent Color', default='#0047ab', description='Accent color to use for Keanu Convene (HTML hex code)' ) enable_clean_insights = BooleanField( 'Enable Clean Insights', description='When enabled, a Clean Insights Measurement Proxy endpoint is deployed on the mirror to allow for ' 'submission of results from any of the supported Clean Insights SDKs.' ) submit = SubmitField('Save Changes') def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.group.choices = [(x.id, x.group_name) for x in Group.query.all()] self.storage_cloud_account.choices = [(x.id, f"{x.provider.description} - {x.description}") for x in CloudAccount.query.filter( CloudAccount.provider == CloudProvider.AWS).all()] self.source_cloud_account.choices = [(x.id, f"{x.provider.description} - {x.description}") for x in CloudAccount.query.filter( CloudAccount.provider == CloudProvider.GITLAB).all()] self.matrix_homeserver.choices = [(x, x) for x in current_app.config['MATRIX_HOMESERVERS']] @bp.route("/new", methods=['GET', 'POST']) @bp.route("/new/", methods=['GET', 'POST']) def static_new(group_id: Optional[int] = None) -> ResponseReturnValue: form = StaticOriginForm() if len(form.source_cloud_account.choices) == 0 or len(form.storage_cloud_account.choices) == 0: flash("You must add at least one AWS account and at least one GitLab account before creating static origins.", "warning") return redirect(url_for("portal.cloud.cloud_account_list")) if form.validate_on_submit(): try: static = create_static_origin( form.description.data, int(form.group.data), int(form.storage_cloud_account.data), int(form.source_cloud_account.data), form.source_project.data, form.auto_rotate.data, form.matrix_homeserver.data, form.keanu_convene_path.data, form.keanu_convene_logo.data, form.keanu_convene_color.data, form.enable_clean_insights.data, True ) flash(f"Created new static origin #{static.id}.", "success") return redirect(url_for("portal.static.static_edit", static_id=static.id)) except ValueError as e: # may be returned by create_static_origin and from the int conversion logging.warning(e) flash("Failed to create new static origin due to an invalid input.", "danger") return redirect(url_for("portal.static.static_list")) except exc.SQLAlchemyError as e: flash("Failed to create new static origin due to a database error.", "danger") logging.warning(e) return redirect(url_for("portal.static.static_list")) if group_id: form.group.data = group_id return render_template("new.html.j2", section="static", form=form) @bp.route('/edit/', methods=['GET', 'POST']) def static_edit(static_id: int) -> ResponseReturnValue: static_origin: Optional[StaticOrigin] = StaticOrigin.query.filter(StaticOrigin.id == static_id).first() if static_origin is None: return Response(render_template("error.html.j2", section="static", header="404 Origin Not Found", message="The requested static origin could not be found."), status=404) form = StaticOriginForm(description=static_origin.description, group=static_origin.group_id, storage_cloud_account=static_origin.storage_cloud_account_id, source_cloud_account=static_origin.source_cloud_account_id, source_project=static_origin.source_project, matrix_homeserver=static_origin.matrix_homeserver, keanu_convene_path=static_origin.keanu_convene_path, auto_rotate=static_origin.auto_rotate, enable_clean_insights=bool(static_origin.clean_insights_backend)) form.group.render_kw = {"disabled": ""} form.storage_cloud_account.render_kw = {"disabled": ""} form.source_cloud_account.render_kw = {"disabled": ""} if form.validate_on_submit(): try: static_origin.update( form.source_project.data, form.description.data, form.auto_rotate.data, form.matrix_homeserver.data, form.keanu_convene_path.data, form.keanu_convene_logo.data, form.keanu_convene_color.data, form.enable_clean_insights.data, True ) flash("Saved changes to group.", "success") except ValueError as e: # may be returned by create_static_origin and from the int conversion logging.warning(e) flash("An error occurred saving the changes to the static origin due to an invalid input.", "danger") except exc.SQLAlchemyError as e: logging.warning(e) flash("An error occurred saving the changes to the static origin due to a database error.", "danger") try: origin = Origin.query.filter_by(domain_name=static_origin.origin_domain_name).one() proxies = origin.proxies except sqlalchemy.exc.NoResultFound: proxies = [] return render_template("static.html.j2", section="static", static=static_origin, form=form, proxies=proxies) @bp.route("/list") def static_list() -> ResponseReturnValue: statics: List[StaticOrigin] = StaticOrigin.query.order_by(StaticOrigin.description).all() return render_template("list.html.j2", section="static", title="Static Origins", item="static", new_link=url_for("portal.static.static_new"), items=statics ) @bp.route("/destroy/", methods=['GET', 'POST']) def static_destroy(static_id: int) -> ResponseReturnValue: static = StaticOrigin.query.filter(StaticOrigin.id == static_id, StaticOrigin.destroyed.is_(None)).first() if static is None: return response_404("The requested static origin could not be found.") return view_lifecycle( header=f"Destroy static origin {static.description}", message=static.description, success_message="All proxies from the destroyed static origin will shortly be destroyed at their providers, " "and the static content will be removed from the cloud provider.", success_view="portal.static.static_list", section="static", resource=static, action="destroy" )