migrated forward

- Legacy-Id: 18144
This commit is contained in:
Robert Sparks 2020-07-09 21:07:05 +00:00
commit 53f7bc3ce6
53 changed files with 15982 additions and 15546 deletions

View file

@ -11,8 +11,9 @@ from .models import (StateType, State, RelatedDocument, DocumentAuthor, Document
StateDocEvent, ConsensusDocEvent, BallotType, BallotDocEvent, WriteupDocEvent, LastCallDocEvent,
TelechatDocEvent, BallotPositionDocEvent, ReviewRequestDocEvent, InitialReviewDocEvent,
AddedMessageEvent, SubmissionDocEvent, DeletedEvent, EditedAuthorsDocEvent, DocumentURL,
ReviewAssignmentDocEvent, IanaExpertDocEvent, IRSGBallotDocEvent )
ReviewAssignmentDocEvent, IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource )
from ietf.utils.validators import validate_external_resource_value
class StateTypeAdmin(admin.ModelAdmin):
list_display = ["slug", "label"]
@ -183,3 +184,14 @@ class DocumentUrlAdmin(admin.ModelAdmin):
search_fields = ['doc__name', 'url', ]
raw_id_fields = ['doc', ]
admin.site.register(DocumentURL, DocumentUrlAdmin)
class DocExtResourceAdminForm(forms.ModelForm):
def clean(self):
validate_external_resource_value(self.cleaned_data['name'],self.cleaned_data['value'])
class DocExtResourceAdmin(admin.ModelAdmin):
form = DocExtResourceAdminForm
list_display = ['id', 'doc', 'name', 'display_name', 'value',]
search_fields = ['doc__name', 'value', 'display_name', 'name__slug',]
raw_id_fields = ['doc', ]
admin.site.register(DocExtResource, DocExtResourceAdmin)

View file

@ -0,0 +1,30 @@
# Copyright The IETF Trust 2020, All Rights Reserved
import json
from django.core.management.base import BaseCommand
from ietf.extresource.models import ExtResource
from ietf.doc.models import DocExtResource
from ietf.group.models import GroupExtResource
from ietf.person.models import PersonExtResource
class Command(BaseCommand):
help = ('Locate information about gihub repositories to backup')
def handle(self, *args, **options):
info_dict = {}
for repo in ExtResource.objects.filter(name__slug='github_repo'):
if repo not in info_dict:
info_dict[repo.value] = []
for username in DocExtResource.objects.filter(extresource__name__slug='github_username', doc__name__in=repo.docextresource_set.values_list('doc__name',flat=True).distinct()):
info_dict[repo.value].push(username.value)
for username in GroupExtResource.objects.filter(extresource__name__slug='github_username', group__acronym__in=repo.groupextresource_set.values_list('group__acronym',flat=True).distinct()):
info_dict[repo.value].push(username.value)
for username in PersonExtResource.objects.filter(extresource__name__slug='github_username', person_id__in=repo.personextresource_set.values_list('person__id',flat=True).distinct()):
info_dict[repo.value].push(username.value)
print (json.dumps(info_dict))

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-04-15 10:20
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import ietf.utils.models
class Migration(migrations.Migration):
dependencies = [
('name', '0013_extres'),
('doc', '0032_auto_20200624_1332'),
]
operations = [
migrations.CreateModel(
name='DocExtResource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('display_name', models.CharField(blank=True, default='', max_length=255)),
('value', models.CharField(max_length=2083)),
('doc', ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='doc.Document')),
('name', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='name.ExtResourceName')),
],
),
]

View file

@ -0,0 +1,118 @@
# Copyright The IETF Trust 2020, All Rights Reserved
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-03-19 13:06
from __future__ import unicode_literals
import re
import debug
from collections import OrderedDict, Counter
from django.db import migrations
from ietf.utils.validators import validate_external_resource_value
from django.core.exceptions import ValidationError
name_map = {
"Issue.*": "tracker",
".*FAQ.*": "faq",
".*Area Web Page": "webpage",
".*Wiki": "wiki",
"Home Page": "webpage",
"Slack.*": "slack",
"Additional .* Web Page": "webpage",
"Additional .* Page": "webpage",
"Yang catalog entry.*": "yc_entry",
"Yang impact analysis.*": "yc_impact",
"GitHub": "github_repo",
"Github page": "github_repo",
"GitHub repo.*": "github_repo",
"Github repository.*": "github_repo",
"GitHub notifications": "github_notify",
"GitHub org.*": "github_org",
"GitHub User.*": "github_username",
"GitLab User": "gitlab_username",
"GitLab User Name": "gitlab_username",
}
# TODO: Review all the None values below and make sure ignoring the URLs they match is really the right thing to do.
url_map = OrderedDict({
"https?://github\\.com": "github_repo",
"https://git.sr.ht/": "repo",
"https://todo.sr.ht/": "tracker",
"https?://trac\\.ietf\\.org/.*/wiki": "wiki",
"ietf\\.org.*/trac/wiki": "wiki",
"trac.*wiki": "wiki",
"www\\.ietf\\.org/mailman" : None,
"www\\.ietf\\.org/mail-archive" : None,
"mailarchive\\.ietf\\.org" : None,
"ietf\\.org/logs": "jabber_log",
"ietf\\.org/jabber/logs": "jabber_log",
"xmpp:.*?join": "jabber_room",
"bell-labs\\.com": None,
"html\\.charters": None,
"datatracker\\.ietf\\.org": None,
})
def forward(apps, schema_editor):
DocExtResource = apps.get_model('doc', 'DocExtResource')
ExtResourceName = apps.get_model('name', 'ExtResourceName')
DocumentUrl = apps.get_model('doc', 'DocumentUrl')
stats = Counter()
for doc_url in DocumentUrl.objects.all():
match_found = False
for regext,slug in name_map.items():
if re.match(regext, doc_url.desc):
match_found = True
stats['mapped'] += 1
name = ExtResourceName.objects.get(slug=slug)
DocExtResource.objects.create(doc=doc_url.doc, name_id=slug, value=doc_url.url, display_name=doc_url.desc) # TODO: validate this value against name.type
break
if not match_found:
for regext, slug in url_map.items():
doc_url.url = doc_url.url.strip()
if re.search(regext, doc_url.url):
match_found = True
if slug:
stats['mapped'] +=1
name = ExtResourceName.objects.get(slug=slug)
# Munge the URL if it's the first github repo match
# Remove "/tree/master" substring if it exists
# Remove trailing "/issues" substring if it exists
# Remove "/blob/master/.*" pattern if present
if regext == "https?://github\\.com":
doc_url.url = doc_url.url.replace("/tree/master","")
doc_url.url = re.sub('/issues$', '', doc_url.url)
doc_url.url = re.sub('/blob/master.*$', '', doc_url.url)
try:
validate_external_resource_value(name, doc_url.url)
DocExtResource.objects.create(doc=doc_url.doc, name=name, value=doc_url.url, display_name=doc_url.desc) # TODO: validate this value against name.type
except ValidationError as e: # pyflakes:ignore
debug.show('("Failed validation:", doc_url.url, e)')
stats['failed_validation'] +=1
else:
stats['ignored'] +=1
break
if not match_found:
debug.show('("Not Mapped:",doc_url.desc, doc_url.tag.slug, doc_url.doc.name, doc_url.url)')
stats['not_mapped'] += 1
print (stats)
def reverse(apps, schema_editor):
DocExtResource = apps.get_model('doc', 'DocExtResource')
DocExtResource.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('doc', '0033_extres'),
('name', '0014_populate_extres'),
]
operations = [
migrations.RunPython(forward, reverse)
]

View file

@ -24,7 +24,7 @@ import debug # pyflakes:ignore
from ietf.group.models import Group
from ietf.name.models import ( DocTypeName, DocTagName, StreamName, IntendedStdLevelName, StdLevelName,
DocRelationshipName, DocReminderTypeName, BallotPositionName, ReviewRequestStateName, ReviewAssignmentStateName, FormalLanguageName,
DocUrlTagName)
DocUrlTagName, ExtResourceName)
from ietf.person.models import Email, Person
from ietf.person.utils import get_active_balloters
from ietf.utils import log
@ -105,6 +105,7 @@ class DocumentInfo(models.Model):
note = models.TextField(blank=True)
internal_comments = models.TextField(blank=True)
def file_extension(self):
if not hasattr(self, '_cached_extension'):
if self.uploaded_filename:
@ -861,6 +862,15 @@ class DocumentURL(models.Model):
desc = models.CharField(max_length=255, default='', blank=True)
url = models.URLField(max_length=2083) # 2083 is the legal max for URLs
class DocExtResource(models.Model):
doc = ForeignKey(Document) # Should this really be to DocumentInfo rather than Document?
name = models.ForeignKey(ExtResourceName, on_delete=models.CASCADE)
display_name = models.CharField(max_length=255, default='', blank=True)
value = models.CharField(max_length=2083) # 2083 is the maximum legal URL length
def __str__(self):
priority = self.display_name or self.name.name
return u"%s (%s) %s" % (priority, self.name.slug, self.value)
class RelatedDocHistory(models.Model):
source = ForeignKey('DocHistory')
target = ForeignKey('DocAlias', related_name="reversely_related_document_history_set")

View file

@ -17,7 +17,7 @@ from ietf.doc.models import (BallotType, DeletedEvent, StateType, State, Documen
InitialReviewDocEvent, DocHistoryAuthor, BallotDocEvent, RelatedDocument,
RelatedDocHistory, BallotPositionDocEvent, AddedMessageEvent, SubmissionDocEvent,
ReviewRequestDocEvent, ReviewAssignmentDocEvent, EditedAuthorsDocEvent, DocumentURL,
IanaExpertDocEvent, IRSGBallotDocEvent )
IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource )
from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource
class BallotTypeResource(ModelResource):
@ -767,3 +767,23 @@ class IRSGBallotDocEventResource(ModelResource):
"ballotdocevent_ptr": ALL_WITH_RELATIONS,
}
api.doc.register(IRSGBallotDocEventResource())
from ietf.name.resources import ExtResourceNameResource
class DocExtResourceResource(ModelResource):
doc = ToOneField(DocumentResource, 'doc')
name = ToOneField(ExtResourceNameResource, 'name')
class Meta:
queryset = DocExtResource.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
resource_name = 'docextresource'
ordering = ['id', ]
filtering = {
"id": ALL,
"display_name": ALL,
"value": ALL,
"doc": ALL_WITH_RELATIONS,
"name": ALL_WITH_RELATIONS,
}
api.doc.register(DocExtResourceResource())

View file

@ -1104,24 +1104,44 @@ class IndividualInfoFormsTests(TestCase):
q = PyQuery(r.content)
self.assertTrue(q('textarea')[0].text.strip().startswith("As required by RFC 4858"))
def test_doc_change_document_urls(self):
url = urlreverse('ietf.doc.views_draft.edit_document_urls', kwargs=dict(name=self.docname))
# get
def test_edit_doc_extresources(self):
url = urlreverse('ietf.doc.views_draft.edit_doc_extresources', kwargs=dict(name=self.docname))
login_testing_unauthorized(self, "secretary", url)
r = self.client.get(url)
self.assertEqual(r.status_code,200)
q = PyQuery(r.content)
self.assertEqual(len(q('form textarea[id=id_urls]')),1)
self.assertEqual(len(q('form textarea[id=id_resources]')),1)
# direct edit
r = self.client.post(url, dict(urls='wiki https://wiki.org/ Wiki\nrepository https://repository.org/ Repo\n', submit="1"))
badlines = (
'github_repo https://github3.com/some/repo',
'github_notify badaddr',
'website /not/a/good/url'
'notavalidtag blahblahblah'
)
for line in badlines:
r = self.client.post(url, dict(resources=line, submit="1"))
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertTrue(q('.alert-danger'))
goodlines = """
github_repo https://github.com/some/repo Some display text
github_notify notify@example.com
github_username githubuser
website http://example.com/http/is/fine
"""
r = self.client.post(url, dict(resources=goodlines, submit="1"))
self.assertEqual(r.status_code,302)
doc = Document.objects.get(name=self.docname)
self.assertTrue(doc.latest_event(DocEvent,type="changed_document").desc.startswith('Changed document URLs'))
self.assertIn('wiki https://wiki.org/', doc.latest_event(DocEvent,type="changed_document").desc)
self.assertIn('https://wiki.org/', [ u.url for u in doc.documenturl_set.all() ])
self.assertEqual(doc.latest_event(DocEvent,type="changed_document").desc[:35], 'Changed document external resources')
self.assertIn('github_username githubuser', doc.latest_event(DocEvent,type="changed_document").desc)
self.assertEqual(doc.docextresource_set.count(), 4)
self.assertEqual(doc.docextresource_set.get(name__slug='github_repo').display_name, 'Some display text')
class SubmitToIesgTests(TestCase):

