InvenTree/InvenTree/InvenTree/views.py

664 lines
20 KiB
Python

"""Various Views which provide extra functionality over base Django Views.
In particular these views provide base functionality for rendering Django forms
as JSON objects and passing them to modal forms (using jQuery / bootstrap).
"""
from django.contrib.auth import password_validation
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import ValidationError
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import DeleteView, DetailView, ListView, UpdateView
from django.views.generic.base import RedirectView, TemplateView
from allauth.account.forms import AddEmailForm
from allauth.account.models import EmailAddress
from allauth.account.views import EmailView, LoginView, PasswordResetFromKeyView
from allauth.socialaccount.forms import DisconnectForm
from allauth.socialaccount.views import ConnectionsView
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
import common.models as common_models
import common.settings as common_settings
from part.models import PartCategory
from users.models import RuleSet, check_user_role
from .forms import EditUserForm, SetPasswordForm
from .helpers import remove_non_printable_characters, strip_html_tags
def auth_request(request):
"""Simple 'auth' endpoint used to determine if the user is authenticated.
Useful for (for example) redirecting authentication requests through django's permission framework.
"""
if request.user.is_authenticated:
return HttpResponse(status=200)
return HttpResponse(status=403)
class InvenTreeRoleMixin(PermissionRequiredMixin):
"""Permission class based on user roles, not user 'permissions'.
There are a number of ways that the permissions can be specified for a view:
1. Specify the 'role_required' attribute (e.g. part.change)
2. Specify the 'permission_required' attribute (e.g. part.change_bomitem)
(Note: This is the "normal" django-esque way of doing this)
3. Do nothing. The mixin will attempt to "guess" what permission you require:
a) If there is a queryset associated with the View, we have the model!
b) The *type* of View tells us the permission level (e.g. AjaxUpdateView = change)
c) 1 + 1 = 3
d) Use the combination of model + permission as we would in 2)
1. Specify the 'role_required' attribute
=====================================
To specify which role is required for the mixin,
set the class attribute 'role_required' to something like the following:
role_required = 'part.add'
role_required = [
'part.change',
'build.add',
]
2. Specify the 'permission_required' attribute
===========================================
To specify a particular low-level permission,
set the class attribute 'permission_required' to something like:
permission_required = 'company.delete_company'
3. Do Nothing
==========
See above.
"""
# By default, no roles are required
# Roles must be specified
role_required = None
def has_permission(self):
"""Determine if the current user has specified permissions."""
roles_required = []
if type(self.role_required) is str:
roles_required.append(self.role_required)
elif type(self.role_required) in [list, tuple]:
roles_required = self.role_required
user = self.request.user
# Superuser can have any permissions they desire
if user.is_superuser:
return True
for required in roles_required:
(role, permission) = required.split('.')
if role not in RuleSet.RULESET_NAMES:
raise ValueError(f"Role '{role}' is not a valid role")
if permission not in RuleSet.RULESET_PERMISSIONS:
raise ValueError(f"Permission '{permission}' is not a valid permission")
# Return False if the user does not have *any* of the required roles
if not check_user_role(user, role, permission):
return False
# If a permission_required is specified, use that!
if self.permission_required:
# Ignore role-based permissions
return super().has_permission()
# Ok, so at this point we have not explicitly require a "role" or a "permission"
# Instead, we will use the model to introspect the data we need
model = getattr(self, 'model', None)
if not model:
queryset = getattr(self, 'queryset', None)
if queryset is not None:
model = queryset.model
# We were able to introspect a database model
if model is not None:
app_label = model._meta.app_label
model_name = model._meta.model_name
table = f'{app_label}_{model_name}'
permission = self.get_permission_class()
if not permission:
raise AttributeError(
f'permission_class not defined for {type(self).__name__}'
)
# Check if the user has the required permission
return RuleSet.check_table_permission(user, table, permission)
# We did not fail any required checks
return True
def get_permission_class(self):
"""Return the 'permission_class' required for the current View.
Must be one of:
- view
- change
- add
- delete
This can either be explicitly defined, by setting the
'permission_class' attribute,
or it can be "guessed" by looking at the type of class
"""
perm = getattr(self, 'permission_class', None)
# Permission is specified by the class itself
if perm:
return perm
# Otherwise, we will need to have a go at guessing...
permission_map = {
AjaxView: 'view',
ListView: 'view',
DetailView: 'view',
UpdateView: 'change',
DeleteView: 'delete',
AjaxUpdateView: 'change',
}
for view_class in permission_map.keys():
if issubclass(type(self), view_class):
return permission_map[view_class]
return None
class AjaxMixin(InvenTreeRoleMixin):
"""AjaxMixin provides basic functionality for rendering a Django form to JSON. Handles jsonResponse rendering, and adds extra data for the modal forms to process on the client side.
Any view which inherits the AjaxMixin will need
correct permissions set using the 'role_required' attribute
"""
# By default, allow *any* role
role_required = None
# By default, point to the modal_form template
# (this can be overridden by a child class)
ajax_template_name = 'modal_form.html'
ajax_form_title = ''
def get_form_title(self):
"""Default implementation - return the ajax_form_title variable."""
return self.ajax_form_title
def get_param(self, name, method='GET'):
"""Get a request query parameter value from URL e.g. ?part=3.
Args:
name: Variable name e.g. 'part'
method: Request type ('GET' or 'POST')
Returns:
Value of the supplier parameter or None if parameter is not available
"""
if method == 'POST':
return self.request.POST.get(name, None)
return self.request.GET.get(name, None)
def get_data(self):
"""Get extra context data (default implementation is empty dict).
Returns:
dict object (empty)
"""
return {}
def validate(self, obj, form, **kwargs):
"""Hook for performing custom form validation steps.
If a form error is detected, add it to the form,
with 'form.add_error()'
Ref: https://docs.djangoproject.com/en/dev/topics/forms/
"""
# Do nothing by default
pass
def renderJsonResponse(self, request, form=None, data=None, context=None):
"""Render a JSON response based on specific class context.
Args:
request: HTTP request object (e.g. GET / POST)
form: Django form object (may be None)
data: Extra JSON data to pass to client
context: Extra context data to pass to template rendering
Returns:
JSON response object
"""
# a empty dict as default can be dangerous - set it here if empty
if not data:
data = {}
if not request.is_ajax():
return HttpResponseRedirect('/')
if context is None:
try:
context = self.get_context_data()
except AttributeError:
context = {}
# If no 'form' argument is supplied, look at the underlying class
if form is None:
try:
form = self.get_form()
except AttributeError:
pass
if form:
context['form'] = form
else:
context['form'] = None
data['title'] = self.get_form_title()
data['html_form'] = render_to_string(
self.ajax_template_name, context, request=request
)
# Custom feedback`data
fb = self.get_data()
for key in fb.keys():
data[key] = fb[key]
return JsonResponse(data, safe=False)
class AjaxView(AjaxMixin, View):
"""An 'AJAXified' View for displaying an object."""
def post(self, request, *args, **kwargs):
"""Return a json formatted response.
This renderJsonResponse function must be supplied by your function.
"""
return self.renderJsonResponse(request)
def get(self, request, *args, **kwargs):
"""Return a json formatted response.
This renderJsonResponse function must be supplied by your function.
"""
return self.renderJsonResponse(request)
class AjaxUpdateView(AjaxMixin, UpdateView):
"""An 'AJAXified' UpdateView for updating an object in the db.
- Returns form in JSON format (for delivery to a modal window)
- Handles repeated form validation (via AJAX) until the form is valid
"""
def get(self, request, *args, **kwargs):
"""Respond to GET request.
- Populates form with object data
- Renders form to JSON and returns to client
"""
super(UpdateView, self).get(request, *args, **kwargs)
return self.renderJsonResponse(
request, self.get_form(), context=self.get_context_data()
)
def save(self, object, form, **kwargs):
"""Method for updating the object in the database. Default implementation is very simple, but can be overridden if required.
Args:
object - The current object, to be updated
form - The validated form
Returns:
object instance for supplied form
"""
self.object = form.save()
return self.object
def post(self, request, *args, **kwargs):
"""Respond to POST request.
- Updates model with POST field data
- Performs form and object validation
- If errors exist, re-render the form
- Otherwise, return success status
"""
self.request = request
# Make sure we have an object to point to
self.object = self.get_object()
form = self.get_form()
# Perform initial form validation
form.is_valid()
# Perform custom validation
self.validate(self.object, form)
valid = form.is_valid()
data = {
'form_valid': valid,
'form_errors': form.errors.as_json(),
'non_field_errors': form.non_field_errors().as_json(),
}
# Add in any extra class data
for value, key in enumerate(self.get_data()):
data[key] = value
if valid:
# Save the updated object to the database
self.save(self.object, form)
self.object = self.get_object()
# Include context data about the updated object
data['pk'] = self.object.pk
try:
data['url'] = self.object.get_absolute_url()
except AttributeError:
pass
return self.renderJsonResponse(request, form, data)
class EditUserView(AjaxUpdateView):
"""View for editing user information."""
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit User Information')
form_class = EditUserForm
def get_object(self):
"""Set form to edit current user."""
return self.request.user
class SetPasswordView(AjaxUpdateView):
"""View for setting user password."""
ajax_template_name = 'InvenTree/password.html'
ajax_form_title = _('Set Password')
form_class = SetPasswordForm
def get_object(self):
"""Set form to edit current user."""
return self.request.user
def post(self, request, *args, **kwargs):
"""Validate inputs and change password."""
form = self.get_form()
valid = form.is_valid()
p1 = request.POST.get('enter_password', '')
p2 = request.POST.get('confirm_password', '')
old_password = request.POST.get('old_password', '')
user = self.request.user
if valid:
# Passwords must match
if p1 != p2:
error = _('Password fields must match')
form.add_error('enter_password', error)
form.add_error('confirm_password', error)
valid = False
if valid:
# Old password must be correct
if user.has_usable_password() and not user.check_password(old_password):
form.add_error('old_password', _('Wrong password provided'))
valid = False
if valid:
try:
# Validate password
password_validation.validate_password(p1, user)
# Update the user
user.set_password(p1)
user.save()
except ValidationError as error:
form.add_error('confirm_password', str(error))
valid = False
return self.renderJsonResponse(request, form, data={'form_valid': valid})
class IndexView(TemplateView):
"""View for InvenTree index page."""
template_name = 'InvenTree/index.html'
class SearchView(TemplateView):
"""View for InvenTree search page.
Displays results of search query
"""
template_name = 'InvenTree/search.html'
def post(self, request, *args, **kwargs):
"""Handle POST request (which contains search query).
Pass the search query to the page template
"""
context = self.get_context_data()
query = request.POST.get('search', '')
query = strip_html_tags(query, raise_error=False)
query = remove_non_printable_characters(query)
context['query'] = query
return super(TemplateView, self).render_to_response(context)
class DynamicJsView(TemplateView):
"""View for returning javacsript files, which instead of being served dynamically, are passed through the django translation engine!"""
template_name = ''
content_type = 'text/javascript'
class SettingsView(TemplateView):
"""View for configuring User settings."""
template_name = 'InvenTree/settings/settings.html'
def get_context_data(self, **kwargs):
"""Add data for template."""
ctx = super().get_context_data(**kwargs).copy()
ctx['settings'] = common_models.InvenTreeSetting.objects.all().order_by('key')
ctx['base_currency'] = common_settings.currency_code_default()
ctx['currencies'] = common_settings.currency_codes
ctx['rates'] = Rate.objects.filter(backend='InvenTreeExchange')
ctx['categories'] = PartCategory.objects.all().order_by(
'tree_id', 'lft', 'name'
)
# When were the rates last updated?
try:
backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
if backend.exists():
backend = backend.first()
ctx['rates_updated'] = backend.last_update
except Exception:
ctx['rates_updated'] = None
# Forms and context for allauth
ctx['add_email_form'] = AddEmailForm
ctx['can_add_email'] = EmailAddress.objects.can_add_email(self.request.user)
# Form and context for allauth social-accounts
ctx['request'] = self.request
ctx['social_form'] = DisconnectForm(request=self.request)
# user db sessions
ctx['session_key'] = self.request.session.session_key
ctx['session_list'] = self.request.user.session_set.filter(
expire_date__gt=now()
).order_by('-last_activity')
return ctx
class AllauthOverrides(LoginRequiredMixin):
"""Override allauths views to always redirect to success_url."""
def get(self, request, *args, **kwargs):
"""Always redirect to success_url (set to settings)."""
return HttpResponseRedirect(self.success_url)
class CustomEmailView(AllauthOverrides, EmailView):
"""Override of allauths EmailView to always show the settings but leave the functions allow."""
success_url = reverse_lazy('settings')
class CustomConnectionsView(AllauthOverrides, ConnectionsView):
"""Override of allauths ConnectionsView to always show the settings but leave the functions allow."""
success_url = reverse_lazy('settings')
class CustomPasswordResetFromKeyView(PasswordResetFromKeyView):
"""Override of allauths PasswordResetFromKeyView to always show the settings but leave the functions allow."""
success_url = reverse_lazy('account_login')
class UserSessionOverride:
"""Overrides sucessurl to lead to settings."""
def get_success_url(self):
"""Revert to settings page after success."""
return str(reverse_lazy('settings'))
class CustomSessionDeleteView(UserSessionOverride, SessionDeleteView):
"""Revert to settings after session delete."""
pass
class CustomSessionDeleteOtherView(UserSessionOverride, SessionDeleteOtherView):
"""Revert to settings after session delete."""
pass
class CustomLoginView(LoginView):
"""Custom login view that allows login with urlargs."""
def get(self, request, *args, **kwargs):
"""Extendend get to allow for auth via url args."""
# Check if login is present
if 'login' in request.GET:
# Initiate form
form = self.get_form_class()(request.GET.dict(), request=request)
# Validate form data
form.is_valid()
# Try to login
form.full_clean()
return form.login(request)
return super().get(request, *args, **kwargs)
class AppearanceSelectView(RedirectView):
"""View for selecting a color theme."""
def get_user_theme(self):
"""Get current user color theme."""
try:
user_theme = common_models.ColorTheme.objects.filter(
user=self.request.user
).get()
except common_models.ColorTheme.DoesNotExist:
user_theme = None
return user_theme
def post(self, request, *args, **kwargs):
"""Save user color theme selection."""
theme = request.POST.get('theme', None)
# Get current user theme
user_theme = self.get_user_theme()
# Create theme entry if user did not select one yet
if not user_theme:
user_theme = common_models.ColorTheme()
user_theme.user = request.user
if theme:
try:
user_theme.name = theme
user_theme.save()
except Exception:
pass
return redirect(reverse_lazy('settings'))
class DatabaseStatsView(AjaxView):
"""View for displaying database statistics."""
ajax_template_name = 'stats.html'
ajax_form_title = _('System Information')
class AboutView(AjaxView):
"""A view for displaying InvenTree version information"""
ajax_template_name = 'about.html'
ajax_form_title = _('About InvenTree')
class NotificationsView(TemplateView):
"""View for showing notifications."""
template_name = 'InvenTree/notifications/notifications.html'