Skip to content

Commit 32057f3

Browse files
feat: allow blanket IPR disclosures (ietf-tools#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
1 parent 3507466 commit 32057f3

8 files changed

Lines changed: 275 additions & 69 deletions

File tree

ietf/ipr/forms.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,19 @@ def clean(self):
338338

339339
return cleaned_data
340340

341+
341342
class HolderIprDisclosureForm(IprDisclosureFormBase):
343+
is_blanket_disclosure = forms.BooleanField(
344+
label=mark_safe(
345+
'This is a blanket IPR disclosure '
346+
'(see Section 5.4.3 of <a href="https://www.ietf.org/rfc/rfc8179.txt">RFC 8179</a>)'
347+
),
348+
help_text="In satisfaction of its disclosure obligations, Patent Holder commits to license all of "
349+
"IPR (as defined in RFC 8179) that would have required disclosure under RFC 8179 on a "
350+
"royalty-free (and otherwise reasonable and non-discriminatory) basis. Patent Holder "
351+
"confirms that all other terms and conditions are described in this IPR disclosure.",
352+
required=False,
353+
)
342354
licensing = CustomModelChoiceField(IprLicenseTypeName.objects.all(),
343355
widget=forms.RadioSelect,empty_label=None)
344356

@@ -356,6 +368,15 @@ def __init__(self, *args, **kwargs):
356368
else:
357369
# entering new disclosure
358370
self.fields['licensing'].queryset = IprLicenseTypeName.objects.exclude(slug='none-selected')
371+
372+
if self.data.get("is_blanket_disclosure", False):
373+
# for a blanket disclosure, patent details are not required
374+
self.fields["patent_number"].required = False
375+
self.fields["patent_inventor"].required = False
376+
self.fields["patent_title"].required = False
377+
self.fields["patent_date"].required = False
378+
# n.b., self.fields["patent_notes"] is never required
379+
359380

360381
def clean(self):
361382
cleaned_data = super(HolderIprDisclosureForm, self).clean()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright The IETF Trust 2024, All Rights Reserved
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
dependencies = [
7+
("ipr", "0003_alter_iprdisclosurebase_docs"),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name="holderiprdisclosure",
13+
name="is_blanket_disclosure",
14+
field=models.BooleanField(default=False),
15+
),
16+
]

ietf/ipr/models.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44

55
from django.conf import settings
6+
from django.core.exceptions import ValidationError
67
from django.db import models
78
from django.urls import reverse
89
from django.utils import timezone
@@ -124,17 +125,30 @@ def is_thirdparty(self):
124125

125126

126127
class HolderIprDisclosure(IprDisclosureBase):
127-
ietfer_name = models.CharField(max_length=255, blank=True) # "Whose Personal Belief Triggered..."
128-
ietfer_contact_email = models.EmailField(blank=True)
129-
ietfer_contact_info = models.TextField(blank=True)
130-
patent_info = models.TextField()
131-
has_patent_pending = models.BooleanField(default=False)
132-
holder_contact_email = models.EmailField()
133-
holder_contact_name = models.CharField(max_length=255)
134-
holder_contact_info = models.TextField(blank=True, help_text="Address, phone, etc.")
135-
licensing = ForeignKey(IprLicenseTypeName)
136-
licensing_comments = models.TextField(blank=True)
128+
ietfer_name = models.CharField(
129+
max_length=255, blank=True
130+
) # "Whose Personal Belief Triggered..."
131+
ietfer_contact_email = models.EmailField(blank=True)
132+
ietfer_contact_info = models.TextField(blank=True)
133+
patent_info = models.TextField()
134+
has_patent_pending = models.BooleanField(default=False)
135+
holder_contact_email = models.EmailField()
136+
holder_contact_name = models.CharField(max_length=255)
137+
holder_contact_info = models.TextField(blank=True, help_text="Address, phone, etc.")
138+
licensing = ForeignKey(IprLicenseTypeName)
139+
licensing_comments = models.TextField(blank=True)
137140
submitter_claims_all_terms_disclosed = models.BooleanField(default=False)
141+
is_blanket_disclosure = models.BooleanField(default=False)
142+
143+
def clean(self):
144+
if self.is_blanket_disclosure:
145+
# If the IprLicenseTypeName does not exist, we have a serious problem and a 500 response is ok,
146+
# so not handling failure of the `get()`
147+
royalty_free_licensing = IprLicenseTypeName.objects.get(slug="royalty-free")
148+
if self.licensing_id != royalty_free_licensing.pk:
149+
raise ValidationError(
150+
f'Must select "{royalty_free_licensing.desc}" for a blanket IPR disclosure.')
151+
138152