View file

@ -128,7 +128,7 @@ urlpatterns = [
url(r'^%(name)s/edit/approveballot/$' % settings.URL_REGEXPS, views_ballot.approve_ballot),
url(r'^%(name)s/edit/approvedownrefs/$' % settings.URL_REGEXPS, views_ballot.approve_downrefs),
url(r'^%(name)s/edit/makelastcall/$' % settings.URL_REGEXPS, views_ballot.make_last_call),
url(r'^%(name)s/edit/urls/$' % settings.URL_REGEXPS, views_draft.edit_document_urls),
url(r'^%(name)s/edit/resources/$' % settings.URL_REGEXPS, views_draft.edit_doc_extresources),
url(r'^%(name)s/edit/issueballot/irsg/$' % settings.URL_REGEXPS, views_ballot.issue_irsg_ballot),
url(r'^%(name)s/edit/closeballot/irsg/$' % settings.URL_REGEXPS, views_ballot.close_irsg_ballot),

View file

@ -14,7 +14,6 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.core.validators import URLValidator
from django.db.models import Q
from django.http import HttpResponseRedirect, HttpResponseForbidden, Http404
from django.shortcuts import render, get_object_or_404, redirect
@ -43,11 +42,12 @@ from ietf.iesg.models import TelechatDate
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, user_is_person, is_individual_draft_author
from ietf.ietfauth.utils import role_required
from ietf.message.models import Message
from ietf.name.models import IntendedStdLevelName, DocTagName, StreamName, DocUrlTagName
from ietf.name.models import IntendedStdLevelName, DocTagName, StreamName, ExtResourceName
from ietf.person.fields import SearchableEmailField
from ietf.person.models import Person, Email
from ietf.utils.mail import send_mail, send_mail_message, on_behalf_of
from ietf.utils.textupload import get_cleaned_text_file_content
from ietf.utils.validators import validate_external_resource_value
from ietf.utils import log
from ietf.mailtrigger.utils import gather_address_lists
@ -1177,42 +1177,46 @@ def edit_consensus(request, name):
},
)
def edit_document_urls(request, name):
class DocumentUrlForm(forms.Form):
urls = forms.CharField(widget=forms.Textarea, label="Additional URLs", required=False,
help_text=("Format: 'tag https://site/path (Optional description)'."
" Separate multiple entries with newline. Prefer HTTPS URLs where possible.") )
def clean_urls(self):
lines = [x.strip() for x in self.cleaned_data["urls"].splitlines() if x.strip()]
url_validator = URLValidator()
def edit_doc_extresources(request, name):
class DocExtResourceForm(forms.Form):
resources = forms.CharField(widget=forms.Textarea, label="Additional Resources", required=False,
help_text=("Format: 'tag value (Optional description)'."
" Separate multiple entries with newline. When the value is a URL, use https:// where possible.") )
def clean_resources(self):
lines = [x.strip() for x in self.cleaned_data["resources"].splitlines() if x.strip()]
errors = []
for l in lines:
parts = l.split()
if len(parts) == 1:
errors.append("Too few fields: Expected at least url and tag: '%s'" % l)
errors.append("Too few fields: Expected at least tag and value: '%s'" % l)
elif len(parts) >= 2:
tag = parts[0]
url = parts[1]
name_slug = parts[0]
try:
url_validator(url)
except ValidationError as e:
errors.append(e)
try:
DocUrlTagName.objects.get(slug=tag)
name = ExtResourceName.objects.get(slug=name_slug)
except ObjectDoesNotExist:
errors.append("Bad tag in '%s': Expected one of %s" % (l, ', '.join([ o.slug for o in DocUrlTagName.objects.all() ])))
errors.append("Bad tag in '%s': Expected one of %s" % (l, ', '.join([ o.slug for o in ExtResourceName.objects.all() ])))
continue
value = parts[1]
try:
validate_external_resource_value(name, value)
except ValidationError as e:
e.message += " : " + value
errors.append(e)
if errors:
raise ValidationError(errors)
return lines
def format_urls(urls, fs="\n"):
def format_resources(resources, fs="\n"):
res = []
for u in urls:
if u.desc:
res.append("%s %s (%s)" % (u.tag.slug, u.url, u.desc.strip('()')))
for r in resources:
if r.display_name:
res.append("%s %s (%s)" % (r.name.slug, r.value, r.display_name.strip('()')))
else:
res.append("%s %s" % (u.tag.slug, u.url))
res.append("%s %s" % (r.name.slug, r.value))
# TODO: This is likely problematic if value has spaces. How then to delineate value and display_name? Perhaps in the short term move to comma or pipe separation.
# Might be better to shift to a formset instead of parsing these lines.
return fs.join(res)
doc = get_object_or_404(Document, name=name)
@ -1222,37 +1226,39 @@ def edit_document_urls(request, name):
or is_individual_draft_author(request.user, doc)):
return HttpResponseForbidden("You do not have the necessary permissions to view this page")
old_urls = format_urls(doc.documenturl_set.all())
old_resources = format_resources(doc.docextresource_set.all())
if request.method == 'POST':
form = DocumentUrlForm(request.POST)
form = DocExtResourceForm(request.POST)
if form.is_valid():
old_urls = sorted(old_urls.splitlines())
new_urls = sorted(form.cleaned_data['urls'])
if old_urls != new_urls:
doc.documenturl_set.all().delete()
for u in new_urls:
old_resources = sorted(old_resources.splitlines())
new_resources = sorted(form.cleaned_data['resources'])
if old_resources != new_resources:
doc.docextresource_set.all().delete()
for u in new_resources:
parts = u.split(None, 2)
tag = parts[0]
url = parts[1]
desc = ' '.join(parts[2:]).strip('()')
doc.documenturl_set.create(url=url, tag_id=tag, desc=desc)
new_urls = format_urls(doc.documenturl_set.all())
e = DocEvent(doc=doc, rev=doc.rev, by=request.user.person, type='changed_document')
e.desc = "Changed document URLs from:\n\n%s\n\nto:\n\n%s" % (old_urls, new_urls)
e.save()
doc.save_with_history([e])
messages.success(request,"Document URLs updated.")
name = parts[0]
value = parts[1]
display_name = ' '.join(parts[2:]).strip('()')
doc.docextresource_set.create(value=value, name_id=name, display_name=display_name)
new_resources = format_resources(doc.docextresource_set.all())
e = DocEvent(doc=doc, rev=doc.rev, by=request.user.person, type='changed_document')
e.desc = "Changed document external resources from:\n\n%s\n\nto:\n\n%s" % (old_resources, new_resources)
e.save()
doc.save_with_history([e])
messages.success(request,"Document resources updated.")
else:
messages.info(request,"No change in Document URLs.")
messages.info(request,"No change in Document resources.")
return redirect('ietf.doc.views_doc.document_main', name=doc.name)
else:
form = DocumentUrlForm(initial={'urls': old_urls, })
form = DocExtResourceForm(initial={'resources': old_resources, })
info = "Valid tags:<br><br> %s" % ', '.join([ o.slug for o in DocUrlTagName.objects.all() ])
title = "Additional document URLs"
info = "Valid tags:<br><br> %s" % ', '.join([ o.slug for o in ExtResourceName.objects.all().order_by('slug') ])
# May need to explain the tags more - probably more reason to move to a formset.
title = "Additional document resources"
return render(request, 'doc/edit_field.html',dict(doc=doc, form=form, title=title, info=info) )
def request_publication(request, name):
"""Request publication by RFC Editor for a document which hasn't
been through the IESG ballot process."""

View file

@ -18,7 +18,9 @@ from django.utils.translation import ugettext as _
from ietf.group.models import (Group, GroupFeatures, GroupHistory, GroupEvent, GroupURL, GroupMilestone,
GroupMilestoneHistory, GroupStateTransitions, Role, RoleHistory, ChangeStateGroupEvent,
MilestoneGroupEvent, )
MilestoneGroupEvent, GroupExtResource, )
from ietf.utils.validators import validate_external_resource_value
class RoleInline(admin.TabularInline):
model = Role
@ -203,3 +205,14 @@ class MilestoneGroupEventAdmin(admin.ModelAdmin):
list_filter = ['time']
raw_id_fields = ['group', 'by', 'milestone']
admin.site.register(MilestoneGroupEvent, MilestoneGroupEventAdmin)
class GroupExtResourceAdminForm(forms.ModelForm):
def clean(self):
validate_external_resource_value(self.cleaned_data['name'],self.cleaned_data['value'])
class GroupExtResourceAdmin(admin.ModelAdmin):
form = GroupExtResourceAdminForm
list_display = ['id', 'group', 'name', 'display_name', 'value',]
search_fields = ['group__acronym', 'value', 'display_name', 'name__slug',]
raw_id_fields = ['group', ]
admin.site.register(GroupExtResource, GroupExtResourceAdmin)

View file

@ -12,10 +12,11 @@ import debug # pyflakes:ignore
from django import forms
from django.utils.html import mark_safe # type:ignore
from django.db.models import F
from django.core.exceptions import ValidationError, ObjectDoesNotExist
# IETF imports
from ietf.group.models import Group, GroupHistory, GroupStateName, GroupFeatures
from ietf.name.models import ReviewTypeName, RoleName
from ietf.name.models import ReviewTypeName, RoleName, ExtResourceName
from ietf.person.fields import SearchableEmailsField, PersonEmailChoiceField
from ietf.person.models import Person, Email
from ietf.review.models import ReviewerSettings, UnavailablePeriod, ReviewSecretarySettings
@ -24,6 +25,7 @@ from ietf.review.utils import close_review_request_states
from ietf.utils.textupload import get_cleaned_text_file_content
#from ietf.utils.ordereddict import insert_after_in_ordered_dict
from ietf.utils.fields import DatepickerDateField, MultiEmailField
from ietf.utils.validators import validate_external_resource_value
# --- Constants --------------------------------------------------------
@ -65,6 +67,7 @@ class GroupForm(forms.Form):
list_subscribe = forms.CharField(max_length=255, required=False)
list_archive = forms.CharField(max_length=255, required=False)
urls = forms.CharField(widget=forms.Textarea, label="Additional URLs", help_text="Format: https://site/path (Optional description). Separate multiple entries with newline. Prefer HTTPS URLs where possible.", required=False)
resources = forms.CharField(widget=forms.Textarea, label="Additional Resources", help_text="UPDATEME: Format: https://site/path (Optional description). Separate multiple entries with newline. Prefer HTTPS URLs where possible.", required=False)
closing_note = forms.CharField(widget=forms.Textarea, label="Closing note", required=False)
def __init__(self, *args, **kwargs):
@ -129,6 +132,12 @@ class GroupForm(forms.Form):
for f in keys:
if f != field and not (f == 'closing_note' and field == 'state'):
del self.fields[f]
if 'resources' in self.fields:
info = "Format: 'tag value (Optional description)'. " \
+ "Separate multiple entries with newline. When the value is a URL, use https:// where possible.<br>" \
+ "Valid tags: %s" % ', '.join([ o.slug for o in ExtResourceName.objects.all().order_by('slug') ])
self.fields['resources'].help_text = mark_safe('<div>'+info+'</div>')
def clean_acronym(self):
# Changing the acronym of an already existing group will cause 404s all
@ -188,6 +197,30 @@ class GroupForm(forms.Form):
def clean_urls(self):
return [x.strip() for x in self.cleaned_data["urls"].splitlines() if x.strip()]
def clean_resources(self):
lines = [x.strip() for x in self.cleaned_data["resources"].splitlines() if x.strip()]
errors = []
for l in lines:
parts = l.split()
if len(parts) == 1:
errors.append("Too few fields: Expected at least tag and value: '%s'" % l)
elif len(parts) >= 2:
name_slug = parts[0]
try:
name = ExtResourceName.objects.get(slug=name_slug)
except ObjectDoesNotExist:
errors.append("Bad tag in '%s': Expected one of %s" % (l, ', '.join([ o.slug for o in ExtResourceName.objects.all() ])))
continue
value = parts[1]
try:
validate_external_resource_value(name, value)
except ValidationError as e:
e.message += " : " + value
errors.append(e)
if errors:
raise ValidationError(errors)
return lines
def clean_delegates(self):
if len(self.cleaned_data["delegates"]) > MAX_GROUP_DELEGATES:
raise forms.ValidationError("At most %s delegates can be appointed at the same time, please remove %s delegates." % (

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-04-15 10:20
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import ietf.utils.models
class Migration(migrations.Migration):
dependencies = [
('name', '0013_extres'),
('group', '0032_add_meeting_seen_as_area'),
]
operations = [
migrations.CreateModel(
name='GroupExtResource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('display_name', models.CharField(blank=True, default='', max_length=255)),
('value', models.CharField(max_length=2083)),
('group', ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='group.Group')),
('name', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='name.ExtResourceName')),
],
),
]

