Skip to content

Commit

Permalink
Merge pull request #483 from AndersenLab/enhancement/announcements-re…
Browse files Browse the repository at this point in the history
…order

Enhancement/announcements reorder
  • Loading branch information
r-vieira authored May 31, 2024
2 parents 26e7032 + a2cb69a commit 81699d0
Show file tree
Hide file tree
Showing 10 changed files with 281 additions and 39 deletions.
9 changes: 3 additions & 6 deletions src/modules/site-v2/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,15 +377,12 @@ def inject_announcements():
if not hasattr(g, 'site_announcements'):
g.site_announcements = set()

# Queue up all the active announcements that apply to this path
# Track and flash all the active announcements that apply to this path
# Announcements are ordered, so by default query_ds returns them in the correct order
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())
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
Expand Down
53 changes: 53 additions & 0 deletions src/modules/site-v2/base/views/api/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,46 @@ def announcement_list():
}


@api_notifications_bp.route('/announcements/order', methods=['POST'])
@admin_required()
@jsonify_request
def announcements_reorder():
'''
Change the order of the given announcements.
Expects body as mapping from announcement ID to new order.
Any announcements not given in the body will not be changed.
It is on the caller to ensure the new order is consistent, i.e. that no two announcements
have the same order. In this case, their order will be undefined.
TODO: Should this function just take a list of IDs in the desired order,
and assign the order field "implicitly"?
'''

# Retrieve all announcements in the request body, aborting if any lookup fails
try:
announcements = [
(Announcement.get_ds(announcement_id, silent=False), new_order)
for (announcement_id, new_order) in request.json.items()
]
except NotFoundError as ex:
abort(422, description=ex.description)
except Exception as ex:
abort(400)

# Update all the orders locally
for announcement, new_order in announcements:
announcement['order'] = new_order

# Save new order in one batch transaction
# If this fails, it should all fail together
Announcement.save_batch(*[announcement for (announcement, new_order) in announcements])

# Return success
return {}


