diff --git a/src/modules/site-v2/application.py b/src/modules/site-v2/application.py index 32822875a..210700880 100755 --- a/src/modules/site-v2/application.py +++ b/src/modules/site-v2/application.py @@ -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 @@ -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 @@ -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) @@ -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 @@ -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"): diff --git a/src/modules/site-v2/base/forms/forms.py b/src/modules/site-v2/base/forms/forms.py index c8988c1e7..79b37fd00 100755 --- a/src/modules/site-v2/base/forms/forms.py +++ b/src/modules/site-v2/base/forms/forms.py @@ -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 @@ -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, @@ -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() diff --git a/src/modules/site-v2/base/utils/announcements.py b/src/modules/site-v2/base/utils/announcements.py new file mode 100644 index 000000000..723a18671 --- /dev/null +++ b/src/modules/site-v2/base/utils/announcements.py @@ -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 diff --git a/src/modules/site-v2/base/utils/markdown.py b/src/modules/site-v2/base/utils/markdown.py index ec2ed0f58..b0cd689da 100644 --- a/src/modules/site-v2/base/utils/markdown.py +++ b/src/modules/site-v2/base/utils/markdown.py @@ -1,3 +1,4 @@ +import bleach import os import markdown import requests @@ -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): diff --git a/src/modules/site-v2/base/utils/view_decorators.py b/src/modules/site-v2/base/utils/view_decorators.py index 5174d26da..4e6f094ba 100644 --- a/src/modules/site-v2/base/utils/view_decorators.py +++ b/src/modules/site-v2/base/utils/view_decorators.py @@ -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 @@ -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. @@ -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): @@ -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 diff --git a/src/modules/site-v2/base/views/admin/admin.py b/src/modules/site-v2/base/views/admin/admin.py index d96dd04a7..03b123595 100755 --- a/src/modules/site-v2/base/views/admin/admin.py +++ b/src/modules/site-v2/base/views/admin/admin.py @@ -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') @@ -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/', 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, + }) diff --git a/src/modules/site-v2/base/views/api/notifications.py b/src/modules/site-v2/base/views/api/notifications.py index f997818ad..50455ae2e 100644 --- a/src/modules/site-v2/base/views/api/notifications.py +++ b/src/modules/site-v2/base/views/api/notifications.py @@ -1,14 +1,20 @@ from flask import jsonify, Blueprint, url_for, abort, request -from base.utils.auth import access_token_required +from caendr.services.logger import logger + +from base.forms import AnnouncementForm +from base.utils.auth import access_token_required, admin_required from base.utils.tools import lookup_report from base.views.tools import pairwise_indel_finder_bp, genetic_mapping_bp, heritability_calculator_bp -from caendr.models.datastore import NemascanReport, HeritabilityReport, IndelPrimerReport -from caendr.models.error import ReportLookupError +from base.utils.view_decorators import parse_entity_id, validate_form + +from caendr.models.datastore import NemascanReport, HeritabilityReport, IndelPrimerReport, Announcement +from caendr.models.error import ReportLookupError, NotFoundError from caendr.models.status import JobStatus from caendr.services.email import REPORT_SUCCESS_EMAIL_TEMPLATE, REPORT_ERROR_EMAIL_TEMPLATE from caendr.services.cloud.secret import get_secret +from caendr.utils.json import jsonify_request API_SITE_ACCESS_TOKEN = get_secret('CAENDR_API_SITE_ACCESS_TOKEN') @@ -28,6 +34,12 @@ def notifications(): abort(404) + +# +# Job Status Notifications +# + + @api_notifications_bp.route('/job-finish///', methods=['GET']) @access_token_required(API_SITE_ACCESS_TOKEN) def job_finish(kind, id, status): @@ -70,3 +82,82 @@ def job_finish(kind, id, status): report_link = f'{link}', ), }) + + + +# +# Site Announcements +# + + +@api_notifications_bp.route('/announcements', methods=['GET']) +@admin_required() +@jsonify_request +def announcement_list(): + ''' + Get the list of all site announcements. + ''' + return { + 'data': [ e.serialize(include_name=True) for e in Announcement.query_ds(deleted=False) ] + } + + +@api_notifications_bp.route('/announcement', methods=['POST']) +@api_notifications_bp.route('/announcement/', methods=['GET', 'PATCH', 'DELETE']) +@admin_required() +@parse_entity_id(Announcement, required=False, kw_name_id='entity_id', kw_name_entity='announcement') +@validate_form(AnnouncementForm, methods=['POST', 'PATCH']) +@jsonify_request +def announcement(announcement: Announcement = None, form_data = None, no_cache: bool = False): + ''' + Manage one of the site announcements. + + Methods: + GET: Get the data for the given announcement ID. + POST: Create a new announcement. + PATCH: Update an existing announcement. + DELETE: Delete an existing announcement. + ''' + + # GET Request + # Return the list of announcement entities + if request.method == 'GET': + return announcement.serialize() + + # POST Request + # Create a new announcement, and return its unique ID + if request.method == 'POST': + new_announcement = Announcement(**{ + prop: form_data.get(prop) for prop in Announcement.get_props_set() + }) + new_announcement.save() + return { 'id': new_announcement.name } + + # PATCH Request + # Lookup the desired announcement and update its properties + if request.method == 'PATCH': + + # Extract the new property values from the form, casting "active" to a bool + new_values = { + prop: form_data.get(prop) + for prop in Announcement.get_props_set() + if form_data.get(prop) is not None + } + if form_data.get('active') is not None: + new_values['active'] = form_data.get('active') == 'true' + + # Update the announcement object + announcement.set_properties(**new_values) + announcement.save() + return { 'id': announcement.name } + + # DELETE Request + # Lookup the desired announcement and soft delete it + if request.method == 'DELETE': + announcement.soft_delete() + announcement.save() + return {}, 200 + + # If somehow the method didn't match any of the above, + # return a Method Not Allowed error + abort(405) diff --git a/src/modules/site-v2/base/views/auth/auth.py b/src/modules/site-v2/base/views/auth/auth.py index 6b176dbdc..353c28bcc 100644 --- a/src/modules/site-v2/base/views/auth/auth.py +++ b/src/modules/site-v2/base/views/auth/auth.py @@ -23,10 +23,16 @@ from caendr.services.cloud.secret import get_secret from base.views.auth.oauth import transfer_cart +from base.utils.announcements import block_announcements_from_bp + + PASSWORD_PEPPER = get_secret('PASSWORD_PEPPER') auth_bp = Blueprint('auth', __name__, template_folder='templates') +block_announcements_from_bp(auth_bp) + + @auth_bp.route('/') def auth(): return redirect(url_for('auth.choose_login')) diff --git a/src/modules/site-v2/base/views/tools/genetic_mapping.py b/src/modules/site-v2/base/views/tools/genetic_mapping.py index 65bb78351..de9805233 100755 --- a/src/modules/site-v2/base/views/tools/genetic_mapping.py +++ b/src/modules/site-v2/base/views/tools/genetic_mapping.py @@ -5,6 +5,7 @@ from flask import jsonify from base.forms import MappingForm +from base.utils.announcements import block_announcements from base.utils.auth import get_jwt, jwt_required, admin_required, get_current_user, user_is_admin from base.utils.tools import list_reports, try_submit from base.utils.view_decorators import parse_job_id, validate_form @@ -145,6 +146,7 @@ def report(job: NemascanPipeline): @genetic_mapping_bp.route('/report//fullscreen', methods=['GET']) @jwt_required() +@block_announcements @parse_job_id(NemascanPipeline, fetch=False) def report_fullscreen(job: NemascanPipeline): diff --git a/src/modules/site-v2/templates/_includes/alert.html b/src/modules/site-v2/templates/_includes/alert.html index cf5d26a91..c87510fef 100644 --- a/src/modules/site-v2/templates/_includes/alert.html +++ b/src/modules/site-v2/templates/_includes/alert.html @@ -1,4 +1,17 @@ -
-

{{ msg }}

+{% if not dismissable is defined %} + {% set dismissable = true %} +{% endif %} + +
+
+ {%- if msg is defined %} + {{ msg | markdown }} + {%- endif %} +
+ {%- if dismissable %} + {%- endif %}
\ No newline at end of file diff --git a/src/modules/site-v2/templates/_includes/head.html b/src/modules/site-v2/templates/_includes/head.html index df9f30d32..aa040cea5 100755 --- a/src/modules/site-v2/templates/_includes/head.html +++ b/src/modules/site-v2/templates/_includes/head.html @@ -60,6 +60,10 @@ + + + + diff --git a/src/modules/site-v2/templates/_includes/macros.html b/src/modules/site-v2/templates/_includes/macros.html index 3a8e96837..15a3e1f24 100755 --- a/src/modules/site-v2/templates/_includes/macros.html +++ b/src/modules/site-v2/templates/_includes/macros.html @@ -45,6 +45,8 @@ {% if form_prefix %}{{ form_prefix }}{% endif %} {% if field.type == 'RadioField' %} {{ field(**kwargs) }} + {% elif field.type == 'BooleanField' %} + {{ field(**kwargs) }} {% elif field.type == 'FileField' %}
{{ field(class_="form-control", required=kwargs.get('required', field.flags.required), **kwargs) }} diff --git a/src/modules/site-v2/templates/_includes/phenotype-database-table.html b/src/modules/site-v2/templates/_includes/phenotype-database-table.html index 8b36d22a8..69bc7140d 100644 --- a/src/modules/site-v2/templates/_includes/phenotype-database-table.html +++ b/src/modules/site-v2/templates/_includes/phenotype-database-table.html @@ -122,7 +122,13 @@ } }, {"data": "trait_name_caendr"}, - {"data": "description_long"}, + { + "data": "description_long", + // TODO: Should this be Markdown? + // "render": function(data, type, row) { + // return markdownToHTML(data); + // }, + }, { "data": "created_on", "render": function(data, type, row) { diff --git a/src/modules/site-v2/templates/_scripts/utils.js b/src/modules/site-v2/templates/_scripts/utils.js index 936299c10..4a0c3fc2c 100644 --- a/src/modules/site-v2/templates/_scripts/utils.js +++ b/src/modules/site-v2/templates/_scripts/utils.js @@ -105,6 +105,17 @@ function create_node(html) { } +// Convert text from Markdown to HTML using Toast UI +// This protects against code injection +function markdownToHTML(text) { + const editor = new toastui.Editor({ + el: document.createElement('div'), + initialValue: text, + }); + return editor.getHTML(); +} + + function save_svg(selector, filename=null) { const svg_el = document.querySelector(selector); const data = (new XMLSerializer()).serializeToString(svg_el); @@ -132,10 +143,9 @@ function flash_message(message, full_msg_link=null, full_msg_body=null) { const raw_html = `{% include '_includes/alert.html' %}`; {%- endwith %} - // Create as a new DOM node, and insert the desired message as text - // Inserting as text protects against code injection + // Create as a new DOM node, and insert the desired message as formatted (cleaned) HTML const node = create_node(raw_html); - node.firstElementChild.innerText = message; + node.firstElementChild.innerHTML = markdownToHTML(message); // If both full message fields are provided, add as a link & collapse dropdown if (full_msg_link && full_msg_body) { diff --git a/src/modules/site-v2/templates/admin/announcements/edit.html b/src/modules/site-v2/templates/admin/announcements/edit.html new file mode 100644 index 000000000..1047a56a5 --- /dev/null +++ b/src/modules/site-v2/templates/admin/announcements/edit.html @@ -0,0 +1,209 @@ +{% extends "_layouts/default.html" %} + + +{% block content %} +{% from "_includes/macros.html" import render_field %} + + +
+ + {%- with dismissable = false %} + {%- with alert_id = 'preview-alert' %} + {%- with alert_classes = 'mt-3' %} + {% include '_includes/alert.html' %} + {%- endwith %} + {%- endwith %} + {%- endwith %} +
+ +
+ {{ form.csrf_token }} + + +
+
+ + +
+
+ +
+
{{ render_field(form.active, class_='form-check-input d-block') }}
+
{{ render_field(form.style) }}
+
{{ render_field(form.url_list) }}
+ + +
+
+ +
+ +{% endblock %} + + +{% block script %} + + + + + + +{% endblock %} + diff --git a/src/modules/site-v2/templates/admin/announcements/list.html b/src/modules/site-v2/templates/admin/announcements/list.html new file mode 100644 index 000000000..313cf30b5 --- /dev/null +++ b/src/modules/site-v2/templates/admin/announcements/list.html @@ -0,0 +1,226 @@ +{% extends "_layouts/default.html" %} + +{% block content %} + + +
+
+
+ + + + + + + + + + + + + + +
A list of announcements.
ActiveTextURLsStyleEditDelete
+
+
+
+ + +{% endblock %} + + + +{% block script %} + +{% endblock %} diff --git a/src/pkg/caendr/caendr/models/datastore/__init__.py b/src/pkg/caendr/caendr/models/datastore/__init__.py index aebc57a4d..fc15dface 100644 --- a/src/pkg/caendr/caendr/models/datastore/__init__.py +++ b/src/pkg/caendr/caendr/models/datastore/__init__.py @@ -38,6 +38,7 @@ from .phenotype_report import PhenotypeReport # Subclasses ReportEntity, HashableEntity; imports TraitFile # Other +from .announcement import Announcement from .profile import Profile from .markdown import Markdown from .cart import Cart # Subclasses DeletableEntity @@ -69,6 +70,7 @@ def get_class_by_kind(kind): HeritabilityReport.kind: HeritabilityReport, NemascanReport.kind: NemascanReport, + Announcement.kind: Announcement, GeneBrowserTracks.kind: GeneBrowserTracks, Markdown.kind: Markdown, Species.kind: Species, diff --git a/src/pkg/caendr/caendr/models/datastore/announcement.py b/src/pkg/caendr/caendr/models/datastore/announcement.py new file mode 100644 index 000000000..5f8fce6db --- /dev/null +++ b/src/pkg/caendr/caendr/models/datastore/announcement.py @@ -0,0 +1,187 @@ +import bleach +from enum import Enum +import markdown +import re + +from flask import Markup + +from caendr.models.datastore import DeletableEntity +from caendr.utils.data import unique_id + + + +class AnnouncementType(Enum): + GENERAL = 'General' + MAINTENANCE = 'Maintenance' + ERROR = 'Error' + + def get_bootstrap_color(self): + __BOOTSTRAP_COLOR_MAP = { + AnnouncementType.GENERAL: 'success', + AnnouncementType.MAINTENANCE: 'warning', + AnnouncementType.ERROR: 'danger', + } + return __BOOTSTRAP_COLOR_MAP[self] + + + + +class Announcement(DeletableEntity): + kind = 'announcement' + + exclude_from_indexes = ('content', 'url_list') + + + def __init__(self, name_or_obj = None, *args, **kwargs): + + # If nothing passed for name_or_obj, create a new ID to use for this object + if name_or_obj is None: + name_or_obj = unique_id() + self.set_properties_meta(id = name_or_obj) + + # Initialize from superclass + super().__init__(name_or_obj, *args, **kwargs) + + + + # + # Props + # + + @classmethod + def get_props_set(cls): + return { + *super().get_props_set(), + 'active', + 'content', + 'url_list', + 'style', + } + + + @property + def active(self): + return self._get_raw_prop('active', False) + + @active.setter + def active(self, val): + self._set_raw_prop('active', bool(val)) + + + @property + def content(self): + return bleach.clean( self._get_raw_prop('content', '') ) + + @content.setter + def content(self, val): + if not isinstance(val, str): + raise ValueError(f'Prop "content" must be a string, got: {val}') + return self._set_raw_prop( 'content', bleach.clean( val ) ) + + + @property + def style(self): + return self._get_enum_prop(AnnouncementType, 'style', None) + + @style.setter + def style(self, val): + return self._set_enum_prop(AnnouncementType, 'style', val) + + + @property + def url_list(self): + return self._get_raw_prop('url_list', '') + + @url_list.setter + def url_list(self, val): + ''' + Validate the list of URL patterns before saving. + ''' + + # Accept newline-separated string or iterable of patterns + # Convert to a list for validating + if isinstance(val, str): + val = [ pattern.strip() for pattern in val.split('\n') ] + if not isinstance(val, list): + try: + val = [*val] + except: + raise ValueError('URL list must be an iterable or a newline-separated string') + + # Validate each pattern + for idx, path in enumerate(val): + if not self.validate_path(path): + raise ValueError(f'Invalid pattern on line {idx}: "{path}"') + + # If validation succeeded, convert to newline-separated string and save + return self._set_raw_prop('url_list', '\n'.join(val)) + + + + # + # Extra Props + # + + @property + def content_html(self): + ''' + The content of this announcement as HTML. + ''' + return Markup(markdown.markdown( self['content'] )) + + + def serialize(self, **kwargs): + props = super().serialize(**kwargs) + props['content_html'] = self.content_html + return props + + + + # + # URL Paths + # + + @classmethod + def validate_path(cls, path: str) -> bool: + ''' + Each path must meet the following constraints: + - The first character is a forward slash + - If there is an asterisk (wildcard), it must: + - be the final character + - be preceded by a forward slash + + Sample valid paths: + / + /* + /foo/bar/baz/* + + Sample invalid paths: + foo/bar + /foo* + /foo*/bar + ''' + # Validate the above rules using Regex: + # ^\/ First character is a forward slash '/' + # [^\*]* String of any length that doesn't include a '*' character + # ((?<=\/)\*)?$ Optionally, last character may be '*', and if so previous character must be '/' + return bool(re.match('^\/[^\*]*((?<=\/)\*)?$', path)) + + + def matches_path(self, path: str) -> bool: + + # Loop through each line in the URL list + for pattern in self['url_list'].split('\n'): + + # Turn our wildcard into a Regex wildcard, if given + if pattern.endswith('/*'): + pattern = pattern[:-2] + suffix = '(/.*)?' + else: + suffix = '' + + # Check this pattern + if re.match(f'^{ pattern }{ suffix }$', path): + return True + + # If no patterns matched, path does not match + return False diff --git a/src/pkg/caendr/caendr/models/datastore/deletable_entity.py b/src/pkg/caendr/caendr/models/datastore/deletable_entity.py index 5e545b351..eb4562727 100644 --- a/src/pkg/caendr/caendr/models/datastore/deletable_entity.py +++ b/src/pkg/caendr/caendr/models/datastore/deletable_entity.py @@ -30,7 +30,18 @@ def get_props_set(cls): *super().get_props_set(), 'is_deleted' } - + + + + @classmethod + def query_ds(cls, *args, deleted=None, **kwargs): + + if deleted is None: + return super().query_ds(*args, **kwargs) + + return [ + match for match in super().query_ds(*args, **kwargs) if (match['is_deleted'] == deleted) + ] @classmethod diff --git a/src/pkg/caendr/caendr/models/datastore/entity.py b/src/pkg/caendr/caendr/models/datastore/entity.py index 71e1cad48..a3fe0b43a 100644 --- a/src/pkg/caendr/caendr/models/datastore/entity.py +++ b/src/pkg/caendr/caendr/models/datastore/entity.py @@ -235,7 +235,7 @@ def __iter__(self): return ( (k, self[k]) for k in props if self[k] is not None ) - def serialize(self, include_meta=True): + def serialize(self, include_meta=True, include_name=False): ''' Get a `dict` of all props in the entity, mapped to serializable values. @@ -252,6 +252,10 @@ def serialize(self, include_meta=True): prop: getattr(self, prop) for prop in self.get_props_set_meta() }) + # Add unique ID ("name"), if applicable + if include_name: + props['name'] = self.name + # Map non-serializable values to raw strings # TODO: Can we pull this out somewhere? # E.g. assigning "types" to props and handling automatically, and/or using _get/_set_raw_prop?