View file

@ -0,0 +1,124 @@
# Copyright The IETF Trust 2020, All Rights Reserved
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-03-19 13:06
from __future__ import unicode_literals
import re
import debug
from collections import OrderedDict, Counter
from django.db import migrations
from django.core.exceptions import ValidationError
from ietf.utils.validators import validate_external_resource_value
name_map = {
"Issue.*": "tracker",
".*FAQ.*": "faq",
".*Area Web Page": "webpage",
".*Wiki": "wiki",
"Home Page": "webpage",
"Slack.*": "slack",
"Additional .* Web Page": "webpage",
"Additional .* Page": "webpage",
"Yang catalog entry.*": "yc_entry",
"Yang impact analysis.*": "yc_impact",
"GitHub": "github_repo",
"Github page": "github_repo",
"GitHub repo.*": "github_repo",
"Github repository.*": "github_repo",
"GitHub notifications": "github_notify",
"GitHub org.*": "github_org",
"GitHub User.*": "github_username",
"GitLab User": "gitlab_username",
"GitLab User Name": "gitlab_username",
}
# TODO: Consider dropping known bad links at this point
# " *https?://www.ietf.org/html.charters/*": None, # all these links are dead
# " *http://www.bell-labs.com/mailing-lists/pint": None, # dead link
# "http://www.ietf.org/wg/videos/mile-overview.html": None, # dead link
# " http://domen.uninett.no/~hta/ietf/notary-status.h": None, # dead link
# " http://www.ERC.MsState.Edu/packetway": None, # dead link
# "mailarchive\\.ietf\\.org" : None,
# "bell-labs\\.com": None,
# "html\\.charters": None,
# "datatracker\\.ietf\\.org": None,
# etc.
url_map = OrderedDict({
"https?://github\\.com": "github_repo",
"https?://trac\\.ietf\\.org/.*/wiki": "wiki",
"ietf\\.org.*/trac/wiki": "wiki",
"trac.*wiki": "wiki",
"www\\.ietf\\.org/mailman" : "mailing_list",
"www\\.ietf\\.org/mail-archive" : "mailing_list_archive",
"ietf\\.org/logs": "jabber_log",
"ietf\\.org/jabber/logs": "jabber_log",
"xmpp:.*?join": "jabber_room",
"https?://.*": "webpage"
})
def forward(apps, schema_editor):
GroupExtResource = apps.get_model('group', 'GroupExtResource')
ExtResourceName = apps.get_model('name', 'ExtResourceName')
GroupUrl = apps.get_model('group', 'GroupUrl')
stats = Counter()
for group_url in GroupUrl.objects.all():
match_found = False
for regext,slug in name_map.items():
if re.match(regext, group_url.name):
match_found = True
stats['mapped'] += 1
name = ExtResourceName.objects.get(slug=slug)
GroupExtResource.objects.create(group=group_url.group, name_id=slug, value=group_url.url, display_name=group_url.name) # TODO: validate this value against name.type
break
if not match_found:
for regext, slug in url_map.items():
group_url.url = group_url.url.strip()
if re.search(regext, group_url.url):
match_found = True
if slug:
stats['mapped'] +=1
name = ExtResourceName.objects.get(slug=slug)
# Munge the URL if it's the first github repo match
# Remove "/tree/master" substring if it exists
# Remove trailing "/issues" substring if it exists
# Remove "/blob/master/.*" pattern if present
if regext == "https?://github\\.com":
group_url.url = group_url.url.replace("/tree/master","")
group_url.url = re.sub('/issues$', '', group_url.url)
group_url.url = re.sub('/blob/master.*$', '', group_url.url)
try:
validate_external_resource_value(name, group_url.url)
GroupExtResource.objects.create(group=group_url.group, name=name, value=group_url.url, display_name=group_url.name) # TODO: validate this value against name.type
except ValidationError as e: # pyflakes:ignore
debug.show('("Failed validation:", group_url.url, e)')
stats['failed_validation'] +=1
else:
stats['ignored'] +=1
break
if not match_found:
debug.show('("Not Mapped:",group_url.group.acronym, group_url.name, group_url.url)')
stats['not_mapped'] += 1
print(stats)
def reverse(apps, schema_editor):
GroupExtResource = apps.get_model('group', 'GroupExtResource')
GroupExtResource.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('group', '0033_extres'),
('name', '0014_populate_extres'),
]
operations = [
migrations.RunPython(forward, reverse)
]

View file

@ -21,7 +21,7 @@ from simple_history.models import HistoricalRecords
import debug # pyflakes:ignore
from ietf.group.colors import fg_group_colors, bg_group_colors
from ietf.name.models import GroupStateName, GroupTypeName, DocTagName, GroupMilestoneStateName, RoleName, AgendaTypeName
from ietf.name.models import GroupStateName, GroupTypeName, DocTagName, GroupMilestoneStateName, RoleName, AgendaTypeName, ExtResourceName
from ietf.person.models import Email, Person
from ietf.utils.mail import formataddr, send_mail_text
from ietf.utils import log
@ -41,6 +41,7 @@ class GroupInfo(models.Model):
comments = models.TextField(blank=True)
meeting_seen_as_area = models.BooleanField(default=False, help_text='For meeting scheduling, should be considered an area meeting, even if the type is WG')
unused_states = models.ManyToManyField('doc.State', help_text="Document states that have been disabled for the group.", blank=True)
unused_tags = models.ManyToManyField(DocTagName, help_text="Document tags that have been disabled for the group.", blank=True)
@ -260,6 +261,15 @@ class GroupURL(models.Model):
def __str__(self):
return u"%s (%s)" % (self.url, self.name)
class GroupExtResource(models.Model):
group = ForeignKey(Group) # Should this really be to GroupInfo?
name = models.ForeignKey(ExtResourceName, on_delete=models.CASCADE)
display_name = models.CharField(max_length=255, default='', blank=True)
value = models.CharField(max_length=2083) # 2083 is the maximum legal URL length
def __str__(self):
priority = self.display_name or self.name.name
return u"%s (%s) %s" % (priority, self.name.slug, self.value)
class GroupMilestoneInfo(models.Model):
group = ForeignKey(Group)
# a group has two sets of milestones, current milestones

View file

@ -13,7 +13,7 @@ from ietf import api
from ietf.group.models import (Group, GroupStateTransitions, GroupMilestone, GroupHistory, # type: ignore
GroupURL, Role, GroupEvent, RoleHistory, GroupMilestoneHistory, MilestoneGroupEvent,
ChangeStateGroupEvent, GroupFeatures, HistoricalGroupFeatures)
ChangeStateGroupEvent, GroupFeatures, HistoricalGroupFeatures, GroupExtResource)
from ietf.person.resources import PersonResource
@ -348,3 +348,23 @@ class HistoricalGroupFeaturesResource(ModelResource):
"history_user": ALL_WITH_RELATIONS,
}
api.group.register(HistoricalGroupFeaturesResource())
from ietf.name.resources import ExtResourceNameResource
class GroupExtResourceResource(ModelResource):
group = ToOneField(GroupResource, 'group')
name = ToOneField(ExtResourceNameResource, 'name')
class Meta:
queryset = GroupExtResource.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
resource_name = 'groupextresource'
ordering = ['id', ]
filtering = {
"id": ALL,
"display_name": ALL,
"value": ALL,
"group": ALL_WITH_RELATIONS,
"name": ALL_WITH_RELATIONS,
}
api.group.register(GroupExtResourceResource())

View file

@ -610,7 +610,6 @@ class GroupEditTests(TestCase):
list_email="mars@mail",
list_subscribe="subscribe.mars",
list_archive="archive.mars",
urls="http://mars.mars (MARS site)"
))
self.assertEqual(r.status_code, 302)
@ -624,8 +623,7 @@ class GroupEditTests(TestCase):
self.assertEqual(group.list_email, "mars@mail")
self.assertEqual(group.list_subscribe, "subscribe.mars")
self.assertEqual(group.list_archive, "archive.mars")
self.assertEqual(group.groupurl_set.all()[0].url, "http://mars.mars")
self.assertEqual(group.groupurl_set.all()[0].name, "MARS site")
self.assertTrue(os.path.exists(os.path.join(self.charter_dir, "%s-%s.txt" % (group.charter.canonical_name(), group.charter.rev))))
self.assertEqual(len(outbox), 2)
self.assertTrue('Personnel change' in outbox[0]['Subject'])
@ -633,6 +631,46 @@ class GroupEditTests(TestCase):
self.assertTrue(prefix+'@' in outbox[0]['To'])
self.assertTrue(get_payload_text(outbox[0]).startswith('Sec Retary'))
def test_edit_extresources(self):
group = GroupFactory(acronym='mars',parent=GroupFactory(type_id='area'))
CharterFactory(group=group)
url = urlreverse('ietf.group.views.edit', kwargs=dict(group_type=group.type_id, acronym=group.acronym, action="edit", field="resources"))
login_testing_unauthorized(self, "secretary", url)
r = self.client.get(url)
self.assertEqual(r.status_code,200)
q = PyQuery(r.content)
self.assertEqual(len(q('form textarea[id=id_resources]')),1)
badlines = (
'github_repo https://github3.com/some/repo',
'github_notify badaddr',
'website /not/a/good/url'
'notavalidtag blahblahblah'
)
for line in badlines:
r = self.client.post(url, dict(resources=line, submit="1"))
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertTrue(q('.alert-danger'))
goodlines = """
github_repo https://github.com/some/repo Some display text
github_notify notify@example.com
github_username githubuser
website http://example.com/http/is/fine
"""
r = self.client.post(url, dict(resources=goodlines, submit="1"))
self.assertEqual(r.status_code,302)
group = Group.objects.get(acronym=group.acronym)
self.assertEqual(group.latest_event(GroupEvent,type="info_changed").desc[:20], 'Resources changed to')
self.assertIn('github_username githubuser', group.latest_event(GroupEvent,type="info_changed").desc)
self.assertEqual(group.groupextresource_set.count(), 4)
self.assertEqual(group.groupextresource_set.get(name__slug='github_repo').display_name, 'Some display text')
def test_edit_field(self):
group = GroupFactory(acronym="mars")

View file

