diff --git a/capeditor/constants.py b/capeditor/constants.py index ab1972a..1d9d5b9 100644 --- a/capeditor/constants.py +++ b/capeditor/constants.py @@ -90,3 +90,85 @@ "ceiling" ] } + +SEVERITY_MAPPING = { + "Extreme": { + "label": "Red severity", + "color": "#d72f2a", + "background_color": "#fcf2f2", + "border_color": "#721515", + "icon_color": "#fff", + "severity": "Extreme", + "id": 4 + }, + "Severe": { + "label": "Orange severity", + "color": "#fe9900", + "background_color": "#fff9f2", + "border_color": "#9a6100", + "severity": "Severe", + "id": 3 + }, + "Moderate": { + "label": "Yellow severity", + "color": "#ffff00", + "background_color": "#fffdf1", + "border_color": "#938616", + "severity": "Moderate", + "id": 2 + }, + "Minor": { + "label": "Minor severity", + "color": "#03ffff", + "background_color": "#fffdf1", + "border_color": "#938616", + "severity": "Minor", + "id": 1 + } +} + +URGENCY_MAPPING = { + "Immediate": { + "label": "Immediate", + "certainty": "Immediate", + "id": 4 + }, + "Expected": { + "label": "Expected", + "certainty": "Expected", + "id": 3 + }, + "Future": { + "label": "Future", + "certainty": "Future", + "id": 2 + }, + "Past": { + "label": "Past", + "certainty": "Past", + "id": 1 + }, +} + +CERTAINTY_MAPPING = { + "Observed": { + "label": "Observed", + "certainty": "Observed", + "id": 4 + }, + "Likely": { + "label": "Likely", + "certainty": "Likely", + "id": 3 + }, + "Possible": { + "label": "Possible", + "certainty": "Possible", + "id": 2 + }, + "Unlikely": { + "label": "Unlikely", + "certainty": "Unlikely", + "id": 1 + }, +} diff --git a/capeditor/migrations/0003_capsetting_logo.py b/capeditor/migrations/0003_capsetting_logo.py new file mode 100644 index 0000000..a85a742 --- /dev/null +++ b/capeditor/migrations/0003_capsetting_logo.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.10 on 2024-02-07 15:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailimages', '0025_alter_image_file_alter_rendition_file'), + ('capeditor', '0002_alter_capsetting_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='capsetting', + name='logo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='Logo of the sending institution'), + ), + ] diff --git a/capeditor/models.py b/capeditor/models.py index 830807c..19a56c0 100644 --- a/capeditor/models.py +++ b/capeditor/models.py @@ -1,6 +1,10 @@ import json +import os import uuid +from datetime import datetime +from adminboundarymanager.models import AdminBoundarySettings +from django.conf import settings from django.contrib.gis.db import models from django.utils import timezone from django.utils.functional import cached_property @@ -28,8 +32,9 @@ AlertIncident, ContactBlock ) +from capeditor.constants import SEVERITY_MAPPING, URGENCY_MAPPING, CERTAINTY_MAPPING from capeditor.forms.widgets import HazardEventTypeWidget -from capeditor.serializers import parse_tz +from capeditor.shareable.png import cap_geojson_to_image, CapAlertCardImage @register_setting @@ -38,6 +43,8 @@ class CapSetting(BaseSiteSetting, ClusterableModel): help_text=_("Can be the website link or email of the sending institution")) sender_name = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("CAP Sender Name"), help_text=_("Name of the sending institution")) + logo = models.ForeignKey("wagtailimages.Image", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", + verbose_name=_("Logo of the sending institution")) contacts = StreamField([ ("contact", ContactBlock(label=_("Contact"))) @@ -55,6 +62,7 @@ class Meta: panels = [ FieldPanel("sender_name"), FieldPanel("sender"), + FieldPanel("logo"), FieldPanel("contacts"), InlinePanel("hazard_event_types", heading=_("Hazard Types"), label=_("Hazard Type"), help_text=_("Hazards monitored by the institution")), @@ -266,23 +274,13 @@ class Meta: FieldPanel("incidents"), ] - @cached_property - def alert_info(self): - if self.info: - return self.info[0] - return None - - @cached_property - def cap_reference_id(self): - sent = parse_tz(self.sent.isoformat()) - return f"{self.sender},{self.identifier.hex},{sent}" - @cached_property def feature_collection(self): fc = {"type": "FeatureCollection", "features": []} for info in self.info: if info.value.features: for feature in info.value.features: + feature.get("properties", {}).update({"info-id": info.id}) fc["features"].append(feature) return fc @@ -308,15 +306,132 @@ def bounds(self): return list(bounds) + @property + def xml_link(self): + return None + @cached_property - def affected_area(self): - areas = [] + def infos(self): + alert_infos = [] for info in self.info: - for area in info.value.area: - areas.append(area.get("areaDesc")) + start_time = info.value.get("effective") or self.sent - return ", ".join(areas) + if info.value.get('expires').date() < datetime.today().date(): + status = "Expired" + elif timezone.now() > start_time: + status = "Ongoing" + else: + status = "Expected" + + area_desc = [area.get("areaDesc") for area in info.value.area] + area_desc = ", ".join(area_desc) + + event = f"{info.value.get('event')} ({area_desc})" + severity = SEVERITY_MAPPING[info.value.get("severity")] + urgency = URGENCY_MAPPING[info.value.get("urgency")] + certainty = CERTAINTY_MAPPING[info.value.get("certainty")] + + effective = start_time + expires = info.value.get('expires') + url = self.url + event_icon = info.value.event_icon + + alert_info = { + "info": info, + "status": status, + "url": self.url, + "event": event, + "event_icon": event_icon, + "severity": severity, + "utc": start_time, + "urgency": urgency, + "certainty": certainty, + "effective": effective, + "expires": expires, + "properties": { + "id": self.identifier, + "event": event, + "event_type": info.value.get('event'), + "headline": info.value.get("headline"), + "severity": info.value.get("severity"), + "urgency": info.value.get("urgency"), + "certainty": info.value.get("certainty"), + "severity_color": severity.get("color"), + "sent": self.sent, + "onset": info.value.get("onset"), + "expires": expires, + "web": url, + "description": info.value.get("description"), + "instruction": info.value.get("instruction"), + "event_icon": event_icon, + "area_desc": area_desc, + } + } + + alert_infos.append(alert_info) + + return alert_infos + + def get_geojson_features(self, request=None): + features = [] + for info_item in self.infos: + info = info_item.get("info") + if info.value.geojson: + properties = info_item.get("properties") + if request: + web = request.build_absolute_uri(properties.get("web")) + properties.update({ + "web": web + }) + + info_features = info.value.features + for feature in info_features: + feature["properties"].update(**properties) + features.append(feature) + + return features + + def generate_alert_card_image(self): + + site = Site.objects.get(is_default_site=True) + + abm_settings = AdminBoundarySettings.for_site(site) + cap_settings = CapSetting.for_site(site) + abm_extents = abm_settings.combined_countries_bounds + + info = self.infos[0] + + features = self.get_geojson_features() + if features: + feature_coll = { + "type": "FeatureCollection", + "features": features, + } + + if abm_extents: + # format to what matplotlib expects + abm_extents = [abm_extents[0], abm_extents[2], abm_extents[1], abm_extents[3]] + + cap_detail = { + "title": self.title, + "event": info.get("event"), + "sent_on": self.sent, + "org_name": cap_settings.sender_name, + "severity": info.get("severity"), + "properties": info.get("properties"), + } + + org_logo = cap_settings.logo + if org_logo: + cap_detail.update({ + "org_logo_file": os.path.join(settings.MEDIA_ROOT, org_logo.file.path) + }) + + map_img_buffer = cap_geojson_to_image(feature_coll, abm_extents) + + image_content_file = CapAlertCardImage(map_img_buffer, cap_detail, + f"{self.identifier}.png").render() + + return image_content_file - @property - def xml_link(self): return None diff --git a/capeditor/shareable/__init__.py b/capeditor/shareable/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capeditor/shareable/png.py b/capeditor/shareable/png.py new file mode 100644 index 0000000..b5b2133 --- /dev/null +++ b/capeditor/shareable/png.py @@ -0,0 +1,377 @@ +import textwrap +from io import BytesIO + +import cartopy.feature as cf +import geopandas as gpd +import matplotlib +import matplotlib.pyplot as plt +from PIL import Image, ImageDraw, ImageFont, ImageColor +from cartopy import crs as ccrs +from django.contrib.staticfiles import finders +from django.core.files.base import ContentFile +from django.template.defaultfilters import truncatechars + +from capeditor.utils import rasterize_svg_to_png + +matplotlib.use('Agg') + +fonts = { + "Roboto-Regular": finders.find("capeditor/fonts/Roboto/Roboto-Regular.ttf"), + "Roboto-Bold": finders.find("capeditor/fonts/Roboto/Roboto-Bold.ttf") +} + +severity_icons = { + "Extreme": finders.find("capeditor/images/extreme.png"), + "Severe": finders.find("capeditor/images/severe.png"), + "Moderate": finders.find("capeditor/images/moderate.png"), + "Minor": finders.find("capeditor/images/minor.png"), +} +meta_icons = { + "urgency": finders.find("capeditor/images/urgency.png"), + "certainty": finders.find("capeditor/images/certainty.png") +} + +warning_icon = finders.find("capeditor/images/alert.png") +area_icon = finders.find("capeditor/images/area.png") + + +def cap_geojson_to_image(geojson_feature_collection, extents=None): + gdf = gpd.GeoDataFrame.from_features(geojson_feature_collection) + + width = 2 + height = 2 + + fig = plt.figure(figsize=(width, height,)) + ax = plt.axes([0, 0, 1, 1], projection=ccrs.PlateCarree()) + + # set line width + [x.set_linewidth(0) for x in ax.spines.values()] + + # set extent + if extents: + ax.set_extent(extents, crs=ccrs.PlateCarree()) + + # add country borders + ax.add_feature(cf.LAND) + ax.add_feature(cf.OCEAN) + ax.add_feature(cf.BORDERS, linewidth=0.1, linestyle='-', alpha=1) + + # Plot the GeoDataFrame using the plot() method + gdf.plot(ax=ax, color=gdf["severity_color"], edgecolor='#333', linewidth=0.3, legend=True) + # label areas + gdf.apply(lambda x: ax.annotate(text=truncatechars(x["areaDesc"], 20), + xy=x.geometry.centroid.coords[0], ha='center', + fontsize=5, ), axis=1) + + # create plot + buffer = BytesIO() + plt.savefig(buffer, format='png', bbox_inches="tight", dpi=200) + + # close plot + plt.close() + + return buffer + + +class CapAlertCardImage(object): + def __init__(self, area_map_img_buffer, cap_detail, file_name): + + self.out_img_width = 800 + self.out_img_height = 800 + + self.standard_map_height = 300 + + self.padding = 28 + self.drawContext = None + self.out_img = None + + self.file_name = file_name + + self.area_map_img_buffer = area_map_img_buffer + self.cap_detail = cap_detail + + self.map_image_h = 0 + self.map_image_w = 0 + + self._get_font_paths() + + def render(self): + self.draw_base() + + # draw logo + logo_lly = self.draw_org_logo() + + # draw title + title_lly = self.draw_title(y=logo_lly + self.padding) + + # draw issued date + issued_date_lly = self.draw_issued_date(y=title_lly + self.padding) + + # draw area map + map_ulx, map_lly = self.draw_area_map(y=issued_date_lly + self.padding) + + # draw severity badge + severity_badge_lly = self.draw_severity_badge(x=map_ulx + self.padding, y=issued_date_lly + self.padding) + + # draw info items + info_items_lly = self.draw_info_items(x=map_ulx + self.padding, y=severity_badge_lly + self.padding) + + # draw alert area details + self.draw_area_info(x=map_ulx + self.padding, y=info_items_lly + self.padding) + + # draw description + description_title = "Description: " + description_text = self.cap_detail.get("properties").get("description") + description_text = truncatechars(description_text, 360) + desc_lly = self.draw_title_text_block(description_title, description_text, x=self.padding, + y=map_lly + self.padding) + + # draw instruction + instruction_title = "Instruction: " + instruction_text = self.cap_detail.get("properties").get("instruction") + instruction_text = truncatechars(instruction_text, 360) + instruction_lly = self.draw_title_text_block(instruction_title, instruction_text, x=self.padding, + y=desc_lly + self.padding) + + return self.save_image() + + def _get_font_paths(self): + roboto_regular_font_path = fonts.get("Roboto-Regular") + roboto_bold_font_path = fonts.get("Roboto-Bold") + + if not roboto_regular_font_path: + raise FileNotFoundError("Roboto-Regular font not found") + + self.roboto_regular_font_path = roboto_regular_font_path + + if not roboto_bold_font_path: + raise FileNotFoundError("Roboto-Bold font not found") + + self.roboto_bold_font_path = roboto_bold_font_path + + def draw_title(self, y, font_size=20): + title = self.cap_detail.get("title") + title = truncatechars(title, 100) + title_lly = self._draw_centered_text(title, y=y, font_path=self.roboto_bold_font_path, font_size=font_size) + return title_lly + + def draw_issued_date(self, y, font_size=20): + sent = self.cap_detail.get("properties", {}).get('sent') + time_fmt = '%d/%m/%Y %H:%M' + sent = sent.strftime(time_fmt) + issued_date_text = f"Issued on: {sent} local time" + issued_date_lly = self._draw_centered_text(issued_date_text, y=y, font_path=self.roboto_bold_font_path, + font_size=font_size) + return issued_date_lly + + def draw_base(self): + self.map_image_w, self.map_image_h = self._get_image_size(self.area_map_img_buffer) + + if self.map_image_h > self.standard_map_height: + self.out_img_height = self.out_img_height + (self.map_image_h - self.standard_map_height) + + self.out_img = Image.new(mode="RGBA", size=(self.out_img_width, self.out_img_height), color="WHITE") + self.drawContext = ImageDraw.Draw(self.out_img) + + def draw_org_logo(self, y_offset=10, max_logo_height=60): + org_logo_file = self.cap_detail.get("org_logo_file") + if org_logo_file: + logo_image = Image.open(org_logo_file) + logo_w, logo_h = self._get_image_size(org_logo_file) + if logo_h > max_logo_height: + ratio = logo_w / logo_h + new_width = int(ratio * max_logo_height) + logo_image = logo_image.resize((new_width, max_logo_height)) + logo_w, logo_h = logo_image.size + + offset = ((self.out_img_width - logo_w) // 2, y_offset) + self.out_img.paste(logo_image, offset, logo_image) + + return y_offset + logo_h + + def _draw_centered_text(self, text, y, font_path, font_size=20): + text_max_width = int(self.out_img_width * 0.07) + wrapper = textwrap.TextWrapper(width=text_max_width) + word_list = wrapper.wrap(text=text) + title_new = '' + for ii in word_list: + title_new = title_new + ii + '\n' + + font = ImageFont.truetype(font_path, font_size) + _, _, text_w, text_h = self.drawContext.textbbox((0, 0), title_new, font=font) + text_offset = ((self.out_img_width - text_w) // 2, y) + self.drawContext.text(text_offset, title_new, font=font, fill="BLACK") + + return y + text_h + + def draw_area_map(self, y): + map_offset = (self.padding, y) + map_image = Image.open(self.area_map_img_buffer) + self.out_img.paste(map_image, map_offset) + + return self.padding + self.map_image_w, y + self.map_image_h + + def draw_severity_badge(self, x, y): + alert_badge_height = 50 + badge_lrx = self.out_img_width - self.padding # x1 + badge_lry = y + alert_badge_height # y1 + + severity_background_color = self.cap_detail.get("severity").get("background_color") + severity_border_color = self.cap_detail.get("severity").get("border_color") + severity_icon_color = self.cap_detail.get("severity").get("icon_color") + severity_color = self.cap_detail.get("severity").get("color") + + badge_bg_color = ImageColor.getrgb(severity_background_color) + badge_border_color = ImageColor.getrgb(severity_border_color) + + self.drawContext.rectangle((x, y, badge_lrx, badge_lry), fill=badge_bg_color, + outline=badge_border_color) + + event_icon = self.cap_detail.get("properties").get("event_icon") + if not event_icon: + event_icon = "alert" + + max_icon_height = 30 + + try: + icon_file = rasterize_svg_to_png(icon_name=event_icon, fill_color=severity_icon_color) + except Exception: + icon_file = warning_icon + + event_icon_img = Image.open(icon_file, formats=["PNG"]) + icon_w, icon_h = event_icon_img.size + if icon_h > max_icon_height: + icon_ratio = icon_w / icon_h + new_icon_width = int(icon_ratio * max_icon_height) + event_icon_img = event_icon_img.resize((new_icon_width, max_icon_height)) + icon_w, icon_h = event_icon_img.size + + badge_width = self.out_img_width - x - self.padding + rec_width = 40 + rec_padding = 5 + + icon_ulx = x + rec_padding + icon_uly = y + rec_padding + icon_lrx = badge_lrx - (badge_width - (rec_width + rec_padding)) + icon_lry = badge_lry - rec_padding + + icon_rect_fill_color = ImageColor.getrgb(severity_color) + self.drawContext.rectangle((icon_ulx, icon_uly, icon_lrx, icon_lry), fill=icon_rect_fill_color, + outline=badge_border_color) + + event_icon_offset = ( + x + (rec_padding * 2), + y + (rec_padding * 2)) + + self.out_img.paste(event_icon_img, event_icon_offset, event_icon_img) + + # draw event text + event_text = self.cap_detail.get("event") + event_text = truncatechars(event_text, 35) + + font = ImageFont.truetype(self.roboto_bold_font_path, 12) + self.drawContext.text((icon_lrx + 10, icon_uly + 10), event_text, font=font, fill="black") + + return badge_lry + + def draw_info_items(self, x, y): + meta_h_padding = 10 + urgency_val = self.cap_detail.get("properties").get("urgency") + urgency_icon_file = meta_icons.get("urgency") + urgency_icon_h = self._draw_info_item(self.out_img, "Urgency", urgency_val, urgency_icon_file, x, y, + self.drawContext) + + severity_y_offset = y + meta_h_padding + urgency_icon_h + severity = self.cap_detail.get("properties").get("severity") + severity_icon_file = severity_icons.get(severity) + severity_icon_h = self._draw_info_item(self.out_img, "Severity", severity, severity_icon_file, x, + severity_y_offset, self.drawContext) + + certainty_y_offset = severity_y_offset + meta_h_padding + severity_icon_h + certainty = self.cap_detail.get("properties").get("certainty") + certainty_icon_file = meta_icons.get("certainty") + certainty_icon_h = self._draw_info_item(self.out_img, "Certainty", certainty, certainty_icon_file, x, + certainty_y_offset, self.drawContext) + + return certainty_y_offset + certainty_icon_h + + def draw_area_info(self, x, y): + area_icon_img = Image.open(area_icon) + area_icon_w, area_icon_h = area_icon_img.size + area_title_offset = (x, y) + self.out_img.paste(area_icon_img, area_title_offset, area_icon_img) + + font = ImageFont.truetype(self.roboto_bold_font_path, 16) + self.drawContext.text((x + area_icon_w + 5, y), text="Area of concern", font=font, fill="black") + + areaDesc = self.cap_detail.get("properties").get("area_desc") + + # Truncate the area description to 85 characters + areaDesc = truncatechars(areaDesc, 85) + + area_wrapper = textwrap.TextWrapper( + width=int((self.out_img_width - (x + area_icon_w + 5 + self.padding)) * 0.14)) + area_word_list = area_wrapper.wrap(text=areaDesc) + area_desc_new = '' + for ii in area_word_list: + area_desc_new = area_desc_new + ii + '\n' + + regular_font = ImageFont.truetype(self.roboto_regular_font_path, size=14) + self.drawContext.text((x + self.padding, y + area_icon_h + 1), area_desc_new, font=regular_font, fill="black") + + def draw_title_text_block(self, title, text, x, y): + title_text_spacing = 10 + font = ImageFont.truetype(self.roboto_bold_font_path, 20) + _, _, title_w, title_h = self.drawContext.textbbox((0, 0), title, font=font) + self.drawContext.text((x, y), title, font=font, fill="black") + + text_wrapper = textwrap.TextWrapper(width=int(self.out_img_width * 0.16)) + + text_word_list = text_wrapper.wrap(text=text) + text_new = '' + for ii in text_word_list: + text_new = text_new + ii + '\n' + regular_font = ImageFont.truetype(self.roboto_regular_font_path, size=12) + + text_offset_y = y + title_h + title_text_spacing + _, _, text_w, text_h = self.drawContext.textbbox((0, 0), text_new, font=regular_font) + self.drawContext.text((x, text_offset_y), text_new, font=regular_font, fill="black") + + return text_offset_y + text_h + + def save_image(self): + buffer = BytesIO() + self.out_img.convert("RGB").save(fp=buffer, format='PNG') + buff_val = buffer.getvalue() + + return ContentFile(buff_val, self.file_name) + + @staticmethod + def _get_image_size(file): + image = Image.open(file) + return image.size + + def _draw_info_item(self, out_img, key, value, icon_file, x_offset, y_offset, draw): + meta_icon_img = Image.open(icon_file) + icon_w, icon_h = meta_icon_img.size + offset = (x_offset, y_offset) + + out_img.paste(meta_icon_img, offset, meta_icon_img) + + meta_font_size = 16 + regular_font = ImageFont.truetype(self.roboto_regular_font_path, meta_font_size) + bold_font = ImageFont.truetype(self.roboto_bold_font_path, meta_font_size) + + padding = 10 + + label_x_offset = x_offset + icon_w + padding + label_y_offset = y_offset + 7 + + label = f"{key}: " + _, _, label_w, label_h = draw.textbbox((0, 0), label, font=regular_font) + draw.text((label_x_offset, label_y_offset), text=label, font=regular_font, fill="black") + + _, _, value_w, value_h = draw.textbbox((0, 0), value, font=bold_font) + draw.text((label_x_offset + label_w + 5, label_y_offset), text=value, font=bold_font, fill="black") + + return icon_h diff --git a/capeditor/static/capeditor/fonts/Roboto/LICENSE.txt b/capeditor/static/capeditor/fonts/Roboto/LICENSE.txt new file mode 100644 index 0000000..75b5248 --- /dev/null +++ b/capeditor/static/capeditor/fonts/Roboto/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-Black.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-Black.ttf new file mode 100644 index 0000000..0112e7d Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-Black.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-BlackItalic.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-BlackItalic.ttf new file mode 100644 index 0000000..b2c6aca Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-BlackItalic.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-Bold.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-Bold.ttf new file mode 100644 index 0000000..43da14d Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-Bold.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-BoldItalic.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-BoldItalic.ttf new file mode 100644 index 0000000..bcfdab4 Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-BoldItalic.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-Italic.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-Italic.ttf new file mode 100644 index 0000000..1b5eaa3 Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-Italic.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-Light.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-Light.ttf new file mode 100644 index 0000000..e7307e7 Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-Light.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-LightItalic.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-LightItalic.ttf new file mode 100644 index 0000000..2d277af Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-LightItalic.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-Medium.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-Medium.ttf new file mode 100644 index 0000000..ac0f908 Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-Medium.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-MediumItalic.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-MediumItalic.ttf new file mode 100644 index 0000000..fc36a47 Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-MediumItalic.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-Regular.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-Regular.ttf new file mode 100644 index 0000000..ddf4bfa Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-Regular.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-Thin.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-Thin.ttf new file mode 100644 index 0000000..2e0dee6 Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-Thin.ttf differ diff --git a/capeditor/static/capeditor/fonts/Roboto/Roboto-ThinItalic.ttf b/capeditor/static/capeditor/fonts/Roboto/Roboto-ThinItalic.ttf new file mode 100644 index 0000000..084f9c0 Binary files /dev/null and b/capeditor/static/capeditor/fonts/Roboto/Roboto-ThinItalic.ttf differ diff --git a/capeditor/static/capeditor/images/alert.png b/capeditor/static/capeditor/images/alert.png new file mode 100644 index 0000000..e4094a8 Binary files /dev/null and b/capeditor/static/capeditor/images/alert.png differ diff --git a/capeditor/static/capeditor/images/alert.svg b/capeditor/static/capeditor/images/alert.svg new file mode 100644 index 0000000..252c2fd --- /dev/null +++ b/capeditor/static/capeditor/images/alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/capeditor/static/capeditor/images/area.png b/capeditor/static/capeditor/images/area.png new file mode 100644 index 0000000..16f4f5f Binary files /dev/null and b/capeditor/static/capeditor/images/area.png differ diff --git a/capeditor/static/capeditor/images/certainty.png b/capeditor/static/capeditor/images/certainty.png new file mode 100644 index 0000000..4eac9d9 Binary files /dev/null and b/capeditor/static/capeditor/images/certainty.png differ diff --git a/capeditor/static/capeditor/images/extreme.png b/capeditor/static/capeditor/images/extreme.png new file mode 100644 index 0000000..95b68a4 Binary files /dev/null and b/capeditor/static/capeditor/images/extreme.png differ diff --git a/capeditor/static/capeditor/images/minor.png b/capeditor/static/capeditor/images/minor.png new file mode 100644 index 0000000..3734f54 Binary files /dev/null and b/capeditor/static/capeditor/images/minor.png differ diff --git a/capeditor/static/capeditor/images/moderate.png b/capeditor/static/capeditor/images/moderate.png new file mode 100644 index 0000000..582717f Binary files /dev/null and b/capeditor/static/capeditor/images/moderate.png differ diff --git a/capeditor/static/capeditor/images/severe.png b/capeditor/static/capeditor/images/severe.png new file mode 100644 index 0000000..9c0ab47 Binary files /dev/null and b/capeditor/static/capeditor/images/severe.png differ diff --git a/capeditor/static/capeditor/images/urgency.png b/capeditor/static/capeditor/images/urgency.png new file mode 100644 index 0000000..13ad793 Binary files /dev/null and b/capeditor/static/capeditor/images/urgency.png differ diff --git a/capeditor/utils.py b/capeditor/utils.py index 4dbd8e0..e8e2e53 100644 --- a/capeditor/utils.py +++ b/capeditor/utils.py @@ -1,10 +1,15 @@ from collections import OrderedDict +from io import BytesIO +from xml.dom import minidom -import magic +from cairosvg import svg2png +from django.utils.safestring import mark_safe +from magic import from_file +from wagtailiconchooser.utils import get_svg_icons def file_path_mime(file_path): - mimetype = magic.from_file(file_path, mime=True) + mimetype = from_file(file_path, mime=True) return mimetype @@ -49,3 +54,38 @@ def dict_add_after_key(original_dict, key_to_insert_after, new_key, new_value): if key == key_to_insert_after: new_dict[new_key] = new_value return new_dict + + +def rasterize_svg_to_png(icon_name, fill_color=None): + svg_icons = get_svg_icons() + + svg_str = svg_icons.get(icon_name) + + if not svg_str: + return None + + doc = minidom.parseString(svg_str) + svg = doc.getElementsByTagName("svg") + if svg: + svg = svg[0] + + svg.setAttribute("height", "26") + svg.setAttribute("width", "26") + + if fill_color: + svg.setAttribute("fill", fill_color) + svg_str = mark_safe(svg.toxml()) + + svg_bytes = svg_str.encode('utf-8') + + in_buf = BytesIO(svg_bytes) + + # Prepare a buffer for output + out_buf = BytesIO() + + # Rasterise the SVG to PNG + svg2png(file_obj=in_buf, write_to=out_buf) + out_buf.seek(0) + + # Return a Willow PNGImageFile + return out_buf diff --git a/sandbox/home/migrations/0019_capalertpage_search_image_alter_capalertpage_info.py b/sandbox/home/migrations/0019_capalertpage_search_image_alter_capalertpage_info.py new file mode 100644 index 0000000..17fccf9 --- /dev/null +++ b/sandbox/home/migrations/0019_capalertpage_search_image_alter_capalertpage_info.py @@ -0,0 +1,29 @@ +# Generated by Django 4.1.10 on 2024-02-07 15:11 + +import capeditor.blocks +from django.db import migrations, models +import django.db.models.deletion +import wagtail.blocks +import wagtail.documents.blocks +import wagtail.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailimages', '0025_alter_image_file_alter_rendition_file'), + ('home', '0018_alter_capalertpage_info'), + ] + + operations = [ + migrations.AddField( + model_name='capalertpage', + name='search_image', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='Search image'), + ), + migrations.AlterField( + model_name='capalertpage', + name='info', + field=wagtail.fields.StreamField([('alert_info', wagtail.blocks.StructBlock([('event', wagtail.blocks.ChoiceBlock(choices=capeditor.blocks.get_hazard_types, help_text='The text denoting the type of the subject event of the alert message. You can define hazards events monitored by your institution from CAP settings', label='Event')), ('category', wagtail.blocks.ChoiceBlock(choices=[('Geo', 'Geophysical'), ('Met', 'Meteorological'), ('Safety', 'General emergency and public safety'), ('Security', 'Law enforcement, military, homeland and local/private security'), ('Rescue', 'Rescue and recovery'), ('Fire', 'Fire suppression and rescue'), ('Health', 'Medical and public health'), ('Env', 'Pollution and other environmental'), ('Transport', 'Public and private transportation'), ('Infra', 'Utility, telecommunication, other non-transport infrastructure'), ('Cbrne', 'Chemical, Biological, Radiological, Nuclear or High-Yield Explosive threat or attack'), ('Other', 'Other events')], help_text='The code denoting the category of the subject event of the alert message', label='Category')), ('language', wagtail.blocks.ChoiceBlock(choices=[('en', 'English'), ('fr', 'French')], help_text='The code denoting the language of the alert message', label='Language', required=False)), ('urgency', wagtail.blocks.ChoiceBlock(choices=[('Immediate', 'Immediate - Responsive action SHOULD be taken immediately'), ('Expected', 'Expected - Responsive action SHOULD be taken soon (within next hour)'), ('Future', 'Future - Responsive action SHOULD be taken in the near future'), ('Past', 'Past - Responsive action is no longer required'), ('Unknown', 'Unknown - Urgency not known')], help_text='The code denoting the urgency of the subject event of the alert message', label='Urgency')), ('severity', wagtail.blocks.ChoiceBlock(choices=[('Extreme', 'Extreme - Extraordinary threat to life or property'), ('Severe', 'Severe - Significant threat to life or property'), ('Moderate', 'Moderate - Possible threat to life or property'), ('Minor', 'Minor - Minimal to no known threat to life or property'), ('Unknown', 'Unknown - Severity unknown')], help_text='The code denoting the severity of the subject event of the alert message', label='Severity')), ('certainty', wagtail.blocks.ChoiceBlock(choices=[('Observed', 'Observed - Determined to have occurred or to be ongoing'), ('Likely', 'Likely - Likely (percentage > ~50%)'), ('Possible', 'Possible - Possible but not likely (percentage <= ~50%)'), ('Unlikely', 'Unlikely - Not expected to occur (percentage ~ 0)'), ('Unknown', 'Unknown - Certainty unknown')], help_text='The code denoting the certainty of the subject event of the alert message', label='Certainty')), ('headline', wagtail.blocks.CharBlock(help_text='The text headline of the alert message. Make it direct and actionable as possible while remaining short', label='Headline', max_length=160, required=False)), ('description', wagtail.blocks.TextBlock(help_text='The text describing the subject event of the alert message. An extended description of the hazard or event that occasioned this message', label='Description', required=True)), ('instruction', wagtail.blocks.TextBlock(help_text='The text describing the recommended action to be taken by recipients of the alert message', label='Instruction', required=False)), ('effective', wagtail.blocks.DateTimeBlock(help_text='The effective time of the information of the alert message. If not set, the sent date will be used', label='Effective', required=False)), ('onset', wagtail.blocks.DateTimeBlock(help_text='The expected time of the beginning of the subject event of the alert message', label='Onset', required=False)), ('expires', wagtail.blocks.DateTimeBlock(help_text='The expiry time of the information of the alert message. If not set, each recipient is free to set its own policy as to when the message is no longer in effect.', label='Expires', required=True)), ('responseType', wagtail.blocks.ListBlock(wagtail.blocks.StructBlock([('response_type', wagtail.blocks.ChoiceBlock(choices=[('Shelter', 'Shelter - Take shelter in place or per instruction'), ('Evacuate', 'Evacuate - Relocate as instructed in the instruction'), ('Prepare', 'Prepare - Relocate as instructed in the instruction'), ('Execute', 'Execute - Execute a pre-planned activity identified in instruction'), ('Avoid', 'Avoid - Avoid the subject event as per the instruction'), ('Monitor', 'Monitor - Attend to information sources as described in instruction'), ('Assess', 'Assess - Evaluate the information in this message - DONT USE FOR PUBLIC ALERTS'), ('AllClear', 'All Clear - The subject event no longer poses a threat or concern and any follow on action is described in instruction'), ('None', 'No action recommended')], help_text='The code denoting the type of action recommended for the target audience', label='Response type'))], label='Response Type'), default=[], label='Response Types')), ('senderName', wagtail.blocks.CharBlock(help_text='The human-readable name of the agency or authority issuing this alert.', label='Sender name', max_length=255, required=False)), ('contact', wagtail.blocks.CharBlock(help_text='The text describing the contact for follow-up and confirmation of the alert message', label='Contact', max_length=255, required=False)), ('audience', wagtail.blocks.CharBlock(help_text='The text describing the intended audience of the alert message', label='Audience', max_length=255, required=False)), ('area', wagtail.blocks.StreamBlock([('boundary_block', wagtail.blocks.StructBlock([('areaDesc', wagtail.blocks.TextBlock(help_text='The text describing the affected area of the alert message', label='Affected areas / Regions')), ('admin_level', wagtail.blocks.ChoiceBlock(choices=[(0, 'Level 0'), (1, 'Level 1'), (2, 'Level 2'), (3, 'Level 3')], label='Administrative Level')), ('boundary', capeditor.blocks.BoundaryFieldBlock(help_text='The paired values of points defining a polygon that delineates the affected area of the alert message', label='Boundary')), ('altitude', wagtail.blocks.CharBlock(help_text='The specific or minimum altitude of the affected area of the alert message', label='Altitude', max_length=100, required=False)), ('ceiling', wagtail.blocks.CharBlock(help_text='The maximum altitude of the affected area of the alert message.MUST NOT be used except in combination with the altitude element. ', label='Ceiling', max_length=100, required=False))], label='Admin Boundary')), ('polygon_block', wagtail.blocks.StructBlock([('areaDesc', wagtail.blocks.TextBlock(help_text='The text describing the affected area of the alert message', label='Affected areas / Regions')), ('polygon', capeditor.blocks.PolygonFieldBlock(help_text='The paired values of points defining a polygon that delineates the affected area of the alert message', label='Polygon')), ('altitude', wagtail.blocks.CharBlock(help_text='The specific or minimum altitude of the affected area of the alert message', label='Altitude', max_length=100, required=False)), ('ceiling', wagtail.blocks.CharBlock(help_text='The maximum altitude of the affected area of the alert message.MUST NOT be used except in combination with the altitude element. ', label='Ceiling', max_length=100, required=False))], label='Draw Polygon')), ('circle_block', wagtail.blocks.StructBlock([('areaDesc', wagtail.blocks.TextBlock(help_text='The text describing the affected area of the alert message', label='Affected areas / Regions')), ('circle', capeditor.blocks.CircleFieldBlock(help_text='Drag the marker to change position', label='Circle')), ('altitude', wagtail.blocks.CharBlock(help_text='The specific or minimum altitude of the affected area of the alert message', label='Altitude', max_length=100, required=False)), ('ceiling', wagtail.blocks.CharBlock(help_text='The maximum altitude of the affected area of the alert message.MUST NOT be used except in combination with the altitude element. ', label='Ceiling', max_length=100, required=False))], label='Circle')), ('geocode_block', wagtail.blocks.StructBlock([('areaDesc', wagtail.blocks.TextBlock(help_text='The text describing the affected area of the alert message', label='Affected areas / Regions')), ('valueName', wagtail.blocks.TextBlock(label='Name')), ('value', wagtail.blocks.TextBlock(label='Value')), ('altitude', wagtail.blocks.CharBlock(help_text='The specific or minimum altitude of the affected area of the alert message', label='Altitude', max_length=100, required=False)), ('ceiling', wagtail.blocks.CharBlock(help_text='The maximum altitude of the affected area of the alert message.MUST NOT be used except in combination with the altitude element. ', label='Ceiling', max_length=100, required=False))], label='Geocode'))], help_text='Admin Boundary, Polygon, Circle or Geocode', label='Alert Area')), ('resource', wagtail.blocks.StreamBlock([('file_resource', wagtail.blocks.StructBlock([('resourceDesc', wagtail.blocks.TextBlock(help_text='The text describing the type and content of the resource file', label='Resource Description')), ('file', wagtail.documents.blocks.DocumentChooserBlock())])), ('external_resource', wagtail.blocks.StructBlock([('resourceDesc', wagtail.blocks.TextBlock(help_text='The text describing the type and content of the resource file', label='Resource Description')), ('external_url', wagtail.blocks.URLBlock(help_text='Link to external resource. This can be for example a link to related websites. ', verbose_name='External Resource Link'))]))], help_text='Additional file with supplemental information related to this alert information', label='Resources', required=False)), ('parameter', wagtail.blocks.ListBlock(wagtail.blocks.StructBlock([('valueName', wagtail.blocks.TextBlock(label='Name')), ('value', wagtail.blocks.TextBlock(label='Value'))], label='Parameter'), default=[], label='Parameters')), ('eventCode', wagtail.blocks.ListBlock(wagtail.blocks.StructBlock([('valueName', wagtail.blocks.TextBlock(help_text='Name for the event code', label='Name')), ('value', wagtail.blocks.TextBlock(help_text='Value of the event code', label='Value'))], label='Event Code'), default=[], label='Event codes'))], label='Alert Information'))], blank=True, null=True, use_json_field=True, verbose_name='Alert Information'), + ), + ] diff --git a/sandbox/home/models.py b/sandbox/home/models.py index 6ef9303..37072f2 100644 --- a/sandbox/home/models.py +++ b/sandbox/home/models.py @@ -1,13 +1,19 @@ +import json +from datetime import datetime + +from django.db import models from django.urls import reverse from django.utils.functional import cached_property +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _, gettext_lazy +from wagtail.admin.panels import MultiFieldPanel, FieldPanel +from wagtail.admin.widgets.slug import SlugInput +from wagtail.images import get_image_model_string +from wagtail.images.models import Image from wagtail.models import Page -from django.utils.translation import gettext_lazy as _ - -from capeditor.models import AbstractCapAlertPage -import json -from datetime import datetime, timedelta from wagtail.signals import page_published +from capeditor.models import AbstractCapAlertPage from capeditor.pubsub.publish import publish_cap_mqtt_message @@ -17,7 +23,7 @@ class HomePage(Page): @cached_property def get_alerts(self): - alerts = CapAlertPage.objects.all().order_by('-sent')[:2] + alerts = CapAlertPage.objects.all().order_by('-sent')[:2] active_alert_infos = [] @@ -36,27 +42,55 @@ def get_alerts(self): # print(CapAlertPage.objects.filter(info__in = active_alert_infos )) return { - 'active_alerts': CapAlertPage.objects.filter(info__in = active_alert_infos ), - 'geojson':json.dumps(geojson) + 'active_alerts': CapAlertPage.objects.filter(info__in=active_alert_infos), + 'geojson': json.dumps(geojson) } class CapAlertPage(AbstractCapAlertPage): - template = "capeditor/cap_alert_page.html" + template = "home/cap_alert_detail.html" parent_page_type = ["home.HomePage"] subpage_types = [] exclude_fields_in_copy = ["identifier", ] + """An implementation of MetadataMixin for Wagtail pages.""" + search_image = models.ForeignKey( + get_image_model_string(), + null=True, + blank=True, + related_name='+', + on_delete=models.SET_NULL, + verbose_name=gettext_lazy('Search image') + ) + content_panels = Page.content_panels + [ *AbstractCapAlertPage.content_panels ] + promote_panels = [ + MultiFieldPanel( + [ + FieldPanel("slug", widget=SlugInput), + FieldPanel("seo_title"), + FieldPanel('show_in_menus'), + FieldPanel("search_description"), + FieldPanel('search_image'), + ], + gettext_lazy("For search engines"), + ), + MultiFieldPanel( + [ + FieldPanel("show_in_menus"), + ], + gettext_lazy("For site menus"), + ), + ] + class Meta: verbose_name = _("CAP Alert Page") verbose_name_plural = _("CAP Alert Pages") - - + @cached_property def xml_link(self): return reverse("cap_alert_detail", args=(self.identifier,)) @@ -65,9 +99,24 @@ def xml_link(self): def on_publish_cap_alert(sender, **kwargs): instance = kwargs['instance'] + # publish to mqtt topic = "cap/alerts/all" - publish_cap_mqtt_message(instance, topic) + # create summary image + image_content_file = instance.generate_alert_card_image() + if image_content_file: + + # delete old image + if instance.search_image: + instance.search_image.delete() + + # create new image + instance.search_image = Image(title=instance.title, file=image_content_file) + instance.search_image.save() + + # save the instance + instance.save() + page_published.connect(on_publish_cap_alert, sender=CapAlertPage) diff --git a/sandbox/home/static/css/cap_detail_page.css b/sandbox/home/static/css/cap_detail_page.css new file mode 100644 index 0000000..a578cca --- /dev/null +++ b/sandbox/home/static/css/cap_detail_page.css @@ -0,0 +1,253 @@ +.cap-map-wrapper { + box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.06) 0px 1px 2px 0px; +} + +#cap-map { + height: 500px; + width: 100%; +} + +.c-icon { + height: 20px; + width: 20px; + margin-right: 4px; +} + +.c-icon svg { + height: 100%; + width: 100%; +} + +.map-legend { + display: flex; + padding: 4px 8px; + align-items: center; + background-color: rgba(78, 142, 203, .1); + font-size: 14px; + flex-wrap: wrap; +} + +.legend-items { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + margin-left: 10px; +} + +.map-legend .legend-items .legend-item .legend-color { + margin-right: 4px; + height: 12px; + width: 12px; +} + +.cap-meta-wrapper { + width: 100%; +} + +.cap-event { + display: flex; +} + +.cap-event-header { + font-weight: 600; + font-size: 16px; + padding-bottom: 10px; + margin-right: 10px; +} + +.cap-event-detail { + display: flex; + align-items: center; + padding-bottom: 20px; +} + +.cap-event-name { + margin-left: 4px; +} + +.cap-time-wrapper { + margin-bottom: 10px; + border-radius: 4px; +} + +.cap-time-wrapper .time-type { + font-weight: 600; + color: #555; +} + +.warning-timeline { + position: relative; + z-index: 0; + list-style: none; +} + +.warning-timeline .warning-timeline__item { + position: relative; + padding-left: 20px; + padding-bottom: 10px; +} + +.warning-timeline__item:not(:last-child):before { + background-image: -webkit-gradient(linear, left bottom, left top, color-stop(60%, #56616c), color-stop(25%, transparent)); + background-image: -webkit-linear-gradient(bottom, #56616c 60%, transparent 25%); + background-image: linear-gradient(0deg, #56616c 60%, transparent 25%); + background-size: 1px 5px; + content: ""; + display: block; + position: absolute; + width: 1px; + left: 5px; + top: 17px; + bottom: -6.5px; +} + +.warning-timeline__circle { + border-radius: 50%; + -webkit-box-shadow: rgb(86, 97, 108) 0px 0px 0px 1px inset; + box-shadow: rgb(86, 97, 108) 0px 0px 0px 1px inset; + height: 11px; + left: 0; + position: absolute; + width: 11px; + top: 6.5px; +} + +.warning-timeline__circle:after { + background-color: #56616c; + border-radius: 50%; + content: ""; + display: block; + height: 7px; + width: 7px; + left: 2px; + position: absolute; + top: 2px; + +} + + +.cap-info-summary-item { + display: flex; + align-items: center; + padding-bottom: 10px; +} + +.cap-info-summary-icon { + height: 20px; + width: 20px; + margin-right: 4px; +} + +.cap-info-summary-icon svg { + height: 100%; + width: 100%; +} + +.cap-info-summary-type { + margin-left: 4px; + font-weight: 600; +} + +.cap-info-summary-value { + margin-left: 4px; +} + +.affected-areas { + padding: 20px 0; +} + +.affected-area-title { + font-size: 16px; + font-weight: 600; + padding: 10px 0; + display: flex; + align-items: center; +} + +.cap-info-section { + padding: 20px 0; +} + +.info-item { + padding-top: 10px; +} + +.info-header { + font-weight: 600; + font-size: 18px; + padding: 10px 0; +} + +.cap-share-wrapper { + padding: 20px 20px 40px 0; +} + +.cap-share-item { + display: flex; + padding: 10px 0; +} + +.cap-share-icon { + height: 24px; + width: 24px; + margin-right: 4px; +} + +.cap-share-icon svg { + height: 100%; + width: 100%; +} + +/*768px and lower*/ +@media screen and (max-width: 768px) { + #cap-map { + height: 400px; + } +} + + +.alert-item { + margin-bottom: 20px; + overflow: hidden; + padding: 8px 16px; + position: relative; + border-radius: 4px; + display: flex; + align-items: center; +} + +.alert-item-icon { + margin-right: 10px; +} + +.alert-icon-wrapper { + position: relative; + width: 32px; + height: 32px; + border: 1px solid; + display: flex; + justify-content: center; + align-items: center; +} + +.alert-icon-wrapper svg { + height: 80%; + width: 80%; + fill: currentColor; +} + + +.alert-item-title { + font-weight: 600; + font-size: 15px; + +} + +.alert-severity-label { + font-size: 13px; +} \ No newline at end of file diff --git a/sandbox/home/templates/home/cap_alert_detail.html b/sandbox/home/templates/home/cap_alert_detail.html new file mode 100644 index 0000000..3fd733c --- /dev/null +++ b/sandbox/home/templates/home/cap_alert_detail.html @@ -0,0 +1,469 @@ +{% extends 'base.html' %} + +{% load static wagtailcore_tags wagtailiconchooser_tags i18n %} + +{% block extra_css %} + + + + +{% endblock extra_css %} + +{% block content %} +
+
+
+
+

