import datetime, os, email.utils import debug # pyflakes:ignore from django.http import HttpResponseForbidden, JsonResponse from django.shortcuts import render, get_object_or_404, redirect from django import forms from django.contrib.auth.decorators import login_required from django.utils.html import mark_safe from django.core.exceptions import ValidationError from django.template.loader import render_to_string, TemplateDoesNotExist from django.urls import reverse as urlreverse from ietf.doc.models import (Document, NewRevisionDocEvent, State, DocAlias, LastCallDocEvent, ReviewRequestDocEvent, DocumentAuthor) from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName from ietf.review.models import ReviewRequest from ietf.group.models import Group from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role from ietf.message.models import Message from ietf.person.fields import PersonEmailChoiceField, SearchablePersonField from ietf.review.utils import (active_review_teams, assign_review_request_to_reviewer, can_request_review_of_doc, can_manage_review_requests_for_team, email_review_request_change, make_new_review_request_from_existing, close_review_request_states, close_review_request, setup_reviewer_field) from ietf.review import mailarch from ietf.utils.fields import DatepickerDateField from ietf.utils.text import strip_prefix, xslugify from ietf.utils.textupload import get_cleaned_text_file_content from ietf.utils.mail import send_mail_message from ietf.mailtrigger.utils import gather_address_lists from ietf.utils.fields import MultiEmailField def clean_doc_revision(doc, rev): if rev: rev = rev.rjust(2, "0") if not NewRevisionDocEvent.objects.filter(doc=doc, rev=rev).exists(): raise forms.ValidationError("Could not find revision \"{}\" of the document.".format(rev)) return rev class RequestReviewForm(forms.ModelForm): team = forms.ModelMultipleChoiceField(queryset=Group.objects.all(), widget=forms.CheckboxSelectMultiple) deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" }) class Meta: model = ReviewRequest fields = ('requested_by', 'type', 'deadline', 'requested_rev', 'comment') def __init__(self, user, doc, *args, **kwargs): super(RequestReviewForm, self).__init__(*args, **kwargs) self.doc = doc f = self.fields["team"] f.queryset = active_review_teams() f.initial = [group.pk for group in f.queryset if can_manage_review_requests_for_team(user, group, allow_personnel_outside_team=False)] self.fields['type'].queryset = self.fields['type'].queryset.filter(used=True, reviewteamsettings__group__in=self.fields["team"].queryset).distinct() self.fields['type'].widget = forms.RadioSelect(choices=[t for t in self.fields['type'].choices if t[0]]) self.fields["requested_rev"].label = "Document revision" if has_role(user, "Secretariat"): self.fields["requested_by"] = SearchablePersonField() else: self.fields["requested_by"].widget = forms.HiddenInput() self.fields["requested_by"].initial = user.person.pk def clean_deadline(self): v = self.cleaned_data.get('deadline') if v < datetime.date.today(): raise forms.ValidationError("Select today or a date in the future.") return v def clean_requested_rev(self): return clean_doc_revision(self.doc, self.cleaned_data.get("requested_rev")) def clean(self): chosen_type = self.cleaned_data.get("type") chosen_teams = self.cleaned_data.get("team") if chosen_type and chosen_teams: for t in chosen_teams: if chosen_type not in t.reviewteamsettings.review_types.all(): self.add_error("type", "{} does not use the review type {}.".format(t.name, chosen_type.name)) return self.cleaned_data @login_required def request_review(request, name): doc = get_object_or_404(Document, name=name) if not can_request_review_of_doc(request.user, doc): return HttpResponseForbidden("You do not have permission to perform this action") now = datetime.datetime.now() lc_ends = None e = doc.latest_event(LastCallDocEvent, type="sent_last_call") if e and e.expires >= now: lc_ends = e.expires scheduled_for_telechat = doc.telechat_date() if request.method == "POST": form = RequestReviewForm(request.user, doc, request.POST) if form.is_valid(): teams = form.cleaned_data["team"] for team in teams: review_req = form.save(commit=False) review_req.id = None review_req.doc = doc review_req.state = ReviewRequestStateName.objects.get(slug="requested", used=True) review_req.team = team review_req.save() ReviewRequestDocEvent.objects.create( type="requested_review", doc=doc, rev=doc.rev, by=request.user.person, desc="Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()), time=review_req.time, review_request=review_req, state=None, ) subject = "%s %s Review requested" % (review_req.team.acronym, review_req.type.name) msg = subject if review_req.comment: msg += "\n\n"+review_req.comment email_review_request_change(request, review_req, subject, msg, by=request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=True) return redirect('ietf.doc.views_doc.document_main', name=doc.name) else: if lc_ends: review_type = "lc" deadline = lc_ends.date().isoformat() elif scheduled_for_telechat: review_type = "telechat" deadline = doc.telechat_date()-datetime.timedelta(days=2) else: review_type = "early" deadline = None form = RequestReviewForm(request.user, doc, initial={ "type": review_type, "requested_by": request.user.person, "deadline": deadline, }) return render(request, 'doc/review/request_review.html', { 'doc': doc, 'form': form, 'lc_ends': lc_ends, 'lc_ends_days': (lc_ends - now).days if lc_ends else None, 'scheduled_for_telechat': scheduled_for_telechat, 'scheduled_for_telechat_days': (scheduled_for_telechat - now.date()).days if scheduled_for_telechat else None, }) def review_request(request, name, request_id): doc = get_object_or_404(Document, name=name) review_req = get_object_or_404(ReviewRequest, pk=request_id) is_reviewer = review_req.reviewer and user_is_person(request.user, review_req.reviewer.person) can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) can_close_request = (review_req.state_id in ["requested", "accepted"] and (is_authorized_in_doc_stream(request.user, doc) or can_manage_request)) can_assign_reviewer = (review_req.state_id in ["requested", "accepted"] and can_manage_request) can_accept_reviewer_assignment = (review_req.state_id == "requested" and review_req.reviewer and (is_reviewer or can_manage_request)) can_reject_reviewer_assignment = (review_req.state_id in ["requested", "accepted"] and review_req.reviewer and (is_reviewer or can_manage_request)) can_complete_review = (review_req.state_id in ["requested", "accepted", "overtaken", "no-response", "part-completed", "completed"] and review_req.reviewer and (is_reviewer or can_manage_request)) can_edit_comment = can_request_review_of_doc(request.user, doc) if request.method == "POST" and request.POST.get("action") == "accept" and can_accept_reviewer_assignment: review_req.state = ReviewRequestStateName.objects.get(slug="accepted") review_req.save() return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) return render(request, 'doc/review/review_request.html', { 'doc': doc, 'review_req': review_req, 'can_close_request': can_close_request, 'can_reject_reviewer_assignment': can_reject_reviewer_assignment, 'can_assign_reviewer': can_assign_reviewer, 'can_accept_reviewer_assignment': can_accept_reviewer_assignment, 'can_complete_review': can_complete_review, 'can_edit_comment': can_edit_comment, }) class CloseReviewRequestForm(forms.Form): close_reason = forms.ModelChoiceField(queryset=close_review_request_states(), widget=forms.RadioSelect, empty_label=None) def __init__(self, can_manage_request, *args, **kwargs): super(CloseReviewRequestForm, self).__init__(*args, **kwargs) if not can_manage_request: self.fields["close_reason"].queryset = self.fields["close_reason"].queryset.filter(slug__in=["withdrawn"]) if len(self.fields["close_reason"].queryset) == 1: self.fields["close_reason"].initial = self.fields["close_reason"].queryset.first().pk self.fields["close_reason"].widget = forms.HiddenInput() @login_required def close_request(request, name, request_id): doc = get_object_or_404(Document, name=name) review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"]) can_request = is_authorized_in_doc_stream(request.user, doc) can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) if not (can_request or can_manage_request): return HttpResponseForbidden("You do not have permission to perform this action") if request.method == "POST": form = CloseReviewRequestForm(can_manage_request, request.POST) if form.is_valid(): close_review_request(request, review_req, form.cleaned_data["close_reason"]) return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) else: form = CloseReviewRequestForm(can_manage_request) return render(request, 'doc/review/close_request.html', { 'doc': doc, 'review_req': review_req, 'form': form, }) class AssignReviewerForm(forms.Form): reviewer = PersonEmailChoiceField(empty_label="(None)", required=False) add_skip = forms.BooleanField(label='Skip next time', required=False) def __init__(self, review_req, *args, **kwargs): super(AssignReviewerForm, self).__init__(*args, **kwargs) setup_reviewer_field(self.fields["reviewer"], review_req) @login_required def assign_reviewer(request, name, request_id): doc = get_object_or_404(Document, name=name) review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"]) if not can_manage_review_requests_for_team(request.user, review_req.team): return HttpResponseForbidden("You do not have permission to perform this action") if request.method == "POST" and request.POST.get("action") == "assign": form = AssignReviewerForm(review_req, request.POST) if form.is_valid(): reviewer = form.cleaned_data["reviewer"] add_skip = form.cleaned_data["add_skip"] assign_review_request_to_reviewer(request, review_req, reviewer, add_skip) return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) else: form = AssignReviewerForm(review_req) return render(request, 'doc/review/assign_reviewer.html', { 'doc': doc, 'review_req': review_req, 'form': form, }) class RejectReviewerAssignmentForm(forms.Form): message_to_secretary = forms.CharField(widget=forms.Textarea, required=False, help_text="Optional explanation of rejection, will be emailed to team secretary if filled in", strip=False) @login_required def reject_reviewer_assignment(request, name, request_id): doc = get_object_or_404(Document, name=name) review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"]) if not review_req.reviewer: return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) is_reviewer = user_is_person(request.user, review_req.reviewer.person) can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) if not (is_reviewer or can_manage_request): return HttpResponseForbidden("You do not have permission to perform this action") if request.method == "POST" and request.POST.get("action") == "reject": form = RejectReviewerAssignmentForm(request.POST) if form.is_valid(): # reject the request review_req.state = ReviewRequestStateName.objects.get(slug="rejected") review_req.save() ReviewRequestDocEvent.objects.create( type="closed_review_request", doc=review_req.doc, rev=review_req.doc.rev, by=request.user.person, desc="Assignment of request for {} review by {} to {} was rejected".format( review_req.type.name, review_req.team.acronym.upper(), review_req.reviewer.person, ), review_request=review_req, state=review_req.state, ) # make a new unassigned review request new_review_req = make_new_review_request_from_existing(review_req) new_review_req.save() msg = render_to_string("review/reviewer_assignment_rejected.txt", { "by": request.user.person, "message_to_secretary": form.cleaned_data.get("message_to_secretary") }) email_review_request_change(request, review_req, "Reviewer assignment rejected", msg, by=request.user.person, notify_secretary=True, notify_reviewer=True, notify_requested_by=False) return redirect(review_request, name=new_review_req.doc.name, request_id=new_review_req.pk) else: form = RejectReviewerAssignmentForm() return render(request, 'doc/review/reject_reviewer_assignment.html', { 'doc': doc, 'review_req': review_req, 'form': form, }) class CompleteReviewForm(forms.Form): state = forms.ModelChoiceField(queryset=ReviewRequestStateName.objects.filter(slug__in=("completed", "part-completed")).order_by("-order"), widget=forms.RadioSelect, initial="completed") reviewed_rev = forms.CharField(label="Reviewed revision", max_length=4) result = forms.ModelChoiceField(queryset=ReviewResultName.objects.filter(used=True), widget=forms.RadioSelect, empty_label=None) ACTIONS = [ ("enter", "Enter review content (automatically posts to {mailing_list})"), ("upload", "Upload review content in text file (automatically posts to {mailing_list})"), ("link", "Link to review message already sent to {mailing_list}"), ] review_submission = forms.ChoiceField(choices=ACTIONS, widget=forms.RadioSelect) review_url = forms.URLField(label="Link to message", required=False) review_file = forms.FileField(label="Text file to upload", required=False) review_content = forms.CharField(widget=forms.Textarea, required=False, strip=False) completion_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1" }, initial=datetime.date.today, help_text="Date of announcement of the results of this review") completion_time = forms.TimeField(widget=forms.HiddenInput, initial=datetime.time.min) cc = MultiEmailField(required=False, help_text="Email addresses to send to in addition to the review team list") def __init__(self, review_req, *args, **kwargs): self.review_req = review_req super(CompleteReviewForm, self).__init__(*args, **kwargs) doc = self.review_req.doc known_revisions = NewRevisionDocEvent.objects.filter(doc=doc).order_by("time", "id").values_list("rev", flat=True) revising_review = review_req.state_id not in ["requested", "accepted"] if not revising_review: self.fields["state"].choices = [ (slug, "{} - extra reviewer is to be assigned".format(label)) if slug == "part-completed" else (slug, label) for slug, label in self.fields["state"].choices ] self.fields["reviewed_rev"].help_text = mark_safe( " ".join("{}".format(r) for r in known_revisions)) self.fields["result"].queryset = self.fields["result"].queryset.filter(reviewteamsettings__group=review_req.team) def format_submission_choice(label): if revising_review: label = label.replace(" (automatically posts to {mailing_list})", "") return label.format(mailing_list=review_req.team.list_email or "[error: team has no mailing list set]") self.fields["review_submission"].choices = [ (k, format_submission_choice(label)) for k, label in self.fields["review_submission"].choices] if revising_review: del self.fields["cc"] else: del self.fields["completion_date"] del self.fields["completion_time"] def clean_reviewed_rev(self): return clean_doc_revision(self.review_req.doc, self.cleaned_data.get("reviewed_rev")) def clean_review_content(self): return self.cleaned_data["review_content"].replace("\r", "") def clean_review_file(self): return get_cleaned_text_file_content(self.cleaned_data["review_file"]) def clean(self): if "@" in self.review_req.reviewer.person.ascii: raise forms.ValidationError("Reviewer name must be filled in (the ASCII version is currently \"{}\" - since it contains an @ sign the name is probably still the original email address).".format(self.review_req.reviewer.person.ascii)) def require_field(f): if not self.cleaned_data.get(f): self.add_error(f, ValidationError("You must fill in this field.")) submission_method = self.cleaned_data.get("review_submission") if submission_method == "enter": require_field("review_content") elif submission_method == "upload": require_field("review_file") elif submission_method == "link": require_field("review_url") require_field("review_content") @login_required def complete_review(request, name, request_id): doc = get_object_or_404(Document, name=name) review_req = get_object_or_404(ReviewRequest, pk=request_id) revising_review = review_req.state_id not in ["requested", "accepted"] if not review_req.reviewer: return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) is_reviewer = user_is_person(request.user, review_req.reviewer.person) can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) if not (is_reviewer or can_manage_request): return HttpResponseForbidden("You do not have permission to perform this action") (to, cc) = gather_address_lists('review_completed',review_req = review_req) if request.method == "POST": form = CompleteReviewForm(review_req, request.POST, request.FILES) if form.is_valid(): review_submission = form.cleaned_data['review_submission'] review = review_req.review if not review: # create review doc for i in range(1, 100): name_components = [ "review", strip_prefix(review_req.doc.name, "draft-"), form.cleaned_data["reviewed_rev"], review_req.team.acronym, review_req.type.slug, xslugify(review_req.reviewer.person.ascii_parts()[3]), datetime.date.today().isoformat(), ] if i > 1: name_components.append(str(i)) name = "-".join(c for c in name_components if c).lower() if not Document.objects.filter(name=name).exists(): review = Document.objects.create(name=name) DocAlias.objects.create(document=review, name=review.name) break review.type = DocTypeName.objects.get(slug="review") review.group = review_req.team review.rev = "00" if not review.rev else "{:02}".format(int(review.rev) + 1) review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, form.cleaned_data["reviewed_rev"]) review.time = datetime.datetime.now() if review_submission == "link": review.external_url = form.cleaned_data['review_url'] e = NewRevisionDocEvent.objects.create( type="new_revision", doc=review, by=request.user.person, rev=review.rev, desc='New revision available', time=review.time, ) review.set_state(State.objects.get(type="review", slug="active")) review.save_with_history([e]) # save file on disk if review_submission == "upload": encoded_content = form.cleaned_data['review_file'] else: encoded_content = form.cleaned_data['review_content'].encode("utf-8") filename = os.path.join(review.get_file_path(), '{}.txt'.format(review.name, review.rev)) with open(filename, 'wb') as destination: destination.write(encoded_content) # close review request review_req.state = form.cleaned_data["state"] review_req.reviewed_rev = form.cleaned_data["reviewed_rev"] review_req.result = form.cleaned_data["result"] review_req.review = review review_req.save() need_to_email_review = review_submission != "link" and review_req.team.list_email and not revising_review desc = "Request for {} review by {} {}: {}. Reviewer: {}.".format( review_req.type.name, review_req.team.acronym.upper(), review_req.state.name, review_req.result.name, review_req.reviewer.person, ) if need_to_email_review: desc += " " + "Sent review to list." completion_datetime = datetime.datetime.now() if "completion_date" in form.cleaned_data: completion_datetime = datetime.datetime.combine(form.cleaned_data["completion_date"], form.cleaned_data.get("completion_time") or datetime.time.min) close_event = ReviewRequestDocEvent.objects.filter(type="closed_review_request", review_request=review_req).first() if not close_event: close_event = ReviewRequestDocEvent(type="closed_review_request", review_request=review_req) close_event.doc = review_req.doc close_event.rev = review_req.doc.rev close_event.by = request.user.person close_event.desc = desc close_event.state = review_req.state close_event.time = completion_datetime close_event.save() if review_req.state_id == "part-completed" and not revising_review: existing_open_reqs = ReviewRequest.objects.filter(doc=review_req.doc, team=review_req.team, state__in=("requested", "accepted")) new_review_req_url = new_review_req = None if not existing_open_reqs: new_review_req = make_new_review_request_from_existing(review_req) new_review_req.save() new_review_req_url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": new_review_req.doc.name, "request_id": new_review_req.pk }) new_review_req_url = request.build_absolute_uri(new_review_req_url) subject = "Review of {}-{} completed partially".format(review_req.doc.name, review_req.reviewed_rev) msg = render_to_string("review/partially_completed_review.txt", { "new_review_req_url": new_review_req_url, "existing_open_reqs": existing_open_reqs, "by": request.user.person, }) email_review_request_change(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=False) role = request.user.person.role_set.filter(group=review_req.team,name='reviewer').first() if role and role.email.active: author_email = role.email frm = role.formatted_email() else: author_email = request.user.person.email() frm = request.user.person.formatted_email() author, created = DocumentAuthor.objects.get_or_create(document=review, author=author_email) if need_to_email_review: # email the review subject = "{} of {}-{}".format("Partial review" if review_req.state_id == "part-completed" else "Review", review_req.doc.name, review_req.reviewed_rev) related_groups = [ review_req.team, ] if review_req.doc.group: related_groups.append(review_req.doc.group) msg = Message.objects.create( by=request.user.person, subject=subject, frm=frm, to=", ".join(to), cc=form.cleaned_data["cc"], body = render_to_string("review/completed_review.txt", { "review_req": review_req, "content": encoded_content.decode("utf-8"), }), ) msg.related_groups.add(*related_groups) msg.related_docs.add(review_req.doc) msg = send_mail_message(request, msg) list_name = mailarch.list_name_from_email(review_req.team.list_email) if list_name: review.external_url = mailarch.construct_message_url(list_name, email.utils.unquote(msg["Message-ID"])) review.save_with_history([close_event]) return redirect("ietf.doc.views_doc.document_main", name=review_req.review.name) else: initial={ "reviewed_rev": review_req.reviewed_rev, "result": review_req.result_id, "cc": ", ".join(cc), } try: initial['review_content'] = render_to_string('/group/%s/review/content_templates/%s.txt' % (review_req.team.acronym, review_req.type.slug), {'review_req':review_req,'today':datetime.date.today()}) except TemplateDoesNotExist: pass form = CompleteReviewForm(review_req, initial=initial) mail_archive_query_urls = mailarch.construct_query_urls(review_req) return render(request, 'doc/review/complete_review.html', { 'doc': doc, 'review_req': review_req, 'form': form, 'mail_archive_query_urls': mail_archive_query_urls, 'revising_review': revising_review, }) def search_mail_archive(request, name, request_id): #doc = get_object_or_404(Document, name=name) review_req = get_object_or_404(ReviewRequest, pk=request_id) is_reviewer = user_is_person(request.user, review_req.reviewer.person) can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) if not (is_reviewer or can_manage_request): return HttpResponseForbidden("You do not have permission to perform this action") res = mailarch.construct_query_urls(review_req, query=request.GET.get("query")) if not res: return JsonResponse({ "error": "Couldn't do lookup in mail archive - don't know where to look"}) MAX_RESULTS = 30 try: res["messages"] = mailarch.retrieve_messages(res["query_data_url"])[:MAX_RESULTS] except Exception as e: res["error"] = "Retrieval from mail archive failed: {}".format(unicode(e)) # raise # useful when debugging return JsonResponse(res) class EditReviewRequestCommentForm(forms.ModelForm): comment = forms.CharField(widget=forms.Textarea, strip=False) class Meta: fields = ['comment',] model = ReviewRequest def edit_comment(request, name, request_id): review_req = get_object_or_404(ReviewRequest, pk=request_id) if not can_request_review_of_doc(request.user, review_req.doc): return HttpResponseForbidden("You do not have permission to perform this action") if request.method == "POST": form = EditReviewRequestCommentForm(request.POST, instance=review_req) if form.is_valid(): form.save() return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) else: form = EditReviewRequestCommentForm(instance=review_req) return render(request, 'doc/review/edit_request_comment.html', { 'review_req': review_req, 'form' : form, })