@ -73,7 +73,7 @@ from ietf.group.forms import (GroupForm, StatusUpdateForm, ConcludeGroupForm, St
ManageReviewRequestForm, EmailOpenAssignmentsForm, ReviewerSettingsForm,
AddUnavailablePeriodForm, EndUnavailablePeriodForm, ReviewSecretarySettingsForm, )
from ietf.group.mails import email_admin_re_charter, email_personnel_change, email_comment
from ietf.group.models import ( Group, Role, GroupEvent, GroupStateTransitions, GroupURL,
from ietf.group.models import ( Group, Role, GroupEvent, GroupStateTransitions,
ChangeStateGroupEvent, GroupFeatures )
from ietf.group.utils import (get_charter_text, can_manage_group_type,
milestone_reviewer_for_group_type, can_provide_status_update,
@ -346,7 +346,7 @@ def active_wgs(request):
+ list(sorted(roles(area, "pre-ad"), key=extract_last_name)))
area.groups = Group.objects.filter(parent=area, type="wg", state="active").order_by("acronym")
area.urls = area.groupurl_set.all().order_by("name")
area.urls = area.groupextresource_set.all().order_by("name")
for group in area.groups:
group.chairs = sorted(roles(group, "chair"), key=extract_last_name)
group.ad_out_of_area = group.ad_role() and group.ad_role().person not in [role.person for role in area.ads]
@ -869,6 +869,17 @@ def edit(request, group_type=None, acronym=None, action="edit", field=None):
res.append(u.url)
return fs.join(res)
def format_resources(resources, fs="\n"):
res = []
for r in resources:
if r.display_name:
res.append("%s %s (%s)" % (r.name.slug, r.value, r.display_name.strip('()')))
else:
res.append("%s %s" % (r.name.slug, r.value))
# TODO: This is likely problematic if value has spaces. How then to delineate value and display_name? Perhaps in the short term move to comma or pipe separation.
# Might be better to shift to a formset instead of parsing these lines.
return fs.join(res)
def diff(attr, name):
if field and attr != field:
return
@ -922,11 +933,6 @@ def edit(request, group_type=None, acronym=None, action="edit", field=None):
else:
save_group_in_history(group)
## XXX Remove after testing
# if action == "charter" and not group.charter: # make sure we have a charter
# group.charter = get_or_create_initial_charter(group, group_type)
changes = []
# update the attributes, keeping track of what we're doing
@ -996,22 +1002,18 @@ def edit(request, group_type=None, acronym=None, action="edit", field=None):
personnel_change_text = "%s has updated %s personnel:\n\n" % (request.user.person.plain_name(), group.acronym.upper() ) + personnel_change_text
email_personnel_change(request, group, personnel_change_text, changed_personnel)
# update urls
if 'urls' in clean:
new_urls = clean['urls']
old_urls = format_urls(group.groupurl_set.order_by('url'), ", ")
if ", ".join(sorted(new_urls)) != old_urls:
changes.append(('urls', new_urls, desc('Urls', ", ".join(sorted(new_urls)), old_urls)))
group.groupurl_set.all().delete()
# Add new ones
for u in new_urls:
m = re.search(r'(?P<url>[\w\d:#@%/;$()~_?\+-=\\\.&]+)( \((?P<name>.+)\))?', u)
if m:
if m.group('name'):
url = GroupURL(url=m.group('url'), name=m.group('name'), group=group)
else:
url = GroupURL(url=m.group('url'), name='', group=group)
url.save()
if 'resources' in clean:
old_resources = sorted(format_resources(group.groupextresource_set.all()).splitlines())
new_resources = sorted(clean['resources'])
if old_resources != new_resources:
group.groupextresource_set.all().delete()
for u in new_resources:
parts = u.split(None, 2)
name = parts[0]
value = parts[1]
display_name = ' '.join(parts[2:]).strip('()')
group.groupextresource_set.create(value=value, name_id=name, display_name=display_name)
changes.append(('resources', new_resources, desc('Resources', ", ".join(new_resources), ", ".join(old_resources))))
group.time = datetime.datetime.now()
@ -1064,7 +1066,7 @@ def edit(request, group_type=None, acronym=None, action="edit", field=None):
list_email=group.list_email if group.list_email else None,
list_subscribe=group.list_subscribe if group.list_subscribe else None,
list_archive=group.list_archive if group.list_archive else None,
urls=format_urls(group.groupurl_set.all()),
resources=format_resources(group.groupextresource_set.all()),
closing_note = closing_note,
)

View file

@ -664,6 +664,45 @@ class IetfAuthTests(TestCase):
self.assertIn(" %s times" % count, body)
self.assertIn(date, body)
def test_edit_person_extresources(self):
url = urlreverse('ietf.ietfauth.views.edit_person_externalresources')
person = PersonFactory()
r = self.client.get(url)
self.assertNotEqual(r.status_code, 200)
self.client.login(username=person.user.username,password=person.user.username+'+password')
r = self.client.get(url)
self.assertEqual(r.status_code,200)
q = PyQuery(r.content)
self.assertEqual(len(q('form textarea[id=id_resources]')),1)
badlines = (
'github_repo https://github3.com/some/repo',
'github_notify badaddr',
'website /not/a/good/url'
'notavalidtag blahblahblah'
)
for line in badlines:
r = self.client.post(url, dict(resources=line, submit="1"))
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertTrue(q('.alert-danger'))
goodlines = """
github_repo https://github.com/some/repo Some display text
github_notify notify@example.com
github_username githubuser
website http://example.com/http/is/fine
"""
r = self.client.post(url, dict(resources=goodlines, submit="1"))
self.assertEqual(r.status_code,302)
self.assertEqual(person.personextresource_set.count(), 4)
self.assertEqual(person.personextresource_set.get(name__slug='github_repo').display_name, 'Some display text')
class OpenIDConnectTests(TestCase):
def request_matcher(self, request):
@ -797,3 +836,4 @@ class OpenIDConnectTests(TestCase):
# handler, causing later logging to become visible even if that wasn't intended.
# Fail here if that happens.
self.assertEqual(logging.root.handlers, [])

View file

@ -18,6 +18,7 @@ urlpatterns = [
url(r'^logout/$', LogoutView.as_view(), name="django.contrib.auth.views.logout"),
url(r'^password/$', views.change_password),
url(r'^profile/$', views.profile),
url(r'^editexternalresources/$', views.edit_person_externalresources),
url(r'^reset/$', views.password_reset),
url(r'^reset/confirm/(?P<auth>[^/]+)/$', views.confirm_password_reset),
url(r'^review/$', views.review_overview),

View file

@ -67,12 +67,15 @@ from ietf.ietfauth.forms import ( RegistrationForm, PasswordForm, ResetPasswordF
from ietf.ietfauth.htpasswd import update_htpasswd_file
from ietf.ietfauth.utils import role_required, has_role
from ietf.mailinglists.models import Subscribed, Whitelisted
from ietf.name.models import ExtResourceName
from ietf.person.models import Person, Email, Alias, PersonalApiKey, PERSON_API_KEY_VALUES
from ietf.review.models import ReviewerSettings, ReviewWish, ReviewAssignment
from ietf.review.utils import unavailable_periods_to_list, get_default_filter_re
from ietf.doc.fields import SearchableDocumentField
from ietf.utils.decorators import person_required
from ietf.utils.mail import send_mail
from ietf.utils.validators import validate_external_resource_value
def index(request):
return render(request, 'registration/index.html')
@ -288,6 +291,79 @@ def profile(request):
'settings':settings,
})
@login_required
@person_required
def edit_person_externalresources(request):
class PersonExtResourceForm(forms.Form):
resources = forms.CharField(widget=forms.Textarea, label="Additional Resources", required=False,
help_text=("Format: 'tag value (Optional description)'."
" Separate multiple entries with newline. When the value is a URL, use https:// where possible.") )
def clean_resources(self):
lines = [x.strip() for x in self.cleaned_data["resources"].splitlines() if x.strip()]
errors = []
for l in lines:
parts = l.split()
if len(parts) == 1:
errors.append("Too few fields: Expected at least tag and value: '%s'" % l)
elif len(parts) >= 2:
name_slug = parts[0]
try:
name = ExtResourceName.objects.get(slug=name_slug)
except ObjectDoesNotExist:
errors.append("Bad tag in '%s': Expected one of %s" % (l, ', '.join([ o.slug for o in ExtResourceName.objects.all() ])))
continue
value = parts[1]
try:
validate_external_resource_value(name, value)
except ValidationError as e:
e.message += " : " + value
errors.append(e)
if errors:
raise ValidationError(errors)
return lines
def format_resources(resources, fs="\n"):
res = []
for r in resources:
if r.display_name:
res.append("%s %s (%s)" % (r.name.slug, r.value, r.display_name.strip('()')))
else:
res.append("%s %s" % (r.name.slug, r.value))
# TODO: This is likely problematic if value has spaces. How then to delineate value and display_name? Perhaps in the short term move to comma or pipe separation.
# Might be better to shift to a formset instead of parsing these lines.
return fs.join(res)
person = request.user.person
old_resources = format_resources(person.personextresource_set.all())
if request.method == 'POST':
form = PersonExtResourceForm(request.POST)
if form.is_valid():
old_resources = sorted(old_resources.splitlines())
new_resources = sorted(form.cleaned_data['resources'])
if old_resources != new_resources:
person.personextresource_set.all().delete()
for u in new_resources:
parts = u.split(None, 2)
name = parts[0]
value = parts[1]
display_name = ' '.join(parts[2:]).strip('()')
person.personextresource_set.create(value=value, name_id=name, display_name=display_name)
new_resources = format_resources(person.personextresource_set.all())
messages.success(request,"Person resources updated.")
else:
messages.info(request,"No change in Person resources.")
return redirect('ietf.ietfauth.views.profile')
else:
form = PersonExtResourceForm(initial={'resources': old_resources, })
info = "Valid tags:<br><br> %s" % ', '.join([ o.slug for o in ExtResourceName.objects.all().order_by('slug') ])
# May need to explain the tags more - probably more reason to move to a formset.
title = "Additional person resources"
return render(request, 'ietfauth/edit_field.html',dict(person=person, form=form, title=title, info=info) )
def confirm_new_email(request, auth):
try:
username, email = django.core.signing.loads(auth, salt="add_email", max_age=settings.DAYS_TO_EXPIRE_REGISTRATION_LINK * 24 * 60 * 60)

View file