@api_notifications_bp.route('/announcement', methods=['POST'])
@api_notifications_bp.route('/announcement/<string:entity_id>', methods=['GET', 'PATCH', 'DELETE'])
@admin_required()
Expand All @@ -127,10 +167,22 @@ def announcement(announcement: Announcement = None, form_data = None, no_cache:
# POST Request
# Create a new announcement, and return its unique ID
if request.method == 'POST':

# Create and save the new announcement
new_announcement = Announcement(**{
prop: form_data.get(prop) for prop in Announcement.get_props_set()
})
new_announcement.save()

# Try explicitly placing the new announcement at the bottom of the list
# If this fails, it will get a default order value, so we can ignore errors
try:
new_announcement['order'] = len(Announcement.query_ds(deleted=False))
new_announcement.save()
except Exception as ex:
pass

# Return the ID of the new announcement
return { 'id': new_announcement.name }

# PATCH Request
Expand All @@ -155,6 +207,7 @@ def announcement(announcement: Announcement = None, form_data = None, no_cache:
# Lookup the desired announcement and soft delete it
if request.method == 'DELETE':
announcement.soft_delete()
announcement['order'] = None
announcement.save()
return {}, 200

Expand Down
4 changes: 2 additions & 2 deletions src/modules/site-v2/templates/_includes/head.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/js/brands.min.js" integrity="sha512-vefaKmSAX3XohXhN50vLfnK12TPIO+4uRpHjXVkX726CqbicEiAQGRzsMTE+EpLkBk4noUcUYu6AQ5af2vfRLA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

<!-- DataTables -->
<link href="https://cdn.datatables.net/v/bs4/dt-1.13.4/sp-2.1.2/sl-1.6.2/datatables.min.css" rel="stylesheet"/>
<script src="https://cdn.datatables.net/v/bs4/dt-1.13.4/sp-2.1.2/sl-1.6.2/datatables.min.js"></script>
<link href="https://cdn.datatables.net/v/bs4/dt-2.0.6/rr-1.5.0/sp-2.1.2/sl-1.6.2/datatables.min.css" rel="stylesheet"/>
<script src="https://cdn.datatables.net/v/bs4/dt-2.0.6/rr-1.5.0/sp-2.1.2/sl-1.6.2/datatables.min.js"></script>

{# Hands on Table #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.35.0/handsontable.full.min.js" integrity="sha256-3UlQas2vcjwJZ1Jp5kK+52IdkkolB/UxQxA+67bfaho=" crossorigin="anonymous"></script>
Expand Down
98 changes: 87 additions & 11 deletions src/modules/site-v2/templates/admin/announcements/list.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
{% extends "_layouts/default.html" %}

{% block custom_head %}
<script src="https://cdn.datatables.net/rowreorder/1.5.0/js/dataTables.rowReorder.js"></script>
<script src="https://cdn.datatables.net/rowreorder/1.5.0/js/rowReorder.dataTables.js"></script>
<link href="https://cdn.datatables.net/rowreorder/1.5.0/css/rowReorder.dataTables.css" rel="stylesheet"/>

<style>
.reorderColumn {
cursor: move;
}
</style>
{% endblock %}

{% block content %}
<div class="row pb-3">
<div class="col-md-10"></div>
Expand All @@ -10,11 +22,13 @@

<div class="row">
<div class="col-md-12">
<div class="table-responsive">
<div class="table-responsive position-relative">
<table class="table accordion p-0 text-center w-100" id="announcements-table">
<caption class="visually-hidden">A list of announcements.</caption>
<thead class="table-secondary align-middle">
<tr>
<th scope="col" class="col-1 text-center">Order</th>
<th scope="col" class="col-1 text-center">Order</th>
<th scope="col" class="col-1 text-center">Active</th>
<th scope="col" class="col-1 text-center">Text</th>
<th scope="col" class="col-1 text-center">URLs</th>
Expand Down Expand Up @@ -158,19 +172,40 @@ <h1 class="modal-title fs-5" id="confirm-delete-modal-label">Delete Announcement
}
},
},
"ordering": false,
"autoWidth": false,
"rowId": function(data) {
return `announcements-row-${data.name}`;
},
"rowReorder": {
"dataSrc": "order",
},
"columnDefs": [
{ "targets": "_all", "orderable": false }
],
"columns": [
{
"data": "order",
"visible": false,
"orderable": true,
},
{
"data": "order",
"render": function(data, type, row) {
return `<i class="bi bi-arrows-expand" aria-hidden="true"></i>`;
},
"sClass": "optionsToolbar reorderColumn text-center",
"sWidth": "8%",
},
{
"data": "active",
"render": function(data, type, row) {
return data ? `<i class="bi bi-check-circle-fill" aria-hidden="true"></i>` : "";
},
"sClass": "optionsToolbar",
'sWidth': '10%',
"sClass": "optionsToolbar text-center",
"sWidth": "8%",
},
{
"data": "content",
'sWidth': '35%',
"render": function(data, type, row) {
// Cap text length
if (data.length > tableTextMaxLength) {
Expand All @@ -179,22 +214,23 @@ <h1 class="modal-title fs-5" id="confirm-delete-modal-label">Delete Announcement
// Protect against code injection
return $('<div />').text(data).html().replace('\n', '<br>');
},
"sWidth": "32%",
},
{
"data": "url_list",
"render": function(data, type, row) {
return data.replaceAll('\n', '<br>');
},
'sWidth': '25%',
"sClass": "text-start",
"sWidth": "22%",
},
{
"data": "style",
'sWidth': '10%',
"createdCell": function( cell, cellData, rowData, rowIndex, colIndex ) {
cell.classList.add(`bg-${ alertClassMap[ cellData ] }`);
cell.classList.add('bg-opacity-25');
},
"sWidth": "10%",
},
{
"render": function(data, type, row) {
Expand All @@ -203,8 +239,8 @@ <h1 class="modal-title fs-5" id="confirm-delete-modal-label">Delete Announcement
<a href='${url}'><i class="bi bi-arrow-right-circle-fill" aria-hidden="true"></i>Edit</a>
`;
},
"sClass": "optionsToolbar",
'sWidth': '10%',
"sClass": "optionsToolbar text-center",
"sWidth": "10%",
},
{
"data": "name",
Expand All @@ -215,12 +251,52 @@ <h1 class="modal-title fs-5" id="confirm-delete-modal-label">Delete Announcement
</button>
`;
},
"sClass": "optionsToolbar",
'sWidth': '10%',
"sClass": "optionsToolbar text-center",
"sWidth": "10%",
},
]
});


// When table rows are re-ordered, persist this change
table.on('row-reorder', function (e, diff, edit) {

// Create a mapping from announcement ID to new position
newRowOrders = {}
for (row of diff) {
newRowOrders[row.node.id.replace(/^(announcements-row-)/, "")] = row.newPosition
}

// POST request to update order
$.ajax({
type: "POST",
url: "{{ url_for('notifications.announcements_reorder') }}",
headers: {
'X-CSRF-TOKEN': "{{ form.csrf_token._value() }}",
},
contentType: 'application/json; charset=utf-8',
dataType: 'json',
data: JSON.stringify(newRowOrders),

// Signal that table is updating
beforeSend: function() {
table.processing(true);
},

// On success, clear the processing indicator
success: function(result) {
table.processing(false);
},

// On failure, show error message
error: function(error) {
table.processing(false);
console.error('Error updating announcement order: ', error)
flash_message('Failed to update announcement order. Please reload the page and try again.', 'Full error message', error.responseJSON.message);
}
})
});

});
</script>
{% endblock %}
4 changes: 2 additions & 2 deletions src/pkg/caendr/caendr/models/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def set(self, key, value, timeout=None):
expires = time() + timeout
try:
value = base64.b64encode(pickle.dumps(value))
save_ds_entity(self.kind, self.key_prefix + "/" + key, value=value, expires=expires, exclude_from_indexes=['value'])
save_ds_entity(self.kind, self.key_prefix + "/" + key, properties={'value': value, 'expires': expires}, exclude_from_indexes=['value'])
return True
except:
return False
Expand Down Expand Up @@ -57,5 +57,5 @@ def has(self, key):

def set_many(self, mapping, timeout):
for k, v in mapping.items():
save_ds_entity(self.kind, k, value=v)
save_ds_entity(self.kind, k, properties={'value': v})

1 change: 1 addition & 0 deletions src/pkg/caendr/caendr/models/datastore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# Abstract template classes (add basic field(s) & functionality)
from .file_record_entity import FileRecordEntity
from .hashable_entity import HashableEntity
from .orderable_entity import OrderableEntity
from .publishable_entity import PublishableEntity
from .species_entity import SpeciesEntity # Imports Species
from .status_entity import StatusEntity
Expand Down
4 changes: 2 additions & 2 deletions src/pkg/caendr/caendr/models/datastore/announcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from flask import Markup

from caendr.models.datastore import DeletableEntity
from caendr.models.datastore import DeletableEntity, OrderableEntity
from caendr.utils.data import unique_id


Expand All @@ -26,7 +26,7 @@ def get_bootstrap_color(self):



class Announcement(DeletableEntity):
class Announcement(DeletableEntity, OrderableEntity):
kind = 'announcement'

exclude_from_indexes = ('content', 'url_list')
Expand Down
34 changes: 30 additions & 4 deletions src/pkg/caendr/caendr/models/datastore/entity.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import json
from datetime import datetime, timezone
from enum import Enum
from typing import Tuple

from google.cloud import datastore
from caendr.services.logger import logger

from caendr.models.error import NonUniqueEntity, NotFoundError
from caendr.services.cloud.datastore import get_ds_entity, save_ds_entity, query_ds_entities
from caendr.services.cloud.datastore import get_ds_entity, save_ds_entity, save_ds_entities, query_ds_entities
from caendr.utils.tokens import TokenizedString


Expand Down Expand Up @@ -152,10 +153,11 @@ def __repr__(self):

## Saving to Datastore ##

def save(self):
def _format_props_for_ds(self):
'''
Append metadata to the Entity and save it to the datastore.
Format this entity's props to be saved in the datastore.
'''

now = datetime.now(timezone.utc)

# Get serialized dict of all props and meta props
Expand All @@ -170,8 +172,32 @@ def save(self):
props['created_on'] = now
props['modified_on'] = now

return props


def save(self):
'''
Append metadata to the Entity and save it to the datastore.
'''

# Format the properties
props = self._format_props_for_ds()

# Save the serialized entity in datastore
save_ds_entity(self.kind, self.name, exclude_from_indexes=self.exclude_from_indexes, **props)
save_ds_entity(self.kind, self.name, properties=props, exclude_from_indexes=self.exclude_from_indexes)


@classmethod
def save_batch(cls, *entities: 'Entity'):
'''
Save multiple Entity objects to the datastore in a single transaction.
'''

# Format the props of each entity, and save in dict with the entity's unique ID
entities = { e.name: e._format_props_for_ds() for e in entities }

# Save all entities in a single datastore call
save_ds_entities(cls.kind, entities, exclude_from_indexes=cls.exclude_from_indexes)



Expand Down
Loading

0 comments on commit 81699d0

Please sign in to comment.