datatracker/ietf/group/utils.py
Lars Eggert 57f23f5198
chore: feat/dark-mode <- main (#6103)
* chore: Remove unused "rendertest" stuff (#6015)

* fix: restore ability to create status change documents (#5963)

* fix: restore ability to create status change documents

Fixes #5962

* chore: address review comment

* fix: Provide human-friendly status in submission status API response (#6011)

Co-authored-by: nectostr <bastinda96@gmail.com>

* fix: Make name/email lookups case-insensitive (#5972) (#6007)

* fix: Make name/email lookups case-insensitive (#5972)

Use icontains so that looking up name or email is case insensitive
Added a test

Fixes: 5972

* fix: Use __iexact not __icontains

* fix: Clarify no-action-needed (#5918) (#6020)

When a draft is submitted for manual processing, clarify that
no action is needed; the Secretariat has the next steps.

Fixes: #5918

* fix: Fix menu hover issue (#6019)

* fix: Fix menu hover issue

Fixes #5702

* Fix leftmenu hover issue

* fix: Server error from api_get_session_materials() (#6025)

Fixes #5877

* fix: Clarify Questionnaire label (#4688) (#6017)

When filtering nominees, `Questionnaire` implies `Accepted == yes`
so fix the dropdown test tosay that.

Fixes: #4688

* chore: Merge from @martinthomson's rfc-txt-html (#6023)

* fix:no history entry when changing RFC Editor note for doc (#6021)

* fix:no history entry when changing RFC Editor note for doc

* fix:no history entry when changing RFC Editor note for doc

---------

Co-authored-by: Priyanka Narkar <priyankanarkar@dhcp-91f8.meeting.ietf.org>

* fix: avoid deprecation warning on view_list() for objs without CommunityList

Fixes #5942

* fix: return 404 for non-existing revisions (#6014)

* fix: return 404 for non-existing revisions
Links to non-existing revisions to docs should return 404

* fix: change rfc/rev and search behaviour

* refactor: fix tab level

* fix: return 404 for rfc revision for bibtex

* fix: provide date for revisions in bibtex output (#6029)

* fix: provide date for revisions in bibtex output

* refactor: change walrus to if's

* fix: specify particular revision for events

* fix: review refactoring issue

fixes #5447

* fix:  Remove automatically suggested document for document that is already has review request (fixes #3211) (#5425)

* Added check that if there is already review request for the document
in question, ignore the automatic suggestion for that document.
Fixes #3211.

* fix: dont block on open requests for a previous version. Add tests

---------

Co-authored-by: Nicolas Giard <github@ngpixel.com>
Co-authored-by: Robert Sparks <rjsparks@nostrum.com>

* feat: IAB statements (#5940)

* feat: support iab and iesg statements. Import iab statements. (#5895)

* feat: infrastructure for statements doctype

* chore: basic test framework

* feat: basic statement document view

* feat: show replaced statements

* chore: black

* fix: state help for statements

* fix: cleanout non-relevant email expansions

* feat: import iab statements, provide group statements tab

* fix: guard against running import twice

* feat: build redirect csv for iab statements

* fix: set document state on import

* feat: show published date on main doc view

* feat: handle pdf statements

* feat: create new and update statements

* chore: copyright block updates

* chore: remove flakes

* chore: black

* feat: add edit/new buttons for the secretariat

* fix: address PR #5895 review comments

* fix: pin pydantic until inflect catches up (#5901) (#5902)

* chore: re-un-pin pydantic

* feat: include submitter in email about submitted slides (#6033)

* feat: include submitter in email about submitted slides

fixes #6031

* chore: remove unintended whitespace change

* chore(dev): update .vscode/settings.json with new taskExplorer settings

* fix: Add editorial stream to proceedings (#6027)

* fix: Add editorial stream to proceedings

Fixes #5717

* fix: Move editorial stream after the irtf in proceedings

* fix: Add editorial stream to meeting materials (#6047)

Fixes #6042

* fix: Shows requested reviews for doc fixes (#6022)

* Fix: Shows requested reviews for doc

* Changed template includes to only give required variables to them.

* feat: allow openId to choose an unactive email if there are none active (#6041)

* feat: allow openId to choose an unactive email if there are no active ones

* chore: correct typo

* chore: rename unactive to inactive

* fix: Make review table more responsive (#6053)

* fix: Improve layout of review table

* Progress

* Progress

* Final changes

* Fix tests

* Remove fluff

* Undo commits

* ci: add --validate-html-harder to tests

* ci: add  --validate-html-harder to build.yml workflow

* fix: Set colspan to actual number of columns (#6069)

* fix: Clean up view_feedback_pending (#6070)

- Remove "Unclassified" column header, which caused misalignment in the table body.

- Show the message author - previously displayed as `(None)`.

* docs: Update LICENSE year

* fix: Remove IESG state edit button when state is 'dead' (#6051) (#6065)

* fix: Correctly order "last call requested" column in the IESG dashboard (#6079)

* ci: update dev sandbox init script to start memcached

* feat: Reclassify nomcom feedback (#6002)

* fix: Clean up view_feedback_pending

- Remove "Unclassified" column header, which caused misalignment in the table body.

- Show the message author - previously displayed as `(None)`.

* feat: Reclassify nomcom feedback (#4669)

- There's a new `Chair/Advisor Tasks` menu item `Reclassify feedback`.

- I overloaded `view_feedback*` URLs with a `?reclassify` parameter.

- This adds a checkbox to each feedback message, and a `Reclassify` button
at the bottom of each feedback page.

- "Reclassifying" basically de-classifies the feedback, and punts it back
to the "Pending emails" view for reclassification.

- If a feedback has been applied to multiple nominees, declassifying it
from one nominee removes it from all.

* fix: Remove unused local variables

* fix: Fix some missing and mis-nested html

* test: Add tests for reclassifying feedback

* refactor: Substantial redesign of feedback reclassification

- Break out reclassify_feedback* as their own URLs and views,
  and revert changes to view_feedback*.html.

- Replace checkboxes with a Reclassify button on each message.

* fix: Remember to clear the feedback associations when reclassifying

* feat: Add an 'Overcome by events' feedback type

* refactor: When invoking reclassification from a view-feedback page, load the corresponding reclassify-feedback page

* fix: De-conflict migration with 0004_statements

Also change the coding style to match, and add a reverse migration.

* fix: Fix a test case to account for new feedback type

* fix: 842e730 broke the Back button

* refactor: Reclassify feedback directly instead of putting it back in the work queue

* fix: Adjust tests to new workflow

* refactor: Further refine reclassification to avoid redirects

* refactor: Impose a FeedbackTypeName ordering

Also add FeedbackTypeName.legend field, rather than synthesizing it every
time we classify or reclassify feedback.

In the reclassification forms, only show the relevant feedback types.

* refactor: Merge reclassify_feedback_* back into view_feedback_*

This means the "Reclassify" button is always present, but eliminates some
complexity.

* refactor: Add filter(used=True) on FeedbackTypeName querysets

* refactor: Add the new FeedbackTypeName to the reclassification success message

* fix: Secure reclassification against rogue nomcom members

* fix: Print decoded key and fully clean up test nomcom (#6094)

* fix: Delete Person records when deleting a test nomcom

* fix: Decode test nomcom private key before printing

* test: Use correct time zone for test_statement_doc_view (#6064)

* chore(deps): update all npm dependencies for playwright (#6061)

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>

* chore(deps): update all npm dependencies for dev/diff (#6062)

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>

* chore(deps): update all npm dependencies for dev/coverage-action (#6063)

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>

* fix: Hash cache key for default memcached cache (#6089)

* feat: Show docs that an AD hasn't balloted on that need ballots to progress (#6075)

* fix(doc): Unify help texts for document states (#6060)

* Fix IESG State help text link (only)

* Intermediate checkpoint

* Correct URL filtering of state descriptions

* Unify help texts for document states

* Remove redundant load static from template

---------

Co-authored-by: Robert Sparks <rjsparks@nostrum.com>

* ci: fix sandbox start.sh memcached user

* fix: refactor how settings handles cache definitions (#6099)

* fix: refactor how settings handles cache definitions

* chore: more english-speaker readable expression

* fix: Cast cache key to str before calling encode (#6100)

---------

Co-authored-by: Robert Sparks <rjsparks@nostrum.com>
Co-authored-by: Liubov Kurafeeva <liubov.kurafeeva@gmail.com>
Co-authored-by: nectostr <bastinda96@gmail.com>
Co-authored-by: Rich Salz <rsalz@akamai.com>
Co-authored-by: PriyankaN <priyanka@amsl.com>
Co-authored-by: Priyanka Narkar <priyankanarkar@dhcp-91f8.meeting.ietf.org>
Co-authored-by: Ali <alireza83@gmail.com>
Co-authored-by: Roman Beltiukov <maybe.hello.world@gmail.com>
Co-authored-by: Tero Kivinen <kivinen@iki.fi>
Co-authored-by: Nicolas Giard <github@ngpixel.com>
Co-authored-by: Kesara Rathnayake <kesara@fq.nz>
Co-authored-by: Jennifer Richards <jennifer@staff.ietf.org>
Co-authored-by: Paul Selkirk <paul@painless-security.com>
Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
Co-authored-by: Jim Fenton <fenton@bluepopcorn.net>
2023-08-10 09:49:15 -05:00

357 lines
15 KiB
Python

# Copyright The IETF Trust 2012-2023, All Rights Reserved
# -*- coding: utf-8 -*-
import io
import os
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.urls import reverse as urlreverse
import debug # pyflakes:ignore
from ietf.community.models import CommunityList, SearchRule
from ietf.community.utils import reset_name_contains_index_for_rule, can_manage_community_list
from ietf.doc.models import Document, State
from ietf.group.models import Group, RoleHistory, Role, GroupFeatures, GroupEvent
from ietf.ietfauth.utils import has_role
from ietf.name.models import GroupTypeName, RoleName
from ietf.person.models import Email
from ietf.review.utils import can_manage_review_requests_for_team
from ietf.utils import log
from ietf.utils.history import get_history_object_for, copy_many_to_many_for_history
from ietf.doc.templatetags.ietf_filters import is_valid_url
from functools import reduce
def save_group_in_history(group):
"""This should be called before saving changes to a Group instance,
so that the GroupHistory entries contain all previous states, while
the Group entry contain the current state. XXX TODO: Call this
directly from Group.save()
"""
h = get_history_object_for(group)
h.save()
# save RoleHistory
for role in group.role_set.all():
rh = RoleHistory(name=role.name, group=h, email=role.email, person=role.person)
rh.save()
copy_many_to_many_for_history(h, group)
return h
def get_charter_text(group):
# get file path from settings. Syntesize file name from path, acronym, and suffix
c = group.charter
# find the latest, preferably approved, revision
for h in group.charter.history_set.exclude(rev="").order_by("time"):
h_appr = "-" not in h.rev
c_appr = "-" not in c.rev
if (h.rev > c.rev and not (c_appr and not h_appr)) or (h_appr and not c_appr):
c = h
filename = os.path.join(c.get_file_path(), "%s-%s.txt" % (c.canonical_name(), c.rev))
try:
with io.open(filename, 'rb') as f:
text = f.read()
try:
text = text.decode('utf8')
except UnicodeDecodeError:
text = text.decode('latin1')
return text
except IOError:
return 'Error Loading Group Charter'
def get_group_role_emails(group, roles):
"Get a list of email addresses for a given WG and Role"
if not group or not group.acronym or group.acronym == 'none':
return set()
emails = Email.objects.filter(role__group=group, role__name__in=roles)
return set([_f for _f in [e.email_address() for e in emails] if _f])
def get_child_group_role_emails(parent, roles, group_type='wg'):
"""Get a list of email addresses for a given set of
roles for all child groups of a given type"""
emails = set()
groups = Group.objects.filter(parent=parent, type=group_type, state="active")
for group in groups:
emails |= get_group_role_emails(group, roles)
return emails
def get_group_ad_emails(group):
" Get list of area directors' email addresses for a given GROUP "
if not group.acronym or group.acronym == 'none':
return set()
if group.type.slug == 'area':
emails = get_group_role_emails(group, roles=('pre-ad', 'ad', 'chair'))
else:
emails = get_group_role_emails(group.parent, roles=('pre-ad', 'ad', 'chair'))
# Make sure the assigned AD is included (in case that is not one of the area ADs)
if group.state.slug=='active':
wg_ad_email = group.ad_role() and group.ad_role().email.address
if wg_ad_email:
emails.add(wg_ad_email)
return emails
def save_milestone_in_history(milestone):
h = get_history_object_for(milestone)
h.milestone = milestone
h.save()
copy_many_to_many_for_history(h, milestone)
return h
def can_manage_all_groups_of_type(user, type_id):
if not user.is_authenticated:
return False
log.assertion("isinstance(type_id, (type(''), type(u'')))")
return has_role(user, GroupFeatures.objects.get(type_id=type_id).groupman_authroles)
def can_manage_group(user, group):
if not user.is_authenticated:
return False
if has_role(user, group.features.groupman_authroles):
return True
return group.has_role(user, group.features.groupman_roles)
def groups_managed_by(user, group_queryset=None):
"""Find groups user can manage"""
if group_queryset is None:
group_queryset = Group.objects.all()
query_terms = Q(pk__in=[]) # ensure empty set is returned if no other terms are added
if user.is_authenticated or user.person:
# find the GroupTypes entirely managed by this user based on groupman_authroles
types_can_manage = []
for type_id, groupman_authroles in GroupFeatures.objects.values_list('type_id', 'groupman_authroles'):
if has_role(user, groupman_authroles):
types_can_manage.append(type_id)
query_terms |= Q(type_id__in=types_can_manage)
# find the Groups managed by this user based on groupman_roles
groups_can_manage = []
for group_id, role_name, groupman_roles in user.person.role_set.values_list(
'group_id', 'name_id', 'group__type__features__groupman_roles'
):
if role_name in groupman_roles:
groups_can_manage.append(group_id)
query_terms |= Q(pk__in=groups_can_manage)
return group_queryset.filter(query_terms)
def milestone_reviewer_for_group_type(group_type):
if group_type == "rg":
return "IRTF Chair"
else:
return "Area Director"
def can_manage_materials(user, group):
return has_role(user, 'Secretariat') or (group is not None and group.has_role(user, group.features.matman_roles))
def can_manage_session_materials(user, group, session):
return has_role(user, 'Secretariat') or (group.has_role(user, group.features.matman_roles) and not session.is_material_submission_cutoff())
# Maybe this should be cached...
def can_manage_some_groups(user):
if not user.is_authenticated:
return False
for gf in GroupFeatures.objects.all():
for authrole in gf.groupman_authroles:
if has_role(user, authrole):
return True
if Role.objects.filter(name__in=gf.groupman_roles, group__type_id=gf.type_id, person__user=user).exists():
return True
return False
def can_provide_status_update(user, group):
if not group.features.acts_like_wg:
return False
return has_role(user, 'Secretariat') or group.has_role(user, group.features.groupman_roles)
def get_group_or_404(acronym, group_type):
"""Helper to overcome the schism between group-type prefixed URLs and generic."""
possible_groups = Group.objects.all()
if group_type:
possible_groups = possible_groups.filter(type=group_type)
return get_object_or_404(possible_groups, acronym=acronym)
def setup_default_community_list_for_group(group):
clist = CommunityList.objects.create(group=group)
SearchRule.objects.create(
community_list=clist,
rule_type="group",
group=group,
state=State.objects.get(slug="active", type="draft"),
)
SearchRule.objects.create(
community_list=clist,
rule_type="group_rfc",
group=group,
state=State.objects.get(slug="rfc", type="draft"),
)
SearchRule.objects.create(
community_list=clist,
rule_type="group_exp",
group=group,
state=State.objects.get(slug="expired", type="draft"),
)
related_docs_rule = SearchRule.objects.create(
community_list=clist,
rule_type="name_contains",
text=r"^draft-[^-]+-%s-" % group.acronym,
state=State.objects.get(slug="active", type="draft"),
)
reset_name_contains_index_for_rule(related_docs_rule)
def get_group_materials(group):
return Document.objects.filter(
group=group,
type__in=group.features.material_types
).exclude(states__slug__in=['deleted','archived'])
def construct_group_menu_context(request, group, selected, group_type, others):
"""Return context with info for the group menu filled in."""
kwargs = dict(acronym=group.acronym)
if group_type:
kwargs["group_type"] = group_type
# menu entries
entries = []
entries.append(("About", urlreverse("ietf.group.views.group_about", kwargs=kwargs)))
if group.features.has_documents:
entries.append(("Documents", urlreverse("ietf.group.views.group_documents", kwargs=kwargs)))
if group.features.has_nonsession_materials and get_group_materials(group).exists():
entries.append(("Materials", urlreverse("ietf.group.views.materials", kwargs=kwargs)))
if group.features.has_reviews:
import ietf.group.views
entries.append(("Review requests", urlreverse(ietf.group.views.review_requests, kwargs=kwargs)))
entries.append(("Reviewers", urlreverse(ietf.group.views.reviewer_overview, kwargs=kwargs)))
if group.features.has_meetings:
entries.append(("Meetings", urlreverse("ietf.group.views.meetings", kwargs=kwargs)))
if group.acronym in ["iab", "iesg"]:
entries.append(("Statements", urlreverse("ietf.group.views.statements", kwargs=kwargs)))
entries.append(("History", urlreverse("ietf.group.views.history", kwargs=kwargs)))
entries.append(("Photos", urlreverse("ietf.group.views.group_photos", kwargs=kwargs)))
entries.append(("Email expansions", urlreverse("ietf.group.views.email", kwargs=kwargs)))
if group.list_archive.startswith("http:") or group.list_archive.startswith("https:") or group.list_archive.startswith("ftp:"):
if is_valid_url(group.list_archive):
entries.append((mark_safe("List archive &raquo;"), group.list_archive))
# actions
actions = []
can_manage = can_manage_group(request.user, group)
can_edit_group = False # we'll set this further down
if group.features.has_milestones:
if group.state_id != "proposed" and can_manage:
actions.append(("Edit milestones", urlreverse('ietf.group.milestones.edit_milestones;current', kwargs=kwargs)))
if group.features.has_documents:
clist = CommunityList.objects.filter(group=group).first()
if clist and can_manage_community_list(request.user, clist):
import ietf.community.views
actions.append(('Manage document list', urlreverse(ietf.community.views.manage_list, kwargs=kwargs)))
if group.features.has_nonsession_materials and can_manage_materials(request.user, group):
actions.append(("Upload material", urlreverse("ietf.doc.views_material.choose_material_type", kwargs=kwargs)))
if group.features.has_reviews and can_manage_review_requests_for_team(request.user, group):
import ietf.group.views
actions.append(("Manage unassigned reviews", urlreverse(ietf.group.views.manage_review_requests, kwargs=dict(assignment_status="unassigned", **kwargs))))
#actions.append((u"Manage assigned reviews", urlreverse(ietf.group.views.manage_review_requests, kwargs=dict(assignment_status="assigned", **kwargs))))
if Role.objects.filter(name="secr", group=group, person__user=request.user).exists():
actions.append(("Secretary settings", urlreverse(ietf.group.views.change_review_secretary_settings, kwargs=kwargs)))
actions.append(("Email open assignments summary", urlreverse(ietf.group.views.email_open_review_assignments, kwargs=dict(acronym=group.acronym, group_type=group.type_id))))
if group.state_id != "conclude" and can_manage:
can_edit_group = True
actions.append(("Edit group", urlreverse("ietf.group.views.edit", kwargs=dict(kwargs, action="edit"))))
if group.features.customize_workflow and can_manage:
actions.append(("Customize workflow", urlreverse("ietf.group.views.customize_workflow", kwargs=kwargs)))
if group.state_id in ("active", "dormant") and group.type_id in ["wg", "rg", ] and can_manage_all_groups_of_type(request.user, group.type_id):
actions.append(("Request closing group", urlreverse("ietf.group.views.conclude", kwargs=kwargs)))
d = {
"group": group,
"selected_menu_entry": selected,
"menu_entries": entries,
"menu_actions": actions,
"group_type": group_type,
"can_edit_group": can_edit_group,
}
d.update(others)
return d
def group_features_group_filter(groups, person, feature):
"""This returns a list of groups filtered such that the given person has
a role listed in the given feature for each group."""
feature_groups = set([])
for g in groups:
for r in person.role_set.filter(group=g):
if r.name.slug in getattr(r.group.type.features, feature):
feature_groups.add(g)
return list(feature_groups)
def group_features_role_filter(roles, person, feature):
type_slugs = set(roles.values_list('group__type__slug', flat=True))
group_types = GroupTypeName.objects.filter(slug__in=type_slugs)
if not group_types.exists():
return roles.none()
q = reduce(lambda a,b:a|b, [ Q(person=person, name__slug__in=getattr(t.features, feature)) for t in group_types ])
return roles.filter(q)
def group_attribute_change_desc(attr, new, old=None):
if old is None:
return format_html('{} changed to <b>{}</b>', attr, new)
else:
return format_html('{} changed to <b>{}</b> from {}', attr, new, old)
def update_role_set(group, role_name, new_value, by):
"""Alter role_set for a group
Updates the value and creates history events.
"""
if isinstance(role_name, str):
role_name = RoleName.objects.get(slug=role_name)
new = set(new_value)
old = set(r.email for r in group.role_set.filter(name=role_name).distinct().select_related("person"))
removed = old - new
added = new - old
if added or removed:
GroupEvent.objects.create(
group=group,
by=by,
type='info_changed',
desc=group_attribute_change_desc(
role_name.name,
", ".join(sorted(x.get_name() for x in new)),
", ".join(sorted(x.get_name() for x in old)),
)
)
group.role_set.filter(name=role_name, email__in=removed).delete()
for email in added:
group.role_set.create(name=role_name, email=email, person=email.person)
for e in new:
if not e.origin or (e.person.user and e.origin == e.person.user.username):
e.origin = "role: %s %s" % (group.acronym, role_name.slug)
e.save()
return added, removed