@ -10,7 +10,9 @@ from ietf.name.models import (
LiaisonStatementState, LiaisonStatementTagName, MeetingTypeName, NomineePositionStateName,
ReviewRequestStateName, ReviewResultName, ReviewTypeName, RoleName, RoomResourceName,
SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName, TopicAudienceName,
DocUrlTagName, ReviewAssignmentStateName, ReviewerQueuePolicyName, TimerangeName)
DocUrlTagName, ReviewAssignmentStateName, ReviewerQueuePolicyName, TimerangeName,
ExtResourceName, ExtResourceTypeName, )
from ietf.stats.models import CountryAlias
@ -46,6 +48,10 @@ class ImportantDateNameAdmin(NameAdmin):
ordering = ('-used','default_offset_days',)
admin.site.register(ImportantDateName,ImportantDateNameAdmin)
class ExtResourceNameAdmin(NameAdmin):
list_display = ["slug", "name", "type", "desc", "used",]
admin.site.register(ExtResourceName,ExtResourceNameAdmin)
admin.site.register(AgendaTypeName, NameAdmin)
admin.site.register(BallotPositionName, NameAdmin)
admin.site.register(ConstraintName, NameAdmin)
@ -82,3 +88,4 @@ admin.site.register(TimeSlotTypeName, NameAdmin)
admin.site.register(TimerangeName, NameAdmin)
admin.site.register(TopicAudienceName, NameAdmin)
admin.site.register(DocUrlTagName, NameAdmin)
admin.site.register(ExtResourceTypeName, NameAdmin)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
# Copyright The IETF Trust 2020, All Rights Reserved
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-03-19 13:56
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import ietf.utils.models
class Migration(migrations.Migration):
dependencies = [
('name', '0012_role_name_robots'),
]
operations = [
migrations.CreateModel(
name='ExtResourceName',
fields=[
('slug', models.CharField(max_length=32, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('desc', models.TextField(blank=True)),
('used', models.BooleanField(default=True)),
('order', models.IntegerField(default=0)),
],
options={
'ordering': ['order', 'name'],
'abstract': False,
},
),
migrations.CreateModel(
name='ExtResourceTypeName',
fields=[
('slug', models.CharField(max_length=32, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('desc', models.TextField(blank=True)),
('used', models.BooleanField(default=True)),
('order', models.IntegerField(default=0)),
],
options={
'ordering': ['order', 'name'],
'abstract': False,
},
),
migrations.AddField(
model_name='extresourcename',
name='type',
field=ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='name.ExtResourceTypeName'),
),
]

View file

@ -0,0 +1,65 @@
# Copyright The IETF Trust 2020, All Rights Reserved
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-03-19 11:42
from __future__ import unicode_literals
from collections import namedtuple
from django.db import migrations
def forward(apps, schema_editor):
ExtResourceName = apps.get_model('name','ExtResourceName')
ExtResourceTypeName = apps.get_model('name','ExtResourceTypeName')
ExtResourceTypeName.objects.create(slug='email', name="Email address", desc="Email address", used=True, order=0)
ExtResourceTypeName.objects.create(slug='url', name="URL", desc="URL", used=True, order=0)
ExtResourceTypeName.objects.create(slug='string', name="string", desc="string", used=True, order=0)
resourcename = namedtuple('resourcename', ['slug', 'name', 'type'])
resourcenames= [
resourcename("webpage", "Additional Web Page", "url"),
resourcename("faq", "Frequently Asked Questions", "url"),
resourcename("github_username","GitHub Username", "string"),
resourcename("github_org","GitHub Organization", "url"),
resourcename("github_repo","GitHub Repository", "url"),
resourcename("gitlab_username","GitLab Username", "string"),
resourcename("tracker","Issuer Tracker", "url"),
resourcename("github_notify","GitHub Notifications Email", "email"),
resourcename("slack","Slack Channel", "url"),
resourcename("website","Website", "url"),
resourcename("wiki","Wiki", "url"),
resourcename("yc_entry","Yang Catalog Entry", "url"),
resourcename("yc_impact","Yang Impact Analysis", "url"),
resourcename("jabber_room","Jabber Room", "url"),
resourcename("jabber_log","Jabber Log", "url"),
resourcename("mailing_list","Mailing List", "url"),
resourcename("mailing_list_archive","Mailing List Archive","url"),
resourcename("repo","Other Repository", "url")
]
for name in resourcenames:
ExtResourceName.objects.create(slug=name.slug, name=name.name, desc=name.name, used=True, order=0, type_id=name.type)
def reverse(apps, schema_editor):
ExtResourceName = apps.get_model('name','ExtResourceName')
ExtResourceTypeName = apps.get_model('name','ExtResourceTypeName')
ExtResourceName.objects.all().delete()
ExtResourceTypeName.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('name', '0013_extres'),
('group', '0033_extres'),
('doc', '0033_extres'),
('person', '0013_extres'),
]
operations = [
migrations.RunPython(forward, reverse)
]

View file

@ -125,4 +125,8 @@ class ImportantDateName(NameModel):
default_offset_days = models.SmallIntegerField()
class DocUrlTagName(NameModel):
"Repository, Wiki, Issue Tracker, ..."
class ExtResourceTypeName(NameModel):
"""Url, Email, String"""
class ExtResourceName(NameModel):
"""GitHub Repository URL, GitHub Username, ..."""
type = ForeignKey(ExtResourceTypeName)

View file

@ -17,7 +17,7 @@ from ietf.name.models import ( AgendaTypeName, BallotPositionName, ConstraintNam
LiaisonStatementState, LiaisonStatementTagName, MeetingTypeName, NomineePositionStateName,
ReviewAssignmentStateName, ReviewRequestStateName, ReviewResultName, ReviewTypeName,
RoleName, RoomResourceName, SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName,
TopicAudienceName, ReviewerQueuePolicyName, TimerangeName)
TopicAudienceName, ReviewerQueuePolicyName, TimerangeName, ExtResourceTypeName, ExtResourceName)
class TimeSlotTypeNameResource(ModelResource):
class Meta:
@ -615,3 +615,38 @@ class TimerangeNameResource(ModelResource):
"order": ALL,
}
api.name.register(TimerangeNameResource())
class ExtResourceTypeNameResource(ModelResource):
class Meta:
queryset = ExtResourceTypeName.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
resource_name = 'extresourcetypename'
ordering = ['slug', ]
filtering = {
"slug": ALL,
"name": ALL,
"desc": ALL,
"used": ALL,
"order": ALL,
}
api.name.register(ExtResourceTypeNameResource())
class ExtResourceNameResource(ModelResource):
type = ToOneField(ExtResourceTypeNameResource, 'type')
class Meta:
queryset = ExtResourceName.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
resource_name = 'extresourcename'
ordering = ['slug', ]
filtering = {
"slug": ALL,
"name": ALL,
"desc": ALL,
"used": ALL,
"order": ALL,
"type": ALL_WITH_RELATIONS,
}
api.name.register(ExtResourceNameResource())

View file

@ -1,9 +1,14 @@
from django.contrib import admin
import simple_history
from ietf.person.models import Email, Alias, Person, PersonalApiKey, PersonEvent, PersonApiKeyEvent
from django import forms
from ietf.person.models import Email, Alias, Person, PersonalApiKey, PersonEvent, PersonApiKeyEvent, PersonExtResource
from ietf.person.name import name_parts
from ietf.utils.validators import validate_external_resource_value
class EmailAdmin(simple_history.admin.SimpleHistoryAdmin):
list_display = ["address", "person", "time", "active", "origin"]
raw_id_fields = ["person", ]
@ -55,3 +60,14 @@ class PersonApiKeyEventAdmin(admin.ModelAdmin):
admin.site.register(PersonApiKeyEvent, PersonApiKeyEventAdmin)
class PersonExtResourceAdminForm(forms.ModelForm):
def clean(self):
validate_external_resource_value(self.cleaned_data['name'],self.cleaned_data['value'])
class PersonExtResourceAdmin(admin.ModelAdmin):
form = PersonExtResourceAdminForm
list_display = ['id', 'person', 'name', 'display_name', 'value',]
search_fields = ['person__name', 'value', 'display_name', 'name__slug',]
raw_id_fields = ['person', ]
admin.site.register(PersonExtResource, PersonExtResourceAdmin)

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-04-15 10:20
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import ietf.utils.models
class Migration(migrations.Migration):
dependencies = [
('name', '0013_extres'),
('person', '0012_auto_20200624_1332'),
]
operations = [
migrations.CreateModel(
name='PersonExtResource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('display_name', models.CharField(blank=True, default='', max_length=255)),
('value', models.CharField(max_length=2083)),
('name', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='name.ExtResourceName')),
('person', ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='person.Person')),
],
),
]

View file

