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
This commit is contained in:
Jennifer Richards 2024-09-18 18:37:02 -03:00 committed by GitHub
parent 35074660dc
commit 32057f335a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 275 additions and 69 deletions

View file

@ -338,7 +338,19 @@ class IprDisclosureFormBase(forms.ModelForm):
return cleaned_data return cleaned_data
class HolderIprDisclosureForm(IprDisclosureFormBase): class HolderIprDisclosureForm(IprDisclosureFormBase):
is_blanket_disclosure = forms.BooleanField(
label=mark_safe(
'This is a blanket IPR disclosure '
'(see Section 5.4.3 of <a href="https://www.ietf.org/rfc/rfc8179.txt">RFC 8179</a>)'
),
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(), licensing = CustomModelChoiceField(IprLicenseTypeName.objects.all(),
widget=forms.RadioSelect,empty_label=None) widget=forms.RadioSelect,empty_label=None)
@ -356,6 +368,15 @@ class HolderIprDisclosureForm(IprDisclosureFormBase):
else: else:
# entering new disclosure # entering new disclosure
self.fields['licensing'].queryset = IprLicenseTypeName.objects.exclude(slug='none-selected') 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): def clean(self):
cleaned_data = super(HolderIprDisclosureForm, self).clean() cleaned_data = super(HolderIprDisclosureForm, self).clean()

View file

@ -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),
),
]

View file

@ -3,6 +3,7 @@
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -124,17 +125,30 @@ class IprDisclosureBase(models.Model):
class HolderIprDisclosure(IprDisclosureBase): class HolderIprDisclosure(IprDisclosureBase):
ietfer_name = models.CharField(max_length=255, blank=True) # "Whose Personal Belief Triggered..." ietfer_name = models.CharField(
ietfer_contact_email = models.EmailField(blank=True) max_length=255, blank=True
ietfer_contact_info = models.TextField(blank=True) ) # "Whose Personal Belief Triggered..."
patent_info = models.TextField() ietfer_contact_email = models.EmailField(blank=True)
has_patent_pending = models.BooleanField(default=False) ietfer_contact_info = models.TextField(blank=True)
holder_contact_email = models.EmailField() patent_info = models.TextField()
holder_contact_name = models.CharField(max_length=255) has_patent_pending = models.BooleanField(default=False)
holder_contact_info = models.TextField(blank=True, help_text="Address, phone, etc.") holder_contact_email = models.EmailField()
licensing = ForeignKey(IprLicenseTypeName) holder_contact_name = models.CharField(max_length=255)
licensing_comments = models.TextField(blank=True) 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) 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): class ThirdPartyIprDisclosure(IprDisclosureBase):
ietfer_name = models.CharField(max_length=255) # "Whose Personal Belief Triggered..." ietfer_name = models.CharField(max_length=255) # "Whose Personal Belief Triggered..."

View file

