From 1ea5dcf907eae21fe587f930feea0eab1d41405b Mon Sep 17 00:00:00 2001 From: Ryan Cross Date: Thu, 12 Nov 2015 00:26:52 +0000 Subject: [PATCH] Liaison changes from IETF 94 demo. Includes tab for Action Needed statements and ability to add comments to history. Commit ready for merge - Legacy-Id: 10464 --- ietf/liaisons/forms.py | 56 +++++++++++++++---- .../migrations/0008_auto_20151110_1352.py | 20 +++++++ .../liaisons/migrations/0009_remove_fields.py | 22 ++++++++ ietf/liaisons/models.py | 6 +- ietf/liaisons/tests.py | 31 +++++++++- ietf/liaisons/urls.py | 3 +- ietf/liaisons/views.py | 43 ++++++++++++-- .../name/migrations/0010_new_liaison_names.py | 19 +++++++ ietf/static/ietf/js/liaisons.js | 28 +++------- ietf/templates/liaisons/add_comment.html | 27 +++++++++ ietf/templates/liaisons/detail_history.html | 5 +- ietf/templates/liaisons/edit.html | 2 +- ietf/templates/liaisons/liaison_table.html | 5 +- ietf/templates/liaisons/search_form.html | 6 +- 14 files changed, 221 insertions(+), 52 deletions(-) create mode 100644 ietf/liaisons/migrations/0008_auto_20151110_1352.py create mode 100644 ietf/liaisons/migrations/0009_remove_fields.py create mode 100644 ietf/name/migrations/0010_new_liaison_names.py create mode 100644 ietf/templates/liaisons/add_comment.html diff --git a/ietf/liaisons/forms.py b/ietf/liaisons/forms.py index 2dcb4382b..9c09414ff 100644 --- a/ietf/liaisons/forms.py +++ b/ietf/liaisons/forms.py @@ -117,6 +117,9 @@ def validate_emails(value): # ------------------------------------------------- # Form Classes # ------------------------------------------------- +class AddCommentForm(forms.Form): + comment = forms.CharField(required=True, widget=forms.Textarea) + private = forms.BooleanField(label="Private comment", required=False,help_text="If this box is checked the comment will not appear in the statement's public history view.") class RadioRenderer(RadioFieldRenderer): def render(self): @@ -127,6 +130,7 @@ class RadioRenderer(RadioFieldRenderer): class SearchLiaisonForm(forms.Form): + '''Expects initial keyword argument queryset which then gets filtered based on form data''' text = forms.CharField(required=False) scope = forms.ChoiceField(choices=(("all", "All text fields"), ("title", "Title field")), required=False, initial='title', widget=forms.RadioSelect(renderer=RadioRenderer)) source = forms.CharField(required=False) @@ -135,11 +139,11 @@ class SearchLiaisonForm(forms.Form): end_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='End date', required=False) def __init__(self, *args, **kwargs): - self.state = kwargs.pop('state') + self.queryset = kwargs.pop('queryset') super(SearchLiaisonForm, self).__init__(*args, **kwargs) def get_results(self): - results = LiaisonStatement.objects.filter(state=self.state) + results = self.queryset if self.is_bound: query = self.cleaned_data.get('text') if query: @@ -157,11 +161,19 @@ class SearchLiaisonForm(forms.Form): source = self.cleaned_data.get('source') if source: - results = results.filter(Q(from_groups__name__icontains=source) | Q(from_groups__acronym__iexact=source)) + source_list = source.split(',') + if len(source_list) > 1: + results = results.filter(Q(from_groups__acronym__in=source_list)) + else: + results = results.filter(Q(from_groups__name__icontains=source) | Q(from_groups__acronym__iexact=source)) destination = self.cleaned_data.get('destination') if destination: - results = results.filter(Q(to_groups__name__icontains=destination) | Q(to_groups__acronym__iexact=destination)) + destination_list = destination.split(',') + if len(destination_list) > 1: + results = results.filter(Q(to_groups__acronym__in=destination_list)) + else: + results = results.filter(Q(to_groups__name__icontains=destination) | Q(to_groups__acronym__iexact=destination)) start_date = self.cleaned_data.get('start_date') end_date = self.cleaned_data.get('end_date') @@ -192,10 +204,13 @@ class CustomModelMultipleChoiceField(forms.ModelMultipleChoiceField): class LiaisonModelForm(BetterModelForm): - '''Specify fields which require a custom widget or that are not part of the model''' - from_groups = forms.ModelMultipleChoiceField(queryset=Group.objects.all(),label=u'Groups') + '''Specify fields which require a custom widget or that are not part of the model. + NOTE: from_groups and to_groups are marked as not required because select2 has + a problem with validating + ''' + from_groups = forms.ModelMultipleChoiceField(queryset=Group.objects.all(),label=u'Groups',required=False) from_contact = forms.EmailField() - to_groups = forms.ModelMultipleChoiceField(queryset=Group.objects,label=u'Groups') + to_groups = forms.ModelMultipleChoiceField(queryset=Group.objects,label=u'Groups',required=False) deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='Deadline', required=True) related_to = SearchableLiaisonStatementsField(label=u'Related Liaison Statement', required=False) submitted_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='Submission date', required=True, initial=datetime.date.today()) @@ -221,6 +236,7 @@ class LiaisonModelForm(BetterModelForm): def __init__(self, user, *args, **kwargs): super(LiaisonModelForm, self).__init__(*args, **kwargs) self.user = user + self.edit = False self.person = get_person_for_user(user) self.is_new = not self.instance.pk @@ -237,6 +253,18 @@ class LiaisonModelForm(BetterModelForm): self.set_from_fields() self.set_to_fields() + def clean_from_groups(self): + from_groups = self.cleaned_data.get('from_groups') + if not from_groups: + raise forms.ValidationError('You must specify a From Group') + return from_groups + + def clean_to_groups(self): + to_groups = self.cleaned_data.get('to_groups') + if not to_groups: + raise forms.ValidationError('You must specify a To Group') + return to_groups + def clean_from_contact(self): contact = self.cleaned_data.get('from_contact') try: @@ -294,6 +322,7 @@ class LiaisonModelForm(BetterModelForm): self.save_related_liaisons() self.save_attachments() + self.save_tags() return self.instance @@ -350,6 +379,11 @@ class LiaisonModelForm(BetterModelForm): if related.target not in new_related: related.delete() + def save_tags(self): + '''Create tags as needed''' + if self.instance.deadline and not self.instance.tags.filter(slug='taken'): + self.instance.tags.add('required') + def set_from_fields(self): assert NotImplemented @@ -488,9 +522,9 @@ class EditLiaisonForm(LiaisonModelForm): self.fields['from_groups'].choices = get_internal_choices(self.user) else: if has_role(self.user, "Secretariat"): - queryset = Group.objects.filter(type="sdo", state="active").order_by('name') + queryset = Group.objects.filter(type="sdo").order_by('name') else: - queryset = Group.objects.filter(type="sdo", state="active", role__person=self.person, role__name__in=("liaiman", "auth")).distinct().order_by('name') + queryset = Group.objects.filter(type="sdo", role__person=self.person, role__name__in=("liaiman", "auth")).distinct().order_by('name') self.fields['from_contact'].widget.attrs['readonly'] = True self.fields['from_groups'].queryset = queryset @@ -501,10 +535,10 @@ class EditLiaisonForm(LiaisonModelForm): if self.instance.is_outgoing(): # if the user is a Liaison Manager and nothing more, reduce to set to his SDOs if has_role(self.user, "Liaison Manager") and not self.person.role_set.filter(name__in=('ad','chair'),group__state='active'): - queryset = Group.objects.filter(type="sdo", state="active", role__person=self.person, role__name="liaiman").distinct().order_by('name') + queryset = Group.objects.filter(type="sdo", role__person=self.person, role__name="liaiman").distinct().order_by('name') else: # get all outgoing entities - queryset = Group.objects.filter(type="sdo", state="active").order_by('name') + queryset = Group.objects.filter(type="sdo").order_by('name') self.fields['to_groups'].queryset = queryset else: self.fields['to_groups'].choices = get_internal_choices(None) diff --git a/ietf/liaisons/migrations/0008_auto_20151110_1352.py b/ietf/liaisons/migrations/0008_auto_20151110_1352.py new file mode 100644 index 000000000..02090025f --- /dev/null +++ b/ietf/liaisons/migrations/0008_auto_20151110_1352.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + +def create_required_tags(apps, schema_editor): + LiaisonStatement = apps.get_model("liaisons", "LiaisonStatement") + for s in LiaisonStatement.objects.filter(deadline__isnull=False): + if not s.tags.filter(slug='taken'): + s.tags.add('required') + +class Migration(migrations.Migration): + + dependencies = [ + ('liaisons', '0007_auto_20151009_1220'), + ] + + operations = [ + migrations.RunPython(create_required_tags), + ] diff --git a/ietf/liaisons/migrations/0009_remove_fields.py b/ietf/liaisons/migrations/0009_remove_fields.py new file mode 100644 index 000000000..69068fb33 --- /dev/null +++ b/ietf/liaisons/migrations/0009_remove_fields.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('liaisons', '0008_auto_20151110_1352'), + ] + + operations = [ + migrations.RemoveField( + model_name='liaisonstatement', + name='from_name', + ), + migrations.RemoveField( + model_name='liaisonstatement', + name='to_name', + ), + ] diff --git a/ietf/liaisons/models.py b/ietf/liaisons/models.py index 1744ebd84..c17e79476 100644 --- a/ietf/liaisons/models.py +++ b/ietf/liaisons/models.py @@ -28,7 +28,7 @@ class LiaisonStatement(models.Model): from_groups = models.ManyToManyField(Group, blank=True, related_name='liaisonstatement_from_set') from_contact = models.ForeignKey(Email, blank=True, null=True) to_groups = models.ManyToManyField(Group, blank=True, related_name='liaisonstatement_to_set') - to_contacts = models.CharField(max_length=255, help_text="Contacts at recipient body") + to_contacts = models.CharField(max_length=255, help_text="Contacts at recipient group") response_contacts = models.CharField(blank=True, max_length=255, help_text="Where to send a response") # RFC4053 technical_contacts = models.CharField(blank=True, max_length=255, help_text="Who to contact for clarification") # RFC4053 @@ -44,10 +44,6 @@ class LiaisonStatement(models.Model): attachments = models.ManyToManyField(Document, through='LiaisonStatementAttachment', blank=True) state = models.ForeignKey(LiaisonStatementState, default='pending') - # remove these fields post upgrade - from_name = models.CharField(max_length=255, help_text="Name of the sender body") - to_name = models.CharField(max_length=255, help_text="Name of the recipient body") - def __unicode__(self): return self.title or u"" diff --git a/ietf/liaisons/tests.py b/ietf/liaisons/tests.py index 79f7f8a8e..aaf7e9df1 100644 --- a/ietf/liaisons/tests.py +++ b/ietf/liaisons/tests.py @@ -338,6 +338,35 @@ class LiaisonManagementTests(TestCase): r = self.client.get(url) self.assertEqual(r.status_code, 200) + def test_add_comment(self): + make_test_data() + liaison = make_liaison_models() + + # test unauthorized + url = urlreverse('ietf.liaisons.views.liaison_history',kwargs=dict(object_id=liaison.pk)) + addurl = urlreverse('ietf.liaisons.views.add_comment',kwargs=dict(object_id=liaison.pk)) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual(len(q("a.btn:contains('Add Comment')")), 0) + login_testing_unauthorized(self, "secretary", addurl) + + # public comment + self.client.login(username="secretary", password="secretary+password") + comment = 'Test comment' + r = self.client.post(addurl, dict(comment=comment)) + self.assertEqual(r.status_code,302) + qs = liaison.liaisonstatementevent_set.filter(type='comment',desc=comment) + self.assertTrue(qs.count(),1) + + # private comment + r = self.client.post(addurl, dict(comment='Private comment',private=True),follow=True) + self.assertEqual(r.status_code,200) + self.assertTrue('Private comment' in r.content) + self.client.logout() + r = self.client.get(url) + self.assertFalse('Private comment' in r.content) + def test_taken_care_of(self): make_test_data() liaison = make_liaison_models() @@ -721,7 +750,7 @@ class LiaisonManagementTests(TestCase): r = self.client.post(url, dict(from_groups=from_groups, from_contact=submitter.email_address(), - to_groups=str(to_group.pk), + to_groups=[str(to_group.pk)], to_contacts='to_contacts@example.com', technical_contacts="technical_contact@example.com", action_holder_contacts="action_holder_contacts@example.com", diff --git a/ietf/liaisons/urls.py b/ietf/liaisons/urls.py index e59bc82b2..74a6e70f8 100644 --- a/ietf/liaisons/urls.py +++ b/ietf/liaisons/urls.py @@ -20,8 +20,9 @@ urlpatterns += patterns('ietf.liaisons.views', # Views urlpatterns += patterns('ietf.liaisons.views', (r'^$', 'liaison_list'), - (r'^(?P(posted|pending|dead))/$', 'liaison_list'), + (r'^(?P(posted|pending|dead))/', 'liaison_list'), (r'^(?P\d+)/$', 'liaison_detail'), + (r'^(?P\d+)/addcomment/$', 'add_comment'), (r'^(?P\d+)/edit/$', 'liaison_edit'), (r'^(?P\d+)/edit-attachment/(?P[A-Za-z0-9._+-]+)$', 'liaison_edit_attachment'), (r'^(?P\d+)/delete-attachment/(?P[A-Za-z0-9._+-]+)$', 'liaison_delete_attachment'), diff --git a/ietf/liaisons/views.py b/ietf/liaisons/views.py index d24dc0369..9add4320a 100644 --- a/ietf/liaisons/views.py +++ b/ietf/liaisons/views.py @@ -18,7 +18,7 @@ from ietf.liaisons.models import (LiaisonStatement,LiaisonStatementEvent, from ietf.liaisons.utils import (get_person_for_user, can_add_outgoing_liaison, can_add_incoming_liaison, can_edit_liaison,can_submit_liaison_required, can_add_liaison) -from ietf.liaisons.forms import liaison_form_factory, SearchLiaisonForm, EditAttachmentForm +from ietf.liaisons.forms import liaison_form_factory, SearchLiaisonForm, EditAttachmentForm, AddCommentForm from ietf.liaisons.mails import notify_pending_by_email, send_liaison_by_email from ietf.liaisons.fields import select2_id_liaison_json from ietf.name.models import LiaisonStatementTagName @@ -288,6 +288,32 @@ def redirect_for_approval(request, object_id=None): # ------------------------------------------------- # View Functions # ------------------------------------------------- +@role_required('Secretariat',) +def add_comment(request, object_id): + """Add comment to history""" + statement = get_object_or_404(LiaisonStatement, id=object_id) + login = request.user.person + + if request.method == 'POST': + form = AddCommentForm(request.POST) + if form.is_valid(): + if form.cleaned_data.get('private'): + type_id = 'private_comment' + else: + type_id = 'comment' + + LiaisonStatementEvent.objects.create( + by=login, + type_id=type_id, + statement=statement, + desc=form.cleaned_data['comment'] + ) + messages.success(request, 'Comment added.') + return redirect("ietf.liaisons.views.liaison_history", object_id=statement.id) + else: + form = AddCommentForm() + + return render(request, 'liaisons/add_comment.html',dict(liaison=statement,form=form)) @can_submit_liaison_required def liaison_add(request, type=None, **kwargs): @@ -330,6 +356,8 @@ def liaison_history(request, object_id): """Show the history for a specific liaison statement""" liaison = get_object_or_404(LiaisonStatement, id=object_id) events = liaison.liaisonstatementevent_set.all().order_by("-time", "-id").select_related("by") + if not has_role(request.user, "Secretariat"): + events = events.exclude(type='private_comment') return render(request, "liaisons/detail_history.html", { 'events':events, @@ -439,6 +467,7 @@ def liaison_edit_attachment(request, object_id, doc_id): def liaison_list(request, state='posted'): """A generic list view with tabs for different states: posted, pending, dead""" # use prefetch to speed up main liaison page load + selected_menu_entry = state liaisons = LiaisonStatement.objects.filter(state=state).prefetch_related( Prefetch('from_groups',queryset=Group.objects.order_by('acronym').select_related('type'),to_attr='prefetched_from_groups'), Prefetch('to_groups',queryset=Group.objects.order_by('acronym').select_related('type'),to_attr='prefetched_to_groups'), @@ -451,15 +480,20 @@ def liaison_list(request, state='posted'): msg = "Restricted to participants who are authorized to submit liaison statements on behalf of the various IETF entities" return HttpResponseForbidden(msg) + if 'tags' in request.GET: + value = request.GET.get('tags') + liaisons = liaisons.filter(tags__slug=value) + selected_menu_entry = 'action needed' + # perform search / filter if 'text' in request.GET: - form = SearchLiaisonForm(data=request.GET,state=state) + form = SearchLiaisonForm(data=request.GET,queryset=liaisons) search_conducted = True if form.is_valid(): results = form.get_results() liaisons = results else: - form = SearchLiaisonForm(state=state) + form = SearchLiaisonForm(queryset=liaisons) search_conducted = False # perform sort @@ -482,6 +516,7 @@ def liaison_list(request, state='posted'): entries = [] entries.append(("Posted", urlreverse("ietf.liaisons.views.liaison_list", kwargs={'state':'posted'}))) if can_add_liaison(request.user): + entries.append(("Action Needed", urlreverse("ietf.liaisons.views.liaison_list", kwargs={'state':'posted'}) + '?tags=required')) entries.append(("Pending", urlreverse("ietf.liaisons.views.liaison_list", kwargs={'state':'pending'}))) entries.append(("Dead", urlreverse("ietf.liaisons.views.liaison_list", kwargs={'state':'dead'}))) @@ -494,7 +529,7 @@ def liaison_list(request, state='posted'): return render(request, 'liaisons/liaison_base.html', { 'liaisons':liaisons, - 'selected_menu_entry':state, + 'selected_menu_entry':selected_menu_entry, 'menu_entries':entries, 'menu_actions':actions, 'sort':sort, diff --git a/ietf/name/migrations/0010_new_liaison_names.py b/ietf/name/migrations/0010_new_liaison_names.py new file mode 100644 index 000000000..0123ae53d --- /dev/null +++ b/ietf/name/migrations/0010_new_liaison_names.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + +def populate_names(apps, schema_editor): + LiaisonStatementEventTypeName = apps.get_model("name", "LiaisonStatementEventTypeName") + LiaisonStatementEventTypeName.objects.create(slug="private_comment", order=10, name="Private Comment") + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0009_auto_20151021_1102'), + ] + + operations = [ + migrations.RunPython(populate_names), + ] diff --git a/ietf/static/ietf/js/liaisons.js b/ietf/static/ietf/js/liaisons.js index a55fe5e45..b4c111afc 100644 --- a/ietf/static/ietf/js/liaisons.js +++ b/ietf/static/ietf/js/liaisons.js @@ -124,6 +124,7 @@ var attachmentWidget = { var liaisonForm = { initVariables : function() { + liaisonForm.is_edit_form = liaisonForm.form.attr("data-edit-form") == "True" liaisonForm.from_groups = liaisonForm.form.find('#id_from_groups'); liaisonForm.from_contact = liaisonForm.form.find('#id_from_contact'); liaisonForm.response_contacts = liaisonForm.form.find('#id_response_contacts'); @@ -189,6 +190,11 @@ var liaisonForm = { }, updateInfo : function(first_time, sender) { + // don't overwrite fields when editing existing liaison + if(liaisonForm.is_edit_form){ + return false; + } + var from_ids = liaisonForm.from_groups.val(); var to_ids = liaisonForm.to_groups.val(); var url = liaisonForm.form.data("ajaxInfoUrl"); @@ -278,28 +284,8 @@ var searchForm = { // search form, based on doc search feature init : function() { searchForm.form = $(this); - searchForm.form.find(".search_field input,select").change(searchForm.toggleSubmit).click(searchForm.toggleSubmit).keyup(searchForm.toggleSubmit); $("#search-clear-btn").bind("click", searchForm.clearForm); }, - - anyAdvancedActive : function() { - var advanced = false; - var by = searchForm.form.find("input[name=by]:checked"); - - if (by.length > 0) { - by.closest(".search_field").find("input,select").not("input[name=by]").each(function () { - if ($.trim(this.value)) { - advanced = true; - } - }); - } - return advanced; - }, - - toggleSubmit : function() { - var textSearch = $.trim($("#id_text").val()); - searchForm.form.find("button[type=submit]").get(0).disabled = !textSearch && !searchForm.anyAdvancedActive(); - }, clearForm : function() { var form = $(this).parents("form"); @@ -313,5 +299,5 @@ $(document).ready(function () { $.ajaxSetup({ traditional: true }); $('form.liaisons-form').each(liaisonForm.init); - $('#search_form').each(searchForm.init); + $('#liaison_search_form').each(searchForm.init); }); diff --git a/ietf/templates/liaisons/add_comment.html b/ietf/templates/liaisons/add_comment.html new file mode 100644 index 000000000..6debe0f14 --- /dev/null +++ b/ietf/templates/liaisons/add_comment.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %} + +{% load bootstrap3 %} + +{% block title %}Add comment on {{ liaison.title }}{% endblock %} + +{% block content %} + {% origin %} +

Add comment
{{ liaison.title }}

+ +

The comment will be added to the history trail of the statement.

+ + {% bootstrap_messages %} + +
+ {% csrf_token %} + + {% bootstrap_form form %} + + {% buttons %} + + Back + {% endbuttons %} +
+{% endblock %} diff --git a/ietf/templates/liaisons/detail_history.html b/ietf/templates/liaisons/detail_history.html index af73f9ecc..9749dc9b7 100644 --- a/ietf/templates/liaisons/detail_history.html +++ b/ietf/templates/liaisons/detail_history.html @@ -10,14 +10,11 @@ {% include "liaisons/detail_tabs.html" %} - {% comment %} {% if user|has_role:"Area Director,Secretariat,IANA,RFC Editor" %}

- Add comment - Add email + Add comment

{% endif %} - {% endcomment %} diff --git a/ietf/templates/liaisons/edit.html b/ietf/templates/liaisons/edit.html index 1dee7f659..a7a690f8c 100644 --- a/ietf/templates/liaisons/edit.html +++ b/ietf/templates/liaisons/edit.html @@ -43,7 +43,7 @@

Fields marked with are required. For detailed descriptions of the fields see the field help.

{% endif %} - {% csrf_token %} + {% csrf_token %} {% for fieldset in form.fieldsets %} {% if forloop.first and user|has_role:"Secretariat" %} diff --git a/ietf/templates/liaisons/liaison_table.html b/ietf/templates/liaisons/liaison_table.html index 0b45916ad..fecb0020d 100644 --- a/ietf/templates/liaisons/liaison_table.html +++ b/ietf/templates/liaisons/liaison_table.html @@ -29,7 +29,10 @@ - + {% endfor %} diff --git a/ietf/templates/liaisons/search_form.html b/ietf/templates/liaisons/search_form.html index de76c26f6..9fd569571 100644 --- a/ietf/templates/liaisons/search_form.html +++ b/ietf/templates/liaisons/search_form.html @@ -3,7 +3,7 @@ {% load ietf_filters %} {% load bootstrap3 %} - +
{{ form.text|add_class:"form-control"|attr:"placeholder:Title, body, identifiers, etc." }} @@ -32,7 +32,7 @@
- +
{{ form.source|add_class:"form-control" }} @@ -41,7 +41,7 @@
- +
{{ form.destination|add_class:"form-control" }}
{{ liaison.sort_date|date:"Y-m-d" }} {{ liaison.from_groups_display }} {{ liaison.to_groups_display }}{{ liaison.deadline|default:"-"|date:"Y-m-d" }}{% if liaison.deadline and not liaison.action_taken %}
Action Needed{% endif %}
{{ liaison.deadline|default:"-"|date:"Y-m-d" }} + {% if liaison.deadline and not liaison.action_taken %} +
Action Needed + {% endif %}
{{ liaison.title }}