@ -24,6 +24,7 @@ from simple_history.models import HistoricalRecords
import debug # pyflakes:ignore
from ietf.name.models import ExtResourceName
from ietf.person.name import name_parts, initials, plain_name
from ietf.utils.mail import send_mail_preformatted
from ietf.utils.storage import NoLocationMigrationFileSystemStorage
@ -240,6 +241,15 @@ class Person(models.Model):
return [ (v, n) for (v, n, r) in PERSON_API_KEY_VALUES if r==None or has_role(self.user, r) ]
class PersonExtResource(models.Model):
person = ForeignKey(Person)
name = models.ForeignKey(ExtResourceName, on_delete=models.CASCADE)
display_name = models.CharField(max_length=255, default='', blank=True)
value = models.CharField(max_length=2083) # 2083 is the maximum legal URL length
def __str__(self):
priority = self.display_name or self.name.name
return u"%s (%s) %s" % (priority, self.name.slug, self.value)
class Alias(models.Model):
"""This is used for alternative forms of a name. This is the
primary lookup point for names, and should always contain the

View file

@ -10,7 +10,7 @@ from tastypie.cache import SimpleCache
from ietf import api
from ietf.person.models import (Person, Email, Alias, PersonalApiKey, PersonEvent, PersonApiKeyEvent, HistoricalPerson, HistoricalEmail) # type: ignore
from ietf.person.models import (Person, Email, Alias, PersonalApiKey, PersonEvent, PersonApiKeyEvent, HistoricalPerson, HistoricalEmail, PersonExtResource) # type: ignore
from ietf.utils.resources import UserResource
@ -182,3 +182,23 @@ class HistoricalEmailResource(ModelResource):
"history_user": ALL_WITH_RELATIONS,
}
api.person.register(HistoricalEmailResource())
from ietf.name.resources import ExtResourceNameResource
class PersonExtResourceResource(ModelResource):
person = ToOneField(PersonResource, 'person')
name = ToOneField(ExtResourceNameResource, 'name')
class Meta:
queryset = PersonExtResource.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
resource_name = 'personextresource'
ordering = ['id', ]
filtering = {
"id": ALL,
"display_name": ALL,
"value": ALL,
"person": ALL_WITH_RELATIONS,
"name": ALL_WITH_RELATIONS,
}
api.person.register(PersonExtResourceResource())

View file

@ -1,10 +1,7 @@
from django import forms
from ietf.person.models import Person, Email
from ietf.group.models import Group, GroupURL
from ietf.name.models import GroupTypeName, GroupStateName
import datetime
import re
STATE_CHOICES = (
@ -13,132 +10,6 @@ STATE_CHOICES = (
)
class AWPForm(forms.ModelForm):
class Meta:
model = GroupURL
fields = '__all__'
def __init__(self, *args, **kwargs):
super(AWPForm, self).__init__(*args,**kwargs)
self.fields['url'].widget.attrs['width'] = 40
self.fields['name'].widget.attrs['width'] = 40
self.fields['url'].required = False
self.fields['name'].required = False
# Validation: url without description and vice-versa
def clean(self):
super(AWPForm, self).clean()
cleaned_data = self.cleaned_data
url = cleaned_data.get('url')
name = cleaned_data.get('name')
if (url and not name) or (name and not url):
raise forms.ValidationError('You must fill out URL and Name')
# Always return the full collection of cleaned data.
return cleaned_data
class AreaForm(forms.ModelForm):
class Meta:
model = Group
fields = ('acronym','name','state','comments')
# use this method to set attrs which keeps other meta info from model.
def __init__(self, *args, **kwargs):
super(AreaForm, self).__init__(*args, **kwargs)
self.fields['state'].queryset = GroupStateName.objects.filter(slug__in=('active','conclude'))
self.fields['state'].empty_label = None
self.fields['comments'].widget.attrs['rows'] = 2
"""
# Validation: status and conclude_date must agree
def clean(self):
super(AreaForm, self).clean()
cleaned_data = self.cleaned_data
concluded_date = cleaned_data.get('concluded_date')
state = cleaned_data.get('state')
concluded_status_object = AreaStatus.objects.get(status_id=2)
if concluded_date and status != concluded_status_object:
raise forms.ValidationError('Concluded Date set but status is %s' % (status.status_value))
if status == concluded_status_object and not concluded_date:
raise forms.ValidationError('Status is Concluded but Concluded Date not set.')
# Always return the full collection of cleaned data.
return cleaned_data
"""
class AWPAddModelForm(forms.ModelForm):
class Meta:
model = GroupURL
fields = ('url', 'name')
# for use with Add view, ModelForm doesn't work because the parent type hasn't been created yet
# when initial screen is displayed
class AWPAddForm(forms.Form):
url = forms.CharField(
max_length=50,
required=False,
widget=forms.TextInput(attrs={'size':'40'}))
description = forms.CharField(
max_length=50,
required=False,
widget=forms.TextInput(attrs={'size':'40'}))
# Validation: url without description and vice-versa
def clean(self):
super(AWPAddForm, self).clean()
cleaned_data = self.cleaned_data
url = cleaned_data.get('url')
description = cleaned_data.get('description')
if (url and not description) or (description and not url):
raise forms.ValidationError('You must fill out URL and Description')
# Always return the full collection of cleaned data.
return cleaned_data
class AddAreaModelForm(forms.ModelForm):
start_date = forms.DateField()
class Meta:
model = Group
fields = ('acronym','name','state','start_date','comments')
def __init__(self, *args, **kwargs):
super(AddAreaModelForm, self).__init__(*args, **kwargs)
self.fields['acronym'].required = True
self.fields['name'].required = True
self.fields['start_date'].required = True
self.fields['start_date'].initial = datetime.date.today
def clean_acronym(self):
acronym = self.cleaned_data['acronym']
if Group.objects.filter(acronym=acronym):
raise forms.ValidationError("This acronym already exists. Enter a unique one.")
r1 = re.compile(r'[a-zA-Z\-\. ]+$')
if not r1.match(acronym):
raise forms.ValidationError("Enter a valid acronym (only letters,period,hyphen allowed)")
return acronym
def clean_name(self):
name = self.cleaned_data['name']
if Group.objects.filter(name=name):
raise forms.ValidationError("This name already exists. Enter a unique one.")
r1 = re.compile(r'[a-zA-Z\-\. ]+$')
if name and not r1.match(name):
raise forms.ValidationError("Enter a valid name (only letters,period,hyphen allowed)")
return name
def save(self, force_insert=False, force_update=False, commit=True):
area = super(AddAreaModelForm, self).save(commit=False)
area.type = GroupTypeName.objects.get(slug='area')
area.parent = Group.objects.get(acronym='iesg')
if commit:
area.save()
return area
class AreaDirectorForm(forms.Form):
ad_name = forms.CharField(max_length=100,label='Name',help_text="To see a list of people type the first name, or last name, or both.")

View file

@ -32,19 +32,3 @@ class SecrAreasTestCase(TestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add(self):
"Add Test"
url = reverse('ietf.secr.areas.views.add')
self.client.login(username="secretary", password="secretary+password")
data = {'acronym':'ta',
'name':'Test Area',
'state':'active',
'start_date':'2017-01-01',
'awp-TOTAL_FORMS':'2',
'awp-INITIAL_FORMS':'0',
'submit':'Save'}
response = self.client.post(url,data)
self.assertRedirects(response, reverse('ietf.secr.areas.views.list_areas'))
area = Group.objects.get(acronym='ta')
iesg = Group.objects.get(acronym='iesg')
self.assertTrue(area.parent == iesg)

View file

@ -4,11 +4,9 @@ from ietf.utils.urls import url
urlpatterns = [
url(r'^$', views.list_areas),
url(r'^add/$', views.add),
url(r'^getemails', views.getemails),
url(r'^getpeople', views.getpeople),
url(r'^(?P<name>[A-Za-z0-9.-]+)/$', views.view),
url(r'^(?P<name>[A-Za-z0-9.-]+)/edit/$', views.edit),
url(r'^(?P<name>[A-Za-z0-9.-]+)/people/$', views.people),
url(r'^(?P<name>[A-Za-z0-9.-]+)/people/modify/$', views.modify),
]

View file

@ -1,17 +1,14 @@
import datetime
import json
from django.contrib import messages
from django.forms.formsets import formset_factory
from django.forms.models import inlineformset_factory
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404, redirect
from ietf.group.models import Group, GroupEvent, GroupURL, Role, ChangeStateGroupEvent
from ietf.group.models import Group, GroupEvent, Role
from ietf.group.utils import save_group_in_history
from ietf.ietfauth.utils import role_required
from ietf.person.models import Person
from ietf.secr.areas.forms import AWPAddModelForm, AWPForm, AddAreaModelForm, AreaDirectorForm, AreaForm
from ietf.secr.areas.forms import AreaDirectorForm
# --------------------------------------------------
# AJAX FUNCTIONS
@ -49,114 +46,7 @@ def getemails(request):
# --------------------------------------------------
# STANDARD VIEW FUNCTIONS
# --------------------------------------------------
@role_required('Secretariat')
def add(request):
"""
Add a new IETF Area
**Templates:**
* ``areas/add.html``
**Template Variables:**
* area_form
"""
AWPFormSet = formset_factory(AWPAddModelForm, extra=2)
if request.method == 'POST':
area_form = AddAreaModelForm(request.POST)
awp_formset = AWPFormSet(request.POST, prefix='awp')
if area_form.is_valid() and awp_formset.is_valid():
area = area_form.save()
#save groupevent 'started' record
start_date = area_form.cleaned_data.get('start_date')
login = request.user.person
group_event = GroupEvent(group=area,time=start_date,type='started',by=login)
group_event.save()
# save AWPs
for item in awp_formset.cleaned_data:
if item.get('url', 0):
group_url = GroupURL(group=area,name=item['name'],url=item['url'])
group_url.save()
messages.success(request, 'The Area was created successfully!')
return redirect('ietf.secr.areas.views.list_areas')
else:
# display initial forms
area_form = AddAreaModelForm()
awp_formset = AWPFormSet(prefix='awp')
return render(request, 'areas/add.html', {
'area_form': area_form,
'awp_formset': awp_formset},
)
@role_required('Secretariat')
def edit(request, name):
"""
Edit IETF Areas
**Templates:**
* ``areas/edit.html``
**Template Variables:**
* acronym, area_formset, awp_formset, acronym_form
"""
area = get_object_or_404(Group, acronym=name, type='area')
AWPFormSet = inlineformset_factory(Group, GroupURL, form=AWPForm, max_num=2)
if request.method == 'POST':
button_text = request.POST.get('submit', '')
if button_text == 'Save':
form = AreaForm(request.POST, instance=area)
awp_formset = AWPFormSet(request.POST, instance=area)
if form.is_valid() and awp_formset.is_valid():
state = form.cleaned_data['state']
# save group
save_group_in_history(area)
new_area = form.save()
new_area.time = datetime.datetime.now()
new_area.save()
awp_formset.save()
# create appropriate GroupEvent
if 'state' in form.changed_data:
ChangeStateGroupEvent.objects.create(group=new_area,
type='changed_state',
by=request.user.person,
state=state,
time=new_area.time)
form.changed_data.remove('state')
# if anything else was changed
if form.changed_data:
GroupEvent.objects.create(group=new_area,
type='info_changed',
by=request.user.person,
time=new_area.time)
messages.success(request, 'The Area entry was changed successfully')
return redirect('ietf.secr.areas.views.view', name=name)
else:
return redirect('ietf.secr.areas.views.view', name=name)
else:
form = AreaForm(instance=area)
awp_formset = AWPFormSet(instance=area)
return render(request, 'areas/edit.html', {
'area': area,
'form': form,
'awp_formset': awp_formset,
},
)
@role_required('Secretariat')
def list_areas(request):

View file

@ -6,7 +6,6 @@ from django.db.models import Count
from ietf.group.models import Group, Role
from ietf.name.models import GroupStateName, GroupTypeName, RoleName
from ietf.person.models import Person, Email
from ietf.liaisons.models import LiaisonStatementGroupContacts
# ---------------------------------------------
@ -48,83 +47,6 @@ class DescriptionForm (forms.Form):
description = forms.CharField(widget=forms.Textarea(attrs={'rows':'20'}),required=True, strip=False)
class GroupModelForm(forms.ModelForm):
type = forms.ModelChoiceField(queryset=GroupTypeName.objects.all(),empty_label=None)
parent = forms.ModelChoiceField(queryset=Group.objects.all(),required=False)
ad = forms.ModelChoiceField(queryset=Person.objects.filter(role__name='ad',role__group__state='active',role__group__type='area'),required=False)
state = forms.ModelChoiceField(queryset=GroupStateName.objects.exclude(slug__in=('dormant','unknown')),empty_label=None)
liaison_contacts = forms.CharField(max_length=255,required=False,label='Default Liaison Contacts')
class Meta:
model = Group
fields = ('acronym','name','type','state','parent','ad','list_email','list_subscribe','list_archive','description','comments')
def __init__(self, *args, **kwargs):
super(GroupModelForm, self).__init__(*args, **kwargs)
self.fields['list_email'].label = 'List Email'
self.fields['list_subscribe'].label = 'List Subscribe'
self.fields['list_archive'].label = 'List Archive'
self.fields['ad'].label = 'Area Director'
self.fields['comments'].widget.attrs['rows'] = 3
self.fields['parent'].label = 'Area / Parent'
self.fields['parent'].choices = get_parent_group_choices()
if self.instance.pk:
lsgc = self.instance.liaisonstatementgroupcontacts_set.first() # there can only be one
if lsgc:
self.fields['liaison_contacts'].initial = lsgc.contacts
def clean_acronym(self):
acronym = self.cleaned_data['acronym']
if any(x.isupper() for x in acronym):
raise forms.ValidationError('Capital letters not allowed in group acronym')
return acronym
def clean_parent(self):
parent = self.cleaned_data['parent']
type = self.cleaned_data['type']
if type.features.acts_like_wg and not parent:
raise forms.ValidationError("This field is required.")
return parent
def clean(self):
if any(self.errors):
return self.cleaned_data
super(GroupModelForm, self).clean()
type = self.cleaned_data['type']
parent = self.cleaned_data['parent']
state = self.cleaned_data['state']
irtf_area = Group.objects.get(acronym='irtf')
# ensure proper parent for group type
if type.slug == 'rg' and parent != irtf_area:
raise forms.ValidationError('The Area for a research group must be %s' % irtf_area)
# an RG can't be proposed
if type.slug == 'rg' and state.slug not in ('active','conclude'):
raise forms.ValidationError('You must choose "active" or "concluded" for research group state')
return self.cleaned_data
def save(self, force_insert=False, force_update=False, commit=True):
obj = super(GroupModelForm, self).save(commit=False)
if commit:
obj.save()
contacts = self.cleaned_data.get('liaison_contacts')
if contacts:
try:
lsgc = LiaisonStatementGroupContacts.objects.get(group=self.instance)
lsgc.contacts = contacts
lsgc.save()
except LiaisonStatementGroupContacts.DoesNotExist:
LiaisonStatementGroupContacts.objects.create(group=self.instance,contacts=contacts)
elif LiaisonStatementGroupContacts.objects.filter(group=self.instance):
LiaisonStatementGroupContacts.objects.filter(group=self.instance).delete()
return obj
class RoleForm(forms.Form):
name = forms.ModelChoiceField(RoleName.objects.filter(slug__in=('chair','editor','secr','techadv')),empty_label=None)

View file

@ -9,7 +9,6 @@ from ietf.secr.groups.forms import get_parent_group_choices
from ietf.group.factories import GroupFactory, RoleFactory
from ietf.meeting.factories import MeetingFactory
from ietf.person.factories import PersonFactory
from ietf.person.models import Person
import debug # pyflakes:ignore
class GroupsTest(TestCase):
@ -31,73 +30,6 @@ class GroupsTest(TestCase):
response = self.client.post(url,post_data,follow=True)
self.assertContains(response, group.acronym)
# ------- Test Add -------- #
def test_add_button(self):
url = reverse('ietf.secr.groups.views.search')
target = reverse('ietf.secr.groups.views.add')
post_data = {'submit':'Add'}
self.client.login(username="secretary", password="secretary+password")
response = self.client.post(url,post_data,follow=True)
self.assertRedirects(response, target)
def test_add_group_invalid(self):
url = reverse('ietf.secr.groups.views.add')
post_data = {'acronym':'test',
'type':'wg',
'awp-TOTAL_FORMS':'2',
'awp-INITIAL_FORMS':'0',
'submit':'Save'}
self.client.login(username="secretary", password="secretary+password")
response = self.client.post(url,post_data)
self.assertContains(response, 'This field is required')
def test_add_group_dupe(self):
group = GroupFactory()
area = GroupFactory(type_id='area')
url = reverse('ietf.secr.groups.views.add')
post_data = {'acronym':group.acronym,
'name':'Test Group',
'state':'active',
'type':'wg',
'parent':area.id,
'awp-TOTAL_FORMS':'2',
'awp-INITIAL_FORMS':'0',
'submit':'Save'}
self.client.login(username="secretary", password="secretary+password")
response = self.client.post(url,post_data)
self.assertContains(response, 'Group with this Acronym already exists')
def test_add_group_success(self):
area = GroupFactory(type_id='area')
url = reverse('ietf.secr.groups.views.add')
post_data = {'acronym':'test',
'name':'Test Group',
'type':'wg',
'status':'active',
'parent':area.id,
'awp-TOTAL_FORMS':'2',
'awp-INITIAL_FORMS':'0',
'submit':'Save'}
self.client.login(username="secretary", password="secretary+password")
response = self.client.post(url,post_data)
self.assertEqual(response.status_code, 200)
def test_add_group_capital_acronym(self):
area = GroupFactory(type_id='area')
url = reverse('ietf.secr.groups.views.add')
post_data = {'acronym':'TEST',
'name':'Test Group',
'type':'wg',
'status':'active',
'parent':area.id,
'awp-TOTAL_FORMS':'2',
'awp-INITIAL_FORMS':'0',
'submit':'Save'}
self.client.login(username="secretary", password="secretary+password")
response = self.client.post(url,post_data)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Capital letters not allowed in group acronym')
# ------- Test View -------- #
def test_view(self):
MeetingFactory(type_id='ietf')
@ -107,47 +39,6 @@ class GroupsTest(TestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# ------- Test Edit -------- #
def test_edit_valid(self):
group = GroupFactory()
area = GroupFactory(type_id='area')
ad = Person.objects.get(name='Areað Irector')
MeetingFactory(type_id='ietf')
url = reverse('ietf.secr.groups.views.edit', kwargs={'acronym':group.acronym})
target = reverse('ietf.secr.groups.views.view', kwargs={'acronym':group.acronym})
post_data = {'acronym':group.acronym,
'name':group.name,
'type':'wg',
'state':group.state_id,
'parent':area.id,
'ad':ad.id,
'groupurl_set-TOTAL_FORMS':'2',
'groupurl_set-INITIAL_FORMS':'0',
'submit':'Save'}
self.client.login(username="secretary", password="secretary+password")
response = self.client.post(url,post_data,follow=True)
self.assertRedirects(response, target)
self.assertContains(response, 'changed successfully')
def test_edit_non_wg_group(self):
parent_sdo = GroupFactory.create(type_id='sdo',state_id='active')
child_sdo = GroupFactory.create(type_id='sdo',state_id='active',parent=parent_sdo)
MeetingFactory(type_id='ietf')
url = reverse('ietf.secr.groups.views.edit', kwargs={'acronym':child_sdo.acronym})
target = reverse('ietf.secr.groups.views.view', kwargs={'acronym':child_sdo.acronym})
post_data = {'acronym':child_sdo.acronym,
'name':'New Name',
'type':'sdo',
'state':child_sdo.state_id,
'parent':parent_sdo.id,
'ad':'',
'groupurl_set-TOTAL_FORMS':'2',
'groupurl_set-INITIAL_FORMS':'0',
'submit':'Save'}
self.client.login(username="secretary", password="secretary+password")
response = self.client.post(url,post_data,follow=True)
self.assertRedirects(response, target)
self.assertContains(response, 'changed successfully')
# ------- Test People -------- #
def test_people_delete(self):

View file

@ -5,12 +5,10 @@ from ietf.utils.urls import url
urlpatterns = [
url(r'^$', views.search),
url(r'^add/$', views.add),
url(r'^blue-dot-report/$', views.blue_dot),
#(r'^ajax/get_ads/$', views.get_ads),
url(r'^%(acronym)s/$' % settings.URL_REGEXPS, views.view),
url(r'^%(acronym)s/delete/(?P<id>\d{1,6})/$' % settings.URL_REGEXPS, views.delete_role),
url(r'^%(acronym)s/charter/$' % settings.URL_REGEXPS, views.charter),
url(r'^%(acronym)s/edit/$' % settings.URL_REGEXPS, views.edit),
url(r'^%(acronym)s/people/$' % settings.URL_REGEXPS, views.people),
]

View file

@ -1,14 +1,12 @@
from django.contrib import messages
from django.conf import settings
from django.forms.models import inlineformset_factory
from django.shortcuts import render, get_object_or_404, redirect
from ietf.group.models import Group, ChangeStateGroupEvent, GroupEvent, GroupURL, Role
from ietf.group.utils import save_group_in_history, get_charter_text, setup_default_community_list_for_group
from ietf.group.models import Group, GroupEvent, Role
from ietf.group.utils import save_group_in_history, get_charter_text
from ietf.ietfauth.utils import role_required
from ietf.person.models import Person
from ietf.secr.groups.forms import GroupModelForm, RoleForm, SearchForm
from ietf.secr.areas.forms import AWPForm
from ietf.secr.groups.forms import RoleForm, SearchForm
from ietf.secr.utils.meeting import get_current_meeting
# -------------------------------------------------
@ -71,58 +69,7 @@ def get_ads(request):
# Standard View Functions
# -------------------------------------------------
@role_required('Secretariat')
def add(request):
'''
Add a new IETF or IRTF Group
**Templates:**
* ``groups/add.html``
**Template Variables:**
* form, awp_formset
'''
AWPFormSet = inlineformset_factory(Group, GroupURL, form=AWPForm, max_num=2, can_delete=False)
if request.method == 'POST':
button_text = request.POST.get('submit', '')
if button_text == 'Cancel':
return redirect('ietf.secr.groups.views.search')
form = GroupModelForm(request.POST)
awp_formset = AWPFormSet(request.POST, prefix='awp')
if form.is_valid() and awp_formset.is_valid():
group = form.save()
for form in awp_formset.forms:
if form.has_changed():
awp = form.save(commit=False)
awp.group = group
awp.save()
if group.features.has_documents:
setup_default_community_list_for_group(group)
# create GroupEvent(s)
# always create started event
ChangeStateGroupEvent.objects.create(group=group,
type='changed_state',
by=request.user.person,
state=group.state,
desc='Started group')
messages.success(request, 'The Group was created successfully!')
return redirect('ietf.secr.groups.views.view', acronym=group.acronym)
else:
form = GroupModelForm(initial={'state':'active','type':'wg'})
awp_formset = AWPFormSet(prefix='awp')
return render(request, 'groups/add.html', {
'form': form,
'awp_formset': awp_formset},
)
@role_required('Secretariat')
def blue_dot(request):
@ -202,83 +149,6 @@ def delete_role(request, acronym, id):
return render(request, 'confirm_delete.html', {'object': role})
@role_required('Secretariat')
def edit(request, acronym):
"""
Edit Group details
**Templates:**
* ``groups/edit.html``
**Template Variables:**
* group, form, awp_formset
"""
group = get_object_or_404(Group, acronym=acronym)
AWPFormSet = inlineformset_factory(Group, GroupURL, form=AWPForm, max_num=2)
if request.method == 'POST':
button_text = request.POST.get('submit', '')
if button_text == 'Cancel':
return redirect('ietf.secr.groups.views.view', acronym=acronym)
form = GroupModelForm(request.POST, instance=group)
awp_formset = AWPFormSet(request.POST, instance=group)
if form.is_valid() and awp_formset.is_valid():
awp_formset.save()
if form.changed_data:
state = form.cleaned_data['state']
# save group
save_group_in_history(group)
form.save()
# create appropriate GroupEvent
if 'state' in form.changed_data:
if state.name == 'Active':
desc = 'Started group'
else:
desc = state.name + ' group'
ChangeStateGroupEvent.objects.create(group=group,
type='changed_state',
by=request.user.person,
state=state,
desc=desc)
form.changed_data.remove('state')
# if anything else was changed
if form.changed_data:
GroupEvent.objects.create(group=group,
type='info_changed',
by=request.user.person,
desc='Info Changed')
# if the acronym was changed we'll want to redirect using the new acronym below
if 'acronym' in form.changed_data:
acronym = form.cleaned_data['acronym']
messages.success(request, 'The Group was changed successfully')
return redirect('ietf.secr.groups.views.view', acronym=acronym)
else:
form = GroupModelForm(instance=group)
awp_formset = AWPFormSet(instance=group)
messages.warning(request, "WARNING: don't use this tool to change group names. Use Datatracker when possible.")
return render(request, 'groups/edit.html', {
'group': group,
'awp_formset': awp_formset,
'form': form},
)
@role_required('Secretariat')
def people(request, acronym):
"""
@ -343,8 +213,6 @@ def search(request):
results = []
if request.method == 'POST':
form = SearchForm(request.POST)
if request.POST['submit'] == 'Add':
return redirect('ietf.secr.groups.views.add')
if form.is_valid():
kwargs = {}

View file

@ -1,31 +0,0 @@
{% extends "base_site.html" %}
{% load staticfiles %}
{% block title %}Areas - Add{% endblock %}
{% block extrahead %}{{ block.super }}
<script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script>
{% endblock %}
{% block breadcrumbs %}{{ block.super }}
&raquo; <a href="../">Areas</a>
&raquo; Add
{% endblock %}
{% block content %}
<div class="module">
<h2>Area - Add</h2>
<form enctype="multipart/form-data" action="" method="post">{% csrf_token %}
<table id="area-add-table" class="full-width amstable">
<col width="150">
{{ area_form.as_table }}
</table>
{% include "includes/awp_add_form.html" %}
{% include "includes/buttons_submit.html" %}
</form>
</div> <!-- module -->
{% endblock %}

View file

@ -1,32 +0,0 @@
{% extends "base_site.html" %}
{% load staticfiles %}
{% block title %}Areas - Edit{% endblock %}
{% block extrahead %}{{ block.super }}
<script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script>
{% endblock %}
{% block breadcrumbs %}{{ block.super }}
&raquo; <a href="../../">Areas</a>
&raquo; <a href="../">{{ area.acronym }}</a>
&raquo; Edit
{% endblock %}
{% block content %}
<div class="module">
<form action="." method="post">{% csrf_token %}
<h2>Area - Edit</h2>
<table class="full-width amstable">
<col width="150">
{{ form.as_table }}
</table>
{% include "includes/awp_edit_form.html" %}
{% include "includes/buttons_save_cancel.html" %}
</form>
</div> <!-- module -->
{% endblock %}

View file

@ -13,7 +13,7 @@
{% block content %}
<div class="module">
<h2>Areas <span class="unlocked"><a href="add/" class="addlink">Add</a></span></h2>
<h2>Areas</h2>
<table id="areas-list-table" class="full-width">
<thead>
<tr>

View file

@ -1,32 +0,0 @@
{% extends "base_site.html" %}
{% load staticfiles %}
{% block title %}Groups - Add{% endblock %}
{% block extrahead %}{{ block.super }}
<script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script>
{% endblock %}
{% block breadcrumbs %}{{ block.super }}
&raquo; <a href="../">Groups</a>
&raquo; Add
{% endblock %}
{% block content %}
<div class="module group-container">
<h2>Groups - Add</h2>
<form enctype="multipart/form-data" action="" method="post">{% csrf_token %}
<table id="groups-table" class="full-width amstable">
<col width="150">
{{ form.as_table }}
</table>
{% include "includes/awp_add_form.html" %}
{% include "includes/buttons_save_cancel.html" %}
</form>
</div> <!-- module -->
{% endblock %}

View file

@ -1,33 +0,0 @@
{% extends "base_site.html" %}
{% load staticfiles %}
{% block title %}Groups - Edit{% endblock %}
{% block extrahead %}{{ block.super }}
<script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script>
{% endblock %}
{% block breadcrumbs %}{{ block.super }}
&raquo; <a href="../../">Groups</a>
&raquo; <a href="../">{{ group.acronym }}</a>
&raquo; Edit
{% endblock %}
{% block content %}
<div class="module group-container">
<h2>Groups - Edit</h2>
<form enctype="multipart/form-data" action="" method="post">{% csrf_token %}
<table id="groups-table" class="full-width amstable">
<col width="150">
{{ form }}
</table>
{% include "includes/awp_edit_form.html" %}
{% include "includes/buttons_save_cancel.html" %}
</form>
</div> <!-- module -->
{% endblock %}

View file

@ -14,7 +14,7 @@
{% block content %}
<div class="module group-container">
<h2>Groups - Search <span class="unlocked"><a href="add/" class="addlink">Add</a></span></h2>
<h2>Groups - Search</h2>
<form enctype="multipart/form-data" action="" method="post">{% csrf_token %}
<table class="full-width amstable">
{{ form.as_table }}

View file

@ -47,7 +47,6 @@
<form id="roles-form".>
{{ group_form.as_p }}
</form>
<p><a href="{% url 'ietf.secr.groups.views.add' %}">Add a new group...</a></p>
</div> <!-- inline-related -->
<br>

View file

@ -377,19 +377,19 @@ def post_submission(request, submission, approved_doc_desc, approved_subm_desc):
if 'yang' in code:
modules = code['yang']
# Yang impact analysis URL
draft.documenturl_set.filter(tag_id='yang-impact-analysis').delete()
draft.docextresource_set.filter(name_id='yc_impact').delete()
f = settings.SUBMIT_YANG_CATALOG_MODULEARG
moduleargs = '&'.join([ f.format(module=m) for m in modules])
url = settings.SUBMIT_YANG_CATALOG_IMPACT_URL.format(moduleargs=moduleargs, draft=draft.name)
desc = settings.SUBMIT_YANG_CATALOG_IMPACT_DESC.format(modules=','.join(modules), draft=draft.name)
draft.documenturl_set.create(url=url, tag_id='yang-impact-analysis', desc=desc)
draft.docextresource_set.create(value=url, name_id='yang-impact-analysis', display_name=desc)
# Yang module metadata URLs
old_urls = draft.documenturl_set.filter(tag_id='yang-module-metadata')
old_urls = draft.documenturl_set.filter(tag_id='yc_entry')
old_urls.delete()
for module in modules:
url = settings.SUBMIT_YANG_CATALOG_MODULE_URL.format(module=module)
desc = settings.SUBMIT_YANG_CATALOG_MODULE_DESC.format(module=module)
draft.documenturl_set.create(url=url, tag_id='yang-module-metadata', desc=desc)
draft.docextresource_set.create(value=url, name_id='yc_entry', display_name=desc)
if not draft.get_state('draft-iesg'):
draft.states.add(State.objects.get(type_id='draft-iesg', slug='idexists'))