139153
class ThirdPartyIprDisclosure(IprDisclosureBase):
140154
ietfer_name = models.CharField(max_length=255) # "Whose Personal Belief Triggered..."

ietf/ipr/tests.py

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
IprDocRelFactory,
3434
IprEventFactory
3535
)
36-
from ietf.ipr.forms import DraftForm
36+
from ietf.ipr.forms import DraftForm, HolderIprDisclosureForm
3737
from ietf.ipr.mail import (process_response_email, get_reply_to, get_update_submitter_emails,
3838
get_pseudo_submitter, get_holders, get_update_cc_addrs)
3939
from ietf.ipr.models import (IprDisclosureBase,GenericIprDisclosure,HolderIprDisclosure,
@@ -272,16 +272,16 @@ def test_sitemap(self):
272272

273273
def test_new_generic(self):
274274
"""Ensure new-generic redirects to new-general"""
275-
url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "generic" })
275+
url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "generic" })
276276
r = self.client.get(url)
277277
self.assertEqual(r.status_code,302)
278-
self.assertEqual(urlparse(r["Location"]).path, urlreverse("ietf.ipr.views.new", kwargs={ "type": "general"}))
278+
self.assertEqual(urlparse(r["Location"]).path, urlreverse("ietf.ipr.views.new", kwargs={ "_type": "general"}))
279279

280280

281281
def test_new_general(self):
282282
"""Add a new general disclosure. Note: submitter does not need to be logged in.
283283
"""
284-
url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "general" })
284+
url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "general" })
285285

286286
# invalid post
287287
r = self.client.post(url, {
@@ -319,7 +319,7 @@ def test_new_specific(self):
319319
"""
320320
draft = WgDraftFactory()
321321
rfc = WgRfcFactory()
322-
url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" })
322+
url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" })
323323

324324
# successful post
325325
empty_outbox()
@@ -375,7 +375,7 @@ def test_new_specific(self):
375375
def test_new_specific_no_revision(self):
376376
draft = WgDraftFactory()
377377
rfc = WgRfcFactory()
378-
url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" })
378+
url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" })
379379

380380
# successful post
381381
empty_outbox()
@@ -409,7 +409,7 @@ def test_new_thirdparty(self):
409409
"""
410410
draft = WgDraftFactory()
411411
rfc = WgRfcFactory()
412-
url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "third-party" })
412+
url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "third-party" })
413413

414414
# successful post
415415
empty_outbox()
@@ -456,7 +456,7 @@ def test_edit(self):
456456
r = self.client.get(url)
457457
self.assertContains(r, original_ipr.holder_legal_name)
458458

