diff --git a/ietf/ipr/forms.py b/ietf/ipr/forms.py index 62d3f9c216..dac34bddf6 100644 --- a/ietf/ipr/forms.py +++ b/ietf/ipr/forms.py @@ -338,7 +338,19 @@ def clean(self): 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 @@ def __init__(self, *args, **kwargs): 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 0000000000..66282b3cd5 --- /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 693f19abe9..2d81eb4b42 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 @@ def is_thirdparty(self): 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 b08e359462..3c70567fd8 100644 --- a/ietf/ipr/tests.py +++ b/ietf/ipr/tests.py @@ -33,7 +33,7 @@ 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 @@ def test_sitemap(self): 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 @@ def test_new_specific(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() @@ -375,7 +375,7 @@ def test_new_specific(self): 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 @@ def test_new_thirdparty(self): """ 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 @@ def test_edit(self): 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 @@ def test_update(self): 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 @@ def test_update(self): 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 @@ def test_revisions_invalid(self): "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 6f7b2d4080..2b6abee31c 100644 --- a/ietf/ipr/urls.py +++ b/ietf/ipr/urls.py @@ -25,6 +25,6 @@ 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 45fad9a2cc..0347c4d78d 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 9af5b03591..9d07503791 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 7caf28f1a7..1aadb5bb3d 100644 --- a/ietf/templates/ipr/details_edit.html +++ b/ietf/templates/ipr/details_edit.html @@ -32,7 +32,7 @@

The Patent Disclosure and Licensing Declaration Template for Third Party IPR 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: