Skip to content

Commit

Permalink
Merge branch 'development' into fix/phenotype-db
Browse files Browse the repository at this point in the history
  • Loading branch information
vlagrassa committed May 31, 2024
2 parents deca673 + 26e7032 commit 2b06b05
Show file tree
Hide file tree
Showing 33 changed files with 2,272 additions and 458 deletions.
89 changes: 86 additions & 3 deletions src/modules/site-v2/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from datetime import datetime
from caendr.services.logger import logger
from flask import Flask, render_template, request, redirect
from flask import Flask, render_template, request, redirect, flash, g, session
from flask_wtf.csrf import CSRFProtect
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import generate_password_hash, check_password_hash
Expand All @@ -17,7 +17,7 @@

from caendr.models.error import BasicAuthError
from caendr.services.cloud.postgresql import db, health_database_status
from base.utils.markdown import render_markdown, render_ext_markdown
from base.utils.markdown import render_markdown, render_ext_markdown, render_markdown_inline



Expand Down Expand Up @@ -128,6 +128,7 @@ def create_app(config=config):
configure_jinja(app)
configure_ssl(app)

register_announcement_handlers(app)
password_protect_site(app)

# app.teardown_request(close_active_connections)
Expand Down Expand Up @@ -274,7 +275,8 @@ def inject():
get_env=get_env,
basename=os.path.basename,
render_markdown=render_markdown,
render_ext_markdown=render_ext_markdown
render_ext_markdown=render_ext_markdown,
render_markdown_inline=render_markdown_inline,
)

@app.context_processor
Expand Down Expand Up @@ -309,6 +311,87 @@ def _jinja2_filter_species_italic(text):
def _jinja2_filter_percent(n):
return f'{round(n * 100, 2)}%'

@app.template_filter('markdown')
def _jinja2_filter_markdown(text):
return render_markdown_inline(text)


def register_announcement_handlers(app):
'''
Add site-wide announcements to relevant requests.
'''
from caendr.models.datastore import Announcement

def can_show_announcements(request, response=None, check_session_flags=False) -> bool:
'''
Helper function to determine whether a request should flash the active site-wide announcements.
Announcements only possible for a non-redirecting GET request that returns HTML, excluding static files.
Additionally, specific endpoints can block announcements using the block_announcements decorator
or the block_announcements_from_bp function.
'''

# Request conditions: Must be a GET request to a non-static path
conditions = [
request.endpoint != 'static',
request.method == 'GET',
]

# Response conditions: Must return HTML, and must not be redirecting
if response:
conditions += [
response.mimetype == 'text/html',
not (response.status_code >= 300 and response.status_code < 400),
]

# Sessions flag(s)
if check_session_flags:
conditions += [
not getattr(g, 'block_site_announcements', False),
]

# Check all conditions
return all(conditions)

@app.context_processor
def inject_announcements():
'''
Before rendering any Jinja template, queue up and flash all the site-wide announcements
that apply to this URL.
This has to happen before the template is rendered, otherwise announcements will be queued
for the *next* page load.
This function is registered as a Jinja template context processor, so it will only be run
before a Jinja template is rendered. This prevents announcements from bleeding over to the
next request if e.g. a redirect or a static HTML file is returned instead.
'''

# Make sure this request can show announcements
# If not, we have to return an empty object, since this is a context processor
if not can_show_announcements(request, check_session_flags=True):
return {}

# Make sure site announcements set exists
# Store as a set to help prevent flashing duplicate announcements
if not hasattr(g, 'site_announcements'):
g.site_announcements = set()

# Queue up all the active announcements that apply to this path
for a in Announcement.query_ds(filters=[("active", "=", True)], deleted=False):
if a.matches_path(request.path):
g.site_announcements.add(a)

# Flash all the announcements that apply to this path
# We have to do this before the template is rendered, otherwise they'll be queued for the *next* page
for a in g.site_announcements:
flash(a['content'], a['style'].get_bootstrap_color())

# Since this is a context processor, we have to return a dict of variables to add to the
# Jinja template rendering context
# In this case, we don't need to add any
return {}


def register_errorhandlers(app):
def render_error(e="generic"):
Expand Down
10 changes: 10 additions & 0 deletions src/modules/site-v2/base/forms/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
Optional,
ValidationError)
from wtforms.fields.html5 import EmailField
from wtforms.widgets import CheckboxInput