+ {{ page.title }} +

+ +
+
+
+
+
+
+
+ {% translate "Alert Severity" %}: +
+
+
+
+
+ {% translate "Extreme" %} +
+
+
+
+
+ {% translate "Severe" %} +
+
+
+
+
+ {% translate "Moderate" %} +
+
+
+
+
+ {% translate "Minor" %} +
+
+
+
+
+ {% translate "Unknown" %} +
+
+
+
+
+
+ +
+
+
+
+
+ +{% endblock %} + +{% block extra_js %} + + + + + +{% endblock extra_js %} \ No newline at end of file diff --git a/sandbox/sandbox/templates/wagtailadmin/pages/listing/_page_title_explore.html b/sandbox/sandbox/templates/wagtailadmin/pages/listing/_page_title_explore.html new file mode 100644 index 0000000..bc37eba --- /dev/null +++ b/sandbox/sandbox/templates/wagtailadmin/pages/listing/_page_title_explore.html @@ -0,0 +1,6 @@ +{% extends "wagtailadmin/pages/listing/_page_title_explore.html" %} +{% load wagtailimages_tags %} + +{% block pages_listing_title_extra %} + {% image page.search_image fill-100x200 %} +{% endblock pages_listing_title_extra %} \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 7aa642c..4442524 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = capeditor -version = 0.4.6 +version = 0.4.7 description = Wagtail based CAP Editor long_description = file:README.md long_description_content_type = text/markdown @@ -33,3 +33,9 @@ install_requires = adm-boundary-manager>=0.0.6 wagtail-humanitarian-icons>=2.0.0 paho-mqtt + cairosvg + geopandas + matplotlib + cartopy + +