diff options
author | Michael Elkins <melkins@tislabs.com> | 2010-07-06 06:01:41 +0000 |
---|---|---|
committer | Michael Elkins <melkins@tislabs.com> | 2010-07-06 06:01:41 +0000 |
commit | a8f7b8797ca94e32bf2ca4d6ac599911e1019652 (patch) | |
tree | fd4c5ced3e567ecefa78aad058fab3a8f771b27b | |
parent | add56fabaaf32cabeb02b5fcd0dc92366bad7284 (diff) |
rework portal-gui to support max prefix lengths
svn path=/portal-gui/rpkigui/myrpki/admin.py; revision=3340
-rw-r--r-- | portal-gui/rpkigui/myrpki/admin.py | 4 | ||||
-rw-r--r-- | portal-gui/rpkigui/myrpki/forms.py | 67 | ||||
-rw-r--r-- | portal-gui/rpkigui/myrpki/glue.py | 10 | ||||
-rw-r--r-- | portal-gui/rpkigui/myrpki/misc.py | 10 | ||||
-rw-r--r-- | portal-gui/rpkigui/myrpki/models.py | 64 | ||||
-rw-r--r-- | portal-gui/rpkigui/myrpki/urls.py | 1 | ||||
-rw-r--r-- | portal-gui/rpkigui/myrpki/views.py | 69 | ||||
-rw-r--r-- | portal-gui/rpkigui/settings.py | 6 | ||||
-rw-r--r-- | portal-gui/rpkigui/templates/myrpki/dashboard.html | 4 | ||||
-rw-r--r-- | portal-gui/rpkigui/templates/myrpki/prefix_view.html | 22 |
10 files changed, 196 insertions, 61 deletions
diff --git a/portal-gui/rpkigui/myrpki/admin.py b/portal-gui/rpkigui/myrpki/admin.py index 3d843bb0..b3932770 100644 --- a/portal-gui/rpkigui/myrpki/admin.py +++ b/portal-gui/rpkigui/myrpki/admin.py @@ -23,10 +23,14 @@ class RoaAdmin( admin.ModelAdmin ): class ResourceCertAdmin(admin.ModelAdmin): pass +class RoaRequestAdmin(admin.ModelAdmin): + pass + admin.site.register(models.Conf, ConfAdmin) admin.site.register(models.Child, ChildAdmin) admin.site.register(models.AddressRange, AddressRangeAdmin) admin.site.register(models.Asn, AsnAdmin) admin.site.register(models.Parent, ParentAdmin) admin.site.register(models.Roa, RoaAdmin) +admin.site.register(models.RoaRequest, RoaRequestAdmin) admin.site.register(models.ResourceCert, ResourceCertAdmin) diff --git a/portal-gui/rpkigui/myrpki/forms.py b/portal-gui/rpkigui/myrpki/forms.py index 866be7dd..d0ddc462 100644 --- a/portal-gui/rpkigui/myrpki/forms.py +++ b/portal-gui/rpkigui/myrpki/forms.py @@ -3,6 +3,7 @@ from django import forms import models from rpkigui.myrpki.misc import str_to_addr +from rpkigui.myrpki.asnset import asnset def ConfCertForm(request): class CertForm(forms.ModelForm): @@ -62,7 +63,8 @@ def RangeForm(field_type, *args, **kwargs): hi = self.cleaned_data.get('hi') if lo > hi: # should we just fix it? - raise forms.ValidationError, 'Lower bound is higher than upper.' + raise forms.ValidationError, \ + 'Lower bound is higher than upper.' return self.cleaned_data return wrapped(*args, **kwargs) @@ -92,8 +94,8 @@ def get_pk(child): def SubOrAssignForm(handle, addr, field_type, *args, **kwargs): '''Closure to select child of the specified handle.''' class Wrapper(forms.Form): - '''Form for the address view to allow the user to subdivide or assign - the block to a child.''' + '''Form for the address view to allow the user to subdivide or + assign the block to a child.''' lo = field_type(required=False, label='Lower bound') hi = field_type(required=False, label='Upper bound') child = forms.ModelChoiceField(required=False, label='Assign to child', @@ -107,7 +109,8 @@ def SubOrAssignForm(handle, addr, field_type, *args, **kwargs): lo = None if lo != None: if lo < addr.lo or lo > addr.hi: - raise forms.ValidationError, 'Value is out of range of parent.' + raise forms.ValidationError, \ + 'Value is out of range of parent.' # ensure there is no overlap with other children for c in addr.children.all(): if lo >= c.lo and lo <= c.hi: @@ -162,7 +165,8 @@ def SubOrAssignAsnForm(handle, asn, *args, **kwargs): return SubOrAssignForm(handle, asn, forms.IntegerField, *args, **kwargs) def RoaForm(handle, pk=None, initval=[], *args, **kwargs): - vals = models.AddressRange.objects.filter(from_parent__in=handle.parents.all()) + vals = models.AddressRange.objects.filter( + from_parent__in=handle.parents.all()) class Wrapped(forms.Form): asn = AsnField(initial=pk) @@ -186,9 +190,11 @@ def PrefixSplitForm(prefix, *args, **kwargs): pfx_loaddr = str_to_addr(prefix.lo) pfx_hiaddr = str_to_addr(prefix.hi) if type(loaddr) != type(pfx_hiaddr): - raise forms.ValidationError, 'Not the same IP address type as parent' + raise forms.ValidationError, \ + 'Not the same IP address type as parent' if loaddr < pfx_loaddr or loaddr > pfx_hiaddr: - raise forms.ValidationError, 'Value out of range of parent prefix' + raise forms.ValidationError, \ + 'Value out of range of parent prefix' return lo def clean_hi(self): @@ -201,9 +207,11 @@ def PrefixSplitForm(prefix, *args, **kwargs): pfx_loaddr = str_to_addr(prefix.lo) pfx_hiaddr = str_to_addr(prefix.hi) if type(hiaddr) != type(pfx_loaddr): - raise forms.ValidationError, 'Not the same IP address type as parent' + raise forms.ValidationError, \ + 'Not the same IP address type as parent' if hiaddr < pfx_loaddr or hiaddr > pfx_hiaddr: - raise forms.ValidationError, 'Value out of range of parent prefix' + raise forms.ValidationError, \ + 'Value out of range of parent prefix' return hi def clean(self): @@ -224,21 +232,35 @@ def PrefixSplitForm(prefix, *args, **kwargs): def PrefixAllocateForm(iv, child_set, *args, **kwargs): class _wrapper(forms.Form): - child = forms.ModelChoiceField(initial=iv, queryset=child_set, required=False) + child = forms.ModelChoiceField(initial=iv, queryset=child_set, + required=False) return _wrapper(*args, **kwargs) -class PrefixRoaForm(forms.Form): - asns = forms.CharField(max_length=200, required=False) +def PrefixRoaForm(prefix, *args, **kwargs): + prefix_range = prefix.as_resource_range() - def clean_asns(self): - try: - v = [int(d) for d in self.cleaned_data['asns'].split(',') if d.strip() != ''] - if any([x for x in v if x < 0]): - raise forms.ValidationError, 'must be a positive integer' - return ','.join(str(x) for x in sorted(v)) - except ValueError: - raise forms.ValidationError, 'must be a list of integers separated by commas' - return self.cleaned_data['asns'] + class _wrapper(forms.Form): + asns = forms.CharField(max_length=200, required=False, help_text='Comma-separated list of ASNs') + max_length = forms.IntegerField(required=False, + min_value=prefix_range._prefixlen(), + max_value=prefix_range.datum_type.bits) + + def clean_max_length(self): + v = self.cleaned_data.get('max_length') + if not v: + v = prefix_range._prefixlen() + return v + + def clean_asns(self): + try: + v = asnset(self.cleaned_data.get('asns')) + return ','.join(str(x) for x in sorted(v)) + except ValueError: + raise forms.ValidationError, \ + 'Must be a list of integers separated by commas.' + return self.cleaned_data['asns'] + + return _wrapper(*args, **kwargs) def PrefixDeleteForm(prefix, *args, **kwargs): class _wrapped(forms.Form): @@ -248,7 +270,8 @@ def PrefixDeleteForm(prefix, *args, **kwargs): v = self.cleaned_data.get('delete') if v: if not prefix.parent: - raise forms.ValidationError, 'Can not delete prefix received from parent' + raise forms.ValidationError, \ + 'Can not delete prefix received from parent' if prefix.allocated: raise forms.ValidationError, 'Prefix is allocated to child' if prefix.asns: diff --git a/portal-gui/rpkigui/myrpki/glue.py b/portal-gui/rpkigui/myrpki/glue.py index 1417131b..eececa7a 100644 --- a/portal-gui/rpkigui/myrpki/glue.py +++ b/portal-gui/rpkigui/myrpki/glue.py @@ -64,12 +64,10 @@ def output_prefixes(path, handle): def output_roas(path, handle): f = csv_writer(path) - for r in handle.roas.all(): - for addr in r.prefix.all(): - f.writerow([resource_range_ipv4(v4addr(str(addr.lo)), - v4addr(str(addr.hi))), - r.asn, - '%s-group-%d' % (handle.handle, r.pk)]) + for roa in handle.roas.all(): + for req in roa.from_roa_request.all(): + f.writerow([req.as_roa_prefix(), roa.asn, + '%s-group-%d' % (handle.handle, roa.pk)]) def configure_resources(handle): '''Write out the csv files and invoke the myrpki.py command line tool.''' diff --git a/portal-gui/rpkigui/myrpki/misc.py b/portal-gui/rpkigui/myrpki/misc.py index 4e0970a6..3b54107f 100644 --- a/portal-gui/rpkigui/myrpki/misc.py +++ b/portal-gui/rpkigui/myrpki/misc.py @@ -23,4 +23,14 @@ def str_to_range(lo, hi): else: return rpki.resource_set.resource_range_ipv6(x, y) +#def str_to_roa(lo, hi): +# """Convert IP address strings to a subclass of roa_prefix.""" +# x = str_to_addr(lo) +# y = str_to_addr(hi) +# assert type(x) == type(y) +# if isinstance(x, rpki.ipaddrs.v4addr): +# return rpki.resource_set.roa_prefix_ipv4(x, y) +# else: +# return rpki.resource_set.roa_prefix_ipv6(x, y) + # vim:sw=4 ts=8 expandtab diff --git a/portal-gui/rpkigui/myrpki/models.py b/portal-gui/rpkigui/myrpki/models.py index 9a6952ce..cf062b3c 100644 --- a/portal-gui/rpkigui/myrpki/models.py +++ b/portal-gui/rpkigui/myrpki/models.py @@ -4,8 +4,11 @@ import socket from django.db import models from django.contrib.auth.models import User -from rpkigui.myrpki.misc import str_to_range + +from rpkigui.myrpki.misc import str_to_range, str_to_addr + import rpki.resource_set +import rpki.exceptions class HandleField(models.CharField): def __init__(self, **kwargs): @@ -15,10 +18,6 @@ class IPAddressField(models.CharField): def __init__( self, **kwargs ): models.CharField.__init__(self, max_length=40, **kwargs) -class ASNListField(models.CharField): - def __init__( self, **kwargs ): - models.CharField.__init__(self, max_length=255, **kwargs) - class Conf(models.Model): '''This is the center of the universe, also known as a place to have a handle on a resource-holding entity. It's the <self> @@ -39,8 +38,6 @@ class AddressRange(models.Model): # child to which this resource is delegated allocated = models.ForeignKey('Child', related_name='address_range', blank=True, null=True) - # who can originate routes for this prefix - asns = ASNListField(null=True, blank=True) def __unicode__(self): if self.lo == self.hi: @@ -48,7 +45,7 @@ class AddressRange(models.Model): try: # pretty print cidr - return unicode(str_to_range(self.lo, self.hi)) + return unicode(self.as_resource_range()) except socket.error, err: print err # work around for bug when hi/lo get reversed @@ -59,6 +56,43 @@ class AddressRange(models.Model): def get_absolute_url(self): return u'/myrpki/address/%d' % (self.pk,) + def as_resource_range(self): + '''Convert to rpki.resource_set.resource_range_ip.''' + return str_to_range(self.lo, self.hi) + + def is_prefix(self): + '''Returns True if this address range can be represented as a + prefix.''' + try: + self.as_resource_range()._prefixlen() + except rpki.exceptions.MustBePrefix, err: + print err + return False + return True + +class RoaRequest(models.Model): + roa = models.ForeignKey('Roa', related_name='from_roa_request') + max_length = models.IntegerField() + prefix = models.ForeignKey('AddressRange', related_name='roa_requests') + + def __unicode__(self): + return u'roa request for asn %d on %s-%d' % (self.roa.asn, self.prefix, + self.max_length) + + def as_roa_prefix(self): + '''Convert to a rpki.resouce_set.roa_prefix subclass.''' + + r = self.prefix.as_resource_range() + if isinstance(r, rpki.resource_set.resource_set_ipv4): + return rpki.resource_set.roa_prefix_ipv4(r.min, r._prefixlen(), + self.max_length) + else: + return rpki.resource_set.roa_prefix_ipv6(r.min, r._prefixlen(), + self.max_length) + + def get_absolute_url(self): + return u'/myrpki/roa/%d' % (self.pk,) + class Asn(models.Model): '''An ASN or range thereof.''' lo = models.IntegerField(blank=False) @@ -114,8 +148,8 @@ class ResourceCert(models.Model): # resources granted from my parent asn = models.ManyToManyField(Asn, related_name='from_cert', blank=True, null=True) - address_range = models.ManyToManyField(AddressRange, related_name='from_cert', - blank=True, null=True) + address_range = models.ManyToManyField(AddressRange, + related_name='from_cert', blank=True, null=True) # unique id for this resource certificate # FIXME: URLField(verify_exists=False) doesn't seem to work - the admin @@ -134,13 +168,13 @@ class ResourceCert(models.Model): self.parent.handle) class Roa(models.Model): - '''Maps an ASN to the set of prefixes it can originate routes for. This - differs from a real ROA in that prefixes from multiple parents/resource - certs can be selected. The glue module contains code to split the ROAs - into groups by common resource certs.''' + '''Maps an ASN to the set of prefixes it can originate routes for. + This differs from a real ROA in that prefixes from multiple + parents/resource certs can be selected. The glue module contains + code to split the ROAs into groups by common resource certs.''' + conf = models.ForeignKey(Conf, related_name='roas') asn = models.IntegerField() - prefix = models.ManyToManyField(AddressRange, related_name='from_roa') active = models.BooleanField() def __unicode__(self): diff --git a/portal-gui/rpkigui/myrpki/urls.py b/portal-gui/rpkigui/myrpki/urls.py index be6277d3..4ee97984 100644 --- a/portal-gui/rpkigui/myrpki/urls.py +++ b/portal-gui/rpkigui/myrpki/urls.py @@ -23,6 +23,7 @@ urlpatterns = patterns('', (r'^address/(?P<pk>\d+)/delete$', views.prefix_delete_view), (r'^asn/(?P<pk>\d+)$', views.asn_view), (r'^asn/(?P<pk>\d+)/allocate$', views.asn_allocate_view), + (r'^roa/(?P<pk>\d+)/delete$', views.roa_request_delete_view) # (r'^roa/$', views.roa_edit ), # (r'^roa/(?P<pk>\d+)$', views.roa_edit ), ) diff --git a/portal-gui/rpkigui/myrpki/views.py b/portal-gui/rpkigui/myrpki/views.py index d78eec33..33502631 100644 --- a/portal-gui/rpkigui/myrpki/views.py +++ b/portal-gui/rpkigui/myrpki/views.py @@ -17,6 +17,7 @@ import forms import glue from asnset import asnset from rpkigui.myrpki.misc import str_to_range +from rpkigui.myrpki.asnset import asnset # For each type of object, we have a detail view, a create view and # an update view. We heavily leverage the generic views, only @@ -87,7 +88,7 @@ def unallocated_resources(handle, roa_asns, roa_prefixes, asns, prefixes): child_prefixes = [] for p in prefixes: - child_prefixes.extend(o for o in p.children.filter(allocated__isnull=True).exclude(from_roa__in=roa_prefixes)) + child_prefixes.extend(o for o in p.children.filter(allocated__isnull=True, roa_requests__isnull=True)) if child_asns or child_prefixes: x, y = unallocated_resources(handle, roa_asns, roa_prefixes, @@ -111,14 +112,14 @@ def dashboard(request): # get list of ASNs used in my ROAs roa_asns = [r.asn for r in handle.roas.all()] # get list of address ranges included in ROAs - roa_addrs = [p for r in handle.roas.all() for p in r.prefix.all()] + roa_addrs = [p.prefix for r in handle.roas.all() for p in r.from_roa_request.all()] asns=[] prefixes=[] for p in handle.parents.all(): for c in p.resources.all(): asns.extend(c.asn.filter(allocated__isnull=True).exclude(lo__in=roa_asns)) - prefixes.extend(c.address_range.filter(allocated__isnull=True).exclude(from_roa__in=roa_addrs)) + prefixes.extend(c.address_range.filter(allocated__isnull=True, roa_requests__isnull=True)) asns, prefixes = unallocated_resources(handle, roa_asns, roa_addrs, asns, prefixes) @@ -491,9 +492,18 @@ def prefix_allocate_view(request, pk): return render('myrpki/prefix_view.html', { 'form': form, 'addr': prefix, 'form': form, 'parent': parent_set }, request) +def parent_prefix(prefix): + '''Returns the top-most parent prefix for the given prefix.''' + while prefix.parent: + prefix = prefix.parent + return prefix + def common_cert(prefix, prefix_set): '''Return true if prefix is derived from the same resource cert as all the addresses in prefix_set.''' + while prefix.parent: + prefix = prefix.parent + # list of certs for the target prefix certs = prefix.from_cert.all() # all prefixes will have the same cert, so just check the first one @@ -530,10 +540,34 @@ def update_roas(handle, prefix): else: # no roa is present for this ASN, create a new one print 'creating new roa for asn %d with %s' % (asid, prefix) - roa = models.Roa.objects.create(asn=asid, conf=handle, active=False) + roa = models.Roa.objects.create(asn=asid, conf=handle, + active=False) roa.save() roa.prefix.add(prefix) +def add_roa_requests(handle, prefix, asns, max_length): + for asid in asns: + req_set = prefix.roa_requests.filter(roa__asn=asid, + max_length=max_length) + if not req_set: + # no req is present for this (ASN, prefix, max_length). + + # find all roas with prefixes from the same resource cert + roa_set = handle.roas.filter(asn=asid, + from_roa_request__prefix__from_cert__in=prefix.from_cert.all()) + if roa_set: + roa = roa_set[0] + else: + # no roa is present for this ASN, create a new one + print 'creating new roa for asn %d' % (asid,) + roa = models.Roa.objects.create(asn=asid, conf=handle, + active=False) + roa.save() + + req = models.RoaRequest.objects.create(prefix=prefix, roa=roa, + max_length=max_length) + req.save() + @handle_required def prefix_roa_view(request, pk): handle = request.session['handle'] @@ -542,15 +576,15 @@ def prefix_roa_view(request, pk): parent_set = get_parents_or_404(handle, obj) if request.method == 'POST': - form = forms.PrefixRoaForm(request.POST) + form = forms.PrefixRoaForm(obj, request.POST) if form.is_valid(): - obj.asns = form.cleaned_data['asns'] - obj.save() - update_roas(handle, obj) + asns = asnset(form.cleaned_data['asns']) + add_roa_requests(handle, obj, asns, + form.cleaned_data['max_length']) glue.configure_resources(handle) return http.HttpResponseRedirect(obj.get_absolute_url()) else: - form = forms.PrefixRoaForm(initial={ 'asns': obj.asns }) + form = forms.PrefixRoaForm(obj) return render('myrpki/prefix_view.html', { 'form': form, 'addr': obj, 'form': form, 'parent': parent_set }, request) @@ -575,6 +609,23 @@ def prefix_delete_view(request, pk): 'addr': obj, 'form': form, 'parent': parent_set }, request) @handle_required +def roa_request_delete_view(request, pk): + '''Remove a roa request from a particular prefix.''' + handle = request.session['handle'] + obj = get_object_or_404(models.RoaRequest.objects, pk=pk) + prefix = obj.prefix + # ensure this resource range belongs to a parent of the current conf + parent_set = get_parents_or_404(handle, prefix) + + roa = obj.roa + obj.delete() + if not roa.from_roa_request.all(): + print 'removing empty roa for asn %d' % (roa.asn,) + roa.delete() + + return http.HttpResponseRedirect(prefix.get_absolute_url()) + +@handle_required def asn_allocate_view(request, pk): handle = request.session['handle'] obj = get_object_or_404(models.Asn.objects, pk=pk) diff --git a/portal-gui/rpkigui/settings.py b/portal-gui/rpkigui/settings.py index afabc19d..43025cbd 100644 --- a/portal-gui/rpkigui/settings.py +++ b/portal-gui/rpkigui/settings.py @@ -10,7 +10,7 @@ ADMINS = ( MANAGERS = ADMINS DATABASE_ENGINE = 'sqlite3' # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. -DATABASE_NAME = '/home/melkins/myrpki/rpkiop' # Or path to database file if using sqlite3. +DATABASE_NAME = '/home/me/myrpki/rpkiop' # Or path to database file if using sqlite3. DATABASE_USER = '' # Not used with sqlite3. DATABASE_PASSWORD = '' # Not used with sqlite3. DATABASE_HOST = '' # Set to empty string for localhost. Not used with sqlite3. @@ -72,7 +72,7 @@ TEMPLATE_DIRS = ( # Always use forward slashes, even on Windows. # Don't forget to use absolute paths, not relative paths. #XXX - '/home/melkins/subvert-rpki.hactrn.net/portal-gui/rpkigui/templates', + '/home/me/src/rpki/portal-gui/rpkigui/templates', ) INSTALLED_APPS = ( @@ -99,7 +99,7 @@ TEMPLATE_CONTEXT_PROCESSORS = ( # # Top of directory tree where myrpki.conf, etc are stored for each resource holder -MYRPKI_DATA_DIR = '/home/melkins/myrpki' +MYRPKI_DATA_DIR = '/home/me/myrpki' # where to find the myrpki.py command line tool MYRPKI_SRC_DIR = '/home/melkins/subvert-rpki.hactrn.net/rpkid' diff --git a/portal-gui/rpkigui/templates/myrpki/dashboard.html b/portal-gui/rpkigui/templates/myrpki/dashboard.html index 8abe7f82..07df5ad6 100644 --- a/portal-gui/rpkigui/templates/myrpki/dashboard.html +++ b/portal-gui/rpkigui/templates/myrpki/dashboard.html @@ -82,8 +82,8 @@ {% for roa in request.session.handle.roas.all %} <tr><td> -{% for address_range in roa.prefix.all %} -<li><a href="{{ address_range.get_absolute_url }}">{{ address_range }}</a> +{% for req in roa.from_roa_request.all %} +<li><a href="{{ req.prefix.get_absolute_url }}">{{ req.as_roa_prefix }}</a> {% endfor %} </td> <td>{{ roa.asn }}</td> diff --git a/portal-gui/rpkigui/templates/myrpki/prefix_view.html b/portal-gui/rpkigui/templates/myrpki/prefix_view.html index a41af7c9..74bf45d8 100644 --- a/portal-gui/rpkigui/templates/myrpki/prefix_view.html +++ b/portal-gui/rpkigui/templates/myrpki/prefix_view.html @@ -26,9 +26,6 @@ {% if addr.allocated %} <tr><td>Allocated:</td><td><a href="{{addr.allocated.get_absolute_url}}">{{addr.allocated.handle}}</a></td></tr> {% endif %} - - <tr><td>Allowed to orignate:</td><td>{{addr.asns}}</td></tr> - </table> {% if addr.children.count %} @@ -42,6 +39,21 @@ {% endif %} +{% if addr.roa_requests %} +<h2>ROA requests</h2> +<table style='border: solid 1px; width:100%'> + <tr><th>ASN</th><th>Max Length</th></tr> + + {% for r in addr.roa_requests.all %} + <tr><td style='text-align: center'>{{ r.roa.asn }}</td> + <td style='text-align: center'>{{ r.max_length }}</td> + <td><a href="{{ r.get_absolute_url }}/delete">delete</a></tr> + {% endfor %} + +</table> + +{% endif %} <!-- roa requests --> + {% if form %} <h2>Edit</h2> <form method="POST" action="{{ request.get_full_path }}"> @@ -53,7 +65,9 @@ <p>Action: <a href="{{addr.get_absolute_url}}/split">split</a> | <a href="{{addr.get_absolute_url}}/allocate">give to child</a> | -<a href="{{addr.get_absolute_url}}/roa">edit allowed originators</a> | +{% if addr.is_prefix %} +<a href="{{addr.get_absolute_url}}/roa">roa</a> | +{% endif %} <a href="{{addr.get_absolute_url}}/delete">delete</a> {% endblock %} |