import datetime, math from collections import defaultdict import debug # pyflakes:ignore from django.shortcuts import render, redirect, get_object_or_404 from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect from django.contrib.auth.decorators import login_required from django.urls import reverse as urlreverse from django.db.models import Max from django import forms from django.template.loader import render_to_string from ietf.review.models import ReviewRequest, ReviewerSettings, UnavailablePeriod, ReviewSecretarySettings from ietf.review.utils import (can_manage_review_requests_for_team, can_access_review_stats_for_team, close_review_request_states, extract_revision_ordered_review_requests_for_documents_and_replaced, assign_review_request_to_reviewer, close_review_request, setup_reviewer_field, suggested_review_requests_for_team, unavailable_periods_to_list, current_unavailable_periods_for_reviewers, email_reviewer_availability_change, reviewer_rotation_list, latest_review_requests_for_reviewers, augment_review_requests_with_events, get_default_filter_re, days_needed_to_fulfill_min_interval_for_reviewers, ) from ietf.doc.models import LastCallDocEvent from ietf.group.models import Role from ietf.group.utils import get_group_or_404, construct_group_menu_context from ietf.person.fields import PersonEmailChoiceField from ietf.name.models import ReviewRequestStateName from ietf.utils.mail import send_mail_text, parse_preformatted from ietf.utils.fields import DatepickerDateField, MultiEmailField from ietf.ietfauth.utils import user_is_person from ietf.dbtemplate.models import DBTemplate from ietf.mailtrigger.utils import gather_address_lists from ietf.mailtrigger.models import Recipient def get_open_review_requests_for_team(team, assignment_status=None): open_review_requests = ReviewRequest.objects.filter( team=team, state__in=("requested", "accepted") ).prefetch_related( "reviewer__person", "type", "state", "doc", "doc__states", ).order_by("-time", "-id") if assignment_status == "unassigned": open_review_requests = suggested_review_requests_for_team(team) + list(open_review_requests.filter(reviewer=None)) elif assignment_status == "assigned": open_review_requests = list(open_review_requests.exclude(reviewer=None)) else: open_review_requests = suggested_review_requests_for_team(team) + list(open_review_requests) today = datetime.date.today() unavailable_periods = current_unavailable_periods_for_reviewers(team) for r in open_review_requests: if r.reviewer: r.reviewer_unavailable = any(p.availability == "unavailable" for p in unavailable_periods.get(r.reviewer.person_id, [])) r.due = max(0, (today - r.deadline).days) return open_review_requests def review_requests(request, acronym, group_type=None): group = get_group_or_404(acronym, group_type) if not group.features.has_reviews: raise Http404 assigned_review_requests = [] unassigned_review_requests = [] for r in get_open_review_requests_for_team(group): if r.reviewer: assigned_review_requests.append(r) else: unassigned_review_requests.append(r) open_review_requests = [ ("Unassigned", unassigned_review_requests), ("Assigned", assigned_review_requests), ] closed_review_requests = ReviewRequest.objects.filter( team=group, ).exclude( state__in=("requested", "accepted") ).prefetch_related("reviewer__person", "type", "state", "doc", "result").order_by("-time", "-id") since_choices = [ (None, "1 month"), ("3m", "3 months"), ("6m", "6 months"), ("1y", "1 year"), ("2y", "2 years"), ("all", "All"), ] since = request.GET.get("since", None) if since not in [key for key, label in since_choices]: since = None if since != "all": date_limit = { None: datetime.timedelta(days=31), "3m": datetime.timedelta(days=31 * 3), "6m": datetime.timedelta(days=180), "1y": datetime.timedelta(days=365), "2y": datetime.timedelta(days=2 * 365), }[since] closed_review_requests = closed_review_requests.filter(time__gte=datetime.date.today() - date_limit) return render(request, 'group/review_requests.html', construct_group_menu_context(request, group, "review requests", group_type, { "open_review_requests": open_review_requests, "closed_review_requests": closed_review_requests, "since_choices": since_choices, "since": since, "can_manage_review_requests": can_manage_review_requests_for_team(request.user, group), "can_access_stats": can_access_review_stats_for_team(request.user, group), })) def reviewer_overview(request, acronym, group_type=None): group = get_group_or_404(acronym, group_type) if not group.features.has_reviews: raise Http404 can_manage = can_manage_review_requests_for_team(request.user, group) reviewers = reviewer_rotation_list(group) reviewer_settings = { s.person_id: s for s in ReviewerSettings.objects.filter(team=group) } unavailable_periods = defaultdict(list) for p in unavailable_periods_to_list().filter(team=group): unavailable_periods[p.person_id].append(p) reviewer_roles = { r.person_id: r for r in Role.objects.filter(group=group, name="reviewer").select_related("email") } today = datetime.date.today() req_data_for_reviewers = latest_review_requests_for_reviewers(group) review_state_by_slug = { n.slug: n for n in ReviewRequestStateName.objects.all() } days_needed = days_needed_to_fulfill_min_interval_for_reviewers(group) for person in reviewers: person.settings = reviewer_settings.get(person.pk) or ReviewerSettings(team=group, person=person) person.settings_url = None person.role = reviewer_roles.get(person.pk) if person.role and (can_manage or user_is_person(request.user, person)): kwargs = { "acronym": group.acronym, "reviewer_email": person.role.email.address } if group_type: kwargs["group_type"] = group_type person.settings_url = urlreverse("ietf.group.views_review.change_reviewer_settings", kwargs=kwargs) person.unavailable_periods = unavailable_periods.get(person.pk, []) person.completely_unavailable = any(p.availability == "unavailable" and (p.start_date is None or p.start_date <= today) and (p.end_date is None or today <= p.end_date) for p in person.unavailable_periods) person.busy = person.id in days_needed MAX_CLOSED_REQS = 10 req_data = req_data_for_reviewers.get(person.pk, []) open_reqs = sum(1 for d in req_data if d.state in ["requested", "accepted"]) latest_reqs = [] for d in req_data: if d.state in ["requested", "accepted"] or len(latest_reqs) < MAX_CLOSED_REQS + open_reqs: latest_reqs.append((d.req_pk, d.doc, d.reviewed_rev, d.deadline, review_state_by_slug.get(d.state), int(math.ceil(d.assignment_to_closure_days)) if d.assignment_to_closure_days is not None else None)) person.latest_reqs = latest_reqs return render(request, 'group/reviewer_overview.html', construct_group_menu_context(request, group, "reviewers", group_type, { "reviewers": reviewers, "can_access_stats": can_access_review_stats_for_team(request.user, group) })) class ManageReviewRequestForm(forms.Form): ACTIONS = [ ("assign", "Assign"), ("close", "Close"), ] action = forms.ChoiceField(choices=ACTIONS, widget=forms.HiddenInput, required=False) close = forms.ModelChoiceField(queryset=close_review_request_states(), required=False) reviewer = PersonEmailChoiceField(empty_label="(None)", required=False, label_with="person") add_skip = forms.BooleanField(required=False) def __init__(self, review_req, *args, **kwargs): if not "prefix" in kwargs: if review_req.pk is None: kwargs["prefix"] = "r{}-{}".format(review_req.type_id, review_req.doc_id) else: kwargs["prefix"] = "r{}".format(review_req.pk) super(ManageReviewRequestForm, self).__init__(*args, **kwargs) if review_req.pk is None: self.fields["close"].queryset = self.fields["close"].queryset.filter(slug__in=["no-review-version", "no-review-document"]) close_initial = None if review_req.pk is None: close_initial = "no-review-version" elif review_req.reviewer: close_initial = "no-response" else: close_initial = "overtaken" if close_initial: self.fields["close"].initial = close_initial self.fields["close"].widget.attrs["class"] = "form-control input-sm" setup_reviewer_field(self.fields["reviewer"], review_req) self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm" if self.is_bound: if self.data.get("action") == "close": self.fields["close"].required = True @login_required def manage_review_requests(request, acronym, group_type=None, assignment_status=None): group = get_group_or_404(acronym, group_type) if not group.features.has_reviews: raise Http404 if not can_manage_review_requests_for_team(request.user, group): return HttpResponseForbidden("You do not have permission to perform this action") review_requests = get_open_review_requests_for_team(group, assignment_status=assignment_status) document_requests = extract_revision_ordered_review_requests_for_documents_and_replaced( ReviewRequest.objects.filter(state__in=("part-completed", "completed"), team=group).prefetch_related("result"), set(r.doc_id for r in review_requests), ) # we need a mutable query dict for resetting upon saving with # conflicts query_dict = request.POST.copy() if request.method == "POST" else None for req in review_requests: req.form = ManageReviewRequestForm(req, query_dict) # add previous requests l = [] for r in document_requests.get(req.doc_id, []): # take all on the latest reviewed rev if l and l[0].reviewed_rev: if r.doc_id == l[0].doc_id and r.reviewed_rev: if int(r.reviewed_rev) > int(l[0].reviewed_rev): l = [r] elif int(r.reviewed_rev) == int(l[0].reviewed_rev): l.append(r) else: l = [r] augment_review_requests_with_events(l) req.latest_reqs = l saving = False newly_closed = newly_opened = newly_assigned = 0 if request.method == "POST": form_action = request.POST.get("action", "") saving = form_action.startswith("save") # check for conflicts review_requests_dict = { unicode(r.pk): r for r in review_requests } posted_reqs = set(request.POST.getlist("reviewrequest", [])) current_reqs = set(review_requests_dict.iterkeys()) closed_reqs = posted_reqs - current_reqs newly_closed = len(closed_reqs) opened_reqs = current_reqs - posted_reqs newly_opened = len(opened_reqs) for r in opened_reqs: review_requests_dict[r].form.add_error(None, "New request.") for req in review_requests: existing_reviewer = request.POST.get(req.form.prefix + "-existing_reviewer") if existing_reviewer is None: continue if existing_reviewer != unicode(req.reviewer_id or ""): msg = "Assignment was changed." a = req.form["action"].value() if a == "assign": msg += " Didn't assign reviewer." elif a == "close": msg += " Didn't close request." req.form.add_error(None, msg) req.form.data[req.form.prefix + "-action"] = "" # cancel the action newly_assigned += 1 form_results = [] for req in review_requests: form_results.append(req.form.is_valid()) if saving and all(form_results) and not (newly_closed > 0 or newly_opened > 0 or newly_assigned > 0): for review_req in review_requests: action = review_req.form.cleaned_data.get("action") if action == "assign": assign_review_request_to_reviewer(request, review_req, review_req.form.cleaned_data["reviewer"],review_req.form.cleaned_data["add_skip"]) elif action == "close": close_review_request(request, review_req, review_req.form.cleaned_data["close"]) kwargs = { "acronym": group.acronym } if group_type: kwargs["group_type"] = group_type if form_action == "save-continue": if assignment_status: kwargs["assignment_status"] = assignment_status return redirect(manage_review_requests, **kwargs) else: import ietf.group.views_review return redirect(ietf.group.views_review.review_requests, **kwargs) other_assignment_status = { "unassigned": "assigned", "assigned": "unassigned", }.get(assignment_status) return render(request, 'group/manage_review_requests.html', { 'group': group, 'review_requests': review_requests, 'newly_closed': newly_closed, 'newly_opened': newly_opened, 'newly_assigned': newly_assigned, 'saving': saving, 'assignment_status': assignment_status, 'other_assignment_status': other_assignment_status, }) class EmailOpenAssignmentsForm(forms.Form): frm = forms.CharField(label="From", widget=forms.EmailInput(attrs={"readonly":True})) to = MultiEmailField() cc = MultiEmailField(required=False) reply_to = MultiEmailField(required=False) subject = forms.CharField() body = forms.CharField(widget=forms.Textarea, strip=False) @login_required def email_open_review_assignments(request, acronym, group_type=None): group = get_group_or_404(acronym, group_type) if not group.features.has_reviews: raise Http404 if not can_manage_review_requests_for_team(request.user, group): return HttpResponseForbidden("You do not have permission to perform this action") review_requests = list(ReviewRequest.objects.filter( team=group, state__in=("requested", "accepted"), ).exclude( reviewer=None, ).prefetch_related("reviewer", "type", "state", "doc").distinct().order_by("reviewer","-deadline")) review_requests.sort(key=lambda r:r.reviewer.person.last_name()+r.reviewer.person.first_name()) for r in review_requests: if r.doc.telechat_date(): r.section = 'For telechat %s' % r.doc.telechat_date().isoformat() r.section_order='0'+r.section elif r.type_id == 'early': r.section = 'Early review requests:' r.section_order='2' else: r.section = 'Last calls:' r.section_order='1' e = r.doc.latest_event(LastCallDocEvent, type="sent_last_call") r.lastcall_ends = e and e.expires.date().isoformat() r.earlier_review = ReviewRequest.objects.filter(doc=r.doc,reviewer__in=r.reviewer.person.email_set.all(),state="completed") if r.earlier_review: req_rev = r.requested_rev or r.doc.rev earlier_review_rev = r.earlier_review.aggregate(Max('reviewed_rev'))['reviewed_rev__max'] if req_rev == earlier_review_rev: r.earlier_review_mark = '**' else: r.earlier_review_mark = '*' review_requests.sort(key=lambda r: r.section_order) back_url = request.GET.get("next") if not back_url: kwargs = { "acronym": group.acronym } if group_type: kwargs["group_type"] = group_type import ietf.group.views_review back_url = urlreverse(ietf.group.views_review.review_requests, kwargs=kwargs) if request.method == "POST" and request.POST.get("action") == "email": form = EmailOpenAssignmentsForm(request.POST) if form.is_valid(): send_mail_text(request, form.cleaned_data["to"], form.cleaned_data["frm"], form.cleaned_data["subject"], form.cleaned_data["body"],cc=form.cleaned_data["cc"],extra={"Reply-to":", ".join(form.cleaned_data["reply_to"])}) return HttpResponseRedirect(back_url) else: (to,cc) = gather_address_lists('review_assignments_summarized',group=group) reply_to = Recipient.objects.get(slug='group_secretaries').gather(group=group) frm = request.user.person.formatted_email() templateqs = DBTemplate.objects.filter(path="/group/%s/email/open_assignments.txt" % group.acronym) if templateqs.exists(): template = templateqs.first() else: template = DBTemplate.objects.get(path="/group/defaults/email/open_assignments.txt") partial_msg = render_to_string(template.path, { "review_requests": review_requests, "rotation_list": reviewer_rotation_list(group)[:10], "group" : group, }) (msg,_,_) = parse_preformatted(partial_msg) body = msg.get_payload() subject = msg['Subject'] form = EmailOpenAssignmentsForm(initial={ "to": ", ".join(to), "cc": ", ".join(cc), "reply_to": ", ".join(reply_to), "frm": frm, "subject": subject, "body": body, }) return render(request, 'group/email_open_review_assignments.html', { 'group': group, 'review_requests': review_requests, 'form': form, 'back_url': back_url, }) class ReviewerSettingsForm(forms.ModelForm): class Meta: model = ReviewerSettings fields = ['min_interval', 'filter_re', 'skip_next', 'remind_days_before_deadline'] def __init__(self, *args, **kwargs): exclude_fields = kwargs.pop('exclude_fields', []) super(ReviewerSettingsForm, self).__init__(*args, **kwargs) for field_name in exclude_fields: self.fields.pop(field_name) def clean_skip_next(self): skip_next = self.cleaned_data.get('skip_next') if skip_next < 0: raise forms.ValidationError("Skip next must not be negative") return skip_next class AddUnavailablePeriodForm(forms.ModelForm): class Meta: model = UnavailablePeriod fields = ['start_date', 'end_date', 'availability'] def __init__(self, *args, **kwargs): super(AddUnavailablePeriodForm, self).__init__(*args, **kwargs) self.fields["start_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label=self.fields["start_date"].label, help_text=self.fields["start_date"].help_text, required=self.fields["start_date"].required) self.fields["end_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label=self.fields["end_date"].label, help_text=self.fields["end_date"].help_text, required=self.fields["end_date"].required) self.fields['availability'].widget = forms.RadioSelect(choices=UnavailablePeriod.LONG_AVAILABILITY_CHOICES) def clean(self): start = self.cleaned_data.get("start_date") end = self.cleaned_data.get("end_date") if start and end and start > end: self.add_error("start_date", "Start date must be before or equal to end date.") return self.cleaned_data class EndUnavailablePeriodForm(forms.Form): def __init__(self, start_date, *args, **kwargs): super(EndUnavailablePeriodForm, self).__init__(*args, **kwargs) self.fields["end_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1", "start-date": start_date.isoformat() if start_date else "" }) self.start_date = start_date def clean_end_date(self): end = self.cleaned_data["end_date"] if self.start_date and end < self.start_date: raise forms.ValidationError("End date must be equal to or come after start date.") return end @login_required def change_reviewer_settings(request, acronym, reviewer_email, group_type=None): group = get_group_or_404(acronym, group_type) if not group.features.has_reviews: raise Http404 reviewer_role = get_object_or_404(Role, name="reviewer", group=group, email=reviewer_email) reviewer = reviewer_role.person if not (user_is_person(request.user, reviewer) or can_manage_review_requests_for_team(request.user, group)): return HttpResponseForbidden("You do not have permission to perform this action") exclude_fields = [] if not can_manage_review_requests_for_team(request.user, group): exclude_fields.append('skip_next') settings = ReviewerSettings.objects.filter(person=reviewer, team=group).first() if not settings: settings = ReviewerSettings(person=reviewer, team=group) settings.filter_re = get_default_filter_re(reviewer) back_url = request.GET.get("next") if not back_url: import ietf.group.views_review kwargs = { "acronym": group.acronym} if group_type: kwargs["group_type"] = group_type back_url = urlreverse(ietf.group.views_review.reviewer_overview, kwargs=kwargs) # settings if request.method == "POST" and request.POST.get("action") == "change_settings": prev_min_interval = settings.get_min_interval_display() prev_skip_next = settings.skip_next settings_form = ReviewerSettingsForm(request.POST, instance=settings, exclude_fields=exclude_fields) if settings_form.is_valid(): settings = settings_form.save() changes = [] if settings.get_min_interval_display() != prev_min_interval: changes.append("Frequency changed to \"{}\" from \"{}\".".format(settings.get_min_interval_display() or "Not specified", prev_min_interval or "Not specified")) if settings.skip_next != prev_skip_next: changes.append("Skip next assignments changed to {} from {}.".format(settings.skip_next, prev_skip_next)) if changes: email_reviewer_availability_change(request, group, reviewer_role, "\n\n".join(changes), request.user.person) return HttpResponseRedirect(back_url) else: settings_form = ReviewerSettingsForm(instance=settings,exclude_fields=exclude_fields) # periods unavailable_periods = unavailable_periods_to_list().filter(person=reviewer, team=group) if request.method == "POST" and request.POST.get("action") == "add_period": period_form = AddUnavailablePeriodForm(request.POST) if period_form.is_valid(): period = period_form.save(commit=False) period.team = group period.person = reviewer period.save() today = datetime.date.today() in_the_past = period.end_date and period.end_date < today if not in_the_past: msg = "Unavailable for review: {} - {} ({})".format( period.start_date.isoformat() if period.start_date else "indefinite", period.end_date.isoformat() if period.end_date else "indefinite", period.get_availability_display(), ) if period.availability == "unavailable": # the secretary might need to reassign # assignments, so mention the current ones review_reqs = ReviewRequest.objects.filter(state__in=["requested", "accepted"], reviewer=reviewer_role.email, team=group) msg += "\n\n" if review_reqs: msg += "{} is currently assigned to review:".format(reviewer_role.person) for r in review_reqs: msg += "\n\n" msg += "{} (deadline: {})".format(r.doc_id, r.deadline.isoformat()) else: msg += "{} does not have any assignments currently.".format(reviewer_role.person) email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person) return HttpResponseRedirect(request.get_full_path()) else: period_form = AddUnavailablePeriodForm() if request.method == "POST" and request.POST.get("action") == "delete_period": period_id = request.POST.get("period_id") if period_id is not None: for period in unavailable_periods: if str(period.pk) == period_id: period.delete() today = datetime.date.today() in_the_past = period.end_date and period.end_date < today if not in_the_past: msg = "Removed unavailable period: {} - {} ({})".format( period.start_date.isoformat() if period.start_date else "indefinite", period.end_date.isoformat() if period.end_date else "indefinite", period.get_availability_display(), ) email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person) return HttpResponseRedirect(request.get_full_path()) for p in unavailable_periods: if not p.end_date: p.end_form = EndUnavailablePeriodForm(p.start_date, request.POST if request.method == "POST" and request.POST.get("action") == "end_period" else None) if request.method == "POST" and request.POST.get("action") == "end_period": period_id = request.POST.get("period_id") for period in unavailable_periods: if str(period.pk) == period_id: if not period.end_date and period.end_form.is_valid(): period.end_date = period.end_form.cleaned_data["end_date"] period.save() msg = "Set end date of unavailable period: {} - {} ({})".format( period.start_date.isoformat() if period.start_date else "indefinite", period.end_date.isoformat() if period.end_date else "indefinite", period.get_availability_display(), ) email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person) return HttpResponseRedirect(request.get_full_path()) return render(request, 'group/change_reviewer_settings.html', { 'group': group, 'reviewer_email': reviewer_email, 'back_url': back_url, 'settings_form': settings_form, 'period_form': period_form, 'unavailable_periods': unavailable_periods, }) class ReviewSecretarySettingsForm(forms.ModelForm): class Meta: model = ReviewSecretarySettings fields = ['remind_days_before_deadline'] @login_required def change_review_secretary_settings(request, acronym, group_type=None): group = get_group_or_404(acronym, group_type) if not group.features.has_reviews: raise Http404 if not Role.objects.filter(name="secr", group=group, person__user=request.user).exists(): raise Http404 person = request.user.person settings = (ReviewSecretarySettings.objects.filter(person=person, team=group).first() or ReviewSecretarySettings(person=person, team=group)) import ietf.group.views_review back_url = urlreverse(ietf.group.views_review.review_requests, kwargs={ "acronym": acronym, "group_type": group.type_id }) # settings if request.method == "POST": settings_form = ReviewSecretarySettingsForm(request.POST, instance=settings) if settings_form.is_valid(): settings_form.save() return HttpResponseRedirect(back_url) else: settings_form = ReviewSecretarySettingsForm(instance=settings) return render(request, 'group/change_review_secretary_settings.html', { 'group': group, 'back_url': back_url, 'settings_form': settings_form, })