From 4961f376756de40ca1fe1d2db6a4ec7ff32b92a9 Mon Sep 17 00:00:00 2001
From: Robert Sparks
Date: Tue, 14 Oct 2025 14:51:43 -0500
Subject: [PATCH 1/2] feat: preview ballot email before save (#9646) (#9721)
* feat: skeleton for modal email preview
* fix: rudimentary transfer of the comment and discuss text
* fix: put. the buttons. back.
* fix: transfer of the data on the current form to the modal
* fix: pull cc_select and additional_cc foward in the workflow UI
* fix: refactor where ballot email is sent
* fix: refactor build_position_email
* chore: remove abandoned imports
* chore: remove abandoned template
---
ietf/doc/tests_ballot.py | 103 +++-----
ietf/doc/tests_draft.py | 77 +++++-
ietf/doc/tests_irsg_ballot.py | 63 +++--
ietf/doc/tests_rsab_ballot.py | 51 +---
ietf/doc/urls.py | 3 +-
ietf/doc/views_ballot.py | 247 ++++++++++--------
ietf/mailtrigger/forms.py | 1 +
ietf/templates/doc/ballot/edit_position.html | 89 ++++++-
.../doc/ballot/send_ballot_comment.html | 44 ----
9 files changed, 393 insertions(+), 285 deletions(-)
delete mode 100644 ietf/templates/doc/ballot/send_ballot_comment.html
diff --git a/ietf/doc/tests_ballot.py b/ietf/doc/tests_ballot.py
index 810ee598f65..8420e411e29 100644
--- a/ietf/doc/tests_ballot.py
+++ b/ietf/doc/tests_ballot.py
@@ -25,7 +25,6 @@
from ietf.group.models import Group, Role
from ietf.group.factories import GroupFactory, RoleFactory, ReviewTeamFactory
from ietf.ipr.factories import HolderIprDisclosureFactory
-from ietf.name.models import BallotPositionName
from ietf.iesg.models import TelechatDate
from ietf.person.models import Person
from ietf.person.factories import PersonFactory, PersonalApiKeyFactory
@@ -37,9 +36,18 @@
class EditPositionTests(TestCase):
+
+ # N.B. This test needs to be rewritten to exercise all types of ballots (iesg, irsg, rsab)
+ # and test against the output of the mailtriggers instead of looking for hardcoded values
+ # in the To and CC results. See #7864
def test_edit_position(self):
ad = Person.objects.get(user__username="ad")
- draft = IndividualDraftFactory(ad=ad,stream_id='ietf')
+ draft = WgDraftFactory(
+ ad=ad,
+ stream_id="ietf",
+ notify="somebody@example.com",
+ group__acronym="mars",
+ )
ballot = create_ballot_if_not_open(None, draft, ad, 'approve')
url = urlreverse('ietf.doc.views_ballot.edit_position', kwargs=dict(name=draft.name,
ballot_id=ballot.pk))
@@ -55,11 +63,20 @@ def test_edit_position(self):
self.assertEqual(len(q('form textarea[name=comment]')), 1)
# vote
+ empty_outbox()
events_before = draft.docevent_set.count()
-
- r = self.client.post(url, dict(position="discuss",
- discuss=" This is a discussion test. \n ",
- comment=" This is a test. \n "))
+
+ r = self.client.post(
+ url,
+ dict(
+ position="discuss",
+ discuss=" This is a discussion test. \n ",
+ comment=" This is a test. \n ",
+ additional_cc="test298347@example.com",
+ cc_choices=["doc_notify", "doc_group_chairs"],
+ send_mail=1,
+ ),
+ )
self.assertEqual(r.status_code, 302)
pos = draft.latest_event(BallotPositionDocEvent, balloter=ad)
@@ -70,6 +87,22 @@ def test_edit_position(self):
self.assertTrue(pos.comment_time != None)
self.assertTrue("New position" in pos.desc)
self.assertEqual(draft.docevent_set.count(), events_before + 3)
+ self.assertEqual(len(outbox),1)
+ m = outbox[0]
+ self.assertTrue("COMMENT" in m['Subject'])
+ self.assertTrue("DISCUSS" in m['Subject'])
+ self.assertTrue(draft.name in m['Subject'])
+ self.assertTrue("This is a discussion test." in str(m))
+ self.assertTrue("This is a test" in str(m))
+ self.assertTrue("iesg@" in m['To'])
+ # cc_choice doc_group_chairs
+ self.assertTrue("mars-chairs@" in m['Cc'])
+ # cc_choice doc_notify
+ self.assertTrue("somebody@example.com" in m['Cc'])
+ # cc_choice doc_group_email_list was not selected
+ self.assertFalse(draft.group.list_email in m['Cc'])
+ # extra-cc
+ self.assertTrue("test298347@example.com" in m['Cc'])
# recast vote
events_before = draft.docevent_set.count()
@@ -230,64 +263,6 @@ def test_cannot_edit_position_as_pre_ad(self):
r = self.client.post(url, dict(position="discuss", discuss="Test discuss text"))
self.assertEqual(r.status_code, 403)
- # N.B. This test needs to be rewritten to exercise all types of ballots (iesg, irsg, rsab)
- # and test against the output of the mailtriggers instead of looking for hardcoded values
- # in the To and CC results. See #7864
- def test_send_ballot_comment(self):
- ad = Person.objects.get(user__username="ad")
- draft = WgDraftFactory(ad=ad,group__acronym='mars')
- draft.notify = "somebody@example.com"
- draft.save_with_history([DocEvent.objects.create(doc=draft, rev=draft.rev, type="changed_document", by=Person.objects.get(user__username="secretary"), desc="Test")])
-
- ballot = create_ballot_if_not_open(None, draft, ad, 'approve')
-
- BallotPositionDocEvent.objects.create(
- doc=draft, rev=draft.rev, type="changed_ballot_position",
- by=ad, balloter=ad, ballot=ballot, pos=BallotPositionName.objects.get(slug="discuss"),
- discuss="This draft seems to be lacking a clearer title?",
- discuss_time=timezone.now(),
- comment="Test!",
- comment_time=timezone.now())
-
- url = urlreverse('ietf.doc.views_ballot.send_ballot_comment', kwargs=dict(name=draft.name,
- ballot_id=ballot.pk))
- login_testing_unauthorized(self, "ad", url)
-
- # normal get
- r = self.client.get(url)
- self.assertEqual(r.status_code, 200)
- q = PyQuery(r.content)
- self.assertTrue(len(q('form input[name="extra_cc"]')) > 0)
-
- # send
- mailbox_before = len(outbox)
-
- r = self.client.post(url, dict(extra_cc="test298347@example.com", cc_choices=['doc_notify','doc_group_chairs']))
- self.assertEqual(r.status_code, 302)
-
- self.assertEqual(len(outbox), mailbox_before + 1)
- m = outbox[-1]
- self.assertTrue("COMMENT" in m['Subject'])
- self.assertTrue("DISCUSS" in m['Subject'])
- self.assertTrue(draft.name in m['Subject'])
- self.assertTrue("clearer title" in str(m))
- self.assertTrue("Test!" in str(m))
- self.assertTrue("iesg@" in m['To'])
- # cc_choice doc_group_chairs
- self.assertTrue("mars-chairs@" in m['Cc'])
- # cc_choice doc_notify
- self.assertTrue("somebody@example.com" in m['Cc'])
- # cc_choice doc_group_email_list was not selected
- self.assertFalse(draft.group.list_email in m['Cc'])
- # extra-cc
- self.assertTrue("test298347@example.com" in m['Cc'])
-
- r = self.client.post(url, dict(cc=""))
- self.assertEqual(r.status_code, 302)
- self.assertEqual(len(outbox), mailbox_before + 2)
- m = outbox[-1]
- self.assertTrue("iesg@" in m['To'])
- self.assertFalse(m['Cc'] and draft.group.list_email in m['Cc'])
class BallotWriteupsTests(TestCase):
diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py
index ab33acebe6d..4d262c5a2fb 100644
--- a/ietf/doc/tests_draft.py
+++ b/ietf/doc/tests_draft.py
@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*-
+import json
import os
import datetime
import io
@@ -11,7 +12,7 @@
from pathlib import Path
from pyquery import PyQuery
-from django.db.models import Q
+from django.db.models import Max, Q
from django.urls import reverse as urlreverse
from django.conf import settings
from django.utils import timezone
@@ -2391,3 +2392,77 @@ def test_editorial_metadata(self):
top_level_metadata_headings = q("tbody>tr>th:first-child").text()
self.assertNotIn("IESG", top_level_metadata_headings)
self.assertNotIn("IANA", top_level_metadata_headings)
+
+class BallotEmailAjaxTests(TestCase):
+ def test_ajax_build_position_email(self):
+ def _post_json(self, url, json_to_post):
+ r = self.client.post(
+ url, json.dumps(json_to_post), content_type="application/json"
+ )
+ self.assertEqual(r.status_code, 200)
+ return json.loads(r.content)
+
+ doc = WgDraftFactory()
+ ad = RoleFactory(
+ name_id="ad", group=doc.group, person__name="Some Areadirector"
+ ).person
+ url = urlreverse("ietf.doc.views_ballot.ajax_build_position_email")
+ login_testing_unauthorized(self, "secretary", url)
+ r = self.client.get(url)
+ self.assertEqual(r.status_code, 405)
+ response = _post_json(self, url, {})
+ self.assertFalse(response["success"])
+ self.assertEqual(response["errors"], ["post_data not provided"])
+ response = _post_json(self, url, {"dictis": "not empty"})
+ self.assertFalse(response["success"])
+ self.assertEqual(response["errors"], ["post_data not provided"])
+ response = _post_json(self, url, {"post_data": {}})
+ self.assertFalse(response["success"])
+ self.assertEqual(len(response["errors"]), 7)
+ response = _post_json(
+ self,
+ url,
+ {
+ "post_data": {
+ "discuss": "aaaaaa",
+ "comment": "bbbbbb",
+ "position": "discuss",
+ "balloter": Person.objects.aggregate(maxpk=Max("pk") + 1)["maxpk"],
+ "docname": "this-draft-does-not-exist",
+ "cc_choices": ["doc_group_mail_list"],
+ "additional_cc": "foo@example.com",
+ }
+ },
+ )
+ self.assertFalse(response["success"])
+ self.assertEqual(
+ response["errors"],
+ ["No person found matching balloter", "No document found matching docname"],
+ )
+ response = _post_json(
+ self,
+ url,
+ {
+ "post_data": {
+ "discuss": "aaaaaa",
+ "comment": "bbbbbb",
+ "position": "discuss",
+ "balloter": ad.pk,
+ "docname": doc.name,
+ "cc_choices": ["doc_group_mail_list"],
+ "additional_cc": "foo@example.com",
+ }
+ },
+ )
+ self.assertTrue(response["success"])
+ for snippet in [
+ "aaaaaa",
+ "bbbbbb",
+ "DISCUSS",
+ ad.plain_name(),
+ doc.name,
+ doc.group.list_email,
+ "foo@example.com",
+ ]:
+ self.assertIn(snippet, response["text"])
+
diff --git a/ietf/doc/tests_irsg_ballot.py b/ietf/doc/tests_irsg_ballot.py
index aa62d8aaf9c..d96cf9dbefe 100644
--- a/ietf/doc/tests_irsg_ballot.py
+++ b/ietf/doc/tests_irsg_ballot.py
@@ -355,28 +355,35 @@ def test_issue_ballot(self):
def test_take_and_email_position(self):
draft = RgDraftFactory()
ballot = IRSGBallotDocEventFactory(doc=draft)
- url = urlreverse('ietf.doc.views_ballot.edit_position', kwargs=dict(name=draft.name, ballot_id=ballot.pk)) + self.balloter
+ url = (
+ urlreverse(
+ "ietf.doc.views_ballot.edit_position",
+ kwargs=dict(name=draft.name, ballot_id=ballot.pk),
+ )
+ + self.balloter
+ )
empty_outbox()
login_testing_unauthorized(self, self.username, url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
- r = self.client.post(url, dict(position='yes', comment='oib239sb', send_mail='Save and send email'))
+ empty_outbox()
+ r = self.client.post(
+ url,
+ dict(
+ position="yes",
+ comment="oib239sb",
+ send_mail="Save and send email",
+ cc_choices=["doc_authors", "doc_group_chairs", "doc_group_mail_list"],
+ ),
+ )
self.assertEqual(r.status_code, 302)
e = draft.latest_event(BallotPositionDocEvent)
- self.assertEqual(e.pos.slug,'yes')
- self.assertEqual(e.comment, 'oib239sb')
-
- url = urlreverse('ietf.doc.views_ballot.send_ballot_comment', kwargs=dict(name=draft.name, ballot_id=ballot.pk)) + self.balloter
-
- r = self.client.get(url)
- self.assertEqual(r.status_code, 200)
-
- r = self.client.post(url, dict(cc_choices=['doc_authors','doc_group_chairs','doc_group_mail_list'], body="Stuff"))
- self.assertEqual(r.status_code, 302)
- self.assertEqual(len(outbox),1)
- self.assertNotIn('discuss-criteria', get_payload_text(outbox[0]))
+ self.assertEqual(e.pos.slug, "yes")
+ self.assertEqual(e.comment, "oib239sb")
+ self.assertEqual(len(outbox), 1)
+ self.assertNotIn("discuss-criteria", get_payload_text(outbox[0]))
def test_close_ballot(self):
draft = RgDraftFactory()
@@ -482,27 +489,31 @@ def test_cant_take_position_on_iesg_ballot(self):
def test_take_and_email_position(self):
draft = RgDraftFactory()
ballot = IRSGBallotDocEventFactory(doc=draft)
- url = urlreverse('ietf.doc.views_ballot.edit_position', kwargs=dict(name=draft.name, ballot_id=ballot.pk))
+ url = urlreverse(
+ "ietf.doc.views_ballot.edit_position",
+ kwargs=dict(name=draft.name, ballot_id=ballot.pk),
+ )
empty_outbox()
login_testing_unauthorized(self, self.username, url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
- r = self.client.post(url, dict(position='yes', comment='oib239sb', send_mail='Save and send email'))
+ r = self.client.post(
+ url,
+ dict(
+ position="yes",
+ comment="oib239sb",
+ send_mail="Save and send email",
+ cc_choices=["doc_authors", "doc_group_chairs", "doc_group_mail_list"],
+ ),
+ )
self.assertEqual(r.status_code, 302)
e = draft.latest_event(BallotPositionDocEvent)
- self.assertEqual(e.pos.slug,'yes')
- self.assertEqual(e.comment, 'oib239sb')
-
- url = urlreverse('ietf.doc.views_ballot.send_ballot_comment', kwargs=dict(name=draft.name, ballot_id=ballot.pk))
-
- r = self.client.get(url)
- self.assertEqual(r.status_code, 200)
-
- r = self.client.post(url, dict(cc_choices=['doc_authors','doc_group_chairs','doc_group_mail_list'], body="Stuff"))
+ self.assertEqual(e.pos.slug, "yes")
+ self.assertEqual(e.comment, "oib239sb")
self.assertEqual(r.status_code, 302)
- self.assertEqual(len(outbox),1)
+ self.assertEqual(len(outbox), 1)
class IESGMemberTests(TestCase):
diff --git a/ietf/doc/tests_rsab_ballot.py b/ietf/doc/tests_rsab_ballot.py
index 028f548232e..9086106ba94 100644
--- a/ietf/doc/tests_rsab_ballot.py
+++ b/ietf/doc/tests_rsab_ballot.py
@@ -333,34 +333,19 @@ def test_take_and_email_position(self):
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
- r = self.client.post(
- url,
- dict(position="yes", comment="oib239sb", send_mail="Save and send email"),
- )
- self.assertEqual(r.status_code, 302)
- e = draft.latest_event(BallotPositionDocEvent)
- self.assertEqual(e.pos.slug, "yes")
- self.assertEqual(e.comment, "oib239sb")
-
- url = (
- urlreverse(
- "ietf.doc.views_ballot.send_ballot_comment",
- kwargs=dict(name=draft.name, ballot_id=ballot.pk),
- )
- + self.balloter
- )
-
- r = self.client.get(url)
- self.assertEqual(r.status_code, 200)
-
r = self.client.post(
url,
dict(
+ position="yes",
+ comment="oib239sb",
+ send_mail="Save and send email",
cc_choices=["doc_authors", "doc_group_chairs", "doc_group_mail_list"],
- body="Stuff",
),
)
self.assertEqual(r.status_code, 302)
+ e = draft.latest_event(BallotPositionDocEvent)
+ self.assertEqual(e.pos.slug, "yes")
+ self.assertEqual(e.comment, "oib239sb")
self.assertEqual(len(outbox), 1)
self.assertNotIn("discuss-criteria", get_payload_text(outbox[0]))
@@ -532,31 +517,19 @@ def test_take_and_email_position(self):
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
- r = self.client.post(
- url,
- dict(position="yes", comment="oib239sb", send_mail="Save and send email"),
- )
- self.assertEqual(r.status_code, 302)
- e = draft.latest_event(BallotPositionDocEvent)
- self.assertEqual(e.pos.slug, "yes")
- self.assertEqual(e.comment, "oib239sb")
-
- url = urlreverse(
- "ietf.doc.views_ballot.send_ballot_comment",
- kwargs=dict(name=draft.name, ballot_id=ballot.pk),
- )
-
- r = self.client.get(url)
- self.assertEqual(r.status_code, 200)
-
r = self.client.post(
url,
dict(
+ position="yes",
+ comment="oib239sb",
+ send_mail="Save and send email",
cc_choices=["doc_authors", "doc_group_chairs", "doc_group_mail_list"],
- body="Stuff",
),
)
self.assertEqual(r.status_code, 302)
+ e = draft.latest_event(BallotPositionDocEvent)
+ self.assertEqual(e.pos.slug, "yes")
+ self.assertEqual(e.comment, "oib239sb")
self.assertEqual(len(outbox), 1)
diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py
index 7b444782d77..8e9c0569e22 100644
--- a/ietf/doc/urls.py
+++ b/ietf/doc/urls.py
@@ -93,6 +93,8 @@
url(r'^ballots/irsg/$', views_ballot.irsg_ballot_status),
url(r'^ballots/rsab/$', views_ballot.rsab_ballot_status),
+ url(r'^build-position-email/$', views_ballot.ajax_build_position_email),
+
url(r'^(?P(bcp|std|fyi))/?$', views_search.index_subseries),
url(r'^%(name)s(?:/%(rev)s)?/$' % settings.URL_REGEXPS, views_doc.document_main),
@@ -111,7 +113,6 @@
url(r'^%(name)s/ballot/rsab/$' % settings.URL_REGEXPS, views_doc.document_rsab_ballot),
url(r'^%(name)s/ballot/(?P[0-9]+)/$' % settings.URL_REGEXPS, views_doc.document_ballot),
url(r'^%(name)s/ballot/(?P[0-9]+)/position/$' % settings.URL_REGEXPS, views_ballot.edit_position),
- url(r'^%(name)s/ballot/(?P[0-9]+)/emailposition/$' % settings.URL_REGEXPS, views_ballot.send_ballot_comment),
url(r'^%(name)s/(?:%(rev)s/)?doc.json$' % settings.URL_REGEXPS, views_doc.document_json),
url(r'^%(name)s/ballotpopup/(?P[0-9]+)/$' % settings.URL_REGEXPS, views_doc.ballot_popup),
url(r'^(?P[A-Za-z0-9._+-]+)/reviewrequest/', include("ietf.doc.urls_review")),
diff --git a/ietf/doc/views_ballot.py b/ietf/doc/views_ballot.py
index 0ba340890d6..03cf01a4a16 100644
--- a/ietf/doc/views_ballot.py
+++ b/ietf/doc/views_ballot.py
@@ -4,18 +4,18 @@
# Directors and Secretariat
-import datetime, json
+import datetime
+import json
from django import forms
from django.conf import settings
-from django.http import HttpResponse, HttpResponseRedirect, Http404, HttpResponseBadRequest
+from django.http import HttpResponse, HttpResponseNotAllowed, HttpResponseRedirect, Http404, HttpResponseBadRequest
from django.shortcuts import render, get_object_or_404, redirect
from django.template.defaultfilters import striptags
from django.template.loader import render_to_string
from django.urls import reverse as urlreverse
from django.views.decorators.csrf import csrf_exempt
from django.utils.html import escape
-from urllib.parse import urlencode as urllib_urlencode
import debug # pyflakes:ignore
@@ -34,14 +34,15 @@
from ietf.doc.templatetags.ietf_filters import can_ballot
from ietf.iesg.models import TelechatDate
from ietf.ietfauth.utils import has_role, role_required, is_authorized_in_doc_stream
+from ietf.mailtrigger.models import Recipient
from ietf.mailtrigger.utils import gather_address_lists
from ietf.mailtrigger.forms import CcSelectForm
from ietf.message.utils import infer_message
from ietf.name.models import BallotPositionName, DocTypeName
from ietf.person.models import Person
-from ietf.utils.fields import ModelMultipleChoiceField
+from ietf.utils.fields import ModelMultipleChoiceField, MultiEmailField
from ietf.utils.http import validate_return_to_path
-from ietf.utils.mail import send_mail_text, send_mail_preformatted
+from ietf.utils.mail import decode_header_value, send_mail_text, send_mail_preformatted
from ietf.utils.decorators import require_api_key
from ietf.utils.response import permission_denied
from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO
@@ -179,6 +180,9 @@ def save_position(form, doc, ballot, balloter, login=None, send_email=False):
return pos
+class AdditionalCCForm(forms.Form):
+ additional_cc = MultiEmailField(required=False)
+
@role_required("Area Director", "Secretariat", "IRSG Member", "RSAB Member")
def edit_position(request, name, ballot_id):
"""Vote and edit discuss and comment on document"""
@@ -199,50 +203,67 @@ def edit_position(request, name, ballot_id):
raise Http404
balloter = get_object_or_404(Person, pk=balloter_id)
+ if doc.stream_id == 'irtf':
+ mailtrigger_slug='irsg_ballot_saved'
+ elif doc.stream_id == 'editorial':
+ mailtrigger_slug='rsab_ballot_saved'
+ else:
+ mailtrigger_slug='iesg_ballot_saved'
+
if request.method == 'POST':
old_pos = None
if not has_role(request.user, "Secretariat") and not can_ballot(request.user, doc):
# prevent pre-ADs from taking a position
permission_denied(request, "Must be an active member (not a pre-AD for example) of the balloting body to take a position")
+ if request.POST.get("Defer") and doc.stream.slug != "irtf":
+ return redirect('ietf.doc.views_ballot.defer_ballot', name=doc)
+ elif request.POST.get("Undefer") and doc.stream.slug != "irtf":
+ return redirect('ietf.doc.views_ballot.undefer_ballot', name=doc)
+
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
- if form.is_valid():
+ cc_select_form = CcSelectForm(data=request.POST,mailtrigger_slug=mailtrigger_slug,mailtrigger_context={'doc':doc})
+ additional_cc_form = AdditionalCCForm(request.POST)
+ if form.is_valid() and cc_select_form.is_valid() and additional_cc_form.is_valid():
send_mail = True if request.POST.get("send_mail") else False
- save_position(form, doc, ballot, balloter, login, send_mail)
-
+ pos = save_position(form, doc, ballot, balloter, login, send_mail)
if send_mail:
- query = {}
- if request.GET.get('balloter'):
- query['balloter'] = request.GET.get('balloter')
- if request.GET.get('ballot_edit_return_point'):
- query['ballot_edit_return_point'] = request.GET.get('ballot_edit_return_point')
- qstr = ""
- if len(query) > 0:
- qstr = "?" + urllib_urlencode(query, safe='/')
- return HttpResponseRedirect(urlreverse('ietf.doc.views_ballot.send_ballot_comment', kwargs=dict(name=doc.name, ballot_id=ballot_id)) + qstr)
- elif request.POST.get("Defer") and doc.stream.slug != "irtf":
- return redirect('ietf.doc.views_ballot.defer_ballot', name=doc)
- elif request.POST.get("Undefer") and doc.stream.slug != "irtf":
- return redirect('ietf.doc.views_ballot.undefer_ballot', name=doc)
- else:
- return HttpResponseRedirect(return_to_url)
+ addrs, frm, subject, body = build_position_email(balloter, doc, pos)
+ if doc.stream_id == 'irtf':
+ mailtrigger_slug='irsg_ballot_saved'
+ elif doc.stream_id == 'editorial':
+ mailtrigger_slug='rsab_ballot_saved'
+ else:
+ mailtrigger_slug='iesg_ballot_saved'
+ cc = []
+ cc.extend(cc_select_form.get_selected_addresses())
+ extra_cc = additional_cc_form.cleaned_data["additional_cc"]
+ if extra_cc:
+ cc.extend(extra_cc)
+ cc_set = set(cc)
+ cc_set.discard("")
+ cc = sorted(list(cc_set))
+ send_mail_text(request, addrs.to, frm, subject, body, cc=", ".join(cc))
+ return redirect(return_to_url)
else:
initial = {}
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", balloter=balloter, ballot=ballot)
if old_pos:
initial['position'] = old_pos.pos.slug
initial['discuss'] = old_pos.discuss
- initial['comment'] = old_pos.comment
-
+ initial['comment'] = old_pos.comment
form = EditPositionForm(initial=initial, ballot_type=ballot.ballot_type)
+ cc_select_form = CcSelectForm(mailtrigger_slug=mailtrigger_slug,mailtrigger_context={'doc':doc})
+ additional_cc_form = AdditionalCCForm()
blocking_positions = dict((p.pk, p.name) for p in form.fields["position"].queryset.all() if p.blocking)
-
ballot_deferred = doc.active_defer_event()
return render(request, 'doc/ballot/edit_position.html',
dict(doc=doc,
form=form,
+ cc_select_form=cc_select_form,
+ additional_cc_form=additional_cc_form,
balloter=balloter,
return_to_url=return_to_url,
old_pos=old_pos,
@@ -301,21 +322,98 @@ def err(code, text):
)
-def build_position_email(balloter, doc, pos):
+@role_required("Area Director", "Secretariat")
+@csrf_exempt
+def ajax_build_position_email(request):
+ if request.method != "POST":
+ return HttpResponseNotAllowed(["POST"])
+ errors = list()
+ try:
+ json_body = json.loads(request.body)
+ except json.decoder.JSONDecodeError:
+ errors.append("Post body is not valid json")
+ if len(errors) == 0:
+ post_data = json_body.get("post_data")
+ if post_data is None:
+ errors.append("post_data not provided")
+ else:
+ for key in [
+ "discuss",
+ "comment",
+ "position",
+ "balloter",
+ "docname",
+ "cc_choices",
+ "additional_cc",
+ ]:
+ if key not in post_data:
+ errors.append(f"{key} not found in post_data")
+ if len(errors) == 0:
+ person = Person.objects.filter(pk=post_data.get("balloter")).first()
+ if person is None:
+ errors.append("No person found matching balloter")
+ doc = Document.objects.filter(name=post_data.get("docname")).first()
+ if doc is None:
+ errors.append("No document found matching docname")
+ if len(errors) > 0:
+ response = {
+ "success": False,
+ "errors": errors,
+ }
+ else:
+ wanted = dict() # consider named tuple instead
+ wanted["discuss"] = post_data.get("discuss")
+ wanted["comment"] = post_data.get("comment")
+ wanted["position_name"] = post_data.get("position")
+ wanted["balloter"] = person
+ wanted["doc"] = doc
+ addrs, frm, subject, body = build_position_email_from_dict(wanted)
+
+ recipient_slugs = post_data.get("cc_choices")
+ # Consider refactoring gather_address_lists so this isn't duplicated from there
+ cc_addrs = set()
+ for r in Recipient.objects.filter(slug__in=recipient_slugs):
+ cc_addrs.update(r.gather(doc=doc))
+ additional_cc = post_data.get("additional_cc")
+ for addr in additional_cc.split(","):
+ cc_addrs.add(addr.strip())
+ cc_addrs.discard("")
+ cc_addrs = sorted(list(cc_addrs))
+
+ response_text = "\n".join(
+ [
+ f"From: {decode_header_value(frm)}",
+ f"To: {', '.join([decode_header_value(addr) for addr in addrs.to])}",
+ f"Cc: {', '.join([decode_header_value(addr) for addr in cc_addrs])}",
+ f"Subject: {subject}",
+ "",
+ body,
+ ]
+ )
+
+ response = {
+ "success": True,
+ "text": response_text,
+ }
+ return HttpResponse(json.dumps(response), content_type="application/json")
+
+def build_position_email_from_dict(pos_dict):
+ doc = pos_dict["doc"]
subj = []
d = ""
blocking_name = "DISCUSS"
- if pos.pos.blocking and pos.discuss:
- d = pos.discuss
- blocking_name = pos.pos.name.upper()
+ pos_name = BallotPositionName.objects.filter(slug=pos_dict["position_name"]).first()
+ if pos_name.blocking and pos_dict.get("discuss"):
+ d = pos_dict.get("discuss")
+ blocking_name = pos_name.name.upper()
subj.append(blocking_name)
c = ""
- if pos.comment:
- c = pos.comment
+ if pos_dict.get("comment"):
+ c = pos_dict.get("comment")
subj.append("COMMENT")
-
+ balloter = pos_dict.get("balloter")
balloter_name_genitive = balloter.plain_name() + "'" if balloter.plain_name().endswith('s') else balloter.plain_name() + "'s"
- subject = "%s %s on %s" % (balloter_name_genitive, pos.pos.name if pos.pos else "No Position", doc.name + "-" + doc.rev)
+ subject = "%s %s on %s" % (balloter_name_genitive, pos_name.name if pos_name else "No Position", doc.name + "-" + doc.rev)
if subj:
subject += ": (with %s)" % " and ".join(subj)
@@ -324,7 +422,7 @@ def build_position_email(balloter, doc, pos):
comment=c,
balloter=balloter.plain_name(),
doc=doc,
- pos=pos.pos,
+ pos=pos_name,
blocking_name=blocking_name,
settings=settings))
frm = balloter.role_email("ad").formatted_email()
@@ -338,79 +436,16 @@ def build_position_email(balloter, doc, pos):
return addrs, frm, subject, body
-@role_required('Area Director','Secretariat','IRSG Member', 'RSAB Member')
-def send_ballot_comment(request, name, ballot_id):
- """Email document ballot position discuss/comment for Area Director."""
- doc = get_object_or_404(Document, name=name)
- ballot = get_object_or_404(BallotDocEvent, type="created_ballot", pk=ballot_id, doc=doc)
- if not has_role(request.user, 'Secretariat'):
- if any([
- doc.stream_id == 'ietf' and not has_role(request.user, 'Area Director'),
- doc.stream_id == 'irtf' and not has_role(request.user, 'IRSG Member'),
- doc.stream_id == 'editorial' and not has_role(request.user, 'RSAB Member'),
- ]):
- raise Http404
-
- balloter = request.user.person
-
- try:
- return_to_url = parse_ballot_edit_return_point(request.GET.get('ballot_edit_return_point'), doc.name, ballot_id)
- except ValueError:
- return HttpResponseBadRequest('ballot_edit_return_point is invalid')
-
- if 'HTTP_REFERER' in request.META:
- back_url = request.META['HTTP_REFERER']
- else:
- back_url = urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name, ballot_id=ballot_id))
-
- # if we're in the Secretariat, we can select a balloter (such as an AD) to act as stand-in for
- if has_role(request.user, "Secretariat"):
- balloter_id = request.GET.get('balloter')
- if not balloter_id:
- raise Http404
- balloter = get_object_or_404(Person, pk=balloter_id)
-
- pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", balloter=balloter, ballot=ballot)
- if not pos:
- raise Http404
-
- addrs, frm, subject, body = build_position_email(balloter, doc, pos)
-
- if doc.stream_id == 'irtf':
- mailtrigger_slug='irsg_ballot_saved'
- elif doc.stream_id == 'editorial':
- mailtrigger_slug='rsab_ballot_saved'
- else:
- mailtrigger_slug='iesg_ballot_saved'
-
- if request.method == 'POST':
- cc = []
- cc_select_form = CcSelectForm(data=request.POST,mailtrigger_slug=mailtrigger_slug,mailtrigger_context={'doc':doc})
- if cc_select_form.is_valid():
- cc.extend(cc_select_form.get_selected_addresses())
- extra_cc = [x.strip() for x in request.POST.get("extra_cc","").split(',') if x.strip()]
- if extra_cc:
- cc.extend(extra_cc)
-
- send_mail_text(request, addrs.to, frm, subject, body, cc=", ".join(cc))
-
- return HttpResponseRedirect(return_to_url)
-
- else:
+def build_position_email(balloter, doc, pos):
- cc_select_form = CcSelectForm(mailtrigger_slug=mailtrigger_slug,mailtrigger_context={'doc':doc})
-
- return render(request, 'doc/ballot/send_ballot_comment.html',
- dict(doc=doc,
- subject=subject,
- body=body,
- frm=frm,
- to=addrs.as_strings().to,
- balloter=balloter,
- back_url=back_url,
- cc_select_form = cc_select_form,
- ))
+ pos_dict=dict()
+ pos_dict["doc"]=doc
+ pos_dict["position_name"]=pos.pos.slug
+ pos_dict["discuss"]=pos.discuss
+ pos_dict["comment"]=pos.comment
+ pos_dict["balloter"]=balloter
+ return build_position_email_from_dict(pos_dict)
@role_required('Area Director','Secretariat')
def clear_ballot(request, name, ballot_type_slug):
diff --git a/ietf/mailtrigger/forms.py b/ietf/mailtrigger/forms.py
index 366c429d8c5..8d13c5edf3e 100644
--- a/ietf/mailtrigger/forms.py
+++ b/ietf/mailtrigger/forms.py
@@ -11,6 +11,7 @@
class CcSelectForm(forms.Form):
expansions = dict() # type: Dict[str, List[str]]
cc_choices = forms.MultipleChoiceField(
+ required=False,
label='Cc',
choices=[],
widget=forms.CheckboxSelectMultiple(),
diff --git a/ietf/templates/doc/ballot/edit_position.html b/ietf/templates/doc/ballot/edit_position.html
index 293c186112d..b57e9a36522 100644
--- a/ietf/templates/doc/ballot/edit_position.html
+++ b/ietf/templates/doc/ballot/edit_position.html
@@ -20,24 +20,48 @@
Ballot deferred by {{ ballot_deferred.by }} on {{ ballot_deferred.time|date:"Y-m-d" }}.
{% endif %}
+
+
+ {% if form.errors or cc_select_form.errors or additional_cc_form.errors %}
+
+ There were errors in the submitted form -- see below. Please correct these and resubmit.
+
+ {% if form.errors %}
+ Position entry
+ {% bootstrap_form_errors form %}
+ {% endif %}
+ {% if cc_select_form.errors %}
+ CC selection
+ {% bootstrap_form_errors cc_select_form %}
+ {% endif %}
+ {% if additional_cc_form.errors %}
+ Additional Cc Addresses
+ {% bootstrap_form_errors additional_cc_form %}
+ {% endif %}
+ {% endif %}
+
+
+
{% endblock %}
{% block js %}
+
+
{% endblock %}
\ No newline at end of file
diff --git a/ietf/templates/doc/ballot/send_ballot_comment.html b/ietf/templates/doc/ballot/send_ballot_comment.html
deleted file mode 100644
index 1c5f5218599..00000000000
--- a/ietf/templates/doc/ballot/send_ballot_comment.html
+++ /dev/null
@@ -1,44 +0,0 @@
-{% extends "base.html" %}
-{# Copyright The IETF Trust 2015, All Rights Reserved #}
-{% load origin %}
-{% load django_bootstrap5 %}
-{% load ietf_filters %}
-{% block title %}Send ballot position for {{ balloter }} on {{ doc }}{% endblock %}
-{% block content %}
- {% origin %}
-
- Send ballot position for {{ balloter }}
-
- {{ doc }}
-
-
-{% endblock %}
From 8f2feef631acbd8b181a845140647c2c83a9299f Mon Sep 17 00:00:00 2001
From: NGPixel
Date: Tue, 14 Oct 2025 18:57:50 -0400
Subject: [PATCH 2/2] ci: update build workflow to deploy to dev
---
.github/workflows/build.yml | 71 ++---
dev/k8s-get-deploy-name/.editorconfig | 7 +
dev/k8s-get-deploy-name/.gitignore | 1 +
dev/k8s-get-deploy-name/.npmrc | 3 +
dev/k8s-get-deploy-name/README.md | 16 ++
dev/k8s-get-deploy-name/cli.js | 22 ++
dev/k8s-get-deploy-name/package-lock.json | 303 ++++++++++++++++++++++
dev/k8s-get-deploy-name/package.json | 8 +
8 files changed, 396 insertions(+), 35 deletions(-)
create mode 100644 dev/k8s-get-deploy-name/.editorconfig
create mode 100644 dev/k8s-get-deploy-name/.gitignore
create mode 100644 dev/k8s-get-deploy-name/.npmrc
create mode 100644 dev/k8s-get-deploy-name/README.md
create mode 100644 dev/k8s-get-deploy-name/cli.js
create mode 100644 dev/k8s-get-deploy-name/package-lock.json
create mode 100644 dev/k8s-get-deploy-name/package.json
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8567446cae5..15eaba48d1a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,13 +16,13 @@ on:
- Skip
- Staging Only
- Staging + Prod
- sandbox:
- description: 'Deploy to Sandbox'
+ dev:
+ description: 'Deploy to Dev'
default: true
required: true
type: boolean
- sandboxNoDbRefresh:
- description: 'Sandbox Disable Daily DB Refresh'
+ devNoDbRefresh:
+ description: 'Dev Disable Daily DB Refresh'
default: false
required: true
type: boolean
@@ -392,44 +392,45 @@ jobs:
value: "Failed"
# -----------------------------------------------------------------
- # SANDBOX
+ # DEV
# -----------------------------------------------------------------
- sandbox:
- name: Deploy to Sandbox
- if: ${{ !failure() && !cancelled() && github.event.inputs.sandbox == 'true' }}
+ dev:
+ name: Deploy to Dev
+ if: ${{ !failure() && !cancelled() && github.event.inputs.dev == 'true' }}
needs: [prepare, release]
- runs-on: [self-hosted, dev-server]
+ runs-on: ubuntu-latest
environment:
- name: sandbox
+ name: dev
env:
PKG_VERSION: ${{needs.prepare.outputs.pkg_version}}
steps:
- - uses: actions/checkout@v4
-
- - name: Download a Release Artifact
- uses: actions/download-artifact@v4.3.0
- with:
- name: release-${{ env.PKG_VERSION }}
-
- - name: Deploy to containers
- env:
- DEBIAN_FRONTEND: noninteractive
- run: |
- echo "Reset production flags in settings.py..."
- sed -i -r -e 's/^DEBUG *= *.*$/DEBUG = True/' -e "s/^SERVER_MODE *= *.*\$/SERVER_MODE = 'development'/" ietf/settings.py
- echo "Install Deploy to Container CLI dependencies..."
- cd dev/deploy-to-container
- npm ci
- cd ../..
- echo "Start Deploy..."
- node ./dev/deploy-to-container/cli.js --branch ${{ github.ref_name }} --domain dev.ietf.org --appversion ${{ env.PKG_VERSION }} --commit ${{ github.sha }} --ghrunid ${{ github.run_id }} --nodbrefresh ${{ github.event.inputs.sandboxNoDbRefresh }}
-
- - name: Cleanup old docker resources
- env:
- DEBIAN_FRONTEND: noninteractive
- run: |
- docker image prune -a -f
+ - uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Get Deploy Name
+ env:
+ DEBIAN_FRONTEND: noninteractive
+ run: |
+ echo "Install Get Deploy Name CLI dependencies..."
+ cd dev/k8s-get-deploy-name
+ npm ci
+ echo "Get Deploy Name..."
+ echo "DEPLOY_NAMESPACE=$(node cli.js --branch ${{ github.ref_name }})" >> "$GITHUB_ENV"
+
+ - name: Deploy to dev
+ uses: the-actions-org/workflow-dispatch@v4
+ with:
+ workflow: deploy-dev.yml
+ repo: ietf-tools/infra-k8s
+ ref: main
+ token: ${{ secrets.GH_INFRA_K8S_TOKEN }}
+ inputs: '{ "app":"datatracker", "appVersion":"${{ env.PKG_VERSION }}", "remoteRef":"${{ github.sha }}", "namespace":"${{ env.DEPLOY_NAMESPACE }}" }'
+ wait-for-completion: true
+ wait-for-completion-timeout: 30m
+ wait-for-completion-interval: 30s
+ display-workflow-run-url: false
# -----------------------------------------------------------------
# STAGING
diff --git a/dev/k8s-get-deploy-name/.editorconfig b/dev/k8s-get-deploy-name/.editorconfig
new file mode 100644
index 00000000000..fec5c665197
--- /dev/null
+++ b/dev/k8s-get-deploy-name/.editorconfig
@@ -0,0 +1,7 @@
+[*]
+indent_size = 2
+indent_style = space
+charset = utf-8
+trim_trailing_whitespace = false
+end_of_line = lf
+insert_final_newline = true
diff --git a/dev/k8s-get-deploy-name/.gitignore b/dev/k8s-get-deploy-name/.gitignore
new file mode 100644
index 00000000000..07e6e472cc7
--- /dev/null
+++ b/dev/k8s-get-deploy-name/.gitignore
@@ -0,0 +1 @@
+/node_modules
diff --git a/dev/k8s-get-deploy-name/.npmrc b/dev/k8s-get-deploy-name/.npmrc
new file mode 100644
index 00000000000..580a68c499d
--- /dev/null
+++ b/dev/k8s-get-deploy-name/.npmrc
@@ -0,0 +1,3 @@
+audit = false
+fund = false
+save-exact = true
diff --git a/dev/k8s-get-deploy-name/README.md b/dev/k8s-get-deploy-name/README.md
new file mode 100644
index 00000000000..a6605e4dd2c
--- /dev/null
+++ b/dev/k8s-get-deploy-name/README.md
@@ -0,0 +1,16 @@
+# Datatracker Get Deploy Name
+
+This tool process and slugify a git branch into an appropriate subdomain name.
+
+## Usage
+
+1. From the `dev/k8s-get-deploy-name` directory, install the dependencies:
+```sh
+npm install
+```
+2. Run the command: (replacing the `branch` argument)
+```sh
+node /cli.js --branch feat/fooBar-123
+```
+
+The subdomain name will be output. It can then be used in a workflow as a namespace name and subdomain value.
diff --git a/dev/k8s-get-deploy-name/cli.js b/dev/k8s-get-deploy-name/cli.js
new file mode 100644
index 00000000000..b6c3b5119ef
--- /dev/null
+++ b/dev/k8s-get-deploy-name/cli.js
@@ -0,0 +1,22 @@
+#!/usr/bin/env node
+
+import yargs from 'yargs/yargs'
+import { hideBin } from 'yargs/helpers'
+import slugify from 'slugify'
+
+const argv = yargs(hideBin(process.argv)).argv
+
+let branch = argv.branch
+if (!branch) {
+ throw new Error('Missing --branch argument!')
+}
+if (branch.indexOf('/') >= 0) {
+ branch = branch.split('/').slice(1).join('-')
+}
+branch = slugify(branch, { lower: true, strict: true })
+if (branch.length < 1) {
+ throw new Error('Branch name is empty!')
+}
+process.stdout.write(`dt-${branch}`)
+
+process.exit(0)
diff --git a/dev/k8s-get-deploy-name/package-lock.json b/dev/k8s-get-deploy-name/package-lock.json
new file mode 100644
index 00000000000..e492a4cd38c
--- /dev/null
+++ b/dev/k8s-get-deploy-name/package-lock.json
@@ -0,0 +1,303 @@
+{
+ "name": "k8s-get-deploy-name",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "k8s-get-deploy-name",
+ "dependencies": {
+ "slugify": "1.6.6",
+ "yargs": "17.7.2"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ },
+ "node_modules/escalade": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+ "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/slugify": {
+ "version": "1.6.6",
+ "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz",
+ "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "engines": {
+ "node": ">=12"
+ }
+ }
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "requires": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+ },
+ "emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ },
+ "escalade": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+ "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
+ },
+ "get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
+ },
+ "is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
+ },
+ "require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
+ },
+ "slugify": {
+ "version": "1.6.6",
+ "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz",
+ "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="
+ },
+ "string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "requires": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ }
+ },
+ "strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "requires": {
+ "ansi-regex": "^5.0.1"
+ }
+ },
+ "wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "requires": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ }
+ },
+ "y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="
+ },
+ "yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "requires": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ }
+ },
+ "yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="
+ }
+ }
+}
diff --git a/dev/k8s-get-deploy-name/package.json b/dev/k8s-get-deploy-name/package.json
new file mode 100644
index 00000000000..849f5d9b8d0
--- /dev/null
+++ b/dev/k8s-get-deploy-name/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "k8s-get-deploy-name",
+ "type": "module",
+ "dependencies": {
+ "slugify": "1.6.6",
+ "yargs": "17.7.2"
+ }
+}