@ -33,7 +33,7 @@ from ietf.ipr.factories import (
IprDocRelFactory, IprDocRelFactory,
IprEventFactory 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, from ietf.ipr.mail import (process_response_email, get_reply_to, get_update_submitter_emails,
get_pseudo_submitter, get_holders, get_update_cc_addrs) get_pseudo_submitter, get_holders, get_update_cc_addrs)
from ietf.ipr.models import (IprDisclosureBase,GenericIprDisclosure,HolderIprDisclosure, from ietf.ipr.models import (IprDisclosureBase,GenericIprDisclosure,HolderIprDisclosure,
@ -272,16 +272,16 @@ class IprTests(TestCase):
def test_new_generic(self): def test_new_generic(self):
"""Ensure new-generic redirects to new-general""" """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) r = self.client.get(url)
self.assertEqual(r.status_code,302) 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): def test_new_general(self):
"""Add a new general disclosure. Note: submitter does not need to be logged in. """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 # invalid post
r = self.client.post(url, { r = self.client.post(url, {
@ -319,7 +319,7 @@ class IprTests(TestCase):
""" """
draft = WgDraftFactory() draft = WgDraftFactory()
rfc = WgRfcFactory() rfc = WgRfcFactory()
url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" }) url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" })
# successful post # successful post
empty_outbox() empty_outbox()
@ -375,7 +375,7 @@ class IprTests(TestCase):
def test_new_specific_no_revision(self): def test_new_specific_no_revision(self):
draft = WgDraftFactory() draft = WgDraftFactory()
rfc = WgRfcFactory() rfc = WgRfcFactory()
url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" }) url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" })
# successful post # successful post
empty_outbox() empty_outbox()
@ -409,7 +409,7 @@ class IprTests(TestCase):
""" """
draft = WgDraftFactory() draft = WgDraftFactory()
rfc = WgRfcFactory() rfc = WgRfcFactory()
url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "third-party" }) url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "third-party" })
# successful post # successful post
empty_outbox() empty_outbox()
@ -456,7 +456,7 @@ class IprTests(TestCase):
r = self.client.get(url) r = self.client.get(url)
self.assertContains(r, original_ipr.holder_legal_name) 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 # successful post
empty_outbox() empty_outbox()
post_data = { post_data = {
@ -503,7 +503,7 @@ class IprTests(TestCase):
r = self.client.get(url) r = self.client.get(url)
self.assertContains(r, original_ipr.title) 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 # successful post
empty_outbox() empty_outbox()
r = self.client.post(url, { r = self.client.post(url, {
@ -543,7 +543,7 @@ class IprTests(TestCase):
def test_update_bad_post(self): def test_update_bad_post(self):
draft = WgDraftFactory() draft = WgDraftFactory()
url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" }) url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" })
empty_outbox() empty_outbox()
r = self.client.post(url, { r = self.client.post(url, {
@ -1022,3 +1022,61 @@ class DraftFormTests(TestCase):
"revisions", "revisions",
null_char_error_msg, 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

View file

@ -25,6 +25,6 @@ urlpatterns = [
url(r'^(?P<id>\d+)/state/$', views.state), url(r'^(?P<id>\d+)/state/$', views.state),
url(r'^update/$', RedirectView.as_view(url=reverse_lazy('ietf.ipr.views.showlist'), permanent=True)), url(r'^update/$', RedirectView.as_view(url=reverse_lazy('ietf.ipr.views.showlist'), permanent=True)),
url(r'^update/(?P<id>\d+)/$', views.update), url(r'^update/(?P<id>\d+)/$', views.update),
url(r'^new-(?P<type>(specific|generic|general|third-party))/$', views.new), url(r'^new-(?P<_type>(specific|generic|general|third-party))/$', views.new),
url(r'^search/$', views.search), url(r'^search/$', views.search),
] ]

View file

@ -475,28 +475,34 @@ def by_draft_recursive_txt(request):
return HttpResponse(content, content_type="text/plain; charset=%s"%settings.DEFAULT_CHARSET) 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 """Submit a new IPR Disclosure. If the updates field != None, this disclosure
updates one or more other disclosures.""" updates one or more other disclosures."""
# Note that URL patterns won't ever send updates - updates is only non-null when called from code # 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. # 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. # 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 pass
elif type == 'generic': elif _type == "generic":
return HttpResponseRedirect(urlreverse('ietf.ipr.views.new',kwargs=dict(type='general'))) return HttpResponseRedirect(
elif type == 'general': urlreverse("ietf.ipr.views.new", kwargs=dict(_type="general"))
type = 'generic' )
elif _type == "general":
_type = "generic"
else: else:
pass pass
# 1 to show initially + the template # 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': if request.method == "POST":
form = ipr_form_mapping[type](request.POST) form = ipr_form_mapping[_type](request.POST)
if type != 'generic': if _type != "generic":
draft_formset = DraftFormset(request.POST, instance=IprDisclosureBase()) draft_formset = DraftFormset(request.POST, instance=IprDisclosureBase())
else: else:
draft_formset = None draft_formset = None
@ -505,72 +511,92 @@ def new(request, type, updates=None):
person = Person.objects.get(name="(System)") person = Person.objects.get(name="(System)")
else: else:
person = request.user.person person = request.user.person
# check formset validity # check formset validity
if type != 'generic': if _type != "generic":
valid_formsets = draft_formset.is_valid() valid_formsets = draft_formset.is_valid()
else: else:
valid_formsets = True valid_formsets = True
if form.is_valid() and valid_formsets: if form.is_valid() and valid_formsets:
if 'updates' in form.cleaned_data: if "updates" in form.cleaned_data:
updates = form.cleaned_data['updates'] updates = form.cleaned_data["updates"]
del form.cleaned_data['updates'] del form.cleaned_data["updates"]
disclosure = form.save(commit=False) disclosure = form.save(commit=False)
disclosure.by = person disclosure.by = person
disclosure.state = IprDisclosureStateName.objects.get(slug='pending') disclosure.state = IprDisclosureStateName.objects.get(slug="pending")
disclosure.save() disclosure.save()
if type != 'generic': if _type != "generic":
draft_formset = DraftFormset(request.POST, instance=disclosure) draft_formset = DraftFormset(request.POST, instance=disclosure)
draft_formset.save() draft_formset.save()
set_disclosure_title(disclosure) set_disclosure_title(disclosure)
disclosure.save() disclosure.save()
if updates: if updates:
for ipr in 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 # create IprEvent
IprEvent.objects.create( IprEvent.objects.create(
type_id='submitted', type_id="submitted",
by=person, by=person,
disclosure=disclosure, disclosure=disclosure,
desc="Disclosure Submitted") desc="Disclosure Submitted",
)
# send email notification # send email notification
(to, cc) = gather_address_lists('ipr_disclosure_submitted') (to, cc) = gather_address_lists("ipr_disclosure_submitted")
send_mail(request, to, ('IPR Submitter App', 'ietf-ipr@ietf.org'), send_mail(
'New IPR Submission Notification', request,
to,
("IPR Submitter App", "ietf-ipr@ietf.org"),
"New IPR Submission Notification",
"ipr/new_update_email.txt", "ipr/new_update_email.txt",
{"ipr": disclosure,}, {
cc=cc) "ipr": disclosure,
},
cc=cc,
)
return render(request, "ipr/submitted.html") return render(request, "ipr/submitted.html")
else: else:
if updates: if updates:
original = IprDisclosureBase(id=updates).get_child() original = IprDisclosureBase(id=updates).get_child()
initial = model_to_dict(original) initial = model_to_dict(original)
initial.update({'updates':str(updates), }) initial.update(
patent_info = text_to_dict(initial.get('patent_info', '')) {
"updates": str(updates),
}
)
patent_info = text_to_dict(initial.get("patent_info", ""))
if list(patent_info.keys()): 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: else:
patent_dict = {'patent_notes': initial.get('patent_info', '')} patent_dict = {"patent_notes": initial.get("patent_info", "")}
initial.update(patent_dict) initial.update(patent_dict)
form = ipr_form_mapping[type](initial=initial) form = ipr_form_mapping[_type](initial=initial)
else: else:
form = ipr_form_mapping[type]() form = ipr_form_mapping[_type]()
disclosure = IprDisclosureBase() # dummy disclosure for inlineformset disclosure = IprDisclosureBase() # dummy disclosure for inlineformset
draft_formset = DraftFormset(instance=disclosure) draft_formset = DraftFormset(instance=disclosure)
return render(request, "ipr/details_edit.html", { return render(
'form': form, request,
'draft_formset':draft_formset, "ipr/details_edit.html",
'type':type, {
}) "form": form,
"draft_formset": draft_formset,
"type": _type,
},
)
@role_required('Secretariat',) @role_required('Secretariat',)
def notify(request, id, type): def notify(request, id, type):

View file

@ -69,4 +69,69 @@ $(document)
form.find(".draft-row") form.find(".draft-row")
.each(updateRevisions); .each(updateRevisions);
}, 10); }, 10);
});
// 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()
)
}
});

View file

@ -32,7 +32,7 @@
regarding an IETF document or contribution when the person letting the regarding an IETF document or contribution when the person letting the
IETF know about the patent has no relationship with the patent owners. IETF know about the patent has no relationship with the patent owners.
Click Click
<a href="{% url 'ietf.ipr.views.new' type='specific' %}">here</a> <a href="{% url 'ietf.ipr.views.new' 'specific' %}">here</a>
if you want to disclose information about patents or patent if you want to disclose information about patents or patent
applications where you do have a relationship to the patent owners or applications where you do have a relationship to the patent owners or
patent applicants. patent applicants.
@ -121,12 +121,11 @@
{% endif %} {% endif %}
{% if type != "generic" %} {% if type != "generic" %}
<h2 class="mt-4">{% cycle section %}. IETF document or other contribution to which this IPR disclosure relates</h2> <h2 class="mt-4">{% cycle section %}. IETF document or other contribution to which this IPR disclosure relates</h2>
<p class="form-text"> <p>
If an Internet-Draft or RFC includes multiple parts and it is not 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 reasonably apparent which part of such Internet-Draft or RFC is alleged
to be covered by the patent information disclosed in Section to be covered by the patent information disclosed in Section V,
V(A) or V(B), please identify the sections of please identify the sections of the Internet-Draft or RFC that are alleged to be so
the Internet-Draft or RFC that are alleged to be so
covered. covered.
</p> </p>
{{ draft_formset.management_form }} {{ draft_formset.management_form }}
@ -154,6 +153,13 @@
<small>i.e., patents or patent applications required to be disclosed by Section 5 of RFC8179</small> <small>i.e., patents or patent applications required to be disclosed by Section 5 of RFC8179</small>
</h2> </h2>
{% if form.patent_number %} {% if form.patent_number %}
{% if form.is_blanket_disclosure %}
<p>
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.
</p>
{% bootstrap_field form.is_blanket_disclosure layout='horizontal' %}
{% endif %}
<p> <p>
A. For granted patents or published pending patent applications, A. For granted patents or published pending patent applications,
please provide the following information: please provide the following information: