1197 lines
47 KiB
Python
1197 lines
47 KiB
Python
# Copyright The IETF Trust 2013-2020, All Rights Reserved
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
from collections import defaultdict
|
|
import datetime
|
|
import io
|
|
import os
|
|
import re
|
|
from tempfile import mkstemp
|
|
|
|
from django.http import Http404
|
|
from django.db.models import F, Prefetch
|
|
from django.conf import settings
|
|
from django.contrib import messages
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.urls import reverse
|
|
from django.shortcuts import get_object_or_404
|
|
from django.template.loader import render_to_string
|
|
from django.utils import timezone
|
|
|
|
import debug # pyflakes:ignore
|
|
|
|
from ietf.doc.models import Document
|
|
from ietf.group.models import Group
|
|
from ietf.group.utils import can_manage_some_groups, can_manage_group
|
|
from ietf.ietfauth.utils import has_role, user_is_person
|
|
from ietf.liaisons.utils import get_person_for_user
|
|
from ietf.mailtrigger.utils import gather_address_lists
|
|
from ietf.person.models import Person
|
|
from ietf.meeting.models import Meeting, Schedule, TimeSlot, SchedTimeSessAssignment, ImportantDate, SchedulingEvent, Session
|
|
from ietf.meeting.utils import session_requested_by, add_event_info_to_session_qs
|
|
from ietf.name.models import ImportantDateName, SessionPurposeName
|
|
from ietf.utils import log, meetecho
|
|
from ietf.utils.history import find_history_replacements_active_at
|
|
from ietf.utils.mail import send_mail
|
|
from ietf.utils.pipe import pipe
|
|
from ietf.utils.text import xslugify
|
|
|
|
|
|
def get_meeting(num=None,type_in=['ietf',],days=28):
|
|
meetings = Meeting.objects
|
|
if type_in:
|
|
meetings = meetings.filter(type__in=type_in)
|
|
if num == None:
|
|
meetings = meetings.filter(date__gte=timezone.now()-datetime.timedelta(days=days)).order_by('date')
|
|
else:
|
|
meetings = meetings.filter(number=num)
|
|
if meetings.exists():
|
|
return meetings.first()
|
|
else:
|
|
raise Http404("No such meeting found: %s" % num)
|
|
|
|
def get_current_ietf_meeting():
|
|
meetings = Meeting.objects.filter(type='ietf',date__gte=timezone.now()-datetime.timedelta(days=31)).order_by('date')
|
|
return meetings.first()
|
|
|
|
def get_current_ietf_meeting_num():
|
|
cur = get_current_ietf_meeting()
|
|
return cur.number if cur else None
|
|
|
|
def get_ietf_meeting(num=None):
|
|
if num:
|
|
meeting = Meeting.objects.filter(type='ietf', number=num).first()
|
|
else:
|
|
meeting = get_current_ietf_meeting()
|
|
return meeting
|
|
|
|
def get_schedule(meeting, name=None):
|
|
if name is None:
|
|
schedule = meeting.schedule
|
|
else:
|
|
schedule = get_object_or_404(meeting.schedule_set, name=name)
|
|
return schedule
|
|
|
|
# seems this belongs in ietf/person/utils.py?
|
|
def get_person_by_email(email):
|
|
# email == None may actually match people who haven't set an email!
|
|
if email is None:
|
|
return None
|
|
return Person.objects.filter(email__address=email).distinct().first()
|
|
|
|
def get_schedule_by_name(meeting, owner, name):
|
|
if owner is not None:
|
|
return meeting.schedule_set.filter(owner = owner, name = name).first()
|
|
else:
|
|
return meeting.schedule_set.filter(name = name).first()
|
|
|
|
def preprocess_assignments_for_agenda(assignments_queryset, meeting, extra_prefetches=()):
|
|
"""Add computed properties to assignments
|
|
|
|
For each assignment a, adds
|
|
a.start_timestamp
|
|
a.end_timestamp
|
|
a.session.historic_group
|
|
a.session.historic_parent
|
|
a.session.rescheduled_to (if rescheduled)
|
|
a.session.prefetched_active_materials
|
|
a.session.order_number
|
|
"""
|
|
assignments_queryset = assignments_queryset.prefetch_related(
|
|
'timeslot', 'timeslot__type', 'timeslot__meeting',
|
|
'timeslot__location', 'timeslot__location__floorplan', 'timeslot__location__urlresource_set',
|
|
Prefetch(
|
|
"session",
|
|
queryset=add_event_info_to_session_qs(Session.objects.all().prefetch_related(
|
|
'group', 'group__charter', 'group__charter__group',
|
|
Prefetch('materials',
|
|
queryset=Document.objects.exclude(states__type=F("type"), states__slug='deleted').order_by('sessionpresentation__order').prefetch_related('states'),
|
|
to_attr='prefetched_active_materials'
|
|
)
|
|
))
|
|
),
|
|
*extra_prefetches
|
|
)
|
|
|
|
|
|
# removed list(); it was consuming a very large amount of processor time
|
|
# assignments = list(assignments_queryset) # make sure we're set in stone
|
|
assignments = assignments_queryset
|
|
|
|
# meeting_time is meeting-local midnight at the start of the meeting date
|
|
meeting_time = meeting.tz().localize(
|
|
datetime.datetime.combine(meeting.date, datetime.time())
|
|
)
|
|
|
|
# replace groups with historic counterparts
|
|
groups = [ ]
|
|
for a in assignments:
|
|
if a.session:
|
|
a.session.historic_group = None
|
|
a.session.order_number = None
|
|
|
|
if a.session.group and a.session.group not in groups:
|
|
groups.append(a.session.group)
|
|
|
|
sessions_for_groups = defaultdict(list)
|
|
for a in assignments:
|
|
if a.session and a.session.group:
|
|
sessions_for_groups[(a.session.group, a.session.type_id)].append(a)
|
|
|
|
group_replacements = find_history_replacements_active_at(groups, meeting_time)
|
|
|
|
parent_id_set = set()
|
|
for a in assignments:
|
|
if a.session and a.session.group:
|
|
a.session.historic_group = group_replacements.get(a.session.group_id)
|
|
|
|
if a.session.historic_group:
|
|
a.session.historic_group.historic_parent = None
|
|
|
|
if a.session.historic_group.parent_id:
|
|
parent_id_set.add(a.session.historic_group.parent_id)
|
|
|
|
l = sessions_for_groups.get((a.session.group, a.session.type_id), [])
|
|
a.session.order_number = l.index(a) + 1 if a in l else 0
|
|
|
|
parents = Group.objects.filter(pk__in=parent_id_set)
|
|
parent_replacements = find_history_replacements_active_at(parents, meeting_time)
|
|
|
|
timeslot_by_session_pk = {a.session_id: a.timeslot for a in assignments}
|
|
|
|
for a in assignments:
|
|
if a.session and a.session.historic_group and a.session.historic_group.parent_id:
|
|
a.session.historic_group.historic_parent = parent_replacements.get(a.session.historic_group.parent_id)
|
|
|
|
if a.session.current_status == 'resched':
|
|
a.session.rescheduled_to = timeslot_by_session_pk.get(a.session.tombstone_for_id)
|
|
|
|
for d in a.session.prefetched_active_materials:
|
|
# make sure these are precomputed with the meeting instead
|
|
# of having to look it up
|
|
d.get_href(meeting=meeting)
|
|
d.get_versionless_href(meeting=meeting)
|
|
|
|
a.start_timestamp = int(a.timeslot.utc_start_time().timestamp())
|
|
a.end_timestamp = int(a.timeslot.utc_end_time().timestamp())
|
|
|
|
return assignments
|
|
|
|
|
|
class AgendaKeywordTool:
|
|
"""Base class for agenda keyword-related organizers
|
|
|
|
The purpose of this class is to hold utility methods and data needed by keyword generation
|
|
helper classes. It ensures consistency of, e.g., definitions of when to use legacy keywords or what
|
|
timeslot types should be used to define filters.
|
|
"""
|
|
def __init__(self, *, assignments=None, sessions=None):
|
|
# n.b., single star argument means only keyword parameters are allowed when calling constructor
|
|
if assignments is not None and sessions is None:
|
|
self.assignments = assignments
|
|
self.sessions = [a.session for a in self.assignments if a.session]
|
|
elif sessions is not None and assignments is None:
|
|
self.assignments = None
|
|
self.sessions = sessions
|
|
else:
|
|
raise RuntimeError('Exactly one of assignments or sessions must be specified')
|
|
|
|
self.meeting = self.sessions[0].meeting if len(self.sessions) > 0 else None
|
|
|
|
def _use_legacy_keywords(self):
|
|
"""Should legacy keyword handling be used for this meeting?"""
|
|
# Only IETF meetings need legacy handling. These are identified
|
|
# by having a purely numeric meeting.number.
|
|
return (self.meeting is not None
|
|
and self.meeting.number.isdigit()
|
|
and int(self.meeting.number) <= settings.MEETING_LEGACY_OFFICE_HOURS_END)
|
|
|
|
# Helper methods
|
|
@staticmethod
|
|
def _get_group(s):
|
|
"""Get group of a session, handling historic groups"""
|
|
return getattr(s, 'historic_group', s.group)
|
|
|
|
def _get_group_parent(self, s):
|
|
"""Get parent of a group or parent of a session's group, handling historic groups"""
|
|
g = self._get_group(s) if isinstance(s, Session) else s # accept a group or a session arg
|
|
return getattr(g, 'historic_parent', g.parent)
|
|
|
|
def _purpose_keyword(self, purpose):
|
|
"""Get the keyword corresponding to a session purpose"""
|
|
return purpose.slug.lower()
|
|
|
|
def _group_keyword(self, group):
|
|
"""Get the keyword corresponding to a session group"""
|
|
return group.acronym.lower()
|
|
|
|
def _session_name_keyword(self, session):
|
|
"""Get the keyword identifying a session by name"""
|
|
return xslugify(session.name) if session.name else None
|
|
|
|
@property
|
|
def filterable_purposes(self):
|
|
return SessionPurposeName.objects.exclude(slug='none').order_by('name')
|
|
|
|
|
|
class AgendaFilterOrganizer(AgendaKeywordTool):
|
|
"""Helper class to organize agenda filters given a list of assignments or sessions
|
|
|
|
Either assignments or sessions must be specified (but not both). Keywords should be applied
|
|
to these items before calling either of the 'get_' methods, otherwise some special filters
|
|
may not be included (e.g., 'BoF' or 'Plenary'). If historic_group and/or historic_parent
|
|
attributes are present, these will be used instead of group/parent.
|
|
|
|
The organizer will process its inputs once, when one of its get_ methods is first called.
|
|
|
|
Terminology:
|
|
* column: group of related buttons, usually with a heading button.
|
|
* heading: button at the top of a column, e.g. an area. Has a keyword that applies to all in its column.
|
|
* category: a set of columns displayed as separate from other categories
|
|
* group filters: filters whose keywords derive from the group owning the session, such as for working groups
|
|
* non-group filters: filters whose keywords come from something other than a session's group
|
|
* special filters: group filters of type "special" that have no heading, end up in the catch-all column
|
|
* extra filters: ad hoc filters created based on the extra_labels list, go in the catch-all column
|
|
* catch-all column: column with no heading where extra filters and special filters are gathered
|
|
"""
|
|
# group acronyms in this list will never be used as filter buttons
|
|
exclude_acronyms = ('iesg', 'ietf', 'secretariat')
|
|
# extra keywords to include in the no-heading column if they apply to any sessions
|
|
extra_labels = ('BoF',)
|
|
# group types whose acronyms should be word-capitalized
|
|
capitalized_group_types = ('team',)
|
|
# group types whose acronyms should be all-caps
|
|
uppercased_group_types = ('area', 'ietf', 'irtf')
|
|
# check that the group labeling sets are disjoint
|
|
assert(set(capitalized_group_types).isdisjoint(uppercased_group_types))
|
|
# group acronyms that need special handling
|
|
special_group_labels = dict(edu='EDU', iepg='IEPG')
|
|
|
|
def __init__(self, *, single_category=False, **kwargs):
|
|
super(AgendaFilterOrganizer, self).__init__(**kwargs)
|
|
self.single_category = single_category
|
|
# filled in when _organize_filters() is called
|
|
self.filter_categories = None
|
|
self.special_filters = None
|
|
if self._use_legacy_keywords():
|
|
self.extra_labels += ('Plenary',) # need this when not using session purpose
|
|
|
|
def get_non_area_keywords(self):
|
|
"""Get list of any 'non-area' (aka 'special') keywords
|
|
|
|
These are the keywords corresponding to the right-most, headingless button column.
|
|
"""
|
|
if self.special_filters is None:
|
|
self._organize_filters()
|
|
return [sf['keyword'] for sf in self.special_filters['children']]
|
|
|
|
def get_filter_categories(self):
|
|
"""Get a list of filter categories
|
|
|
|
If single_category is True, this will be a list with one element. Otherwise it
|
|
may have multiple elements. Each element is a list of filter columns.
|
|
"""
|
|
if self.filter_categories is None:
|
|
self._organize_filters()
|
|
return self.filter_categories
|
|
|
|
def _organize_filters(self):
|
|
"""Process inputs to construct and categorize filter lists"""
|
|
headings, special = self._group_filter_headings()
|
|
self.filter_categories = self._categorize_group_filters(headings)
|
|
|
|
# Create an additional category with non-group filters and special/extra filters
|
|
non_group_category = self._non_group_filters()
|
|
|
|
# special filters include self.extra_labels and any 'special' group filters
|
|
self.special_filters = self._extra_filters()
|
|
for g in special:
|
|
self.special_filters['children'].append(self._group_filter_entry(g))
|
|
if len(self.special_filters['children']) > 0:
|
|
self.special_filters['children'].sort(key=self._group_sort_key)
|
|
non_group_category.append(self.special_filters)
|
|
|
|
# if we have any additional filters, add them
|
|
if len(non_group_category) > 0:
|
|
if self.single_category:
|
|
# if a single category is requested, just add them to that category
|
|
self.filter_categories[0].extend(non_group_category)
|
|
else:
|
|
# otherwise add these as a separate category
|
|
self.filter_categories.append(non_group_category)
|
|
|
|
def _group_filter_headings(self):
|
|
"""Collect group-based filters
|
|
|
|
Output is a tuple (dict(group->set), set). The dict keys are groups to be used as headings
|
|
with sets of child groups as associated values. The set is 'special' groups that have no
|
|
heading group.
|
|
"""
|
|
# groups in the schedule that have a historic_parent group
|
|
groups = set(self._get_group(s) for s in self.sessions
|
|
if s
|
|
and self._get_group(s))
|
|
log.assertion('len(groups) == len(set(g.acronym for g in groups))') # no repeated acros
|
|
|
|
group_parents = set(self._get_group_parent(g) for g in groups if self._get_group_parent(g))
|
|
log.assertion('len(group_parents) == len(set(gp.acronym for gp in group_parents))') # no repeated acros
|
|
|
|
all_groups = groups.union(group_parents)
|
|
all_groups.difference_update([g for g in all_groups if g.acronym in self.exclude_acronyms])
|
|
headings = {g: set()
|
|
for g in all_groups
|
|
if g.features.agenda_filter_type_id == 'heading'}
|
|
special = set(g for g in all_groups
|
|
if g.features.agenda_filter_type_id == 'special')
|
|
|
|
for g in groups:
|
|
if g.features.agenda_filter_type_id == 'normal':
|
|
# normal filter group with a heading parent goes in that column
|
|
p = self._get_group_parent(g)
|
|
if p in headings:
|
|
headings[p].add(g)
|
|
else:
|
|
# normal filter group with no heading parent is 'special'
|
|
special.add(g)
|
|
|
|
return headings, special
|
|
|
|
def _categorize_group_filters(self, headings):
|
|
"""Categorize the group-based filters
|
|
|
|
Returns a list of one or more categories of filter columns. When single_category is True,
|
|
it will always be only one category.
|
|
"""
|
|
area_category = [] # headings are area groups
|
|
non_area_category = [] # headings are non-area groups
|
|
|
|
for h in headings:
|
|
if h.type_id == 'area' or self.single_category:
|
|
area_category.append(self._group_filter_column(h, headings[h]))
|
|
else:
|
|
non_area_category.append(self._group_filter_column(h, headings[h]))
|
|
area_category.sort(key=self._group_sort_key)
|
|
if self.single_category:
|
|
return [area_category]
|
|
non_area_category.sort(key=self._group_sort_key)
|
|
return [area_category, non_area_category]
|
|
|
|
def _non_group_filters(self):
|
|
"""Get list of non-group filter columns
|
|
|
|
Empty columns will be omitted.
|
|
"""
|
|
if self.sessions is None:
|
|
sessions = [a.session for a in self.assignments]
|
|
else:
|
|
sessions = self.sessions
|
|
|
|
# Call legacy version for older meetings
|
|
if self._use_legacy_keywords():
|
|
return self._legacy_non_group_filters(sessions)
|
|
|
|
# Not using legacy version
|
|
filter_cols = []
|
|
for purpose in self.filterable_purposes:
|
|
if purpose.slug == 'regular':
|
|
continue
|
|
|
|
# Map label to its keyword, discarding duplicate labels.
|
|
# This does what we want as long as sessions with the same
|
|
# name and purpose belong to the same group.
|
|
sessions_by_name = {
|
|
session.name: session
|
|
for session in sessions if session.purpose == purpose
|
|
}
|
|
if len(sessions_by_name) > 0:
|
|
# keyword needs to match what's tagged in filter_keywords_for_session()
|
|
heading_kw = self._purpose_keyword(purpose)
|
|
children = []
|
|
for name, session in sessions_by_name.items():
|
|
children.append(self._filter_entry(
|
|
label=name,
|
|
keyword=self._session_name_keyword(session),
|
|
toggled_by=[self._group_keyword(session.group)] if session.group else None,
|
|
is_bof=False,
|
|
))
|
|
column = self._filter_column(
|
|
label=purpose.name,
|
|
keyword=heading_kw,
|
|
children=children,
|
|
)
|
|
filter_cols.append(column)
|
|
|
|
return filter_cols
|
|
|
|
def _legacy_non_group_filters(self, sessions):
|
|
"""Get list of non-group filters for older meetings
|
|
|
|
Returns a list of filter columns
|
|
"""
|
|
office_hours_items = set()
|
|
suffix = ' office hours'
|
|
for s in sessions:
|
|
if s.name.lower().endswith(suffix):
|
|
office_hours_items.add((s.name[:-len(suffix)].strip(), s.group))
|
|
|
|
headings = []
|
|
# currently we only do office hours
|
|
if len(office_hours_items) > 0:
|
|
column = self._filter_column(
|
|
label='Office Hours',
|
|
keyword='officehours',
|
|
children=[
|
|
self._filter_entry(
|
|
label=label,
|
|
keyword=f'{label.lower().replace(" ", "")}-officehours',
|
|
toggled_by=[self._group_keyword(group)] if group else None,
|
|
is_bof=False,
|
|
)
|
|
for label, group in sorted(office_hours_items, key=lambda item: item[0].upper())
|
|
])
|
|
headings.append(column)
|
|
return headings
|
|
|
|
def _extra_filters(self):
|
|
"""Get list of filters corresponding to self.extra_labels"""
|
|
item_source = self.assignments or self.sessions or []
|
|
candidates = set(self.extra_labels)
|
|
return self._filter_column(
|
|
label=None,
|
|
keyword=None,
|
|
children=[
|
|
self._filter_entry(label=label, keyword=xslugify(label), toggled_by=[], is_bof=False)
|
|
for label in candidates if any(
|
|
# Keep only those that will affect at least one session
|
|
[label.lower() in item.filter_keywords for item in item_source]
|
|
)]
|
|
)
|
|
|
|
@staticmethod
|
|
def _filter_entry(label, keyword, is_bof, toggled_by=None):
|
|
"""Construct a filter entry representation"""
|
|
# get our own copy of the list for toggled_by
|
|
if toggled_by is None:
|
|
toggled_by = []
|
|
if is_bof:
|
|
toggled_by = ['bof'] + toggled_by
|
|
return dict(
|
|
label=label,
|
|
keyword=keyword,
|
|
toggled_by=toggled_by,
|
|
is_bof=is_bof,
|
|
)
|
|
|
|
def _filter_column(self, label, keyword, children):
|
|
"""Construct a filter column given a label, keyword, and list of child entries"""
|
|
entry = self._filter_entry(label, keyword, False) # heading
|
|
entry['children'] = children
|
|
# all children should be controlled by the heading keyword
|
|
if keyword:
|
|
for child in children:
|
|
if keyword not in child['toggled_by']:
|
|
child['toggled_by'] = [keyword] + child['toggled_by']
|
|
return entry
|
|
|
|
def _group_label(self, group):
|
|
"""Generate a label for a group filter button"""
|
|
label = group.acronym
|
|
if label in self.special_group_labels:
|
|
return self.special_group_labels[label]
|
|
elif group.type_id in self.capitalized_group_types:
|
|
return label.capitalize()
|
|
elif group.type_id in self.uppercased_group_types:
|
|
return label.upper()
|
|
return label
|
|
|
|
def _group_filter_entry(self, group):
|
|
"""Construct a filter_entry for a group filter button"""
|
|
return self._filter_entry(
|
|
label=self._group_label(group),
|
|
keyword=self._group_keyword(group),
|
|
toggled_by=[self._group_keyword(group.parent)] if group.parent else None,
|
|
is_bof=group.is_bof(),
|
|
)
|
|
|
|
def _group_filter_column(self, heading, children):
|
|
"""Construct a filter column given a heading group and a list of its child groups"""
|
|
return self._filter_column(
|
|
label=None if heading is None else self._group_label(heading),
|
|
keyword=self._group_keyword(heading),
|
|
children=sorted([self._group_filter_entry(g) for g in children], key=self._group_sort_key),
|
|
)
|
|
|
|
@staticmethod
|
|
def _group_sort_key(g):
|
|
return 'zzzzzzzz' if g is None else g['label'].upper() # sort blank to the end
|
|
|
|
|
|
class AgendaKeywordTagger(AgendaKeywordTool):
|
|
"""Class for applying keywords to agenda timeslot assignments.
|
|
|
|
This is the other side of the agenda filtering: AgendaFilterOrganizer generates the
|
|
filter buttons, this applies keywords to the entries being filtered.
|
|
"""
|
|
def apply(self):
|
|
"""Apply tags to sessions / assignments"""
|
|
if self.assignments is not None:
|
|
self._tag_assignments_with_filter_keywords()
|
|
else:
|
|
self._tag_sessions_with_filter_keywords()
|
|
|
|
def apply_session_keywords(self):
|
|
"""Tag each item with its session-specific keyword"""
|
|
if self.assignments is not None:
|
|
for a in self.assignments:
|
|
a.session_keyword = self.filter_keyword_for_specific_session(a.session)
|
|
else:
|
|
for s in self.sessions:
|
|
s.session_keyword = self.filter_keyword_for_specific_session(s)
|
|
|
|
def _is_regular_agenda_filter_group(self, group):
|
|
"""Should this group appear in the 'regular' agenda filter button lists?"""
|
|
parent = self._get_group_parent(group)
|
|
return (
|
|
group.features.agenda_filter_type_id == 'normal'
|
|
and parent
|
|
and parent.features.agenda_filter_type_id == 'heading'
|
|
)
|
|
|
|
def _tag_assignments_with_filter_keywords(self):
|
|
"""Add keywords for agenda filtering
|
|
|
|
Keywords are all lower case.
|
|
"""
|
|
for a in self.assignments:
|
|
a.filter_keywords = self._filter_keywords_for_assignment(a)
|
|
a.filter_keywords = sorted(list(a.filter_keywords))
|
|
|
|
def _tag_sessions_with_filter_keywords(self):
|
|
for s in self.sessions:
|
|
s.filter_keywords = self._filter_keywords_for_session(s)
|
|
s.filter_keywords = sorted(list(s.filter_keywords))
|
|
|
|
@staticmethod
|
|
def _legacy_extra_session_keywords(session):
|
|
"""Get extra keywords for a session at a legacy meeting"""
|
|
extra = []
|
|
if session.type_id == 'plenary':
|
|
extra.append('plenary')
|
|
office_hours_match = re.match(r'^ *\w+(?: +\w+)* +office hours *$', session.name, re.IGNORECASE)
|
|
if office_hours_match is not None:
|
|
suffix = 'officehours'
|
|
extra.extend([
|
|
'officehours',
|
|
session.name.lower().replace(' ', '')[:-len(suffix)] + '-officehours',
|
|
])
|
|
return extra
|
|
|
|
def _filter_keywords_for_session(self, session):
|
|
keywords = set()
|
|
if session.purpose in self.filterable_purposes:
|
|
keywords.add(self._purpose_keyword(session.purpose))
|
|
|
|
group = self._get_group(session)
|
|
if group is not None:
|
|
if group.state_id == 'bof':
|
|
keywords.add('bof')
|
|
keywords.add(self._group_keyword(group))
|
|
specific_kw = self.filter_keyword_for_specific_session(session)
|
|
if specific_kw is not None:
|
|
keywords.add(specific_kw)
|
|
|
|
kw = self._session_name_keyword(session)
|
|
if kw:
|
|
keywords.add(kw)
|
|
|
|
# Only sessions belonging to "regular" groups should respond to the
|
|
# parent group filter keyword (often the 'area'). This must match
|
|
# the test used by the agenda() view to decide whether a group
|
|
# gets an area or non-area filter button.
|
|
if self._is_regular_agenda_filter_group(group):
|
|
area = self._get_group_parent(group)
|
|
if area is not None:
|
|
keywords.add(self._group_keyword(area))
|
|
|
|
if self._use_legacy_keywords():
|
|
keywords.update(self._legacy_extra_session_keywords(session))
|
|
|
|
return sorted(keywords)
|
|
|
|
def _filter_keywords_for_assignment(self, assignment):
|
|
keywords = self._filter_keywords_for_session(assignment.session)
|
|
return sorted(keywords)
|
|
|
|
def filter_keyword_for_specific_session(self, session):
|
|
"""Get keyword that identifies a specific session
|
|
|
|
Returns None if the session cannot be selected individually.
|
|
"""
|
|
group = self._get_group(session)
|
|
if group is None:
|
|
return None
|
|
kw = self._group_keyword(group) # start with this
|
|
token = session.docname_token_only_for_multiple()
|
|
return kw if token is None else '{}-{}'.format(kw, token)
|
|
|
|
|
|
def read_session_file(type, num, doc):
|
|
# XXXX FIXME: the path fragment in the code below should be moved to
|
|
# settings.py. The *_PATH settings should be generalized to format()
|
|
# style python format, something like this:
|
|
# DOC_PATH_FORMAT = { "agenda": "/foo/bar/agenda-{meeting.number}/agenda-{meeting-number}-{doc.group}*", }
|
|
#
|
|
# FIXME: uploaded_filename should be replaced with a function call that computes names that are fixed
|
|
path = os.path.join(settings.AGENDA_PATH, "%s/%s/%s" % (num, type, doc.uploaded_filename))
|
|
if doc.uploaded_filename and os.path.exists(path):
|
|
with io.open(path, 'rb') as f:
|
|
return f.read(), path
|
|
else:
|
|
return None, path
|
|
|
|
def read_agenda_file(num, doc):
|
|
return read_session_file('agenda', num, doc)
|
|
|
|
def convert_draft_to_pdf(doc_name):
|
|
inpath = os.path.join(settings.IDSUBMIT_REPOSITORY_PATH, doc_name + ".txt")
|
|
outpath = os.path.join(settings.INTERNET_DRAFT_PDF_PATH, doc_name + ".pdf")
|
|
|
|
try:
|
|
infile = io.open(inpath, "r")
|
|
except IOError:
|
|
return
|
|
|
|
t,tempname = mkstemp()
|
|
os.close(t)
|
|
tempfile = io.open(tempname, "w")
|
|
|
|
pageend = 0;
|
|
newpage = 0;
|
|
formfeed = 0;
|
|
for line in infile:
|
|
line = re.sub("\r","",line)
|
|
line = re.sub("[ \t]+$","",line)
|
|
if re.search(r"\[?[Pp]age [0-9ivx]+\]?[ \t]*$",line):
|
|
pageend=1
|
|
tempfile.write(line)
|
|
continue
|
|
if re.search("^[ \t]*\f",line):
|
|
formfeed=1
|
|
tempfile.write(line)
|
|
continue
|
|
if re.search("^ *INTERNET.DRAFT.+[0-9]+ *$",line) or re.search("^ *Internet.Draft.+[0-9]+ *$",line) or re.search("^draft-[-a-z0-9_.]+.*[0-9][0-9][0-9][0-9]$",line) or re.search("^RFC.+[0-9]+$",line):
|
|
newpage=1
|
|
if re.search("^[ \t]*$",line) and pageend and not newpage:
|
|
continue
|
|
if pageend and newpage and not formfeed:
|
|
tempfile.write("\f")
|
|
pageend=0
|
|
formfeed=0
|
|
newpage=0
|
|
tempfile.write(line)
|
|
|
|
infile.close()
|
|
tempfile.close()
|
|
t,psname = mkstemp()
|
|
os.close(t)
|
|
pipe("enscript --margins 76::76: -B -q -p "+psname + " " +tempname)
|
|
os.unlink(tempname)
|
|
pipe("ps2pdf "+psname+" "+outpath)
|
|
os.unlink(psname)
|
|
|
|
def schedule_permissions(meeting, schedule, user):
|
|
# do this in positive logic.
|
|
cansee = False
|
|
canedit = False
|
|
secretariat = False
|
|
|
|
if has_role(user, 'Secretariat'):
|
|
cansee = True
|
|
secretariat = True
|
|
# NOTE: secretariat is not superuser for edit!
|
|
elif schedule.public:
|
|
cansee = True
|
|
elif schedule.visible and has_role(user, ['Area Director', 'IAB Chair', 'IRTF Chair']):
|
|
cansee = True
|
|
|
|
if user_is_person(user, schedule.owner):
|
|
cansee = True
|
|
canedit = not schedule.is_official_record
|
|
|
|
return cansee, canedit, secretariat
|
|
|
|
|
|
# -------------------------------------------------
|
|
# Interim Meeting Helpers
|
|
# -------------------------------------------------
|
|
|
|
|
|
def can_approve_interim_request(meeting, user):
|
|
'''Returns True if the user has permissions to approve an interim meeting request'''
|
|
if not user or isinstance(user,AnonymousUser):
|
|
return False
|
|
if meeting.type.slug != 'interim':
|
|
return False
|
|
if has_role(user, 'Secretariat'):
|
|
return True
|
|
person = get_person_for_user(user)
|
|
session = meeting.session_set.first()
|
|
if not session:
|
|
return False
|
|
group = session.group
|
|
if group.type.slug in ['wg','ag']:
|
|
if group.parent.role_set.filter(name='ad', person=person) or group.role_set.filter(name='ad', person=person):
|
|
return True
|
|
if group.type.slug in ['rg','rag'] and group.parent.role_set.filter(name='chair', person=person):
|
|
return True
|
|
if group.type.slug == 'program':
|
|
if person.role_set.filter(group__acronym='iab', name='member'):
|
|
return True
|
|
return False
|
|
|
|
|
|
def can_edit_interim_request(meeting, user):
|
|
'''Returns True if the user can edit the interim meeting request'''
|
|
if meeting.type.slug != 'interim':
|
|
return False
|
|
if has_role(user, 'Secretariat'): # Consider removing - can_manage_group should handle this
|
|
return True
|
|
session = meeting.session_set.first()
|
|
if not session:
|
|
return False
|
|
group = session.group
|
|
if can_manage_group(user, group):
|
|
return True
|
|
elif can_approve_interim_request(meeting, user):
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
|
|
def can_request_interim_meeting(user):
|
|
return can_manage_some_groups(user)
|
|
|
|
def can_view_interim_request(meeting, user):
|
|
'''Returns True if the user can see the pending interim request in the pending interim view'''
|
|
if meeting.type.slug != 'interim':
|
|
return False
|
|
session = meeting.session_set.first()
|
|
if not session:
|
|
return False
|
|
group = session.group
|
|
return can_manage_group(user, group)
|
|
|
|
|
|
def create_interim_meeting(group, date, city='', country='', timezone='UTC',
|
|
person=None):
|
|
"""Helper function to create interim meeting and associated schedule"""
|
|
if not person:
|
|
person = Person.objects.get(name='(System)')
|
|
number = get_next_interim_number(group.acronym, date)
|
|
meeting = Meeting.objects.create(
|
|
number=number,
|
|
type_id='interim',
|
|
date=date,
|
|
days=1,
|
|
city=city,
|
|
country=country,
|
|
time_zone=timezone)
|
|
schedule = Schedule.objects.create(
|
|
meeting=meeting,
|
|
owner=person,
|
|
visible=True,
|
|
public=True)
|
|
meeting.schedule = schedule
|
|
meeting.save()
|
|
return meeting
|
|
|
|
|
|
def get_announcement_initial(meeting, is_change=False):
|
|
'''Returns a dictionary suitable to initialize an InterimAnnouncementForm
|
|
(Message ModelForm)'''
|
|
group = meeting.session_set.first().group
|
|
in_person = bool(meeting.city)
|
|
initial = {}
|
|
addrs = gather_address_lists('interim_announced',group=group).as_strings()
|
|
initial['to'] = addrs.to
|
|
initial['cc'] = addrs.cc
|
|
initial['frm'] = settings.INTERIM_ANNOUNCE_FROM_EMAIL_PROGRAM if group.type_id=='program' else settings.INTERIM_ANNOUNCE_FROM_EMAIL_DEFAULT
|
|
if in_person:
|
|
desc = 'Interim'
|
|
else:
|
|
desc = 'Virtual'
|
|
if is_change:
|
|
change = ' CHANGED'
|
|
else:
|
|
change = ''
|
|
type = group.type.slug.upper()
|
|
if group.type.slug == 'wg' and group.state.slug == 'bof':
|
|
type = 'BOF'
|
|
|
|
assignments = SchedTimeSessAssignment.objects.filter(
|
|
schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None],
|
|
session__in=meeting.session_set.not_canceled()
|
|
).order_by('timeslot__time')
|
|
|
|
initial['subject'] = '{name} ({acronym}) {type} {desc} Meeting: {date}{change}'.format(
|
|
name=group.name,
|
|
acronym=group.acronym,
|
|
type=type,
|
|
desc=desc,
|
|
date=meeting.date,
|
|
change=change)
|
|
body = render_to_string('meeting/interim_announcement.txt', locals())
|
|
initial['body'] = body
|
|
return initial
|
|
|
|
|
|
def get_earliest_session_date(formset):
|
|
'''Return earliest date from InterimSession Formset'''
|
|
return sorted([f.cleaned_data['date'] for f in formset.forms if f.cleaned_data.get('date')])[0]
|
|
|
|
|
|
def is_interim_meeting_approved(meeting):
|
|
return add_event_info_to_session_qs(meeting.session_set.all()).first().current_status == 'apprw'
|
|
|
|
def get_next_interim_number(acronym,date):
|
|
'''
|
|
This function takes a group acronym and date object and returns the next number
|
|
to use for an interim meeting. The format is interim-[year]-[acronym]-[01-99]
|
|
'''
|
|
base = 'interim-%s-%s-' % (date.year, acronym)
|
|
# can't use count() to calculate the next number in case one was deleted
|
|
meetings = Meeting.objects.filter(type='interim', number__startswith=base)
|
|
if meetings:
|
|
serial = sorted([ int(x.number.split('-')[-1]) for x in meetings ])[-1]
|
|
else:
|
|
serial = 0
|
|
return "%s%02d" % (base, serial+1)
|
|
|
|
def get_next_agenda_name(meeting):
|
|
"""Returns the next name to use for an agenda document for *meeting*"""
|
|
group = meeting.session_set.first().group
|
|
documents = Document.objects.filter(type='agenda', session__meeting=meeting)
|
|
if documents:
|
|
sequences = [int(d.name.split('-')[-1]) for d in documents]
|
|
last_sequence = sorted(sequences)[-1]
|
|
else:
|
|
last_sequence = 0
|
|
return 'agenda-{meeting}-{group}-{sequence}'.format(
|
|
meeting=meeting.number,
|
|
group=group.acronym,
|
|
sequence=str(last_sequence + 1).zfill(2))
|
|
|
|
|
|
def make_materials_directories(meeting):
|
|
'''
|
|
This function takes a meeting object and creates the appropriate materials directories
|
|
'''
|
|
path = meeting.get_materials_path()
|
|
# Default umask is 0x022, meaning strip write permission for group and others.
|
|
# Change this temporarily to 0x0, to keep write permission for group and others.
|
|
# (WHY??) (Note: this code is old -- was present already when the secretariat code
|
|
# was merged with the regular datatracker code; then in secr/proceedings/views.py
|
|
# in make_directories())
|
|
saved_umask = os.umask(0)
|
|
for leaf in ('slides','agenda','minutes','id','rfc','bluesheets'):
|
|
target = os.path.join(path,leaf)
|
|
if not os.path.exists(target):
|
|
os.makedirs(target)
|
|
os.umask(saved_umask)
|
|
|
|
|
|
def send_interim_approval_request(meetings):
|
|
"""Sends an email to the secretariat, group chairs, and responsible area
|
|
director or the IRTF chair noting that approval has been requested for a
|
|
new interim meeting. Takes a list of one or more meetings."""
|
|
first_session = meetings[0].session_set.first()
|
|
group = first_session.group
|
|
requester = session_requested_by(first_session)
|
|
(to_email, cc_list) = gather_address_lists('session_requested',group=group,person=requester)
|
|
from_email = (settings.SESSION_REQUEST_FROM_EMAIL)
|
|
subject = '{group} - New Interim Meeting Request'.format(group=group.acronym)
|
|
template = 'meeting/interim_approval_request.txt'
|
|
approval_urls = []
|
|
for meeting in meetings:
|
|
url = settings.IDTRACKER_BASE_URL + reverse('ietf.meeting.views.interim_request_details', kwargs={'number': meeting.number})
|
|
approval_urls.append(url)
|
|
if len(meetings) > 1:
|
|
is_series = True
|
|
else:
|
|
is_series = False
|
|
approver_set = set()
|
|
for authrole in group.features.groupman_authroles: # NOTE: This makes an assumption that the authroles are exactly the set of approvers
|
|
approver_set.add(authrole)
|
|
approvers = list(approver_set)
|
|
context = {
|
|
'group': group,
|
|
'is_series': is_series,
|
|
'meetings': meetings,
|
|
'approvers': approvers,
|
|
'requester': requester,
|
|
'approval_urls': approval_urls,
|
|
}
|
|
send_mail(None,
|
|
to_email,
|
|
from_email,
|
|
subject,
|
|
template,
|
|
context,
|
|
cc=cc_list)
|
|
|
|
def send_interim_approval(user, meeting):
|
|
"""Send an email to chairs and whoever initiated the action that resulted in approval that an interim is approved"""
|
|
first_session = meeting.session_set.first()
|
|
(to_email,cc_list) = gather_address_lists('interim_approved',group=first_session.group,person=user.person)
|
|
from_email = (settings.SESSION_REQUEST_FROM_EMAIL)
|
|
subject = f'{meeting.number} interim approved'
|
|
template = 'meeting/interim_approval.txt'
|
|
context = {
|
|
'meeting': meeting,
|
|
'group' : first_session.group,
|
|
'requester' : session_requested_by(first_session),
|
|
}
|
|
send_mail(None,
|
|
to_email,
|
|
from_email,
|
|
subject,
|
|
template,
|
|
context,
|
|
cc=cc_list)
|
|
|
|
def send_interim_announcement_request(meeting):
|
|
"""Sends an email to the secretariat that an interim meeting is ready for
|
|
announcement, includes the link to send the official announcement"""
|
|
first_session = meeting.session_set.first()
|
|
group = first_session.group
|
|
requester = session_requested_by(first_session)
|
|
(to_email, cc_list) = gather_address_lists('interim_announce_requested')
|
|
from_email = (settings.SESSION_REQUEST_FROM_EMAIL)
|
|
subject = '{group} - interim meeting ready for announcement'.format(group=group.acronym)
|
|
template = 'meeting/interim_announcement_request.txt'
|
|
announce_url = settings.IDTRACKER_BASE_URL + reverse('ietf.meeting.views.interim_request_details', kwargs={'number': meeting.number})
|
|
context = locals()
|
|
send_mail(None,
|
|
to_email,
|
|
from_email,
|
|
subject,
|
|
template,
|
|
context,
|
|
cc_list)
|
|
|
|
def send_interim_meeting_cancellation_notice(meeting):
|
|
"""Sends an email that a scheduled interim meeting has been cancelled."""
|
|
session = meeting.session_set.first()
|
|
group = session.group
|
|
(to_email, cc_list) = gather_address_lists('interim_cancelled',group=group)
|
|
from_email = settings.INTERIM_ANNOUNCE_FROM_EMAIL_PROGRAM if group.type_id=='program' else settings.INTERIM_ANNOUNCE_FROM_EMAIL_DEFAULT
|
|
subject = '{group} ({acronym}) {type} Interim Meeting Cancelled (was {date})'.format(
|
|
group=group.name,
|
|
acronym=group.acronym,
|
|
type=group.type.slug.upper(),
|
|
date=meeting.date.strftime('%Y-%m-%d'))
|
|
start_time = session.official_timeslotassignment().timeslot.time
|
|
end_time = start_time + session.requested_duration
|
|
is_multi_day = session.meeting.session_set.with_current_status().filter(current_status='sched').count() > 1
|
|
template = 'meeting/interim_meeting_cancellation_notice.txt'
|
|
context = locals()
|
|
send_mail(None,
|
|
to_email,
|
|
from_email,
|
|
subject,
|
|
template,
|
|
context,
|
|
cc=cc_list)
|
|
|
|
|
|
def send_interim_session_cancellation_notice(session):
|
|
"""Sends an email that one session of a scheduled interim meeting has been cancelled."""
|
|
group = session.group
|
|
start_time = session.official_timeslotassignment().timeslot.time
|
|
end_time = start_time + session.requested_duration
|
|
(to_email, cc_list) = gather_address_lists('interim_cancelled',group=group)
|
|
from_email = settings.INTERIM_ANNOUNCE_FROM_EMAIL_PROGRAM if group.type_id=='program' else settings.INTERIM_ANNOUNCE_FROM_EMAIL_DEFAULT
|
|
|
|
if session.name:
|
|
description = '"%s" session' % session.name
|
|
else:
|
|
description = 'interim meeting session'
|
|
|
|
subject = '{group} ({acronym}) {type} {description} cancelled (was {date})'.format(
|
|
group=group.name,
|
|
acronym=group.acronym,
|
|
type=group.type.slug.upper(),
|
|
description=description,
|
|
date=start_time.date().strftime('%Y-%m-%d'))
|
|
is_multi_day = session.meeting.session_set.with_current_status().filter(current_status='sched').count() > 1
|
|
template = 'meeting/interim_session_cancellation_notice.txt'
|
|
context = locals()
|
|
send_mail(None,
|
|
to_email,
|
|
from_email,
|
|
subject,
|
|
template,
|
|
context,
|
|
cc=cc_list)
|
|
|
|
|
|
def send_interim_minutes_reminder(meeting):
|
|
"""Sends an email reminding chairs to submit minutes of interim *meeting*"""
|
|
session = meeting.session_set.first()
|
|
group = session.group
|
|
(to_email, cc_list) = gather_address_lists('session_minutes_reminder',group=group)
|
|
from_email = 'proceedings@ietf.org'
|
|
subject = 'Action Required: Minutes from {group} ({acronym}) {type} Interim Meeting on {date}'.format(
|
|
group=group.name,
|
|
acronym=group.acronym,
|
|
type=group.type.slug.upper(),
|
|
date=meeting.date.strftime('%Y-%m-%d'))
|
|
template = 'meeting/interim_minutes_reminder.txt'
|
|
context = locals()
|
|
send_mail(None,
|
|
to_email,
|
|
from_email,
|
|
subject,
|
|
template,
|
|
context,
|
|
cc=cc_list)
|
|
|
|
|
|
def sessions_post_save(request, forms):
|
|
"""Helper function to perform various post save operations on each form of a
|
|
InterimSessionModelForm formset"""
|
|
for form in forms:
|
|
if not form.has_changed():
|
|
continue
|
|
|
|
if form.instance.pk is not None and not SchedulingEvent.objects.filter(session=form.instance).exists():
|
|
if not form.requires_approval:
|
|
status_id = 'scheda'
|
|
else:
|
|
status_id = 'apprw'
|
|
SchedulingEvent.objects.create(
|
|
session=form.instance,
|
|
status_id=status_id,
|
|
by=request.user.person,
|
|
)
|
|
|
|
if ('date' in form.changed_data) or ('time' in form.changed_data):
|
|
update_interim_session_assignment(form)
|
|
if 'agenda' in form.changed_data:
|
|
form.save_agenda()
|
|
|
|
try:
|
|
create_interim_session_conferences(
|
|
form.instance for form in forms
|
|
if form.cleaned_data.get('remote_participation', None) == 'meetecho'
|
|
)
|
|
except RuntimeError:
|
|
messages.warning(
|
|
request,
|
|
'An error occurred while creating a Meetecho conference. The interim meeting request '
|
|
'has been created without complete remote participation information. '
|
|
'Please edit the request to add this or contact the secretariat if you require assistance.',
|
|
)
|
|
|
|
|
|
def create_interim_session_conferences(sessions):
|
|
error_occurred = False
|
|
if hasattr(settings, 'MEETECHO_API_CONFIG'): # do nothing if not configured
|
|
meetecho_manager = meetecho.ConferenceManager(settings.MEETECHO_API_CONFIG)
|
|
for session in sessions:
|
|
ts = session.official_timeslotassignment().timeslot
|
|
try:
|
|
confs = meetecho_manager.create(
|
|
group=session.group,
|
|
description=str(session),
|
|
start_time=ts.utc_start_time(),
|
|
duration=ts.duration,
|
|
)
|
|
except Exception as err:
|
|
log.log(f'Exception creating Meetecho conference for {session}: {err}')
|
|
confs = []
|
|
|
|
if len(confs) == 1:
|
|
session.remote_instructions = confs[0].url
|
|
session.save()
|
|
else:
|
|
error_occurred = True
|
|
if error_occurred:
|
|
raise RuntimeError('error creating meetecho conferences')
|
|
|
|
|
|
def delete_interim_session_conferences(sessions):
|
|
"""Delete Meetecho conference for the session, if any"""
|
|
if hasattr(settings, 'MEETECHO_API_CONFIG'): # do nothing if Meetecho API not configured
|
|
meetecho_manager = meetecho.ConferenceManager(settings.MEETECHO_API_CONFIG)
|
|
for session in sessions:
|
|
if session.remote_instructions:
|
|
for conference in meetecho_manager.fetch(session.group):
|
|
if conference.url == session.remote_instructions:
|
|
conference.delete()
|
|
break
|
|
|
|
|
|
def sessions_post_cancel(request, sessions):
|
|
"""Clean up after session cancellation
|
|
|
|
When this is called, the session has already been canceled, so exceptions should
|
|
not be raised.
|
|
"""
|
|
try:
|
|
delete_interim_session_conferences(sessions)
|
|
except Exception as err:
|
|
sess_pks = ', '.join(str(s.pk) for s in sessions)
|
|
log.log(f'Exception deleting Meetecho conferences for sessions [{sess_pks}]: {err}')
|
|
messages.warning(
|
|
request,
|
|
'An error occurred while cleaning up Meetecho conferences for the canceled sessions. '
|
|
'The session or sessions have been canceled, but Meetecho conferences may not have been cleaned '
|
|
'up properly.',
|
|
)
|
|
|
|
|
|
def update_interim_session_assignment(form):
|
|
"""Helper function to create / update timeslot assigned to interim session
|
|
|
|
form is an InterimSessionModelForm
|
|
"""
|
|
session = form.instance
|
|
meeting = session.meeting
|
|
time = meeting.tz().localize(
|
|
datetime.datetime.combine(form.cleaned_data['date'], form.cleaned_data['time'])
|
|
)
|
|
if session.official_timeslotassignment():
|
|
slot = session.official_timeslotassignment().timeslot
|
|
slot.time = time
|
|
slot.duration = session.requested_duration
|
|
slot.save()
|
|
else:
|
|
slot = TimeSlot.objects.create(
|
|
meeting=meeting,
|
|
type_id='regular',
|
|
duration=session.requested_duration,
|
|
time=time)
|
|
SchedTimeSessAssignment.objects.create(
|
|
timeslot=slot,
|
|
session=session,
|
|
schedule=meeting.schedule)
|
|
|
|
def populate_important_dates(meeting):
|
|
assert ImportantDate.objects.filter(meeting=meeting).exists() is False
|
|
assert meeting.type_id=='ietf'
|
|
for datename in ImportantDateName.objects.filter(used=True):
|
|
ImportantDate.objects.create(meeting=meeting,name=datename,date=meeting.date+datetime.timedelta(days=datename.default_offset_days))
|
|
|
|
def update_important_dates(meeting):
|
|
assert meeting.type_id=='ietf'
|
|
for datename in ImportantDateName.objects.filter(used=True):
|
|
date = meeting.date+datetime.timedelta(days=datename.default_offset_days)
|
|
d = ImportantDate.objects.filter(meeting=meeting, name=datename).first()
|
|
if d:
|
|
d.date = date
|
|
d.save()
|
|
else:
|
|
ImportantDate.objects.create(meeting=meeting, name=datename, date=date)
|