459-
#url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" })
459+
#url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" })
460460
# successful post
461461
empty_outbox()
462462
post_data = {
@@ -503,7 +503,7 @@ def test_update(self):
503503
r = self.client.get(url)
504504
self.assertContains(r, original_ipr.title)
505505

506-
#url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" })
506+
#url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" })
507507
# successful post
508508
empty_outbox()
509509
r = self.client.post(url, {
@@ -543,7 +543,7 @@ def test_update(self):
543543

544544
def test_update_bad_post(self):
545545
draft = WgDraftFactory()
546-
url = urlreverse("ietf.ipr.views.new", kwargs={ "type": "specific" })
546+
url = urlreverse("ietf.ipr.views.new", kwargs={ "_type": "specific" })
547547

548548
empty_outbox()
549549
r = self.client.post(url, {
@@ -1022,3 +1022,61 @@ def test_revisions_invalid(self):
10221022
"revisions",
10231023
null_char_error_msg,
10241024
)
1025+
1026+
1027+
class HolderIprDisclosureFormTests(TestCase):
1028+
def setUp(self):
1029+
super().setUp()
1030+
# Checkboxes that are False are left out of the Form data, not sent back at all. These are
1031+
# commented out - if they were checked, their value would be "on".
1032+
self.data = {
1033+
"holder_legal_name": "Test Legal",
1034+
"holder_contact_name": "Test Holder",
1035+
"holder_contact_email": "test@holder.com",
1036+
"holder_contact_info": "555-555-0100",
1037+
"ietfer_name": "Test Participant",
1038+
"ietfer_contact_info": "555-555-0101",
1039+
"iprdocrel_set-TOTAL_FORMS": 2,
1040+
"iprdocrel_set-INITIAL_FORMS": 0,
1041+
"iprdocrel_set-0-document": "1234", # fake id - validates but won't save()
1042+
"iprdocrel_set-0-revisions": '00',
1043+
"iprdocrel_set-1-document": "4567", # fake id - validates but won't save()
1044+
# "is_blanket_disclosure": "on",
1045+
"patent_number": "SE12345678901",
1046+
"patent_inventor": "A. Nonymous",
1047+
"patent_title": "A method of transferring bits",
1048+
"patent_date": "2000-01-01",
1049+
# "has_patent_pending": "on",
1050+
"licensing": "reasonable",
1051+
"submitter_name": "Test Holder",
1052+
"submitter_email": "test@holder.com",
1053+
}
1054+
1055+
def test_blanket_disclosure_licensing_restrictions(self):
1056+
"""when is_blanket_disclosure is True only royalty-free licensing is valid
1057+
1058+
Most of the form functionality is tested via the views in IprTests above. More thorough testing
1059+
of validation ought to move here so we don't have to exercise the whole Django plumbing repeatedly.
1060+
"""
1061+
self.assertTrue(HolderIprDisclosureForm(data=self.data).is_valid())
1062+
self.data["is_blanket_disclosure"] = "on"
1063+
self.assertFalse(HolderIprDisclosureForm(data=self.data).is_valid())
1064+
self.data["licensing"] = "royalty-free"
1065+
self.assertTrue(HolderIprDisclosureForm(data=self.data).is_valid())
1066+
1067+
def test_patent_details_required_unless_blanket(self):
1068+
self.assertTrue(HolderIprDisclosureForm(data=self.data).is_valid())
1069+
patent_fields = ["patent_number", "patent_inventor", "patent_title", "patent_date"]
1070+
# any of the fields being missing should invalidate the form
1071+
for pf in patent_fields:
1072+
val = self.data.pop(pf)
1073+
self.assertFalse(HolderIprDisclosureForm(data=self.data).is_valid())
1074+
self.data[pf] = val
1075+
1076+
# should be optional if is_blanket_disclosure is True
1077+
self.data["is_blanket_disclosure"] = "on"
1078+
self.data["licensing"] = "royalty-free" # also needed for a blanket disclosure
1079+
for pf in patent_fields:
1080+
val = self.data.pop(pf)
1081+
self.assertTrue(HolderIprDisclosureForm(data=self.data).is_valid())
1082+
self.data[pf] = val

ietf/ipr/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@
2525
url(r'^(?P<id>\d+)/state/$', views.state),
2626
url(r'^update/$', RedirectView.as_view(url=reverse_lazy('ietf.ipr.views.showlist'), permanent=True)),
2727
url(r'^update/(?P<id>\d+)/$', views.update),
28-
url(r'^new-(?P<type>(specific|generic|general|third-party))/$', views.new),
28+
url(r'^new-(?P<_type>(specific|generic|general|third-party))/$', views.new),
2929
url(r'^search/$', views.search),
3030
]

0 commit comments

Comments
 (0)