authentic/src/authentic2/manager/views.py

733 lines
26 KiB
Python

# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import json
import inspect
import pickle
from django.core import signing
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.views.generic.base import ContextMixin
from django.views.generic import (FormView, UpdateView, CreateView, DeleteView, TemplateView,
DetailView, View)
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormMixin
from django.http import HttpResponse, Http404
from django.utils.encoding import force_text
from django.utils import six
from django.utils.translation import ugettext_lazy as _
from django.utils.timezone import now
from django.urls import reverse
from django.urls import reverse_lazy
from django.contrib.messages.views import SuccessMessageMixin
from django.forms import MediaDefiningClass
from django_tables2 import SingleTableView, SingleTableMixin
from django_select2.views import AutoResponseView
from gadjo.templatetags.gadjo import xstatic
from django_rbac.utils import get_ou_model
from authentic2.data_transfer import export_site, import_site, ImportContext
from authentic2.forms.profile import modelform_factory
from authentic2.utils import redirect, batch_queryset
from authentic2.decorators import json as json_view
from authentic2 import hooks
from . import app_settings, utils, forms, widgets
class MediaMixinBase(MediaDefiningClass, FormMixin):
pass
class MultipleOUMixin(object):
'''Tell templates if there are multiple OU for adaptation in breadcrumbs for example'''
def get_context_data(self, **kwargs):
kwargs['multiple_ou'] = utils.get_ou_count() > 1
return super(MultipleOUMixin, self).get_context_data(**kwargs)
@six.add_metaclass(MediaMixinBase)
class MediaMixin(object):
'''Expose needed CSS and JS files as a media object'''
class Media:
js = (
reverse_lazy('a2-manager-javascript-catalog'),
xstatic('jquery.js', 'jquery.min.js'),
xstatic('jquery-ui.js', 'jquery-ui.min.js'),
'js/gadjo.js',
'jquery/js/jquery.form.js',
'admin/js/urlify.js',
'authentic2/js/purl.js',
'authentic2/manager/js/manager.js',
)
css = {
'all': (
'authentic2/manager/css/style.css',
)
}
def get_context_data(self, **kwargs):
kwargs['media'] = self.media
ctx = super(MediaMixin, self).get_context_data(**kwargs)
if 'form' in ctx:
ctx['media'] += ctx['form'].media
return ctx
class PermissionMixin(object):
'''Control access to views based on permissions'''
permissions = None
permissions_global = False
def authorize(self, request, *args, **kwargs):
if hasattr(self, 'model'):
app_label = self.model._meta.app_label
model_name = self.model._meta.model_name
add_perm = '%s.add_%s' % (app_label, model_name)
self.can_add = request.user.has_perm_any(add_perm)
if hasattr(self, 'get_object') \
and ((hasattr(self, 'pk_url_kwarg')
and self.pk_url_kwarg in self.kwargs)
or (hasattr(self, 'slug_url_kwarg')
and self.slug_url_kwarg in self.kwargs)):
self.object = self.get_object()
permissions = ('view', 'change', 'delete', 'manage_members')
for permission in permissions:
perm = '%s.%s_%s' % (app_label, permission, model_name)
setattr(self, 'can_' + permission,
request.user.has_perm(perm, self.object))
if self.permissions \
and not request.user.has_perms(
self.permissions, self.object):
raise PermissionDenied
elif self.permissions \
and not request.user.has_perm_any(self.permissions):
raise PermissionDenied
else:
if self.permissions:
if self.permissions_global and not request.user.has_perms(self.permissions):
raise PermissionDenied
if not self.permissions_global and not request.user.has_perm_any(self.permissions):
raise PermissionDenied
def dispatch(self, request, *args, **kwargs):
response = self.authorize(request, *args, **kwargs)
if response is not None:
return response
return super(PermissionMixin, self).dispatch(request, *args, **kwargs)
def filter_view(request, qs):
model = qs.model
perm = '%s.search_%s' % (model._meta.app_label, model._meta.model_name)
return request.user.filter_by_perm(perm, qs)
class FilterQuerysetByPermMixin(object):
def get_queryset(self):
qs = super(FilterQuerysetByPermMixin, self).get_queryset()
return filter_view(self.request, qs)
class FilterTableQuerysetByPermMixin(object):
def get_table_data(self):
qs = super(FilterTableQuerysetByPermMixin, self).get_table_data()
if getattr(self, 'filter_table_by_perm', True):
qs = filter_view(self.request, qs)
return qs
class TableQuerysetMixin(object):
def get_table_queryset(self):
return self.get_queryset()
def get_table_data(self):
return self.get_table_queryset()
class SearchFormMixin(object):
'''Handle a search form on the current table view.
The search form class must implement a .filter(qs) method returning a new queryset.'''
search_form_class = None
def get_search_form_class(self):
return self.search_form_class
def get_search_form_kwargs(self):
return {'request': self.request, 'data': self.request.GET}
def get_search_form(self):
form_class = self.get_search_form_class()
if not form_class:
return
return form_class(**self.get_search_form_kwargs())
def dispatch(self, request, *args, **kwargs):
self.search_form = self.get_search_form()
return super(SearchFormMixin, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super(SearchFormMixin, self).get_context_data(**kwargs)
if self.search_form:
ctx['search_form'] = self.search_form
return ctx
def filter_by_search(self, qs):
if self.search_form and self.search_form.is_valid():
qs = self.search_form.filter(qs)
return qs
def get_table_data(self):
qs = super(SearchFormMixin, self).get_table_data()
qs = self.filter_by_search(qs)
return qs
class FormatsContextData(object):
'''Export list of supported formats in context'''
formats = ['csv']
def get_context_data(self, **kwargs):
ctx = super(FormatsContextData, self).get_context_data(**kwargs)
ctx['formats'] = self.formats
return ctx
class Action(object):
'''Describe an action for view supporting multiples actions.'''
name = None
title = None
confirm = None
url_name = None
url = None
popup = True
permission = None
def __init__(self, name=None, title=None, confirm=None, url_name=None, url=None,
popup=None, permission=None):
if name is not None:
self.name = name
if title is not None:
self.title = title
if confirm is not None:
self.confirm = confirm
if url_name is not None:
self.url_name = url_name
if url is not None:
self.url = url
if popup is not None:
self.popup = popup
if permission is not None:
self.permission = permission
def display(self, instance, request):
if self.permission:
return request.user.has_perm(self.permission, instance)
return True
class AjaxFormViewMixin(object):
'''Implement a JSON response for view which can be included in an AJAX popup'''
success_url = '.'
def dispatch(self, request, *args, **kwargs):
response = super(AjaxFormViewMixin, self).dispatch(request, *args,
**kwargs)
return self.return_ajax_response(request, response)
def return_ajax_response(self, request, response):
if not request.is_ajax():
return response
data = {}
if 'Location' in response:
location = response['Location']
# empty location means that the view can be used from anywhere
# and so the redirect URL should not be used
# otherwise compute an absolute URI from the relative URI
if location and (not location.startswith('http://')
or not location.startswith('https://')
or not location.startswith('/')):
location = request.build_absolute_uri(location)
data['location'] = location
if hasattr(response, 'render'):
response.render()
data['content'] = force_text(response.content)
return HttpResponse(json.dumps(data), content_type='application/json')
class TitleMixin(object):
'''Mixin to provide a title to the view's template'''
title = ''
def get_context_data(self, **kwargs):
ctx = super(TitleMixin, self).get_context_data(**kwargs)
ctx['title'] = self.title
ctx['manager_site_title'] = app_settings.SITE_TITLE
return ctx
class ActionMixin(object):
'''Describe the main action implementd by a view'''
action = None
def get_context_data(self, **kwargs):
ctx = super(ActionMixin, self).get_context_data(**kwargs)
if self.action:
ctx['action'] = self.action
return ctx
class OtherActionsMixin(object):
'''Describe secondary actions possible on a view'''
other_actions = None
def get_context_data(self, **kwargs):
ctx = super(OtherActionsMixin, self).get_context_data(**kwargs)
ctx['other_actions'] = tuple(self.get_displayed_other_actions())
return ctx
def get_other_actions(self):
return self.other_actions or []
def get_displayed_other_actions(self):
actions = []
other_actions = list(self.get_other_actions())
hooks.call_hooks('manager_modify_other_actions', self, other_actions)
for action in other_actions:
if callable(action.display) and not action.display(self.object, self.request):
continue
if action.display:
actions.append(action)
return actions
def post(self, request, *args, **kwargs):
self.object = self.get_object()
for action in self.get_displayed_other_actions():
if action.name in request.POST:
response = None
if hasattr(action, 'do'):
response = action.do(self, request, self.object)
else:
method = getattr(self, 'action_' + action.name, None)
if method:
response = method(request, *args, **kwargs)
hooks.call_hooks('event', name='manager-action', user=self.request.user,
action=action, instance=self.object)
if response:
return response
self.request.method = 'GET'
return self.get(request, *args, **kwargs)
parent = super(OtherActionsMixin, self)
if hasattr(parent, 'post'):
return parent.post(request, *args, **kwargs)
return self.get(request, *args, **kwargs)
class ExportMixin(object):
'''Help in implementd export views'''
http_method_names = ['get', 'head', 'options']
export_prefix = ''
def get_export_prefix(self):
return self.export_prefix
def get_resource(self):
return self.resource_class()
def get_data(self):
qs = self.get_table_data()
return batch_queryset(qs)
def get_dataset(self):
return self.get_resource().export(self.get_data())
def get(self, request, *args, **kwargs):
export_format = kwargs['format'].lower()
content_types = {
'csv': 'text/csv',
}
if export_format not in content_types:
raise Http404('unknown format')
content = getattr(self.get_dataset(), export_format)
content_type = content_types[export_format]
return self.export_response(content, content_type, export_format)
def export_response(self, content, content_type, export_format):
response = HttpResponse(content, content_type=content_type)
filename = '%s%s.%s' % (self.get_export_prefix(), now().strftime('%Y%m%d_%H%M%S'),
export_format)
response['Content-Disposition'] = 'attachment; filename="%s"' \
% filename
return response
class FormNeedsRequest(object):
def get_form_kwargs(self):
kwargs = super(FormNeedsRequest, self).get_form_kwargs()
if getattr(self.get_form_class(), 'need_request', False):
kwargs['request'] = self.request
return kwargs
class ModelNameMixin(MediaMixin):
'''Mixin to provide a model name to view's template'''
def get_model_name(self):
return self.model._meta.verbose_name
def get_instance_name(self):
if hasattr(self, 'get_object'):
return six.text_type(self.get_object())
return u''
def get_context_data(self, **kwargs):
ctx = super(ModelNameMixin, self).get_context_data(**kwargs)
ctx['model_name'] = self.get_model_name()
return ctx
class TableHookMixin(object):
'''Helper class for table views, hiding the OU column from tables if an OU filter exists'''
def get_table(self, **kwargs):
table = super(TableHookMixin, self).get_table(**kwargs)
import copy
table = copy.deepcopy(table)
hooks.call_hooks('manager_modify_table', self, table)
return table
class BaseTableView(TitleMixin, TableHookMixin, FormatsContextData, ModelNameMixin, PermissionMixin,
SearchFormMixin, FilterQuerysetByPermMixin, TableQuerysetMixin,
SingleTableView):
'''Base class for views showing a table of objects'''
pass
class SubTableViewMixin(TableHookMixin, FormatsContextData, ModelNameMixin, PermissionMixin,
SearchFormMixin, FilterTableQuerysetByPermMixin,
TableQuerysetMixin, SingleObjectMixin,
SingleTableMixin, ContextMixin):
'''Helper class for views showing a table of objects related to one object'''
context_object_name = 'object'
paginate_by = None
class SimpleSubTableView(TitleMixin, SubTableViewMixin, TemplateView):
'''Base class for views showing a simple table of objects related to one object'''
pass
class BaseSubTableView(MultipleOUMixin, TitleMixin, SubTableViewMixin,
FormNeedsRequest, FormView):
'''Base class for views showing a table of objects related to one object'''
success_url = '.'
class BaseDeleteView(TitleMixin, ModelNameMixin, PermissionMixin,
AjaxFormViewMixin, DeleteView):
'''Base class for views implementing deletion of an object'''
template_name = 'authentic2/manager/delete.html'
context_object_name = 'object'
@property
def permissions(self):
app_label = self.model._meta.app_label
model_name = self.model._meta.model_name
return ['%s.delete_%s' % (app_label, model_name)]
@property
def title(self):
return _('Delete %s') % self.get_instance_name()
def get_success_url(self):
return '../../'
class ModelFormView(MediaMixin, FormNeedsRequest):
'''Base class for views showing a form for a model'''
fields = None
form_class = None
def get_fields(self):
return self.fields
def get_form_class(self):
return modelform_factory(self.model, form=self.form_class,
fields=self.get_fields())
def get_form(self, form_class=None):
form = super(ModelFormView, self).get_form(form_class=form_class)
hooks.call_hooks('manager_modify_form', self, form)
return form
class BaseDetailView(MultipleOUMixin, TitleMixin, ModelNameMixin, PermissionMixin, ModelFormView,
DetailView):
context_object_name = 'object'
form_class = None
@property
def permissions(self):
app_label = self.model._meta.app_label
model_name = self.model._meta.model_name
return ['%s.view_%s' % (app_label, model_name)]
def get_form(self):
form_class = self.get_form_class()
if getattr(form_class, 'need_request', False):
form = form_class(request=self.request, instance=self.object)
else:
form = form_class(instance=self.object)
for field in form.fields.values():
widget = field.widget
widget.attrs['disabled'] = ''
if 'readonly' in widget.attrs:
del widget.attrs['readonly']
return form
def get_context_data(self, **kwargs):
form = self.get_form()
hooks.call_hooks('manager_modify_form', self, form)
kwargs['form'] = form
ctx = super(BaseDetailView, self).get_context_data(**kwargs)
return ctx
class BaseAddView(MultipleOUMixin, TitleMixin, ModelNameMixin, PermissionMixin,
AjaxFormViewMixin, ModelFormView, CreateView):
'''Base class for views for adding an instance of a model'''
template_name = 'authentic2/manager/form.html'
success_view_name = None
context_object_name = 'object'
@property
def permissions(self):
app_label = self.model._meta.app_label
model_name = self.model._meta.model_name
return ['%s.add_%s' % (app_label, model_name)]
@property
def title(self):
return ('Add %s') % super(BaseAddView, self).get_model_name()
def get_success_url(self):
return reverse(self.success_view_name, kwargs={'pk': self.object.pk})
class BaseEditView(MultipleOUMixin, SuccessMessageMixin, TitleMixin, ModelNameMixin,
PermissionMixin, AjaxFormViewMixin, ModelFormView, UpdateView):
'''Base class for views for editing an instance of a model'''
template_name = 'authentic2/manager/form.html'
context_object_name = 'object'
@property
def permissions(self):
app_label = self.model._meta.app_label
model_name = self.model._meta.model_name
return ['%s.change_%s' % (app_label, model_name)]
@property
def title(self):
return _('Edit %s') % self.get_instance_name()
def get_success_url(self):
return '..'
class HomepageView(TitleMixin, PermissionMixin, MediaMixin, TemplateView):
template_name = 'authentic2/manager/homepage.html'
permissions = ['a2_rbac.search_role', 'a2_rbac.search_organizationalunit',
'auth.search_group', 'custom_user.search_user']
default_entries = [
{
'class': 'icon-organizational-units',
'href': reverse_lazy('a2-manager-ous'),
'label': _('Organizational units'),
'order': -1,
'permission': 'a2_rbac.search_organizationalunit',
'slug': 'organizational-units',
},
{
'class': 'icon-users',
'href': reverse_lazy('a2-manager-users'),
'label': _('Users'),
'order': -1,
'permission': 'custom_user.search_user',
'slug': 'users',
},
{
'class': 'icon-roles',
'href': reverse_lazy('a2-manager-roles'),
'label': _('Roles'),
'order': -1,
'permission': 'a2_rbac.search_role',
'slug': 'roles',
},
{
'class': 'icon-services',
'href': reverse_lazy('a2-manager-services'),
'label': _('Services'),
'order': -1,
'permission': 'authentic2.search_service',
'slug': 'services',
},
]
def dispatch(self, request, *args, **kwargs):
if app_settings.HOMEPAGE_URL:
return redirect(request, app_settings.HOMEPAGE_URL)
return super(HomepageView, self).dispatch(request, *args, **kwargs)
def get_homepage_entries(self):
entries = []
for entry in self.default_entries:
if 'permission' in entry and not self.request.user.has_perm_any(entry['permission']):
continue
entries.append(entry)
for hook_entries in hooks.call_hooks('manager_homepage_entries', self):
if not hasattr(hook_entries, 'append'):
hook_entries = [hook_entries]
for entry in hook_entries:
if 'permission' in entry and not self.request.user.has_perm_any(entry['permission']):
continue
entries.append(entry)
# use possible key order to sort
# list.sort() is supposed to be a stable sort (already sorted entries
# are kept in the same order)
entries.sort(key=lambda d: d.get('order', 0))
return entries
def get_context_data(self, **kwargs):
kwargs['entries'] = self.get_homepage_entries()
return super(HomepageView, self).get_context_data(**kwargs)
homepage = HomepageView.as_view()
class MenuJson(HomepageView):
def get(self, request, *args, **kwargs):
menu_entries = []
for entry in self.get_homepage_entries():
menu_entries.append({
'label': six.text_type(entry['label']),
'slug': entry.get('slug', ''),
'url': request.build_absolute_uri(six.text_type(entry['href'])),
})
return menu_entries
menu_json = json_view(MenuJson.as_view())
class HideOUColumnMixin(object):
'''Helper class for table views, hiding the OU column from tables if an OU filter exists'''
def get_table(self, **kwargs):
OU = get_ou_model()
exclude_ou = False
if (hasattr(self, 'search_form')
and self.search_form.is_valid()
and self.search_form.cleaned_data.get('ou') is not None):
exclude_ou = True
if OU.objects.count() < 2:
exclude_ou = True
if exclude_ou:
exclude = kwargs.setdefault('exclude', [])
if 'ou' not in exclude:
exclude.append('ou')
return super(HideOUColumnMixin, self).get_table(**kwargs)
class Select2View(AutoResponseView):
'''Overrided default django-select2 view to enforce security checks on Select2 AJAX requests.'''
def get_widget_or_404(self):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'filter_by_perm'):
raise Http404('Invalid user')
field_data = self.kwargs.get('field_id', self.request.GET.get('field_id', None))
try:
field_data = signing.loads(field_data)
except (signing.SignatureExpired, signing.BadSignature):
raise Http404('Invalid or expired signature.')
widget_class = field_data.get('class')
if not widget_class or not hasattr(widgets, widget_class):
raise Http404('Missing or unknown widget class.')
widget = getattr(widgets, widget_class)()
if not isinstance(widget, (widgets.SimpleModelSelect2Widget, widgets.SimpleModelSelect2MultipleWidget)):
raise Http404('Reference to invalid widget class')
qs = widget.get_queryset()
qs.query.where = pickle.loads(base64.b64decode(field_data['where_clause']))
# check permissions again as current user may not be the one who obtained the field_id
perm = '%s.search_%s' % (qs.model._meta.app_label, qs.model._meta.model_name)
qs = self.request.user.filter_by_perm(perm, qs)
widget.queryset = qs
widget.view = self
return widget
select2 = Select2View.as_view()
class SiteExport(View):
def get(self, request, *args, **kwargs):
if not request.user.is_superuser:
raise PermissionDenied
return HttpResponse(
json.dumps(export_site(), indent=4), content_type='application/json')
site_export = SiteExport.as_view()
class SiteImportView(MediaMixin, TitleMixin, FormView):
form_class = forms.SiteImportForm
template_name = 'authentic2/manager/import_form.html'
success_url = reverse_lazy('a2-manager-homepage')
title = _('Site Import')
def form_valid(self, form):
try:
with transaction.atomic():
import_site(form.cleaned_data['site_json'], ImportContext())
except ValidationError as e:
form.add_error('site_json', e)
return self.form_invalid(form)
return super(SiteImportView, self).form_valid(form)
def dispatch(self, request, *args, **kwargs):
if not request.user.is_superuser:
raise PermissionDenied
return super(SiteImportView, self).dispatch(request, *args, **kwargs)
site_import = SiteImportView.as_view()