datatracker/ietf/group/admin.py
Paul Selkirk 65cf001ecf
feat: Group admin form accepts acronyms starting with numbers for SDO groups (#7051)
* feat: Group admin form accepts acronyms starting with numbers for SDO groups (#6825)

* fix: Acronym can't start with hyphen

* fix: Restore some tests
2024-02-20 16:38:11 -06:00

352 lines
13 KiB
Python

# Copyright The IETF Trust 2010-2024, All Rights Reserved
# -*- coding: utf-8 -*-
import re
from functools import update_wrapper
from base64 import b64encode
import debug # pyflakes:ignore
from django import forms
from django.contrib import admin
from django.contrib.admin.utils import unquote
from django.core.management import load_command_class
from django.db.models import BinaryField
from django.http import Http404
from django.shortcuts import render
from django.utils.encoding import force_str
from django.utils.html import escape
from django.utils.translation import gettext as _
from ietf.group.models import (Group, GroupFeatures, GroupHistory, GroupEvent, GroupURL, GroupMilestone,
GroupMilestoneHistory, GroupStateTransitions, Role, RoleHistory, ChangeStateGroupEvent,
MilestoneGroupEvent, GroupExtResource, Appeal, AppealArtifact )
from ietf.name.models import GroupTypeName
from ietf.utils.validators import validate_external_resource_value
from ietf.utils.response import permission_denied
class RoleInline(admin.TabularInline):
model = Role
raw_id_fields = ["person", "email"]
class GroupURLInline(admin.TabularInline):
model = GroupURL
class GroupForm(forms.ModelForm):
# Use CharField with our own validation instead of default SlugField. The real check is in the clean() method.
acronym = forms.CharField(min_length=2, max_length=40)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['type'].required = True # require this even though the model field can nominally be null
class Meta:
model = Group
fields = '__all__'
def clean_used_roles(self):
data = self.cleaned_data['used_roles']
if data is None or data == '':
raise forms.ValidationError("Must contain a valid json expression. To use the defaults prove an empty list: []")
return data
def clean(self):
"""Clean parts of the form that involve multiple fields"""
# Constrain the acronym form. Note that this doesn't look for collisions.
# See ietf.group.forms.GroupForm.clean_acronym()
if 'acronym' in self.cleaned_data:
acronym = self.cleaned_data['acronym'].strip().lower()
self.cleaned_data['acronym'] = acronym
if 'type' in self.cleaned_data and not self.instance.pk:
features = GroupFeatures.objects.get(type=self.cleaned_data['type'])
new_and_has_documents = features.has_documents if features else False
else:
new_and_has_documents = False
if new_and_has_documents:
valid_re = r'^[a-z][a-z0-9]+$'
error_msg = (
'Acronym is invalid. For groups that create documents, the acronym must be at least '
'two characters and only contain lowercase letters and numbers starting with a letter.'
)
elif self.cleaned_data['type'].pk == 'sdo':
valid_re = r'^[a-z0-9][a-z0-9-]*[a-z0-9]$'
error_msg = (
'Acronym is invalid. It must be at least two characters and only contain lowercase '
'letters and numbers. It may contain hyphens, but that is discouraged.'
)
else:
valid_re = r'^[a-z][a-z0-9-]*[a-z0-9]$'
error_msg = (
'Acronym is invalid. It must be at least two characters and only contain lowercase '
'letters and numbers starting with a letter. It may contain hyphens, but that is discouraged.'
)
if not re.match(valid_re, acronym):
self.add_error('acronym', error_msg)
class GroupAdmin(admin.ModelAdmin):
form = GroupForm
list_display = ["acronym", "name", "type", "state", "time", "role_list"]
list_display_links = ["acronym", "name"]
list_filter = ["type", "state", "time"]
search_fields = ["acronym", "name"]
ordering = ["name"]
raw_id_fields = ["charter", "parent"]
inlines = [RoleInline, GroupURLInline]
prepopulated_fields = {"acronym": ("name", )}
def role_list(self, obj):
roles = Role.objects.filter(group=obj).order_by("name", "person__name").select_related('person')
res = []
for r in roles:
res.append('<a href="../../person/person/%s/">%s</a> (<a href="../../group/role/%s/">%s)' % (r.person.pk, escape(r.person.plain_name()), r.pk, r.name.name))
return ", ".join(res)
role_list.short_description = "Persons" # type: ignore # https://github.com/python/mypy/issues/2087
role_list.allow_tags = True # type: ignore # https://github.com/python/mypy/issues/2087
# SDO reminder
def get_urls(self):
from ietf.utils.urls import url
def wrap(view):
def wrapper(*args, **kwargs):
return self.admin_site.admin_view(view)(*args, **kwargs)
return update_wrapper(wrapper, view)
info = self.model._meta.app_label, self.model._meta.model_name
urls = [
url(r'^reminder/$', wrap(self.send_reminder), name='%s_%s_reminder' % info),
url(r'^(.+)/reminder/$', wrap(self.send_one_reminder), name='%s_%s_one_reminder' % info),
]
urls += super(GroupAdmin, self).get_urls()
return urls
def send_reminder(self, request, sdo=None):
opts = self.model._meta
app_label = opts.app_label
output = None
sdo_pk = sdo and sdo.pk or None
if request.method == 'POST' and request.POST.get('send', False):
command = load_command_class('ietf.liaisons', 'remind_update_sdo_list')
output=command.handle(return_output=True, sdo_pk=sdo_pk)
output='\n'.join(output)
context = {
'opts': opts,
'has_change_permission': self.has_change_permission(request),
'app_label': app_label,
'output': output,
'sdo': sdo,
}
return render(request, 'admin/group/group/send_sdo_reminder.html', context )
def send_one_reminder(self, request, object_id):
model = self.model
opts = model._meta
try:
obj = self.queryset(request).get(pk=unquote(object_id))
except model.DoesNotExist:
obj = None
if not self.has_change_permission(request, obj):
permission_denied(request, "You don't have edit permissions for this change.")
if obj is None:
raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % {'name': force_str(opts.verbose_name), 'key': escape(object_id)})
return self.send_reminder(request, sdo=obj)
admin.site.register(Group, GroupAdmin)
class GroupFeaturesAdminForm(forms.ModelForm):
def clean_default_parent(self):
# called before form clean() method -- cannot access other fields
parent_acro = self.cleaned_data['default_parent'].strip().lower()
if len(parent_acro) > 0:
if Group.objects.filter(acronym=parent_acro).count() == 0:
raise forms.ValidationError(
'No group exists with acronym "%(acro)s"',
params=dict(acro=parent_acro),
)
return parent_acro
def clean(self):
# cleaning/validation that requires multiple fields
parent_acro = self.cleaned_data['default_parent']
if len(parent_acro) > 0:
parent_type = GroupTypeName.objects.filter(group__acronym=parent_acro).first()
if parent_type not in self.cleaned_data['parent_types']:
self.add_error(
'default_parent',
forms.ValidationError(
'Default parent group "%(acro)s" is type "%(gtype)s", which is not an allowed parent type.',
params=dict(acro=parent_acro, gtype=parent_type),
)
)
class GroupFeaturesAdmin(admin.ModelAdmin):
form = GroupFeaturesAdminForm
list_display = [
'type',
'need_parent',
'default_parent',
'gf_parent_types',
'has_milestones',
'has_chartering_process',
'has_documents',
'has_session_materials',
'has_nonsession_materials',
'has_meetings',
'has_reviews',
'has_default_chat',
'acts_like_wg',
'create_wiki',
'custom_group_roles',
'customize_workflow',
'is_schedulable',
'show_on_agenda',
'agenda_filter_type',
'req_subm_approval',
'agenda_type',
'material_types',
'admin_roles',
'docman_roles',
'groupman_roles',
'groupman_authroles',
'matman_roles',
'role_order',
]
def gf_parent_types(self, groupfeatures):
"""Generate list of parent types; needed because many-to-many is not handled automatically"""
return ', '.join([gtn.slug for gtn in groupfeatures.parent_types.all()])
gf_parent_types.short_description = 'Parent Types' # type: ignore # https://github.com/python/mypy/issues/2087
admin.site.register(GroupFeatures, GroupFeaturesAdmin)
class GroupHistoryAdmin(admin.ModelAdmin):
list_display = ["time", "acronym", "name", "type"]
list_display_links = ["acronym", "name"]
list_filter = ["type"]
search_fields = ["acronym", "name"]
ordering = ["name"]
raw_id_fields = ["group", "parent"]
admin.site.register(GroupHistory, GroupHistoryAdmin)
class GroupURLAdmin(admin.ModelAdmin):
list_display = ['id', 'group', 'name', 'url']
raw_id_fields = ['group']
search_fields = ['name']
admin.site.register(GroupURL, GroupURLAdmin)
class GroupMilestoneAdmin(admin.ModelAdmin):
list_display = ["group", "desc", "due", "resolved", "time"]
search_fields = ["group__name", "group__acronym", "desc", "resolved"]
raw_id_fields = ["group", "docs"]
admin.site.register(GroupMilestone, GroupMilestoneAdmin)
admin.site.register(GroupMilestoneHistory, GroupMilestoneAdmin)
class GroupStateTransitionsAdmin(admin.ModelAdmin):
list_display = ['id', 'group', 'state']
raw_id_fields = ['group', 'state']
admin.site.register(GroupStateTransitions, GroupStateTransitionsAdmin)
class RoleAdmin(admin.ModelAdmin):
list_display = ["name", "person", "email", "group"]
list_display_links = ["name"]
search_fields = ["name__name", "person__name", "email__address"]
list_filter = ["name", "group"]
ordering = ["id"]
raw_id_fields = ["email", "person", "group"]
admin.site.register(Role, RoleAdmin)
admin.site.register(RoleHistory, RoleAdmin)
class GroupEventAdmin(admin.ModelAdmin):
list_display = ["id", "group", "time", "type", "by", ]
search_fields = ["group__name", "group__acronym"]
admin.site.register(GroupEvent, GroupEventAdmin)
class ChangeStateGroupEventAdmin(admin.ModelAdmin):
list_display = ["id", "group", "state", "time", "type", "by", ]
list_filter = ["state", "time", ]
search_fields = ["group__name", "group__acronym"]
admin.site.register(ChangeStateGroupEvent, ChangeStateGroupEventAdmin)
class MilestoneGroupEventAdmin(admin.ModelAdmin):
list_display = ['id', 'group', 'time', 'type', 'by', 'desc', 'milestone']
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)
class AppealAdmin(admin.ModelAdmin):
list_display = ["group", "date", "name"]
search_fields = ["group__acronym", "date", "name"]
raw_id_fields = ["group"]
admin.site.register(Appeal, AppealAdmin)
# From https://stackoverflow.com/questions/58529099/adding-file-upload-widget-for-binaryfield-to-django-admin
class BinaryFileInput(forms.ClearableFileInput):
def is_initial(self, value):
"""
Return whether value is considered to be initial value.
"""
return bool(value)
def format_value(self, value):
"""Format the size of the value in the db.
We can't render it's name or url, but we'd like to give some information
as to wether this file is not empty/corrupt.
"""
if self.is_initial(value):
return f'{len(value)} bytes'
def value_from_datadict(self, data, files, name):
"""Return the file contents so they can be put in the db."""
upload = super().value_from_datadict(data, files, name)
if upload:
bits = upload.read()
return b64encode(bits).decode("ascii") # Who made this so hard?
class RestrictContentTypeChoicesForm(forms.ModelForm):
content_type = forms.ChoiceField(
choices=(
( "text/markdown;charset=utf-8", "Markdown"),
( "application/pdf", "PDF")
)
)
class AppealArtifactAdmin(admin.ModelAdmin):
list_display = ["display_title", "appeal","date"]
ordering = ["-appeal__date", "date"]
formfield_overrides = {
BinaryField: { "widget": BinaryFileInput() },
}
form = RestrictContentTypeChoicesForm
admin.site.register(AppealArtifact, AppealArtifactAdmin)