Add google login and view for public services (#533)

* add login using google login
* Add `is_public flag` to services
* Add public view for services marked as public
* Add base_public.html for unauthenticated users and use that with authenticated views
* fix first time setup issue. change setup.html and about.html to extend from base_public.html template and redirect from home to setup if necessary

Pull-request #533
This commit is contained in:
Pablo González 2017-08-15 10:28:41 -03:00 committed by Jean-Frédéric
parent 4034d98a37
commit cb2fc2e896
14 changed files with 312 additions and 166 deletions

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cabotapp', '0003_auto_20170201_1045'),
]
operations = [
migrations.AddField(
model_name='Service',
name='is_public',
field=models.BooleanField(default=False, help_text=b'The service will be shown in the public home', verbose_name=b'Is Public'),
),
]

View File

@ -305,6 +305,12 @@ class Service(CheckGroupMixin):
help_text="URL of service."
)
is_public = models.BooleanField(
verbose_name='Is Public',
default=False,
help_text='The service will be shown in the public home'
)
class Meta:
ordering = ['name']

View File

@ -318,7 +318,8 @@ class ServiceForm(forms.ModelForm):
'alerts',
'alerts_enabled',
'hackpad_id',
'runbook_link'
'runbook_link',
'is_public'
)
widgets = {
'name': forms.TextInput(attrs={'style': 'width: 70%;'}),
@ -801,6 +802,18 @@ class ServiceListView(LoginRequiredMixin, ListView):
return Service.objects.all().order_by('name').prefetch_related('status_checks')
class ServicePublicListView(TemplateView):
model = Service
context_object_name = 'services'
template_name = "cabotapp/service_public_list.html"
def get_context_data(self, **kwargs):
context = super(ServicePublicListView, self).get_context_data(**kwargs)
context[self.context_object_name] = Service.objects\
.filter(is_public=True, alerts_enabled=True)\
.order_by('name').prefetch_related('status_checks')
return context
class InstanceDetailView(LoginRequiredMixin, DetailView):
model = Instance
context_object_name = 'instance'

View File

@ -285,8 +285,9 @@ if AUTH_LDAP:
# Github SSO
AUTH_GITHUB_ENTERPRISE_ORG = force_bool(os.environ.get('AUTH_GITHUB_ENTERPRISE_ORG', False))
AUTH_GITHUB_ORG = force_bool(os.environ.get('AUTH_GITHUB_ORG', False))
AUTH_GOOGLE_OAUTH2 = force_bool(os.environ.get('AUTH_GOOGLE_OAUTH2', False))
AUTH_SOCIAL = AUTH_GITHUB_ORG or AUTH_GITHUB_ENTERPRISE_ORG
AUTH_SOCIAL = AUTH_GITHUB_ORG or AUTH_GITHUB_ENTERPRISE_ORG or AUTH_GOOGLE_OAUTH2
if AUTH_SOCIAL:
SOCIAL_AUTH_URL_NAMESPACE = 'social'
@ -307,4 +308,10 @@ if AUTH_GITHUB_ENTERPRISE_ORG:
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET = os.environ.get('AUTH_GITHUB_ENTERPRISE_ORG_CLIENT_SECRET')
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME = os.environ.get('AUTH_GITHUB_ENTERPRISE_ORG_NAME')
if AUTH_GOOGLE_OAUTH2:
AUTHENTICATION_BACKENDS += tuple(['social_core.backends.google.GoogleOAuth2'])
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.environ.get('AUTH_GOOGLE_OAUTH2_KEY')
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.environ.get('AUTH_GOOGLE_OAUTH2_SECRET')
SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = os.environ.get('AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS', '').split(',')
EXPOSE_USER_API = force_bool(os.environ.get('EXPOSE_USER_API', False))

View File

@ -1,98 +1,55 @@
{% load static from staticfiles %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{% block title %}Cabot by Arachnys{% endblock title %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{% load compress %}
{% compress css %}
<link href="{% static 'bootstrap/css/bootstrap.css' %}" rel="stylesheet">
<link id="chosen-css" href="{% static 'theme/css/chosen.css' %}" rel="stylesheet">
<link id="chosen-css" href="{% static 'theme/css/bootstrap-chosen.css' %}" rel="stylesheet">
<link href="{% static 'bootstrap/css/dashboard.css' %}" rel="stylesheet">
<link href="{% static 'theme/css/jquery-ui-1.8.21.custom.css' %}" type="text/css" rel="stylesheet">
<link href="{% static 'arachnys/css/base.less' %}" type="text/less" rel="stylesheet">
<link rel="stylesheet" href="{% static 'arachnys/css/morris.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'arachnys/css/graph.css' %}" type="text/css">
{% endcompress %}
<link href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" rel="stylesheet">
<link rel="icon" href="{% static 'arachnys/img/icon_48x48.png'%}" type="image/png">
{% compress js %}
<script type="text/coffeescript">
if window.location.host.indexOf('localhost') != -1
window.ENVIRONMENT = 'DEVELOPMENT'
else
window.ENVIRONMENT = 'PRODUCTION'
</script>
{% endcompress %}
</head>
<body>
<div class="navbar navbar-default navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a href="{% url "services" %}" class="navbar-brand"><img src="{% static 'arachnys/img/icon_48x48.png' %}" width='20' height='20' /> Cabot by Arachnys</a>
<button class="navbar-toggle" type="button" data-toggle="collapse" data-target="#navbar-main">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
</div>
<div class="navbar-collapse collapse" id="navbar-main">
<ul class="nav navbar-nav">
{% extends 'base_public.html' %}
{% block header_navbar_menu %}
<ul class="nav navbar-nav">
<li>
<a href="{% url 'instances' %}"><i class="fa fa-desktop"></i> Instances</a>
</li>
<li>
<a href="{% url 'services' %}"><i class="fa fa-gears"></i> Services</a>
</li>
<li>
<a href="{% url 'checks' %}"><i class="fa fa-cog"></i> Checks</a>
</li>
<ul class="nav navbar-nav navbar-left">
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#" id="download">New <span class="caret"></span></a>
<ul class="dropdown-menu" aria-labelledby="New">
<li>
<a href="{% url "instances" %}"><i class="fa fa-desktop"></i> Instances</a>
<a href="{% url 'create-service' %}"><i class="fa fa-gears"></i> Service</a>
</li>
<li>
<a href="{% url "services" %}"><i class="fa fa-gears"></i> Services</a>
<a href="{% url 'create-instance' %}"><i class="fa fa-desktop"></i> Instance</a>
</li>
<li class="divider"></li>
<li>
<a href="{% url 'create-graphite-check' %}?service={{ service.id }}&instance={{ instance.id }}" class=""><i class="glyphicon glyphicon-signal" title="Add new metric check"></i> Graphite check</a>
</li>
<li>
<a href="{% url "checks" %}"><i class="fa fa-cog"></i> Checks</a>
<a href="{% url 'create-http-check' %}?service={{ service.id }}&instance={{ instance.id }}" class="" title="Add new Http check"><i class="glyphicon glyphicon-arrow-up"></i> Http check</a>
</li>
<ul class="nav navbar-nav navbar-left">
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#" id="download">New <span class="caret"></span></a>
<ul class="dropdown-menu" aria-labelledby="New">
<li>
<a href="{% url "create-service" %}"><i class="fa fa-gears"></i> Service</a>
</li>
<li>
<a href="{% url "create-instance" %}"><i class="fa fa-desktop"></i> Instance</a>
</li>
<li class="divider"></li>
<li>
<a href="{% url "create-graphite-check" %}?service={{ service.id }}&instance={{ instance.id }}" class=""><i class="glyphicon glyphicon-signal" title="Add new metric check"></i> Graphite check</a>
</li>
<li>
<a href="{% url "create-http-check" %}?service={{ service.id }}&instance={{ instance.id }}" class="" title="Add new Http check"><i class="glyphicon glyphicon-arrow-up"></i> Http check</a>
</li>
<li>
<a href="{% url "create-jenkins-check" %}?service={{ service.id }}&instance={{ instance.id }}" class="" title="Add new Jenkins check"><i class="glyphicon glyphicon-ok"></i> Jenkins check</a>
</li>
<li>
<a href="{% url "create-icmp-check" %}?service={{ service.id }}&instance={{ instance.id }}" class="" title="Add new ICMP check"><i class="glyphicon glyphicon-transfer"></i> ICMP check</a>
</li>
<!-- Custom check plugins buttons-->
{% for checktype in custom_check_types %}
<li>
<a href="{% url checktype.creation_url %}?service={{ service.id }}&instance={{ instance.id }}" class="" title="Add new {{checktype.check_name|capfirst}} check"><i class="glyphicon glyphicon-transfer"></i> {{checktype.check_name|capfirst}} check</a>
</li>
{% endfor %}
</ul>
<li>
<a href="{% url 'create-jenkins-check' %}?service={{ service.id }}&instance={{ instance.id }}" class="" title="Add new Jenkins check"><i class="glyphicon glyphicon-ok"></i> Jenkins check</a>
</li>
<li>
<a href="{% url 'create-icmp-check' %}?service={{ service.id }}&instance={{ instance.id }}" class="" title="Add new ICMP check"><i class="glyphicon glyphicon-transfer"></i> ICMP check</a>
</li>
<!-- Custom check plugins buttons-->
{% for checktype in custom_check_types %}
<li>
<a href="{% url checktype.creation_url %}?service={{ service.id }}&instance={{ instance.id }}" class="" title="Add new {{checktype.check_name|capfirst}} check"><i class="glyphicon glyphicon-transfer"></i> {{checktype.check_name|capfirst}} check</a>
</li>
</ul>
{% endfor %}
</ul>
<ul class="nav navbar-nav navbar-right">
</li>
</ul>
</ul>
<ul class="nav navbar-nav navbar-right">
<li>
<a href="{% url "subscriptions" %}"><i class="fa fa-table"></i> Alert subscriptions</a>
<a href="{% url 'subscriptions' %}"><i class="fa fa-table"></i> Alert subscriptions</a>
</li>
<li>
<a href="{% url "shifts" %}"><i class="glyphicon glyphicon-time"></i> Duty rota</a>
<a href="{% url 'shifts' %}"><i class="glyphicon glyphicon-time"></i> Duty rota</a>
</li>
{% if user.is_authenticated %}
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#" id="download">{{ user.username }} <span class="caret"></span></a>
<ul class="dropdown-menu" aria-labelledby="admin">
@ -115,77 +72,5 @@
</li>
</ul>
</li>
{% endif %}
</ul>
</div>
</div>
</div>
<!-- start: Header -->
<div class="container">
<noscript>
<div class="alert alert-block span10">
<h4 class="alert-heading">Warning!</h4>
<p>You need to have <a href="http://en.wikipedia.org/wiki/JavaScript" target="_blank">JavaScript</a> enabled to use this site.</p>
</div>
</noscript>
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissable">
<a href="#" class="close" data-dismiss="alert" aria-label="close">&times;</a>
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
<div class="row">
{% block content %}
{% endblock content %}
</div>
<div class="clearfix"></div>
</div>
{% load compress %}
{% block js %}
{% compress js %}
<script src="{% static 'bootstrap/js/jquery-1.10.2.js' %}"></script>
<script src="{% static 'theme/js/jquery-ui.js' %}"></script>
<script src="{% static 'theme/js/jquery.ui.core.js' %}"></script>
<script src="{% static 'theme/js/jquery.ui.position.js' %}"></script>
<script src="{% static 'theme/js/jquery.ui.autocomplete.js' %}"></script>
<script src="{% static 'bootstrap/js/bootstrap.js' %}"></script>
<script src="{% static 'theme/js/jquery.dataTables.min.js' %}"></script>
<script src="{% static 'theme/js/chosen.jquery.js' %}"></script>
<script src="{% static 'theme/js/jquery.sparkline.min.js' %}"></script>
<script src="{% static 'theme/js/custom.js' %}"></script>
<script src="{% static 'arachnys/js/raphael.js' %}"></script>
<script src="{% static 'arachnys/js/morris.js' %}"></script>
<!-- end: JavaScript-->
<script type="text/coffeescript">
$ () ->
$('.sparktristate').sparkline('html', {type: 'tristate'})
$('ul.nav li a').each () ->
if $($(this))[0].href == String(window.location)
$(this).parent().addClass('active')
$('[data-rel="chosen"],[rel="chosen"]').chosen({ width: '100%' })
#$('.datatable').dataTable
# sDom: "<'row-fluid'<'span6'l><'span6'f>r>t<'row-fluid'<'span12'i><'span12 center'p>>",
# sPaginationType: "bootstrap",
# iDisplayLength: 100,
# oLanguage:
# sLengthMenu: "_MENU_ records per page"
</script>
{% endcompress %}
{% endblock js %}
</body>
</html>
{% endblock header_navbar_menu %}

View File

@ -0,0 +1,124 @@
{% load static from staticfiles %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{% block title %}Cabot by Arachnys{% endblock title %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{% load compress %}
{% compress css %}
<link href="{% static 'bootstrap/css/bootstrap.css' %}" rel="stylesheet">
<link id="chosen-css" href="{% static 'theme/css/chosen.css' %}" rel="stylesheet">
<link id="chosen-css" href="{% static 'theme/css/bootstrap-chosen.css' %}" rel="stylesheet">
<link href="{% static 'bootstrap/css/dashboard.css' %}" rel="stylesheet">
<link href="{% static 'theme/css/jquery-ui-1.8.21.custom.css' %}" type="text/css" rel="stylesheet">
<link href="{% static 'arachnys/css/base.less' %}" type="text/less" rel="stylesheet">
<link rel="stylesheet" href="{% static 'arachnys/css/morris.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'arachnys/css/graph.css' %}" type="text/css">
{% endcompress %}
<link href="//cdnjs.cloudflare.com/ajax/libs/bootstrap-social/5.1.1/bootstrap-social.css" rel="stylesheet">
<link href="//netdna.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.css" rel="stylesheet">
<link rel="icon" href="{% static 'arachnys/img/icon_48x48.png'%}" type="image/png">
{% compress js %}
<script type="text/coffeescript">
if window.location.host.indexOf('localhost') != -1
window.ENVIRONMENT = 'DEVELOPMENT'
else
window.ENVIRONMENT = 'PRODUCTION'
</script>
{% endcompress %}
</head>
<body>
<div class="navbar navbar-default navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a href="{% url 'dashboard' %}" class="navbar-brand"><img src="{% static 'arachnys/img/icon_48x48.png' %}" width='20' height='20' /> Cabot by Arachnys</a>
<button class="navbar-toggle" type="button" data-toggle="collapse" data-target="#navbar-main">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
</div>
<div class="navbar-collapse collapse" id="navbar-main">
{% block header_navbar_menu %}
<ul class="nav navbar-nav navbar-right">
<li>
<a href="{% url 'login' %}"><i class="glyphicon glyphicon-log-in"></i> Log In</a>
</li>
</ul>
{% endblock header_navbar_menu %}
</div>
</div>
</div>
<!-- start: Header -->
<div class="container">
<noscript>
<div class="alert alert-block span10">
<h4 class="alert-heading">Warning!</h4>
<p>You need to have <a href="http://en.wikipedia.org/wiki/JavaScript" target="_blank">JavaScript</a> enabled to use this site.</p>
</div>
</noscript>
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissable">
<a href="#" class="close" data-dismiss="alert" aria-label="close">&times;</a>
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
<div class="row">
{% block content %}
{% endblock content %}
</div>
<div class="clearfix"></div>
</div>
{% load compress %}
{% block js %}
{% compress js %}
<script src="{% static 'bootstrap/js/jquery-1.10.2.js' %}"></script>
<script src="{% static 'theme/js/jquery-ui.js' %}"></script>
<script src="{% static 'theme/js/jquery.ui.core.js' %}"></script>
<script src="{% static 'theme/js/jquery.ui.position.js' %}"></script>
<script src="{% static 'theme/js/jquery.ui.autocomplete.js' %}"></script>
<script src="{% static 'bootstrap/js/bootstrap.js' %}"></script>
<script src="{% static 'theme/js/jquery.dataTables.min.js' %}"></script>
<script src="{% static 'theme/js/chosen.jquery.js' %}"></script>
<script src="{% static 'theme/js/jquery.sparkline.min.js' %}"></script>
<script src="{% static 'theme/js/custom.js' %}"></script>
<script src="{% static 'arachnys/js/raphael.js' %}"></script>
<script src="{% static 'arachnys/js/morris.js' %}"></script>
<!-- end: JavaScript-->
<script type="text/coffeescript">
$ () ->
$('.sparktristate').sparkline('html', {type: 'tristate'})
$('ul.nav li a').each () ->
if $($(this))[0].href == String(window.location)
$(this).parent().addClass('active')
$('[data-rel="chosen"],[rel="chosen"]').chosen({ width: '100%' })
#$('.datatable').dataTable
# sDom: "<'row-fluid'<'span6'l><'span6'f>r>t<'row-fluid'<'span12'i><'span12 center'p>>",
# sPaginationType: "bootstrap",
# iDisplayLength: 100,
# oLanguage:
# sLengthMenu: "_MENU_ records per page"
</script>
{% endcompress %}
{% endblock js %}
</body>
</html>

View File

@ -0,0 +1,36 @@
{% if not services %}
<div class="col-xs-11 col-xs-offset-1">
<hr/>
No available services monitoring
</div>
{% else %}
<div class="row">
<div class="col-xs-8">
<h4>Name</h4>
</div>
<div class="col-xs-2">
<h4>Overall Checks</h4>
</div>
<div class="col-xs-2">
<h4>Acknowledgment</h4>
</div>
</div>
{% for service in services %}
<hr/>
<div class="row">
<div class="col-xs-8 {% if service.alerts_enabled %}enabled{% else %}warning{% endif %}">
<a href="{{service.url}}" title="{{service.url}}" target="_blank">{{service.name}}</a>
</div>
<div class="col-xs-2">
<span class="label label-{% if not service.alerts_enabled %}warning{% else %}{% if service.overall_status == service.PASSING_STATUS %}success{% else %}danger{% endif %}{% endif %}">{% if service.alerts_enabled %}{{ service.overall_status|lower|capfirst }}{% else %}Disabled{% endif %}</span>
</div>
<div class="col-xs-2">
<span class="label label-{% if not service.unexpired_acknowledgement %}danger{% else %}success{% endif %}">{% if service.overall_status != service.PASSING_STATUS %}{% if service.unexpired_acknowledgement %}Yes{% else %}No{% endif %}{% endif %}</span>
</div>
</div>
{% endfor %}
{% endif %}

View File

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends 'base_public.html' %}
{% load static from staticfiles %}
{% block title %}{{ block.super }} - About{% endblock title %}

View File

@ -0,0 +1,17 @@
{% extends 'base_public.html' %}
{% block header_navbar_menu %}
<ul class="nav navbar-nav">
<li>
<a href="{% url 'public' %}"><i class="fa fa-gears"></i> Services</a>
</li>
</ul>
{{block.super}}
{% endblock header_navbar_menu %}
{% block content %}
<div class="row">
<div class="col-xs-12">
{% include 'cabotapp/_service_public_list.html' %}
</div>
</div>
{% endblock content %}

View File

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends 'base_public.html' %}
{% block content %}
<h1>Welcome to Cabot!</h1>

View File

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends 'base_public.html' %}
{% block content %}
<div class="row">
@ -14,10 +14,10 @@
<div class="form-group">
<div class="col-xs-6 col-xs-offset-2">
<button type="submit" class="btn btn-primary">Log in</button>
<a href="{% url "password-reset" %}" class="btn">Reset password</a>
<a href="{% url 'password-reset' %}" class="btn">Reset password</a>
</div>
</div>
</div>
</form>
</form>
{% include "./social_auth.html" %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends 'base_public.html' %}
{% block content %}

View File

@ -0,0 +1,28 @@
{% load extra %}
{% echo_setting 'AUTH_GOOGLE_OAUTH2' as google_enable %}
{% echo_setting 'AUTH_GITHUB_ORG' as github_enable %}
{% echo_setting 'AUTH_GITHUB_ENTERPRISE_ORG' as github_enterprise_enable %}
<div class="col-xs-3 col-xs-offset-2">
<hr/>
{% if github_enable %}
<a class="btn btn-block btn-social btn-github" href="{% url 'social:begin' 'github-org' %}">
<i class="fa fa-github" aria-hidden="true"></i>
Sig in with Github
</a>
{% endif %}
{% if github_enterprise_enable %}
<a class="btn btn-block btn-social btn-github" href="{% url 'social:begin' 'github-enterprise-org' %}">
<i class="fa fa-github" aria-hidden="true"></i>
Sig in with GitHub Enterprise
</a>
{% endif %}
{% if google_enable %}
<a class="btn btn-block btn-social btn-google" href="{% url 'social:begin' 'google-oauth2' %}">
<i class="fa fa-google" aria-hidden="true"></i>
Sign in with Google
</a>
{% endif %}
</div>

View File

@ -14,7 +14,7 @@ from cabot.cabotapp.views import (
from cabot.cabotapp.views import (InstanceListView, InstanceDetailView,
InstanceUpdateView, InstanceCreateView, InstanceDeleteView,
ServiceListView, ServiceDetailView,
ServiceListView, ServicePublicListView, ServiceDetailView,
ServiceUpdateView, ServiceCreateView, ServiceDeleteView,
UserProfileUpdateView, ShiftListView, subscriptions)
@ -43,11 +43,20 @@ def first_time_setup_wrapper(func):
return func(*args, **kwargs)
return wrapper
def home_authentication_switcher(request, *args, **kwargs):
if cabot_needs_setup():
return redirect('first_time_setup')
if not request.user.is_authenticated():
return ServicePublicListView.as_view()(request, *args, **kwargs)
else:
return ServiceListView.as_view()(request, *args, **kwargs)
urlpatterns = [
# for the password reset views
url('^', include('django.contrib.auth.urls')),
url(r'^$', view=RedirectView.as_view(url='services/', permanent=False),
url(r'^$', view=home_authentication_switcher,
name='dashboard'),
url(r'^subscriptions/', view=subscriptions,
name='subscriptions'),
@ -67,6 +76,8 @@ urlpatterns = [
url(r'^services/', view=ServiceListView.as_view(),
name='services'),
url(r'^public/', view=ServicePublicListView.as_view(),
name='public'),
url(r'^service/create/', view=ServiceCreateView.as_view(),
name='create-service'),
url(r'^service/update/(?P<pk>\d+)/',