From 32057f335aa711095fb0907612496430fa61412a Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 18 Sep 2024 18:37:02 -0300 Subject: [PATCH] feat: allow blanket IPR disclosures (#7934) * refactor: avoid shadowing type() * style: Black * feat: is_blanket_disclosure field * feat: add field to form * feat: js to mark field required/not required * feat: blanket disclosure = royalty-free license * feat: manage licensing radio buttons * fix: adjust wording/format of disclosure page * fix: point at RFC 8179 in checkbox label * test: test blanket disclosure licensing restrictions * fix: conditionally render is_blanket_disclosure * test: refactor test case * test: patent details optional for blanket ipr --- ietf/ipr/forms.py | 21 ++++ ...lderiprdisclosure_is_blanket_disclosure.py | 16 +++ ietf/ipr/models.py | 34 ++++-- ietf/ipr/tests.py | 78 +++++++++++-- ietf/ipr/urls.py | 2 +- ietf/ipr/views.py | 110 +++++++++++------- ietf/static/js/ipr-edit.js | 67 ++++++++++- ietf/templates/ipr/details_edit.html | 16 ++- 8 files changed, 275 insertions(+), 69 deletions(-) create mode 100644 ietf/ipr/migrations/0004_holderiprdisclosure_is_blanket_disclosure.py diff --git a/ietf/ipr/forms.py b/ietf/ipr/forms.py index 62d3f9c21..dac34bddf 100644 --- a/ietf/ipr/forms.py +++ b/ietf/ipr/forms.py @@ -338,7 +338,19 @@ class IprDisclosureFormBase(forms.ModelForm): return cleaned_data + class HolderIprDisclosureForm(IprDisclosureFormBase): + is_blanket_disclosure = forms.BooleanField( + label=mark_safe( + 'This is a blanket IPR disclosure ' + '(see Section 5.4.3 of RFC 8179)' + ), + help_text="In satisfaction of its disclosure obligations, Patent Holder commits to license all of " + "IPR (as defined in RFC 8179) that would have required disclosure under RFC 8179 on a " + "royalty-free (and otherwise reasonable and non-discriminatory) basis. Patent Holder " + "confirms that all other terms and conditions are described in this IPR disclosure.", + required=False, + ) licensing = CustomModelChoiceField(IprLicenseTypeName.objects.all(), widget=forms.RadioSelect,empty_label=None) @@ -356,6 +368,15 @@ class HolderIprDisclosureForm(IprDisclosureFormBase): else: # entering new disclosure self.fields['licensing'].queryset = IprLicenseTypeName.objects.exclude(slug='none-selected') + + if self.data.get("is_blanket_disclosure", False): + # for a blanket disclosure, patent details are not required + self.fields["patent_number"].required = False + self.fields["patent_inventor"].required = False + self.fields["patent_title"].required = False + self.fields["patent_date"].required = False + # n.b., self.fields["patent_notes"] is never required + def clean(self): cleaned_data = super(HolderIprDisclosureForm, self).clean() diff --git a/ietf/ipr/migrations/0004_holderiprdisclosure_is_blanket_disclosure.py b/ietf/ipr/migrations/0004_holderiprdisclosure_is_blanket_disclosure.py new file mode 100644 index 000000000..66282b3cd --- /dev/null +++ b/ietf/ipr/migrations/0004_holderiprdisclosure_is_blanket_disclosure.py @@ -0,0 +1,16 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ipr", "0003_alter_iprdisclosurebase_docs"), + ] + + operations = [ + migrations.AddField( + model_name="holderiprdisclosure", + name="is_blanket_disclosure", + field=models.BooleanField(default=False), + ), + ] diff --git a/ietf/ipr/models.py b/ietf/ipr/models.py index 693f19abe..2d81eb4b4 100644 --- a/ietf/ipr/models.py +++ b/ietf/ipr/models.py @@ -3,6 +3,7 @@ from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils import timezone @@ -124,17 +125,30 @@ class IprDisclosureBase(models.Model): class HolderIprDisclosure(IprDisclosureBase): - ietfer_name = models.CharField(max_length=255, blank=True) # "Whose Personal Belief Triggered..." - ietfer_contact_email = models.EmailField(blank=True) - ietfer_contact_info = models.TextField(blank=True) - patent_info = models.TextField() - has_patent_pending = models.BooleanField(default=False) - holder_contact_email = models.EmailField() - holder_contact_name = models.CharField(max_length=255) - holder_contact_info = models.TextField(blank=True, help_text="Address, phone, etc.") - licensing = ForeignKey(IprLicenseTypeName) - licensing_comments = models.TextField(blank=True) + ietfer_name = models.CharField( + max_length=255, blank=True + ) # "Whose Personal Belief Triggered..." + ietfer_contact_email = models.EmailField(blank=True) + ietfer_contact_info = models.TextField(blank=True) + patent_info = models.TextField() + has_patent_pending = models.BooleanField(default=False) + holder_contact_email = models.EmailField() + holder_contact_name = models.CharField(max_length=255) + holder_contact_info = models.TextField(blank=True, help_text="Address, phone, etc.") + licensing = ForeignKey(IprLicenseTypeName) + licensing_comments = models.TextField(blank=True) submitter_claims_all_terms_disclosed = models.BooleanField(default=False) + is_blanket_disclosure = models.BooleanField(default=False) + + def clean(self): + if self.is_blanket_disclosure: + # If the IprLicenseTypeName does not exist, we have a serious problem and a 500 response is ok, + # so not handling failure of the `get()` + royalty_free_licensing = IprLicenseTypeName.objects.get(slug="royalty-free") + if self.licensing_id != royalty_free_licensing.pk: + raise ValidationError( + f'Must select "{royalty_free_licensing.desc}" for a blanket IPR disclosure.') + class ThirdPartyIprDisclosure(IprDisclosureBase): ietfer_name = models.CharField(max_length=255) # "Whose Personal Belief Triggered..." diff --git a/ietf/ipr/tests.py b/ietf/ipr/tests.py index b08e35946..3c70567fd 100644 --- a/ietf/ipr/tests.py +++ b/ietf/ipr/tests.py @@ -33,7 +33,7 @@ from ietf.ipr.factories import ( IprDocRelFactory, IprEventFactory ) -from ietf.ipr.forms import DraftForm +from ietf.ipr.forms import DraftForm, HolderIprDisclosureForm from ietf.ipr.mail import (process_response_email, get_reply_to, get_update_submitter_emails, get_pseudo_submitter, get_holders, get_update_cc_addrs) from ietf.ipr.models import (IprDisclosureBase,GenericIprDisclosure,HolderIprDisclosure, @@ -272,16 +272,16 @@ class IprTests(TestCase): def test_new_generic(self): """Ensure new-generic redirects to new-general""" - url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "generic" }) + url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "generic" }) r = self.client.get(url) self.assertEqual(r.status_code,302) - self.assertEqual(urlparse(r["Location"]).path, urlreverse("ietf.ipr.views.new", kwargs={ "type": "general"})) + self.assertEqual(urlparse(r["Location"]).path, urlreverse("ietf.ipr.views.new", kwargs={ "_type": "general"})) def test_new_general(self): """Add a new general disclosure. Note: submitter does not need to be logged in. """ - url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "general" }) + url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "general" }) # invalid post r = self.client.post(url, { @@ -319,7 +319,7 @@ class IprTests(TestCase): """ draft = WgDraftFactory() rfc = WgRfcFactory() - url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" }) + url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" }) # successful post empty_outbox() @@ -375,7 +375,7 @@ class IprTests(TestCase): def test_new_specific_no_revision(self): draft = WgDraftFactory() rfc = WgRfcFactory() - url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" }) + url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" }) # successful post empty_outbox() @@ -409,7 +409,7 @@ class IprTests(TestCase): """ draft = WgDraftFactory() rfc = WgRfcFactory() - url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "third-party" }) + url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "third-party" }) # successful post empty_outbox() @@ -456,7 +456,7 @@ class IprTests(TestCase): r = self.client.get(url) self.assertContains(r, original_ipr.holder_legal_name) - #url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" }) + #url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" }) # successful post empty_outbox() post_data = { @@ -503,7 +503,7 @@ class IprTests(TestCase): r = self.client.get(url) self.assertContains(r, original_ipr.title) - #url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" }) + #url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" }) # successful post empty_outbox() r = self.client.post(url, { @@ -543,7 +543,7 @@ class IprTests(TestCase): def test_update_bad_post(self): draft = WgDraftFactory() - url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" }) + url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" }) empty_outbox() r = self.client.post(url, { @@ -1022,3 +1022,61 @@ class DraftFormTests(TestCase): "revisions", null_char_error_msg, ) + + +class HolderIprDisclosureFormTests(TestCase): + def setUp(self): + super().setUp() + # Checkboxes that are False are left out of the Form data, not sent back at all. These are + # commented out - if they were checked, their value would be "on". + self.data = { + "holder_legal_name": "Test Legal", + "holder_contact_name": "Test Holder", + "holder_contact_email": "test@holder.com", + "holder_contact_info": "555-555-0100", + "ietfer_name": "Test Participant", + "ietfer_contact_info": "555-555-0101", + "iprdocrel_set-TOTAL_FORMS": 2, + "iprdocrel_set-INITIAL_FORMS": 0, + "iprdocrel_set-0-document": "1234", # fake id - validates but won't save() + "iprdocrel_set-0-revisions": '00', + "iprdocrel_set-1-document": "4567", # fake id - validates but won't save() + # "is_blanket_disclosure": "on", + "patent_number": "SE12345678901", + "patent_inventor": "A. Nonymous", + "patent_title": "A method of transferring bits", + "patent_date": "2000-01-01", + # "has_patent_pending": "on", + "licensing": "reasonable", + "submitter_name": "Test Holder", + "submitter_email": "test@holder.com", + } + + def test_blanket_disclosure_licensing_restrictions(self): + """when is_blanket_disclosure is True only royalty-free licensing is valid + + Most of the form functionality is tested via the views in IprTests above. More thorough testing + of validation ought to move here so we don't have to exercise the whole Django plumbing repeatedly. + """ + self.assertTrue(HolderIprDisclosureForm(data=self.data).is_valid()) + self.data["is_blanket_disclosure"] = "on" + self.assertFalse(HolderIprDisclosureForm(data=self.data).is_valid()) + self.data["licensing"] = "royalty-free" + self.assertTrue(HolderIprDisclosureForm(data=self.data).is_valid()) + + def test_patent_details_required_unless_blanket(self): + self.assertTrue(HolderIprDisclosureForm(data=self.data).is_valid()) + patent_fields = ["patent_number", "patent_inventor", "patent_title", "patent_date"] + # any of the fields being missing should invalidate the form + for pf in patent_fields: + val = self.data.pop(pf) + self.assertFalse(HolderIprDisclosureForm(data=self.data).is_valid()) + self.data[pf] = val + + # should be optional if is_blanket_disclosure is True + self.data["is_blanket_disclosure"] = "on" + self.data["licensing"] = "royalty-free" # also needed for a blanket disclosure + for pf in patent_fields: + val = self.data.pop(pf) + self.assertTrue(HolderIprDisclosureForm(data=self.data).is_valid()) + self.data[pf] = val diff --git a/ietf/ipr/urls.py b/ietf/ipr/urls.py index 6f7b2d408..2b6abee31 100644 --- a/ietf/ipr/urls.py +++ b/ietf/ipr/urls.py @@ -25,6 +25,6 @@ urlpatterns = [ url(r'^(?P\d+)/state/$', views.state), url(r'^update/$', RedirectView.as_view(url=reverse_lazy('ietf.ipr.views.showlist'), permanent=True)), url(r'^update/(?P\d+)/$', views.update), - url(r'^new-(?P(specific|generic|general|third-party))/$', views.new), + url(r'^new-(?P<_type>(specific|generic|general|third-party))/$', views.new), url(r'^search/$', views.search), ] diff --git a/ietf/ipr/views.py b/ietf/ipr/views.py index 45fad9a2c..0347c4d78 100644 --- a/ietf/ipr/views.py +++ b/ietf/ipr/views.py @@ -475,28 +475,34 @@ def by_draft_recursive_txt(request): return HttpResponse(content, content_type="text/plain; charset=%s"%settings.DEFAULT_CHARSET) -def new(request, type, updates=None): +def new(request, _type, updates=None): """Submit a new IPR Disclosure. If the updates field != None, this disclosure updates one or more other disclosures.""" # Note that URL patterns won't ever send updates - updates is only non-null when called from code # This odd construct flipping generic and general allows the URLs to say 'general' while having a minimal impact on the code. # A cleanup to change the code to switch on type 'general' should follow. - if type == 'generic' and updates: # Only happens when called directly from the updates view + if ( + _type == "generic" and updates + ): # Only happens when called directly from the updates view pass - elif type == 'generic': - return HttpResponseRedirect(urlreverse('ietf.ipr.views.new',kwargs=dict(type='general'))) - elif type == 'general': - type = 'generic' + elif _type == "generic": + return HttpResponseRedirect( + urlreverse("ietf.ipr.views.new", kwargs=dict(_type="general")) + ) + elif _type == "general": + _type = "generic" else: pass # 1 to show initially + the template - DraftFormset = inlineformset_factory(IprDisclosureBase, IprDocRel, form=DraftForm, can_delete=False, extra=1 + 1) + DraftFormset = inlineformset_factory( + IprDisclosureBase, IprDocRel, form=DraftForm, can_delete=False, extra=1 + 1 + ) - if request.method == 'POST': - form = ipr_form_mapping[type](request.POST) - if type != 'generic': + if request.method == "POST": + form = ipr_form_mapping[_type](request.POST) + if _type != "generic": draft_formset = DraftFormset(request.POST, instance=IprDisclosureBase()) else: draft_formset = None @@ -505,72 +511,92 @@ def new(request, type, updates=None): person = Person.objects.get(name="(System)") else: person = request.user.person - + # check formset validity - if type != 'generic': + if _type != "generic": valid_formsets = draft_formset.is_valid() else: valid_formsets = True - + if form.is_valid() and valid_formsets: - if 'updates' in form.cleaned_data: - updates = form.cleaned_data['updates'] - del form.cleaned_data['updates'] + if "updates" in form.cleaned_data: + updates = form.cleaned_data["updates"] + del form.cleaned_data["updates"] disclosure = form.save(commit=False) disclosure.by = person - disclosure.state = IprDisclosureStateName.objects.get(slug='pending') + disclosure.state = IprDisclosureStateName.objects.get(slug="pending") disclosure.save() - - if type != 'generic': + + if _type != "generic": draft_formset = DraftFormset(request.POST, instance=disclosure) draft_formset.save() set_disclosure_title(disclosure) disclosure.save() - + if updates: for ipr in updates: - RelatedIpr.objects.create(source=disclosure,target=ipr,relationship_id='updates') - + RelatedIpr.objects.create( + source=disclosure, target=ipr, relationship_id="updates" + ) + # create IprEvent IprEvent.objects.create( - type_id='submitted', + type_id="submitted", by=person, disclosure=disclosure, - desc="Disclosure Submitted") + desc="Disclosure Submitted", + ) # send email notification - (to, cc) = gather_address_lists('ipr_disclosure_submitted') - send_mail(request, to, ('IPR Submitter App', 'ietf-ipr@ietf.org'), - 'New IPR Submission Notification', + (to, cc) = gather_address_lists("ipr_disclosure_submitted") + send_mail( + request, + to, + ("IPR Submitter App", "ietf-ipr@ietf.org"), + "New IPR Submission Notification", "ipr/new_update_email.txt", - {"ipr": disclosure,}, - cc=cc) - + { + "ipr": disclosure, + }, + cc=cc, + ) + return render(request, "ipr/submitted.html") else: if updates: original = IprDisclosureBase(id=updates).get_child() initial = model_to_dict(original) - initial.update({'updates':str(updates), }) - patent_info = text_to_dict(initial.get('patent_info', '')) + initial.update( + { + "updates": str(updates), + } + ) + patent_info = text_to_dict(initial.get("patent_info", "")) if list(patent_info.keys()): - patent_dict = dict([ ('patent_'+k.lower(), v) for k,v in list(patent_info.items()) ]) + patent_dict = dict( + [("patent_" + k.lower(), v) for k, v in list(patent_info.items())] + ) else: - patent_dict = {'patent_notes': initial.get('patent_info', '')} + patent_dict = {"patent_notes": initial.get("patent_info", "")} initial.update(patent_dict) - form = ipr_form_mapping[type](initial=initial) + form = ipr_form_mapping[_type](initial=initial) else: - form = ipr_form_mapping[type]() - disclosure = IprDisclosureBase() # dummy disclosure for inlineformset + form = ipr_form_mapping[_type]() + disclosure = IprDisclosureBase() # dummy disclosure for inlineformset draft_formset = DraftFormset(instance=disclosure) - return render(request, "ipr/details_edit.html", { - 'form': form, - 'draft_formset':draft_formset, - 'type':type, - }) + return render( + request, + "ipr/details_edit.html", + { + "form": form, + "draft_formset": draft_formset, + "type": _type, + }, + ) + @role_required('Secretariat',) def notify(request, id, type): diff --git a/ietf/static/js/ipr-edit.js b/ietf/static/js/ipr-edit.js index 9af5b0359..9d0750379 100644 --- a/ietf/static/js/ipr-edit.js +++ b/ietf/static/js/ipr-edit.js @@ -69,4 +69,69 @@ $(document) form.find(".draft-row") .each(updateRevisions); }, 10); - }); \ No newline at end of file + + // Manage fields that depend on the Blanket IPR Disclosure choice + const blanketCheckbox = document.getElementById('id_is_blanket_disclosure') + if (blanketCheckbox) { + const patentDetailInputs = [ + // The ids are from the HolderIprDisclosureForm and its base form class, + // intentionally excluding patent_notes because it's never required + 'id_patent_number', + 'id_patent_inventor', + 'id_patent_title', + 'id_patent_date' + ].map((id) => document.getElementById(id)) + const patentDetailRowDivs = patentDetailInputs.map( + (elt) => elt.closest('div.row') + ) + const royaltyFreeLicensingRadio = document.querySelector( + '#id_licensing input[value="royalty-free"]' + ) + let lastSelectedLicensingRadio + const otherLicensingRadios = document.querySelectorAll( + '#id_licensing input:not([value="royalty-free"])' + ) + + const handleBlanketCheckboxChange = () => { + const isBlanket = blanketCheckbox.checked + // Update required fields + for (elt of patentDetailInputs) { + // disable the input element + elt.required = !isBlanket + } + for (elt of patentDetailRowDivs) { + // update the styling on the row that indicates required field + if (isBlanket) { + elt.classList.remove('required') + } else { + elt.classList.add('required') + } + } + // Update licensing selection + if (isBlanket) { + lastSelectedLicensingRadio = document.querySelector( + '#id_licensing input:checked' + ) + royaltyFreeLicensingRadio.checked = true + otherLicensingRadios + .forEach( + (elt) => elt.setAttribute('disabled', '') + ) + } else { + royaltyFreeLicensingRadio.checked = false + if (lastSelectedLicensingRadio) { + lastSelectedLicensingRadio.checked = true + } + otherLicensingRadios + .forEach( + (elt) => elt.removeAttribute('disabled') + ) + } + } + handleBlanketCheckboxChange() + blanketCheckbox.addEventListener( + 'change', + (evt) => handleBlanketCheckboxChange() + ) + } + }); diff --git a/ietf/templates/ipr/details_edit.html b/ietf/templates/ipr/details_edit.html index 7caf28f1a..1aadb5bb3 100644 --- a/ietf/templates/ipr/details_edit.html +++ b/ietf/templates/ipr/details_edit.html @@ -32,7 +32,7 @@ regarding an IETF document or contribution when the person letting the IETF know about the patent has no relationship with the patent owners. Click - here + here if you want to disclose information about patents or patent applications where you do have a relationship to the patent owners or patent applicants. @@ -121,12 +121,11 @@ {% endif %} {% if type != "generic" %}

{% cycle section %}. IETF document or other contribution to which this IPR disclosure relates

-

+

If an Internet-Draft or RFC includes multiple parts and it is not reasonably apparent which part of such Internet-Draft or RFC is alleged - to be covered by the patent information disclosed in Section - V(A) or V(B), please identify the sections of - the Internet-Draft or RFC that are alleged to be so + to be covered by the patent information disclosed in Section V, + please identify the sections of the Internet-Draft or RFC that are alleged to be so covered.

{{ draft_formset.management_form }} @@ -154,6 +153,13 @@ i.e., patents or patent applications required to be disclosed by Section 5 of RFC8179 {% if form.patent_number %} + {% if form.is_blanket_disclosure %} +

+ This IPR disclosure must either identify a specific patent or patents in sections V(A) and V(B) + below, or be made as a blanket IPR disclosure. +

+ {% bootstrap_field form.is_blanket_disclosure layout='horizontal' %} + {% endif %}

A. For granted patents or published pending patent applications, please provide the following information: