diff --git a/ietf/doc/admin.py b/ietf/doc/admin.py index 1124e1ec3..988b4e9f5 100644 --- a/ietf/doc/admin.py +++ b/ietf/doc/admin.py @@ -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) diff --git a/ietf/doc/management/commands/find_github_backup_info.py b/ietf/doc/management/commands/find_github_backup_info.py new file mode 100644 index 000000000..19d95e75a --- /dev/null +++ b/ietf/doc/management/commands/find_github_backup_info.py @@ -0,0 +1,45 @@ +# Copyright The IETF Trust 2020, All Rights Reserved + +from django.core.management.base import BaseCommand +from django.db.models import F + +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 DocExtResource.objects.filter(name__slug='github_repo'): + if not repo.value.endswith('/'): + repo.value += '/' + if repo not in info_dict: + info_dict[repo.value] = [] + for username in DocExtResource.objects.filter(name__slug='github_username', doc=F('doc')): + info_dict[repo.value].push(username.value) + + for repo in GroupExtResource.objects.filter(name__slug='github_repo'): + if not repo.value.endswith('/'): + repo.value += '/' + if repo not in info_dict: + info_dict[repo.value] = [] + for username in GroupExtResource.objects.filter(name__slug='github_username', group=F('group')): + info_dict[repo.value].push(username.value) + + for repo in PersonExtResource.objects.filter(name__slug='github_repo'): + if not repo.value.endswith('/'): + repo.value += '/' + if repo not in info_dict: + info_dict[repo.value] = [] + for username in PersonExtResource.objects.filter(name__slug='github_username', person=F('person')): + info_dict[repo.value].push(username.value) + + #print (json.dumps(info_dict)) + # For now, all we need are the repo names + for name in info_dict.keys(): + print(name) diff --git a/ietf/doc/migrations/0034_extres.py b/ietf/doc/migrations/0034_extres.py new file mode 100644 index 000000000..2157d0e86 --- /dev/null +++ b/ietf/doc/migrations/0034_extres.py @@ -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', '0014_extres'), + ('doc', '0033_populate_auth48_urls'), + ] + + 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')), + ], + ), + ] diff --git a/ietf/doc/migrations/0035_populate_docextresources.py b/ietf/doc/migrations/0035_populate_docextresources.py new file mode 100644 index 000000000..04b396a96 --- /dev/null +++ b/ietf/doc/migrations/0035_populate_docextresources.py @@ -0,0 +1,125 @@ +# 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 # pyflakes:ignore + +from collections import OrderedDict, Counter +from io import StringIO + +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 org.*": "github_org", + "GitHub User.*": "github_username", + "GitLab User": "gitlab_username", + "GitLab User Name": "gitlab_username", +} + +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() + stats_file = StringIO() + + for doc_url in DocumentUrl.objects.all(): + doc_url.url = doc_url.url.strip() + match_found = False + for regext,slug in name_map.items(): + if re.fullmatch(regext, doc_url.desc): + match_found = True + stats['mapped'] += 1 + name = ExtResourceName.objects.get(slug=slug) + try: + validate_external_resource_value(name, doc_url.url) + DocExtResource.objects.create(doc=doc_url.doc, name_id=slug, value=doc_url.url, display_name=doc_url.desc) + except ValidationError as e: # pyflakes:ignore + print("Failed validation:", doc_url.url, e, file=stats_file) + stats['failed_validation'] +=1 + break + if not match_found: + for regext, slug in url_map.items(): + 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) + except ValidationError as e: # pyflakes:ignore + print("Failed validation:", doc_url.url, e, file=stats_file) + stats['failed_validation'] +=1 + else: + stats['ignored'] +=1 + break + if not match_found: + print("Not Mapped:", doc_url.desc, doc_url.tag.slug, doc_url.doc.name, doc_url.url, file=stats_file) + stats['not_mapped'] += 1 + print('') + print(stats_file.getvalue()) + print (stats) + +def reverse(apps, schema_editor): + DocExtResource = apps.get_model('doc', 'DocExtResource') + DocExtResource.objects.all().delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('doc', '0034_extres'), + ('name', '0015_populate_extres'), + ] + + operations = [ + migrations.RunPython(forward, reverse) + ] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 617c6a6d5..3c88f2104 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -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 @@ -862,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") diff --git a/ietf/doc/resources.py b/ietf/doc/resources.py index 623634103..946667b97 100644 --- a/ietf/doc/resources.py +++ b/ietf/doc/resources.py @@ -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()) diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index 3752cfd62..0b2d8327e 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -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_username githubuser + webpage 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(), 3) + self.assertEqual(doc.docextresource_set.get(name__slug='github_repo').display_name, 'Some display text') + self.assertIn(doc.docextresource_set.first().name.slug,str(doc.docextresource_set.first())) + class SubmitToIesgTests(TestCase): diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index 1bef6f9ed..b9aa9b2f8 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -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), diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index 9b025956e..f780bfc5e 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -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:

%s" % ', '.join([ o.slug for o in DocUrlTagName.objects.all() ]) - title = "Additional document URLs" + info = "Valid tags:

%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.""" diff --git a/ietf/group/admin.py b/ietf/group/admin.py index a37741d4f..fda95d968 100644 --- a/ietf/group/admin.py +++ b/ietf/group/admin.py @@ -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) diff --git a/ietf/group/forms.py b/ietf/group/forms.py index 1d16f601c..441631724 100644 --- a/ietf/group/forms.py +++ b/ietf/group/forms.py @@ -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="Format: tag value (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.
" \ + + "Valid tags: %s" % ', '.join([ o.slug for o in ExtResourceName.objects.all().order_by('slug') ]) + self.fields['resources'].help_text = mark_safe('
'+info+'
') + 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." % ( diff --git a/ietf/group/migrations/0033_extres.py b/ietf/group/migrations/0033_extres.py new file mode 100644 index 000000000..2e3d037a2 --- /dev/null +++ b/ietf/group/migrations/0033_extres.py @@ -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', '0014_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')), + ], + ), + ] diff --git a/ietf/group/migrations/0034_populate_groupextresources.py b/ietf/group/migrations/0034_populate_groupextresources.py new file mode 100644 index 000000000..a8c0e1b98 --- /dev/null +++ b/ietf/group/migrations/0034_populate_groupextresources.py @@ -0,0 +1,120 @@ +# 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 # pyflakes:ignore + +from collections import OrderedDict, Counter +from io import StringIO + +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 org.*": "github_org", + "GitHub User.*": "github_username", + "GitLab User": "gitlab_username", + "GitLab User Name": "gitlab_username", +} + +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() + stats_file = StringIO() + + for group_url in GroupUrl.objects.all(): + group_url.url = group_url.url.strip() + match_found = False + for regext,slug in name_map.items(): + if re.fullmatch(regext, group_url.name): + match_found = True + stats['mapped'] += 1 + name = ExtResourceName.objects.get(slug=slug) + try: + validate_external_resource_value(name, group_url.url) + GroupExtResource.objects.create(group=group_url.group, name_id=slug, value=group_url.url, display_name=group_url.name) + except ValidationError as e: # pyflakes:ignore + print("Failed validation:", group_url.url, e, file=stats_file) + stats['failed_validation'] +=1 + break + if not match_found: + for regext, slug in url_map.items(): + 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) + except ValidationError as e: # pyflakes:ignore + print("Failed validation:", group_url.url, e, file=stats_file) + stats['failed_validation'] +=1 + else: + stats['ignored'] +=1 + break + if not match_found: + print("Not Mapped:",group_url.group.acronym, group_url.name, group_url.url, file=stats_file) + stats['not_mapped'] += 1 + print('') + print(stats_file.getvalue()) + 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', '0015_populate_extres'), + ] + + operations = [ + migrations.RunPython(forward, reverse) + ] diff --git a/ietf/group/models.py b/ietf/group/models.py index 093f18be1..7293c3e1a 100644 --- a/ietf/group/models.py +++ b/ietf/group/models.py @@ -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 diff --git a/ietf/group/resources.py b/ietf/group/resources.py index 8c119f7b8..509c6758e 100644 --- a/ietf/group/resources.py +++ b/ietf/group/resources.py @@ -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()) diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index c9281d1e6..304fe9eec 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -31,7 +31,7 @@ from ietf.group.factories import (GroupFactory, RoleFactory, GroupEventFactory, from ietf.group.models import Group, GroupEvent, GroupMilestone, GroupStateTransitions, Role from ietf.group.utils import save_group_in_history, setup_default_community_list_for_group from ietf.meeting.factories import SessionFactory -from ietf.name.models import DocTagName, GroupStateName, GroupTypeName +from ietf.name.models import DocTagName, GroupStateName, GroupTypeName, ExtResourceName from ietf.person.models import Person, Email from ietf.person.factories import PersonFactory from ietf.review.factories import ReviewRequestFactory, ReviewAssignmentFactory @@ -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,54 @@ 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) + ExtResourceName.objects.create(slug='keymaster', name='Keymaster', type_id='email') + + 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', + 'github_repo', + ) + + 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_username githubuser + webpage http://example.com/http/is/fine + jabber_room xmpp:mars@jabber.example.com + keymaster keymaster@example.org Group Rooter + """ + + 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(), 5) + self.assertEqual(group.groupextresource_set.get(name__slug='github_repo').display_name, 'Some display text') + self.assertIn(group.groupextresource_set.first().name.slug, str(group.groupextresource_set.first())) + + # exercise format_resources + r = self.client.get(url) + self.assertIn('Group Rooter', unicontent(r)) + def test_edit_field(self): group = GroupFactory(acronym="mars") diff --git a/ietf/group/views.py b/ietf/group/views.py index d81be7bea..4f7b4d90f 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -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] @@ -860,13 +860,15 @@ def edit(request, group_type=None, acronym=None, action="edit", field=None): return entry % dict(attr=attr, new=new, old=old) - def format_urls(urls, fs="\n"): + def format_resources(resources, fs="\n"): res = [] - for u in urls: - if u.name: - res.append("%s (%s)" % (u.url, u.name)) + 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(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) def diff(attr, name): @@ -922,11 +924,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 +993,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[\w\d:#@%/;$()~_?\+-=\\\.&]+)( \((?P.+)\))?', 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 +1057,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, ) diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index 72ef6729d..45719c7b1 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -667,6 +667,46 @@ 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', + 'website', + ) + + 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_username githubuser + webpage 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(), 3) + self.assertEqual(person.personextresource_set.get(name__slug='github_repo').display_name, 'Some display text') + self.assertIn(person.personextresource_set.first().name.slug, str(person.personextresource_set.first())) + class OpenIDConnectTests(TestCase): def request_matcher(self, request): @@ -800,3 +840,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, []) + diff --git a/ietf/ietfauth/urls.py b/ietf/ietfauth/urls.py index f31255542..12c749fbe 100644 --- a/ietf/ietfauth/urls.py +++ b/ietf/ietfauth/urls.py @@ -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[^/]+)/$', views.confirm_password_reset), url(r'^review/$', views.review_overview), diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 915b977c4..234a7e200 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -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:

%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) diff --git a/ietf/name/admin.py b/ietf/name/admin.py index 8c7660132..8462bddeb 100644 --- a/ietf/name/admin.py +++ b/ietf/name/admin.py @@ -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) diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index edd8d0de5..3822cecc8 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -9748,6 +9748,212 @@ "model": "name.draftsubmissionstatename", "pk": "waiting-for-draft" }, + { + "fields": { + "desc": "Frequently Asked Questions", + "name": "Frequently Asked Questions", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "faq" + }, + { + "fields": { + "desc": "GitHub Organization", + "name": "GitHub Organization", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "github_org" + }, + { + "fields": { + "desc": "GitHub Repository", + "name": "GitHub Repository", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "github_repo" + }, + { + "fields": { + "desc": "GitHub Username", + "name": "GitHub Username", + "order": 0, + "type": "string", + "used": true + }, + "model": "name.extresourcename", + "pk": "github_username" + }, + { + "fields": { + "desc": "GitLab Username", + "name": "GitLab Username", + "order": 0, + "type": "string", + "used": true + }, + "model": "name.extresourcename", + "pk": "gitlab_username" + }, + { + "fields": { + "desc": "Jabber Log", + "name": "Jabber Log", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "jabber_log" + }, + { + "fields": { + "desc": "Jabber Room", + "name": "Jabber Room", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "jabber_room" + }, + { + "fields": { + "desc": "Mailing List", + "name": "Mailing List", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "mailing_list" + }, + { + "fields": { + "desc": "Mailing List Archive", + "name": "Mailing List Archive", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "mailing_list_archive" + }, + { + "fields": { + "desc": "Other Repository", + "name": "Other Repository", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "repo" + }, + { + "fields": { + "desc": "Slack Channel", + "name": "Slack Channel", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "slack" + }, + { + "fields": { + "desc": "Issuer Tracker", + "name": "Issuer Tracker", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "tracker" + }, + { + "fields": { + "desc": "Additional Web Page", + "name": "Additional Web Page", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "webpage" + }, + { + "fields": { + "desc": "Wiki", + "name": "Wiki", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "wiki" + }, + { + "fields": { + "desc": "Yang Catalog Entry", + "name": "Yang Catalog Entry", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "yc_entry" + }, + { + "fields": { + "desc": "Yang Impact Analysis", + "name": "Yang Impact Analysis", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "yc_impact" + }, + { + "fields": { + "desc": "Email address", + "name": "Email address", + "order": 0, + "used": true + }, + "model": "name.extresourcetypename", + "pk": "email" + }, + { + "fields": { + "desc": "string", + "name": "string", + "order": 0, + "used": true + }, + "model": "name.extresourcetypename", + "pk": "string" + }, + { + "fields": { + "desc": "URL", + "name": "URL", + "order": 0, + "used": true + }, + "model": "name.extresourcetypename", + "pk": "url" + }, { "fields": { "desc": "", @@ -14637,7 +14843,7 @@ "fields": { "command": "xym", "switch": "--version", - "time": "2020-05-29T00:13:35.959", + "time": "2020-07-09T00:12:56.528", "used": true, "version": "xym 0.4.8" }, @@ -14648,7 +14854,7 @@ "fields": { "command": "pyang", "switch": "--version", - "time": "2020-05-29T00:13:38.724", + "time": "2020-07-09T00:12:58.135", "used": true, "version": "pyang 2.2.1" }, @@ -14659,7 +14865,7 @@ "fields": { "command": "yanglint", "switch": "--version", - "time": "2020-05-29T00:13:39.026", + "time": "2020-07-09T00:12:58.398", "used": true, "version": "yanglint SO 1.6.7" }, @@ -14670,7 +14876,7 @@ "fields": { "command": "xml2rfc", "switch": "--version", - "time": "2020-05-29T00:13:40.790", + "time": "2020-07-09T00:13:00.193", "used": true, "version": "xml2rfc 2.46.0" }, diff --git a/ietf/name/migrations/0014_extres.py b/ietf/name/migrations/0014_extres.py new file mode 100644 index 000000000..576a98ea3 --- /dev/null +++ b/ietf/name/migrations/0014_extres.py @@ -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', '0013_add_auth48_docurltagname'), + ] + + 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'), + ), + ] diff --git a/ietf/name/migrations/0015_populate_extres.py b/ietf/name/migrations/0015_populate_extres.py new file mode 100644 index 000000000..64a6bf08a --- /dev/null +++ b/ietf/name/migrations/0015_populate_extres.py @@ -0,0 +1,63 @@ +# 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("slack","Slack Channel", "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', '0014_extres'), + ('group', '0033_extres'), + ('doc', '0034_extres'), + ('person', '0015_extres'), + ] + + operations = [ + migrations.RunPython(forward, reverse) + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index 19e53d28a..69c96c29a 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -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) diff --git a/ietf/name/resources.py b/ietf/name/resources.py index 1f0d15549..80c78842a 100644 --- a/ietf/name/resources.py +++ b/ietf/name/resources.py @@ -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()) diff --git a/ietf/person/admin.py b/ietf/person/admin.py index 0c8d24f0b..0bb17b0b6 100644 --- a/ietf/person/admin.py +++ b/ietf/person/admin.py @@ -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", ] @@ -58,3 +63,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) diff --git a/ietf/person/migrations/0015_extres.py b/ietf/person/migrations/0015_extres.py new file mode 100644 index 000000000..1b9828585 --- /dev/null +++ b/ietf/person/migrations/0015_extres.py @@ -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', '0014_extres'), + ('person', '0014_auto_20200717_0743'), + ] + + 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')), + ], + ), + ] diff --git a/ietf/person/models.py b/ietf/person/models.py index 71d4bb34c..c7aad0ed2 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -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 @@ -244,6 +245,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 diff --git a/ietf/person/resources.py b/ietf/person/resources.py index cf54dede5..7db321ac6 100644 --- a/ietf/person/resources.py +++ b/ietf/person/resources.py @@ -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()) diff --git a/ietf/secr/areas/forms.py b/ietf/secr/areas/forms.py index edd399379..2a34408ad 100644 --- a/ietf/secr/areas/forms.py +++ b/ietf/secr/areas/forms.py @@ -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.") diff --git a/ietf/secr/areas/tests.py b/ietf/secr/areas/tests.py index 956277dfa..f7341dfa9 100644 --- a/ietf/secr/areas/tests.py +++ b/ietf/secr/areas/tests.py @@ -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) \ No newline at end of file diff --git a/ietf/secr/areas/urls.py b/ietf/secr/areas/urls.py index 7603403d2..cbb9a4a2f 100644 --- a/ietf/secr/areas/urls.py +++ b/ietf/secr/areas/urls.py @@ -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[A-Za-z0-9.-]+)/$', views.view), - url(r'^(?P[A-Za-z0-9.-]+)/edit/$', views.edit), url(r'^(?P[A-Za-z0-9.-]+)/people/$', views.people), url(r'^(?P[A-Za-z0-9.-]+)/people/modify/$', views.modify), ] diff --git a/ietf/secr/areas/views.py b/ietf/secr/areas/views.py index ca0bcb0a8..93e438cf8 100644 --- a/ietf/secr/areas/views.py +++ b/ietf/secr/areas/views.py @@ -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): diff --git a/ietf/secr/groups/forms.py b/ietf/secr/groups/forms.py index a21d57243..7b9611515 100644 --- a/ietf/secr/groups/forms.py +++ b/ietf/secr/groups/forms.py @@ -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) diff --git a/ietf/secr/groups/tests.py b/ietf/secr/groups/tests.py index 35bd245eb..f4772abcf 100644 --- a/ietf/secr/groups/tests.py +++ b/ietf/secr/groups/tests.py @@ -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): diff --git a/ietf/secr/groups/urls.py b/ietf/secr/groups/urls.py index 32659e323..60d3566ca 100644 --- a/ietf/secr/groups/urls.py +++ b/ietf/secr/groups/urls.py @@ -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\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), ] diff --git a/ietf/secr/groups/views.py b/ietf/secr/groups/views.py index ad3ac8ff7..7f72dce43 100644 --- a/ietf/secr/groups/views.py +++ b/ietf/secr/groups/views.py @@ -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 = {} diff --git a/ietf/secr/templates/areas/add.html b/ietf/secr/templates/areas/add.html deleted file mode 100644 index 19edd797c..000000000 --- a/ietf/secr/templates/areas/add.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "base_site.html" %} -{% load staticfiles %} -{% block title %}Areas - Add{% endblock %} - -{% block extrahead %}{{ block.super }} - -{% endblock %} - -{% block breadcrumbs %}{{ block.super }} - » Areas - » Add -{% endblock %} - -{% block content %} - -
-

Area - Add

-
{% csrf_token %} - - - {{ area_form.as_table }} -
- - {% include "includes/awp_add_form.html" %} - - {% include "includes/buttons_submit.html" %} - -
-
- -{% endblock %} diff --git a/ietf/secr/templates/areas/edit.html b/ietf/secr/templates/areas/edit.html deleted file mode 100644 index 238c295dc..000000000 --- a/ietf/secr/templates/areas/edit.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base_site.html" %} -{% load staticfiles %} -{% block title %}Areas - Edit{% endblock %} - -{% block extrahead %}{{ block.super }} - -{% endblock %} - -{% block breadcrumbs %}{{ block.super }} - » Areas - » {{ area.acronym }} - » Edit -{% endblock %} - -{% block content %} - -
-
{% csrf_token %} -

Area - Edit

- - - {{ form.as_table }} -
- - {% include "includes/awp_edit_form.html" %} - - {% include "includes/buttons_save_cancel.html" %} - -
-
- -{% endblock %} diff --git a/ietf/secr/templates/areas/list.html b/ietf/secr/templates/areas/list.html index ed5ec4511..7cefc114e 100644 --- a/ietf/secr/templates/areas/list.html +++ b/ietf/secr/templates/areas/list.html @@ -13,7 +13,7 @@ {% block content %}
-

Areas Add

+

Areas

diff --git a/ietf/secr/templates/areas/view.html b/ietf/secr/templates/areas/view.html index f3f428ff1..716e15a23 100644 --- a/ietf/secr/templates/areas/view.html +++ b/ietf/secr/templates/areas/view.html @@ -35,10 +35,6 @@
- {% with area.groupurl_set.all as awps %} - {% include "includes/awp_view.html" %} - {% endwith %} -
    diff --git a/ietf/secr/templates/groups/add.html b/ietf/secr/templates/groups/add.html deleted file mode 100644 index 6111f7ab9..000000000 --- a/ietf/secr/templates/groups/add.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base_site.html" %} -{% load staticfiles %} - -{% block title %}Groups - Add{% endblock %} - -{% block extrahead %}{{ block.super }} - -{% endblock %} - -{% block breadcrumbs %}{{ block.super }} - » Groups - » Add -{% endblock %} - -{% block content %} - -
    -

    Groups - Add

    -
    {% csrf_token %} - - - {{ form.as_table }} -
    - - {% include "includes/awp_add_form.html" %} - - {% include "includes/buttons_save_cancel.html" %} - -
    -
    - -{% endblock %} diff --git a/ietf/secr/templates/groups/edit.html b/ietf/secr/templates/groups/edit.html deleted file mode 100644 index b3d21dc12..000000000 --- a/ietf/secr/templates/groups/edit.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base_site.html" %} -{% load staticfiles %} - -{% block title %}Groups - Edit{% endblock %} - -{% block extrahead %}{{ block.super }} - -{% endblock %} - -{% block breadcrumbs %}{{ block.super }} - » Groups - » {{ group.acronym }} - » Edit -{% endblock %} - -{% block content %} - -
    -

    Groups - Edit

    -
    {% csrf_token %} - - - {{ form }} -
    - - {% include "includes/awp_edit_form.html" %} - - {% include "includes/buttons_save_cancel.html" %} - -
    -
    - -{% endblock %} diff --git a/ietf/secr/templates/groups/search.html b/ietf/secr/templates/groups/search.html index 285ce7bde..4b57aa718 100644 --- a/ietf/secr/templates/groups/search.html +++ b/ietf/secr/templates/groups/search.html @@ -14,7 +14,7 @@ {% block content %}
    -

    Groups - Search Add

    +

    Groups - Search

    {% csrf_token %} {{ form.as_table }} diff --git a/ietf/secr/templates/groups/view.html b/ietf/secr/templates/groups/view.html index f64eb4ea3..cfef244cc 100644 --- a/ietf/secr/templates/groups/view.html +++ b/ietf/secr/templates/groups/view.html @@ -58,9 +58,6 @@
    Last Modified Date:{{ group.time }}
    - {% with group.groupurl_set.all as awps %} - {% include "includes/awp_view.html" %} - {% endwith %}
    diff --git a/ietf/secr/templates/includes/awp_add_form.html b/ietf/secr/templates/includes/awp_add_form.html deleted file mode 100644 index fd5d73abd..000000000 --- a/ietf/secr/templates/includes/awp_add_form.html +++ /dev/null @@ -1,15 +0,0 @@ -
    -

    Additional Web Pages

    - {{ awp_formset.management_form }} - {{ awp_formset.non_form_errors }} - - {% for form in awp_formset.forms %} -
    -

    Web Page:  #{{ forloop.counter }}

    - - - {{ form.as_table }} -
    -
    - {% endfor %} -
    diff --git a/ietf/secr/templates/includes/awp_edit_form.html b/ietf/secr/templates/includes/awp_edit_form.html deleted file mode 100644 index b6eeb2c52..000000000 --- a/ietf/secr/templates/includes/awp_edit_form.html +++ /dev/null @@ -1,24 +0,0 @@ -

    Additional Web Pages

    -{{ awp_formset.management_form }} -{{ awp_formset.non_form_errors }} - -{% for form in awp_formset.forms %} -
    -

    Web Page:  #{{ forloop.counter }} - {% if awp_formset.can_delete %}{{ form.DELETE }} Delete{% endif %} -

    - {% if form.non_field_errors %}{{ form.non_field_errors }}{% endif %} - - - - - - - - - - - {{ form.id }} -
    URL:{{ form.url.errors }}{{ form.url }}
    Name:{{ form.name.errors }}{{ form.name }}
    -
    -{% endfor %} diff --git a/ietf/secr/templates/includes/awp_view.html b/ietf/secr/templates/includes/awp_view.html deleted file mode 100644 index 55e070ddc..000000000 --- a/ietf/secr/templates/includes/awp_view.html +++ /dev/null @@ -1,13 +0,0 @@ -
    -

    Additional Web Pages

    - {% for item in awps %} -
    -

    Web Page:  #{{ forloop.counter }}

    - - - - -
    URL:{{ item.url }}
    Name:{{ item.name }}
    -
    - {% endfor %} -
    diff --git a/ietf/secr/templates/roles/main.html b/ietf/secr/templates/roles/main.html index 97fb37010..c552a3921 100755 --- a/ietf/secr/templates/roles/main.html +++ b/ietf/secr/templates/roles/main.html @@ -47,7 +47,6 @@ {{ group_form.as_p }} -

    Add a new group...


    diff --git a/ietf/submit/utils.py b/ietf/submit/utils.py index 82142d134..a833ce7f0 100644 --- a/ietf/submit/utils.py +++ b/ietf/submit/utils.py @@ -377,19 +377,18 @@ 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='yc_impact', display_name=desc) # Yang module metadata URLs - old_urls = draft.documenturl_set.filter(tag_id='yang-module-metadata') - old_urls.delete() + draft.docextresource_set.filter(name_id='yc_entry').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')) diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 71edd686c..9e5b48a54 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -245,33 +245,38 @@ {% endif %} - {% if additional_urls or can_edit_stream_info or can_edit_individual %} - - - Additional URLs - - {% if can_edit_stream_info or can_edit_individual %} - Edit + {% with doc.docextresource_set.all as resources %} + {% if resources or can_edit_stream_info or can_edit_individual %} + + + Additional Resources + + {% if can_edit_stream_info or can_edit_individual %} + Edit + {% endif %} + + + {% if resources or doc.group and doc.group.list_archive %} + + + {% for resource in resources|dictsort:"display_name" %} + {% if resource.name.type.slug == 'url' or resource.name.type.slug == 'email' %} + + {# Maybe make how a resource displays itself a method on the class so templates aren't doing this switching #} + {% else %} + + {% endif %} + {% endfor %} + {% if doc.group and doc.group.list_archive %} + + {% endif %} + +
    - {% firstof resource.display_name resource.name.name %}
    - {% firstof resource.display_name resource.name.name %}: {{resource.value}}
    - Mailing list discussion
    + {% endif %} + + {% endif %} - - - {% if additional_urls or doc.group and doc.group.list_archive %} - - - {% for url in additional_urls|dictsort:"desc" %} - - {% endfor %} - {% if doc.group and doc.group.list_archive %} - - {% endif %} - -
    - {% firstof url.desc url.tag.name %}
    - Mailing list discussion
    - {% endif %} - - - {% endif %} - - + {% endwith %} diff --git a/ietf/templates/group/group_about.html b/ietf/templates/group/group_about.html index ba94d4407..0bc00f6d7 100644 --- a/ietf/templates/group/group_about.html +++ b/ietf/templates/group/group_about.html @@ -114,30 +114,35 @@ {% endif %} - {% with group.groupurl_set.all as urls %} - {% if urls or can_edit_group %} - - - Additional URLs - - {% if can_edit_group %} - Edit - {% endif %} - - - {% if urls %} - - - {% for url in urls %} - - {% endfor %} - -
    - {% firstof url.name url.url %}
    - {% endif %} - - + {% with group.groupextresource_set.all as resources %} + {% if resources or can_edit_group %} + + + Additional Resources + + {% if can_edit_group %} + Edit + {% endif %} + + + {% if resources %} + + + {% for resource in resources|dictsort:"display_name" %} + {% if resource.name.type.slug == 'url' or resource.name.type.slug == 'email' %} + + {# Maybe make how a resource displays itself a method on the class so templates aren't doing this switching #} + {% else %} + + {% endif %} + {% endfor %} + +
    - {% firstof resource.display_name resource.name.name %}
    - {% firstof resource.display_name resource.name.name %}: {{resource.value}}
    + {% endif %} + + {% endif %} - {% endwith %} + {% endwith %} diff --git a/ietf/templates/ietfauth/edit_field.html b/ietf/templates/ietfauth/edit_field.html new file mode 100644 index 000000000..4a3ae692a --- /dev/null +++ b/ietf/templates/ietfauth/edit_field.html @@ -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 %} +

    {{ title }}
    {{ person.plain_name }}

    + +

    + + {{ info|safe }} + +

    + + +
    + {% csrf_token %} + {% bootstrap_form form %} + + {% buttons %} + + + Back + {% endbuttons %} +
    + +{% endblock %} diff --git a/ietf/templates/liaisons/edit.html b/ietf/templates/liaisons/edit.html index 20d2b73e6..16a7ca6eb 100644 --- a/ietf/templates/liaisons/edit.html +++ b/ietf/templates/liaisons/edit.html @@ -45,7 +45,7 @@ {% for fieldset in form.fieldsets %} {% if forloop.first and user|has_role:"Secretariat" %} -

    {{ fieldset.name }}

    +

    {{ fieldset.name }}

    {% else %}

    {{ fieldset.name }}

    {% endif %} diff --git a/ietf/templates/person/profile.html b/ietf/templates/person/profile.html index 1238e3081..3e3c74e9a 100644 --- a/ietf/templates/person/profile.html +++ b/ietf/templates/person/profile.html @@ -57,7 +57,19 @@
{% endif %} - + {% if person.personextresource_set.exists %} +
+

External Resources

+ + {% for extres in person.personextresource_set.all %} + + + + + {% endfor %} +
{% firstof extres.display_name extres.name.name %}{{extres.value}}
+
+ {% endif %}

RFCs

diff --git a/ietf/templates/registration/edit_profile.html b/ietf/templates/registration/edit_profile.html index e4557bdac..3dfaefd54 100644 --- a/ietf/templates/registration/edit_profile.html +++ b/ietf/templates/registration/edit_profile.html @@ -94,7 +94,7 @@
{{person|is_nomcom_eligible|yesno:'Yes,No,No'}}
-

+

This calculation is EXPERIMENTAL.
If you believe it is incorrect, make sure you've added all the @@ -111,6 +111,22 @@

+
+ +
+ {% for extres in person.personextresource_set.all %} +
+
{% firstof extres.display_name extres.name.name %}
+
{{extres.value}} + {% if forloop.first %} {% endif %} +
+
+ {% empty %} +
None 
+ {% endfor %} +
+
+
diff --git a/ietf/utils/management/commands/create_group_wikis.py b/ietf/utils/management/commands/create_group_wikis.py index 13315b012..bbcfadf78 100644 --- a/ietf/utils/management/commands/create_group_wikis.py +++ b/ietf/utils/management/commands/create_group_wikis.py @@ -22,7 +22,7 @@ from django.template.loader import render_to_string import debug # pyflakes:ignore -from ietf.group.models import Group, GroupURL, GroupFeatures +from ietf.group.models import Group, GroupFeatures from ietf.utils.pipe import pipe logtag = __name__.split('.')[-1] @@ -217,8 +217,8 @@ class Command(BaseCommand): env = Environment(group.trac_dir, create=True, options=options) self.remove_demo_components(env) self.remove_demo_milestones(env) - self.maybe_add_group_url(group, 'Wiki', settings.TRAC_WIKI_URL_PATTERN % group.acronym) - self.maybe_add_group_url(group, 'Issue tracker', settings.TRAC_ISSUE_URL_PATTERN % group.acronym) + self.maybe_add_group_url(group, 'wiki', settings.TRAC_WIKI_URL_PATTERN % group.acronym) + self.maybe_add_group_url(group, 'tracker', settings.TRAC_ISSUE_URL_PATTERN % group.acronym) # Use custom assets (if any) from the master setup self.symlink_to_master_assets(group.trac_dir, env) if group.features.acts_like_wg: @@ -301,12 +301,10 @@ class Command(BaseCommand): comp.owner = "%s@ietf.org" % doc.name comp.insert() - def maybe_add_group_url(self, group, name, url): - urls = [ u for u in group.groupurl_set.all() if name.lower() in u.name.lower() ] - if not urls: - self.note(" adding %s %s URL ..." % (group.acronym, name.lower())) - url = GroupURL.objects.create(group=group, name=name, url=url) - group.groupurl_set.add(url) + def maybe_add_group_url(self, group, slug, url): + if not group.groupextresource_set.filter(name__slug=slug).exists(): + self.note(" adding %s %s URL ..." % (group.acronym, slug)) + group.groupextresource_set.create(name_id=slug,value=url) def add_custom_pages(self, group, env): for template_name in settings.TRAC_WIKI_PAGES_TEMPLATES: diff --git a/ietf/utils/validators.py b/ietf/utils/validators.py index b38127154..6bac6fb81 100644 --- a/ietf/utils/validators.py +++ b/ietf/utils/validators.py @@ -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,107 @@ 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}(? 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) + hostname = urlparse(value).netloc.lower() + if not any([ hostname.endswith(x) for x in ('github.com','github.io' ) ]): + 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) +