datatracker/ietf/liaisons/views.py

575 lines
23 KiB
Python

# Copyright The IETF Trust 2007-2020, All Rights Reserved
# -*- coding: utf-8 -*-
import json
from email.utils import parseaddr
from django.contrib import messages
from django.urls import reverse as urlreverse
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db.models import Q, Prefetch
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404, redirect
import debug # pyflakes:ignore
from ietf.doc.models import Document
from ietf.ietfauth.utils import role_required, has_role
from ietf.group.models import Group, Role
from ietf.liaisons.models import (LiaisonStatement,LiaisonStatementEvent,
LiaisonStatementAttachment)
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, 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
from ietf.utils.response import permission_denied
EMAIL_ALIASES = {
'IETFCHAIR':'The IETF Chair <chair@ietf.org>',
'IESG':'The IESG <iesg@ietf.org>',
'IAB':'The IAB <iab@iab.org>',
'IABCHAIR':'The IAB Chair <iab-chair@iab.org>',
'IABEXECUTIVEDIRECTOR':'The IAB Executive Director <execd@iab.org>'}
# -------------------------------------------------
# Helper Functions
# -------------------------------------------------
def _can_reply(liaison, user):
'''Returns true if the user can send a reply to this liaison'''
if user.is_authenticated:
person = get_person_for_user(user)
if has_role(user, "Secretariat"):
return True
if liaison.is_outgoing() and Role.objects.filter(group__in=liaison.to_groups.all(),person=person,name='auth'):
return True
if not liaison.is_outgoing() and Role.objects.filter(group__in=liaison.from_groups.all(),person=person,name='liaiman'):
return True
return False
def _can_take_care(liaison, user):
'''Returns True if user can take care of awaiting actions associated with this liaison'''
if not liaison.deadline or liaison.action_taken:
return False
if user.is_authenticated:
if has_role(user, "Secretariat"):
return True
else:
return _find_person_in_emails(liaison, get_person_for_user(user))
return False
def _find_person_in_emails(liaison, person):
'''Returns true if person corresponds with any of the email addresses associated
with this liaison statement'''
if not person:
return False
emails = ','.join(e for e in [liaison.response_contacts, liaison.cc_contacts,
liaison.to_contacts,liaison.technical_contacts] if e)
for email in emails.split(','):
name, addr = parseaddr(email)
try:
validate_email(addr)
except ValidationError:
continue
if person.email_set.filter(address=addr):
return True
elif addr in ('chair@ietf.org', 'iesg@ietf.org') and has_role(person.user, "IETF Chair"):
return True
elif addr in ('iab@iab.org', 'iab-chair@iab.org') and has_role(person.user, "IAB Chair"):
return True
elif addr in ('execd@iab.org', ) and has_role(person.user, "IAB Executive Director"):
return True
return False
def contact_email_from_role(role):
return '{} <{}>'.format(role.person.plain_name(), role.email.address)
def contacts_from_roles(roles):
'''Returns contact string for given roles'''
emails = [ contact_email_from_role(r) for r in roles ]
return ','.join(emails)
def get_cc(group):
'''Returns list of emails to use as CC for group. Simplified refactor of IETFHierarchy
get_cc() and get_from_cc()
'''
emails = []
# role based CCs
if group.acronym in ('ietf','iesg'):
emails.append(EMAIL_ALIASES['IESG'])
emails.append(EMAIL_ALIASES['IETFCHAIR'])
elif group.acronym in ('iab'):
emails.append(EMAIL_ALIASES['IAB'])
emails.append(EMAIL_ALIASES['IABCHAIR'])
emails.append(EMAIL_ALIASES['IABEXECUTIVEDIRECTOR'])
elif group.type_id == 'area':
emails.append(EMAIL_ALIASES['IETFCHAIR'])
ad_roles = group.role_set.filter(name='ad')
emails.extend([ contact_email_from_role(r) for r in ad_roles ])
elif group.type_id == 'wg':
ad_roles = group.parent.role_set.filter(name='ad')
emails.extend([ contact_email_from_role(r) for r in ad_roles ])
chair_roles = group.role_set.filter(name='chair')
emails.extend([ contact_email_from_role(r) for r in chair_roles ])
if group.list_email:
emails.append('{} Discussion List <{}>'.format(group.name,group.list_email))
elif group.type_id == 'sdo':
liaiman_roles = group.role_set.filter(name='liaiman')
emails.extend([ contact_email_from_role(r) for r in liaiman_roles ])
# explicit CCs
liaison_cc_roles = group.role_set.filter(name='liaison_cc_contact')
emails.extend([ contact_email_from_role(r) for r in liaison_cc_roles ])
return emails
def get_contacts_for_group(group):
'''Returns default contacts for groups as a comma separated string'''
# use explicit default contacts if defined
explicit_contacts = contacts_from_roles(group.role_set.filter(name='liaison_contact'))
if explicit_contacts:
return explicit_contacts
# otherwise construct based on group type
contacts = []
if group.type_id == 'area':
roles = group.role_set.filter(name='ad')
contacts.append(contacts_from_roles(roles))
elif group.type_id == 'wg':
roles = group.role_set.filter(name='chair')
contacts.append(contacts_from_roles(roles))
elif group.acronym == 'ietf':
contacts.append(EMAIL_ALIASES['IETFCHAIR'])
elif group.acronym == 'iab':
contacts.append(EMAIL_ALIASES['IABCHAIR'])
contacts.append(EMAIL_ALIASES['IABEXECUTIVEDIRECTOR'])
elif group.acronym == 'iesg':
contacts.append(EMAIL_ALIASES['IESG'])
return ','.join(contacts)
def get_details_tabs(stmt, selected):
return [
t + (t[0].lower() == selected.lower(),)
for t in [
('Statement', urlreverse('ietf.liaisons.views.liaison_detail', kwargs={ 'object_id': stmt.pk })),
('History', urlreverse('ietf.liaisons.views.liaison_history', kwargs={ 'object_id': stmt.pk }))
]]
def needs_approval(group,person):
'''Returns True if the person does not have authority to send a Liaison Statement
from group. For outgoing Liaison Statements only'''
user = person.user
if group.acronym in ('ietf','iesg') and has_role(user, 'IETF Chair'):
return False
if group.acronym == 'iab' and (has_role(user,'IAB Chair') or has_role(user,'IAB Executive Director')):
return False
if group.type_id == 'area' and group.role_set.filter(name='ad',person=person):
return False
if group.type_id == 'wg' and group.parent and group.parent.role_set.filter(name='ad',person=person):
return False
return True
def normalize_sort(request):
sort = request.GET.get('sort', "")
if sort not in ('date', 'deadline', 'title', 'to_groups', 'from_groups'):
sort = "date"
# reverse dates
order_by = "-" + sort if sort in ("date", "deadline") else sort
return sort, order_by
def post_only(group,person):
'''Returns true if the user is restricted to post_only (vs. post_and_send) for this
group. This is for incoming liaison statements.
- Secretariat have full access.
- Authorized Individuals have full access for the group they are associated with
- Liaison Managers can post only
'''
if group.type_id == 'sdo' and ( not(has_role(person.user,"Secretariat") or group.role_set.filter(name='auth',person=person)) ):
return True
else:
return False
# -------------------------------------------------
# Ajax Functions
# -------------------------------------------------
@can_submit_liaison_required
def ajax_get_liaison_info(request):
'''Returns dictionary of info to update entry form given the groups
that have been selected
'''
person = get_person_for_user(request.user)
from_groups = request.GET.getlist('from_groups', None)
if not any(from_groups):
from_groups = []
to_groups = request.GET.getlist('to_groups', None)
if not any(to_groups):
to_groups = []
from_groups = [ Group.objects.get(id=id) for id in from_groups ]
to_groups = [ Group.objects.get(id=id) for id in to_groups ]
cc = []
does_need_approval = []
can_post_only = []
to_contacts = []
response_contacts = []
result = {'response_contacts':[],'to_contacts': [], 'cc': [], 'needs_approval': False, 'post_only': False, 'full_list': []}
for group in from_groups:
cc.extend(get_cc(group))
does_need_approval.append(needs_approval(group,person))
can_post_only.append(post_only(group,person))
response_contacts.append(get_contacts_for_group(group))
for group in to_groups:
cc.extend(get_cc(group))
to_contacts.append(get_contacts_for_group(group))
# if there are from_groups and any need approval
if does_need_approval:
if any(does_need_approval):
does_need_approval = True
else:
does_need_approval = False
else:
does_need_approval = True
result.update({'error': False,
'cc': list(set(cc)),
'response_contacts':list(set(response_contacts)),
'to_contacts': list(set(to_contacts)),
'needs_approval': does_need_approval,
'post_only': any(can_post_only)})
json_result = json.dumps(result)
return HttpResponse(json_result, content_type='application/json')
def ajax_select2_search_liaison_statements(request):
query = [w.strip() for w in request.GET.get('q', '').split() if w.strip()]
if not query:
objs = LiaisonStatement.objects.none()
else:
qs = LiaisonStatement.objects.filter(state='posted')
for term in query:
if term.isdigit():
q = Q(title__icontains=term)|Q(pk=term)
else:
q = Q(title__icontains=term)
qs = qs.filter(q)
objs = qs.distinct().order_by("-id")[:20]
return HttpResponse(select2_id_liaison_json(objs), content_type='application/json')
# -------------------------------------------------
# Redirects for backwards compatibility
# -------------------------------------------------
def redirect_add(request):
"""Redirects old add urls"""
if 'incoming' in list(request.GET.keys()):
return redirect('ietf.liaisons.views.liaison_add', type='incoming')
else:
return redirect('ietf.liaisons.views.liaison_add', type='outgoing')
def redirect_for_approval(request, object_id=None):
"""Redirects old approval urls"""
if object_id:
return redirect('ietf.liaisons.views.liaison_detail', object_id=object_id)
else:
return redirect('ietf.liaisons.views.liaison_list', state='pending')
# -------------------------------------------------
# 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):
if type == 'incoming' and not can_add_incoming_liaison(request.user):
permission_denied(request, "Restricted to users who are authorized to submit incoming liaison statements.")
if type == 'outgoing' and not can_add_outgoing_liaison(request.user):
permission_denied(request, "Restricted to users who are authorized to submit outgoing liaison statements.")
if request.method == 'POST':
form = liaison_form_factory(request, data=request.POST.copy(),
files=request.FILES, type=type, **kwargs)
if form.is_valid():
liaison = form.save()
# notifications
if 'save' in request.POST:
# the result of an edit, no notifications necessary
messages.success(request, 'The statement has been updated')
elif 'send' in request.POST and liaison.state.slug == 'posted':
send_liaison_by_email(request, liaison)
messages.success(request, 'The statement has been sent and posted')
elif liaison.state.slug == 'pending':
notify_pending_by_email(request, liaison)
messages.success(request, 'The statement has been submitted and is awaiting approval')
return redirect('ietf.liaisons.views.liaison_detail', object_id=liaison.pk)
else:
form = liaison_form_factory(request,type=type,**kwargs)
return render(request, 'liaisons/edit.html', {
'form': form,
'liaison': kwargs.get('instance')
})
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,
'liaison': liaison,
'tabs': get_details_tabs(liaison, 'History'),
'selected_tab_entry':'history'
})
def liaison_delete_attachment(request, object_id, attach_id):
liaison = get_object_or_404(LiaisonStatement, pk=object_id)
attach = get_object_or_404(LiaisonStatementAttachment, pk=attach_id)
if not can_edit_liaison(request.user, liaison):
permission_denied(request, "You are not authorized for this action.")
# FIXME: this view should use POST instead of GET when deleting
attach.removed = True
attach.save()
# create event
LiaisonStatementEvent.objects.create(
type_id='modified',
by=get_person_for_user(request.user),
statement=liaison,
desc='Attachment Removed: {}'.format(attach.document.title)
)
messages.success(request, 'Attachment Deleted')
return redirect('ietf.liaisons.views.liaison_detail', object_id=liaison.pk)
def liaison_detail(request, object_id):
liaison = get_object_or_404(LiaisonStatement, pk=object_id)
can_edit = can_edit_liaison(request.user, liaison)
can_take_care = _can_take_care(liaison, request.user)
can_reply = _can_reply(liaison, request.user)
person = get_person_for_user(request.user)
if request.method == 'POST':
if request.POST.get('approved'):
liaison.change_state(state_id='approved',person=person)
liaison.change_state(state_id='posted',person=person)
send_liaison_by_email(request, liaison)
messages.success(request,'Liaison Statement Approved and Posted')
elif request.POST.get('dead'):
liaison.change_state(state_id='dead',person=person)
messages.success(request,'Liaison Statement Killed')
elif request.POST.get('resurrect'):
liaison.change_state(state_id='pending',person=person)
messages.success(request,'Liaison Statement Resurrected')
elif request.POST.get('do_action_taken') and can_take_care:
liaison.tags.remove('required')
liaison.tags.add('taken')
can_take_care = False
messages.success(request,'Action handled')
relations_by = [i.target for i in liaison.source_of_set.filter(target__state__slug='posted')]
relations_to = [i.source for i in liaison.target_of_set.filter(source__state__slug='posted')]
return render(request, "liaisons/detail.html", {
"liaison": liaison,
'tabs': get_details_tabs(liaison, 'Statement'),
"can_edit": can_edit,
"can_take_care": can_take_care,
"can_reply": can_reply,
"relations_to": relations_to,
"relations_by": relations_by,
})
def liaison_edit(request, object_id):
liaison = get_object_or_404(LiaisonStatement, pk=object_id)
if not can_edit_liaison(request.user, liaison):
permission_denied(request, 'You do not have permission to edit this liaison statement.')
return liaison_add(request, instance=liaison)
def liaison_edit_attachment(request, object_id, doc_id):
'''Edit the Liaison Statement attachment title'''
liaison = get_object_or_404(LiaisonStatement, pk=object_id)
doc = get_object_or_404(Document, pk=doc_id)
if not can_edit_liaison(request.user, liaison):
permission_denied(request, "You are not authorized for this action.")
if request.method == 'POST':
form = EditAttachmentForm(request.POST)
if form.is_valid():
title = form.cleaned_data.get('title')
# create event
e = LiaisonStatementEvent.objects.create(
type_id='modified',
by=get_person_for_user(request.user),
statement=liaison,
desc='Attachment Title changed to {}'.format(title)
)
doc.title = title
doc.save_with_history([e])
messages.success(request,'Attachment title changed')
return redirect('ietf.liaisons.views.liaison_detail', object_id=liaison.pk)
else:
form = EditAttachmentForm(initial={'title':doc.title})
return render(request, 'liaisons/edit_attachment.html', {
'form': form,
'liaison': liaison
})
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'),
Prefetch('tags',queryset=LiaisonStatementTagName.objects.filter(slug='taken'),to_attr='prefetched_tags'),
Prefetch('liaisonstatementevent_set',queryset=LiaisonStatementEvent.objects.filter(type='posted'),to_attr='prefetched_posted_events')
)
# check authorization for pending and dead tabs
if state in ('pending','dead') and not can_add_liaison(request.user):
msg = "Restricted to participants who are authorized to submit liaison statements on behalf of the various IETF entities."
permission_denied(request, 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,queryset=liaisons)
search_conducted = True
if form.is_valid():
results = form.get_results()
liaisons = results
else:
form = SearchLiaisonForm(queryset=liaisons)
search_conducted = False
# perform sort
sort, order_by = normalize_sort(request)
if sort == 'date':
liaisons = sorted(liaisons, key=lambda a: a.sort_date, reverse=True)
if sort == 'from_groups':
liaisons = sorted(liaisons, key=lambda a: a.sort_date, reverse=True)
liaisons = sorted(liaisons, key=lambda a: a.from_groups_display.lower())
if sort == 'to_groups':
liaisons = sorted(liaisons, key=lambda a: a.sort_date, reverse=True)
liaisons = sorted(liaisons, key=lambda a: a.to_groups_display.lower())
if sort == 'deadline':
liaisons = liaisons.order_by('-deadline')
if sort == 'title':
liaisons = liaisons.order_by('title')
# add menu entries
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'})))
# add menu actions
actions = []
if can_add_incoming_liaison(request.user):
actions.append(("New incoming liaison", urlreverse("ietf.liaisons.views.liaison_add", kwargs={'type':'incoming'})))
if can_add_outgoing_liaison(request.user):
actions.append(("New outgoing liaison", urlreverse("ietf.liaisons.views.liaison_add", kwargs={'type':'outgoing'})))
return render(request, 'liaisons/liaison_base.html', {
'liaisons':liaisons,
'selected_menu_entry':selected_menu_entry,
'menu_entries':entries,
'menu_actions':actions,
'sort':sort,
'form':form,
'with_search':True,
'search_conducted':search_conducted,
'state':state,
})
def liaison_reply(request,object_id):
'''Returns a new liaison form with initial data to reply to the given liaison'''
liaison = get_object_or_404(LiaisonStatement, pk=object_id)
if liaison.is_outgoing():
reply_type = 'incoming'
else:
reply_type = 'outgoing'
initial = dict(
to_groups=[ x.pk for x in liaison.from_groups.all() ],
from_groups=[ x.pk for x in liaison.to_groups.all() ],
to_contacts=liaison.response_contacts,
related_to=str(liaison.pk))
return liaison_add(request,type=reply_type,initial=initial)
@role_required('Secretariat',)
def liaison_resend(request, object_id):
'''Resend the liaison statement notification email'''
liaison = get_object_or_404(LiaisonStatement, pk=object_id)
person = get_person_for_user(request.user)
send_liaison_by_email(request,liaison)
LiaisonStatementEvent.objects.create(type_id='resent',by=person,statement=liaison,desc='Statement Resent')
messages.success(request,'Liaison Statement resent')
return redirect('ietf.liaisons.views.liaison_list')