from constants import PRICES, SECTOR_OPTIONS, SHIPPING_OPTIONS, PAYMENT_OPTIONS, TOOL_INPUT_DATA_VALID_FILE_EXTENSIONS, TRAIT_CATEGORY_OPTIONS
Expand All @@ -37,6 +38,7 @@
from caendr.services.indel_primer import get_indel_primer_chrom_choices
from caendr.services.markdown import get_content_type_form_options
from caendr.models.datastore import User, Species, DatasetRelease, TraitFile
from caendr.models.datastore.announcement import AnnouncementType
from caendr.api.strain import query_strains
from base.forms.validators import (validate_duplicate_strain,
validate_duplicate_isotype,
Expand Down Expand Up @@ -119,6 +121,14 @@ class RecoverUserForm(FlaskForm):
email = EmailField('Email Address', [Required(), Email(), Length(min=6, max=320)])
recaptcha = RecaptchaField()


class AnnouncementForm(FlaskForm):
""" Edit form for site announcements """
active = BooleanField('Active')
content = StringField('Content', [Optional()])
url_list = TextAreaField('URL Patterns', [Optional()])
style = SelectField('Style', [Optional()], choices=[(x.name, x.value) for x in AnnouncementType])

class MarkdownForm(FlaskForm):
""" markdown editing form """
_CONTENT_TYPES = get_content_type_form_options()
Expand Down
27 changes: 27 additions & 0 deletions src/modules/site-v2/base/utils/announcements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from functools import wraps

from flask import g



def block_announcements(f):
'''
Prevent an endpoint from flashing site-wide announcements.
'''
@wraps(f)
def decorator(*args, **kwargs):
try:
g.block_site_announcements = True
except:
pass
return f(*args, **kwargs)
return decorator


def block_announcements_from_bp(bp):
'''
Prevent all endpoints in the given blueprint from flashing site-wide announcements.
'''
@bp.before_request
def _block_announcements():
g.block_site_announcements = True
6 changes: 6 additions & 0 deletions src/modules/site-v2/base/utils/markdown.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import bleach
import os
import markdown
import requests
Expand All @@ -10,6 +11,11 @@
MODULE_SITE_BUCKET_PUBLIC_NAME = os.environ.get('MODULE_SITE_BUCKET_PUBLIC_NAME')



def render_markdown_inline(content):
return Markup(markdown.markdown(bleach.clean(content)))


def render_markdown(filename, directory="base/static/content/markdown"):
path = os.path.join(directory, filename)
if not os.path.exists(path):
Expand Down
58 changes: 56 additions & 2 deletions src/modules/site-v2/base/utils/view_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from base.utils.tools import lookup_report, get_upload_err_msg
from constants import TOOL_INPUT_DATA_VALID_FILE_EXTENSIONS

from caendr.models.datastore import DatasetRelease, Species
from caendr.models.datastore import DatasetRelease, Species, Entity
from caendr.models.error import NotFoundError, ReportLookupError, EmptyReportDataError, EmptyReportResultsError, FileUploadError, DataValidationError
from caendr.models.job_pipeline import JobPipeline
from caendr.services.logger import logger
Expand Down Expand Up @@ -147,7 +147,54 @@ def decorator(*args, report_id, **kwargs):



def validate_form(form_class: Type[FlaskForm], from_json: bool = False, err_msg: str = None, flash_err_msg: bool = True):
def parse_entity_id(entity_class: Type[Entity], required: bool = True, kw_name_id: str = 'entity_id', kw_name_entity: str = 'entity'):
'''
Given an entity ID as a keyword argument, lookup and inject the entity with that ID.
'''
def wrapper(f):
@wraps(f)
def decorator(*args, **kwargs):

# Extract the entity ID from the keywords using the given name
entity_id = kwargs.get(kw_name_id)
kwargs = { key: val for key, val in kwargs.items() if key != kw_name_id }

# If no ID given, optionally raise error
if entity_id is None:
if required:
abort(404)
else:
e = None

# If ID given, try retrieving entity from datastore
else:
try:
e = entity_class.get_ds(entity_id)

# If not found, abort with 404
except NotFoundError as ex:
logger.error(f'Could not find {entity_class.kind} with ID {entity_id}: {ex}')
abort(404, description = f'Could not find an {entity_class.kind} object with the given ID.')

# General error: include default message
except Exception as ex:
logger.error(f'Error retrieving {entity_class.kind} with ID {entity_id}: {ex}')
abort(500, description = 'Something went wrong')

# If entity does not exist, abort with 404
if e is None:
logger.error(f'Could not find {entity_class.kind} with ID {entity_id}')
abort(404, description = f'Could not find an {entity_class.kind} object with the given ID.')

# Inject retrieved entity into function call
return f(*args, **{kw_name_entity: e}, **kwargs)

return decorator
return wrapper



def validate_form(form_class: Type[FlaskForm], from_json: bool = False, err_msg: str = None, flash_err_msg: bool = True, methods = None):
'''
Parse the request form into the given form type, validate the fields, and inject the data as a dict.
Expand All @@ -164,6 +211,8 @@ def validate_form(form_class: Type[FlaskForm], from_json: bool = False, err_msg:
- `from_json`: If `True`, use the request `.get_json()` as the fields instead.
- `err_msg`: An error message to add to the response if validation fails.
- `flash_err_msg`: If `True`, flashes the `err_msg` in addition to returning it.
- `methods`: A list of request methods to expect a form from. If `None`, checks for a form in all requests;
otherwise, does not try to extract a form from any request method not in the list.
'''

def wrapper(f):
Expand All @@ -181,6 +230,11 @@ def decorator(*args, **kwargs):
# If user is admin, allow them to bypass cache with URL variable
no_cache = bool(user_is_admin() and request.args.get("nocache", False))

# If a list of methods is provided, make sure the current request method is in it
# If it's not, then there should not be any form data for this request
if methods is not None and request.method not in methods:
return f(*args, form_data=None, no_cache=no_cache, **kwargs)

# Pull the raw data from either the form or the JSON body
raw_data = request.get_json() if from_json else request.form

Expand Down
46 changes: 44 additions & 2 deletions src/modules/site-v2/base/views/admin/admin.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from flask import (render_template,
Blueprint)
from flask import render_template, Blueprint, abort

from config import config
from base.forms import AnnouncementForm
from base.utils.auth import admin_required
from base.utils.view_decorators import parse_entity_id

from caendr.models.datastore import Announcement
from caendr.services.cloud.secret import get_secret
from caendr.services.cloud.sheets import GOOGLE_SHEET_PREFIX

from caendr.models.datastore.announcement import AnnouncementType


ANDERSEN_LAB_STRAIN_SHEET = get_secret('ANDERSEN_LAB_STRAIN_SHEET')
CENDR_PUBLICATIONS_SHEET = get_secret('CENDR_PUBLICATIONS_SHEET')

Expand Down Expand Up @@ -36,3 +41,40 @@ def admin_publications_sheet():
title = "CaeNDR Publications Sheet"
sheet_url = f"{GOOGLE_SHEET_PREFIX}/{CENDR_PUBLICATIONS_SHEET}"
return render_template('admin/google_sheet.html', **locals())


@admin_bp.route('/announcements', methods=['GET'])
@admin_required()
def announcements():
'''
Manage the site announcements.
'''
return render_template('admin/announcements/list.html', **{
'title': 'Site Announcements',
'form': AnnouncementForm(),

'AnnouncementType': AnnouncementType,
})


@admin_bp.route('/announcements/create', methods=['GET'])
@admin_bp.route('/announcements/edit/<string:entity_id>', methods=['GET'])
@admin_required()
@parse_entity_id(Announcement, required=False, kw_name_id='entity_id', kw_name_entity='announcement')
def announcements_edit(announcement: Announcement = None):
'''
Manage the site announcements.
'''

# Initialize the form with the existing object (or None if creating new)
form = AnnouncementForm(obj=announcement)
if announcement:
form.style.data = announcement['style'].name

return render_template('admin/announcements/edit.html', **{
'title': ('Edit' if announcement else 'Create') + ' Announcement',
'form': form,

'announcement': announcement,
'AnnouncementType': AnnouncementType,
})
Loading

0 comments on commit 2b06b05

Please sign in to comment.