View file

@ -245,31 +245,36 @@
</tr>
{% endif %}
{% with doc.documenturl_set.all as urls %}
{% if urls or can_edit_stream_info or can_edit_individual %}
<tr>
<td></td>
<th>Additional URLs</th>
<td class="edit">
{% if can_edit_stream_info or can_edit_individual %}
<a class="btn btn-default btn-xs" href="{% url 'ietf.doc.views_draft.edit_document_urls' name=doc.name %}">Edit</a>
{% endif %}
</td>
<td>
{% if urls or doc.group and doc.group.list_archive %}
<table class="col-md-12 col-sm-12 col-xs-12">
<tbody>
{% for url in urls|dictsort:"desc" %}
<tr><td> - <a href="{{ url.url }}">{% firstof url.desc url.tag.name %}</a></td></tr>
{% endfor %}
{% if doc.group and doc.group.list_archive %}
<tr><td> - <a href="{{doc.group.list_archive}}?q={{doc.name}}">Mailing list discussion</a><td></tr>
{% endif %}
</tbody>
</table>
{% endif %}
</td>
</tr>
{% with doc.docextresource_set.all as resources %}
{% if resources or can_edit_stream_info or can_edit_individual %}
<tr>
<td></td>
<th>Additional Resources</th>
<td class="edit">
{% if can_edit_stream_info or can_edit_individual %}
<a class="btn btn-default btn-xs" href="{% url 'ietf.doc.views_draft.edit_doc_extresources' name=doc.name %}">Edit</a>
{% endif %}
</td>
<td>
{% if resources or doc.group and doc.group.list_archive %}
<table class="col-md-12 col-sm-12 col-xs-12">
<tbody>
{% for resource in resources|dictsort:"display_name" %}
{% if resource.name.type.slug == 'url' or resource.name.type.slug == 'email' %}
<tr><td> - <a href="{{ resource.value }}" title="{{resource.name.name}}">{% firstof resource.display_name resource.name.name %}</a></td></tr>
{# Maybe make how a resource displays itself a method on the class so templates aren't doing this switching #}
{% else %}
<tr><td> - <span title="{{resource.name.name}}">{% firstof resource.display_name resource.name.name %}: {{resource.value}}</span></td></tr>
{% endif %}
{% endfor %}
{% if doc.group and doc.group.list_archive %}
<tr><td> - <a href="{{doc.group.list_archive}}?q={{doc.name}}">Mailing list discussion</a><td></tr>
{% endif %}
</tbody>
</table>
{% endif %}
</td>
</tr>
{% endif %}
{% endwith %}

View file

@ -114,30 +114,35 @@
</tr>
{% endif %}
{% with group.groupurl_set.all as urls %}
{% if urls or can_edit_group %}
<tr>
<td></td>
<th>Additional URLs</th>
<td class="edit">
{% if can_edit_group %}
<a class="btn btn-default btn-xs" href="{% url 'ietf.group.views.edit' acronym=group.acronym field='urls' %}">Edit</a>
{% endif %}
</td>
<td>
{% if urls %}
<table class="col-md-12 col-sm-12 col-xs-12">
<tbody>
{% for url in urls %}
<tr><td> - <a href="{{ url.url }}">{% firstof url.name url.url %}</a></td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</td>
</tr>
{% with group.groupextresource_set.all as resources %}
{% if resources or can_edit_group %}
<tr>
<td></td>
<th>Additional Resources</th>
<td class="edit">
{% if can_edit_group %}
<a class="btn btn-default btn-xs" href="{% url 'ietf.group.views.edit' acronym=group.acronym field='resources' %}">Edit</a>
{% endif %}
</td>
<td>
{% if resources %}
<table class="col-md-12 col-sm-12 col-xs-12">
<tbody>
{% for resource in resources|dictsort:"display_name" %}
{% if resource.name.type.slug == 'url' or resource.name.type.slug == 'email' %}
<tr><td> - <a href="{{ resource.value }}" title="{{resource.name.name}}">{% firstof resource.display_name resource.name.name %}</a></td></tr>
{# Maybe make how a resource displays itself a method on the class so templates aren't doing this switching #}
{% else %}
<tr><td> - <span title="{{resource.name.name}}">{% firstof resource.display_name resource.name.name %}: {{resource.value}}</span></td></tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% endif %}
</td>
</tr>
{% endif %}
{% endwith %}
{% endwith %}
</tbody>
<tbody class="meta">

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load bootstrap3 %}
{% block title %}
{{ title }} {{ person.plain_name }}
{% endblock %}
{% block content %}
{% origin %}
<h1>{{ title }}<br><small>{{ person.plain_name }}</small></h1>
<p>
<b>
{{ info|safe }}
</b>
</p>
<form enctype="multipart/form-data" method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<!-- Regarding placement of buttons: https://www.lukew.com/ff/entry.asp?571 -->
<button type="submit" class="btn btn-primary" name="submit" value="Save">Submit</button>
<a class="btn btn-default pull-right" href="{% url "ietf.ietfauth.views.profile" %}">Back</a>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -45,7 +45,7 @@
{% for fieldset in form.fieldsets %}
{% if forloop.first and user|has_role:"Secretariat" %}
<h2><div class="col-md-2">{{ fieldset.name }}</div><div class="col-md-10"><a class="small" target="_blank" href="{% url 'ietf.secr.groups.views.add' %}">Add new group &gt&gt</a></div></h2>
<h2><div class="col-md-2">{{ fieldset.name }}</div><div class="col-md-10"></div></h2>
{% else %}
<h2>{{ fieldset.name }}</h2>
{% endif %}

View file

@ -57,7 +57,19 @@
</div>
{% endif %}
{% if person.personextresource_set.exists %}
<div class="col-md-12">
<h2 id="extresources">External Resources</h2>
<table class="table">
{% for extres in person.personextresource_set.all %}
<tr>
<td class="col-md-1"><span title="{{ extres.name.name }}">{% firstof extres.display_name extres.name.name %}</span></td>
<td class="col-md-11">{{extres.value}}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
<div class="col-md-6">
<h2 id="rfcs">RFCs</h2>

View file

@ -94,7 +94,7 @@
<label class="col-sm-2 control-label">Nomcom Eligible</label>
<div class="col-sm-1 form-control-static">{{person|is_nomcom_eligible|yesno:'Yes,No,No'}}</div>
<div class="col-sm-9">
<p class="alert alert-info form-control-static ">
<p class="alert alert-info form-control-static">
This calculation is EXPERIMENTAL.<br/>
If you believe it is incorrect, make sure you've added all the
@ -111,6 +111,22 @@
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">External Resources</label>
<div class="col-sm-10 form-control-static">
{% for extres in person.personextresource_set.all %}
<div class="row">
<div class="col-sm-1"><span title="{{ extres.name.name}}">{% firstof extres.display_name extres.name.name %}</span></div>
<div class="col-sm-11">{{extres.value}}
{% if forloop.first %}&nbsp;<a href="{% url 'ietf.ietfauth.views.edit_person_externalresources' %}"><span class="fa fa-pencil"></span></a>{% endif %}
</div>
</div>
{% empty %}
<div class="row"><div class="col-sm-1">None&nbsp;<a href="{% url 'ietf.ietfauth.views.edit_person_externalresources' %}"><span class="fa fa-pencil"></span></a></div></div>
{% endfor %}
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Email addresses</label>
<div class="col-sm-10">

View file

@ -5,12 +5,16 @@
import os
import re
from pyquery import PyQuery
from urllib.parse import urlparse, urlsplit, urlunsplit
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.core.validators import RegexValidator, URLValidator, EmailValidator, _lazy_re_compile
from django.template.defaultfilters import filesizeformat
from django.utils.deconstruct import deconstructible
from django.utils.ipv6 import is_valid_ipv6_address
from django.utils.translation import gettext_lazy as _
import debug # pyflakes:ignore
@ -83,3 +87,106 @@ def validate_no_html_frame(file):
q = PyQuery(file.read())
if q("frameset") or q("frame") or q("iframe"):
raise ValidationError('Found content with html frames. Please upload a file that does not use frames')
# instantiations of sub-validiators used by the external_resource validator
validate_url = URLValidator()
validate_http_url = URLValidator(schemes=['http','https'])
validate_email = EmailValidator()
def validate_ipv6_address(value):
if not is_valid_ipv6_address(value):
raise ValidationError(_('Enter a valid IPv6 address.'), code='invalid')
@deconstructible
class XMPPURLValidator(RegexValidator):
ul = '\u00a1-\uffff' # unicode letters range (must not be a raw string)
# IP patterns
ipv4_re = r'(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}'
ipv6_re = r'\[[0-9a-f:\.]+\]' # (simple regex, validated later)
# Host patterns
hostname_re = r'[a-z' + ul + r'0-9](?:[a-z' + ul + r'0-9-]{0,61}[a-z' + ul + r'0-9])?'
# Max length for domain name labels is 63 characters per RFC 1034 sec. 3.1
domain_re = r'(?:\.(?!-)[a-z' + ul + r'0-9-]{1,63}(?<!-))*'
tld_re = (
r'\.' # dot
r'(?!-)' # can't start with a dash
r'(?:[a-z' + ul + '-]{2,63}' # domain label
r'|xn--[a-z0-9]{1,59})' # or punycode label
r'(?<!-)' # can't end with a dash
r'\.?' # may have a trailing dot
)
host_re = '(' + hostname_re + domain_re + tld_re + '|localhost)'
regex = _lazy_re_compile(
r'^(?:xmpp:)' # Note there is no '//'
r'(?:[^\s:@/]+(?::[^\s:@/]*)?@)?' # user:pass authentication
r'(?:' + ipv4_re + '|' + ipv6_re + '|' + host_re + ')'
r'(?::\d{2,5})?' # port
r'(?:[/?#][^\s]*)?' # resource path
r'\Z', re.IGNORECASE)
message = _('Enter a valid URL.')
schemes = ['http', 'https', 'ftp', 'ftps']
def __call__(self, value):
try:
super().__call__(value)
except ValidationError as e:
# Trivial case failed. Try for possible IDN domain
if value:
try:
scheme, netloc, path, query, fragment = urlsplit(value)
except ValueError: # for example, "Invalid IPv6 URL"
raise ValidationError(self.message, code=self.code)
try:
netloc = netloc.encode('idna').decode('ascii') # IDN -> ACE
except UnicodeError: # invalid domain part
raise e
url = urlunsplit((scheme, netloc, path, query, fragment))
super().__call__(url)
else:
raise
else:
# Now verify IPv6 in the netloc part
host_match = re.search(r'^\[(.+)\](?::\d{2,5})?$', urlsplit(value).netloc)
if host_match:
potential_ip = host_match.groups()[0]
try:
validate_ipv6_address(potential_ip)
except ValidationError:
raise ValidationError(self.message, code=self.code)
# The maximum length of a full host name is 253 characters per RFC 1034
# section 3.1. It's defined to be 255 bytes or less, but this includes
# one byte for the length of the name and one byte for the trailing dot
# that's used to indicate absolute names in DNS.
if len(urlsplit(value).netloc) > 253:
raise ValidationError(self.message, code=self.code)
validate_xmpp = XMPPURLValidator()
def validate_external_resource_value(name, value):
""" validate a resource value using its name's properties """
if name.type.slug == 'url':
if name.slug in ( 'github_org', 'github_repo' ):
validate_http_url(value)
if urlparse(value).netloc.lower() != 'github.com':
raise ValidationError('URL must be a github url')
elif name.slug == 'jabber_room':
validate_xmpp(value)
else:
validate_url(value)
elif name.type.slug == 'email':
validate_email(value)
elif name.type.slug == 'string':
pass
else:
raise ValidationError('Unknown resource type '+name.type.name)