• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inventree / InvenTree / 8704605723

16 Apr 2024 11:12AM UTC coverage: 81.051%. First build
8704605723

Pull #6293

github

web-flow
Merge b2f550718 into b3f6c8f53
Pull Request #6293: Remove django-allauth-2fa

1399 of 2736 branches covered (51.13%)

Branch coverage included in aggregate %.

40 of 47 new or added lines in 7 files covered. (85.11%)

57248 of 69622 relevant lines covered (82.23%)

22.61 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

45.89
/src/backend/InvenTree/InvenTree/views.py
1
"""Various Views which provide extra functionality over base Django Views.
2

3
In particular these views provide base functionality for rendering Django forms
4
as JSON objects and passing them to modal forms (using jQuery / bootstrap).
5
"""
6

7
from django.contrib.auth import password_validation
1✔
8
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
1✔
9
from django.core.exceptions import ValidationError
1✔
10
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
1✔
11
from django.shortcuts import redirect
1✔
12
from django.template.loader import render_to_string
1✔
13
from django.urls import reverse_lazy
1✔
14
from django.utils.timezone import now
1✔
15
from django.utils.translation import gettext_lazy as _
1✔
16
from django.views import View
1✔
17
from django.views.generic import DeleteView, DetailView, ListView, UpdateView
1✔
18
from django.views.generic.base import RedirectView, TemplateView
1✔
19

20
from allauth.account.forms import AddEmailForm
1✔
21
from allauth.account.models import EmailAddress
1✔
22
from allauth.account.views import EmailView, LoginView, PasswordResetFromKeyView
1✔
23
from allauth.socialaccount.forms import DisconnectForm
1✔
24
from allauth.socialaccount.views import ConnectionsView
1✔
25
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
1✔
26
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
1✔
27

28
import common.models as common_models
1✔
29
import common.settings as common_settings
1✔
30
from part.models import PartCategory
1✔
31
from users.models import RuleSet, check_user_role
1✔
32

33
from .forms import EditUserForm, SetPasswordForm
1✔
34
from .helpers import is_ajax, remove_non_printable_characters, strip_html_tags
1✔
35

36

37
def auth_request(request):
1✔
38
    """Simple 'auth' endpoint used to determine if the user is authenticated.
39

40
    Useful for (for example) redirecting authentication requests through django's permission framework.
41
    """
42
    if request.user.is_authenticated:
×
43
        return HttpResponse(status=200)
×
44
    return HttpResponse(status=403)
×
45

46

47
class InvenTreeRoleMixin(PermissionRequiredMixin):
1✔
48
    """Permission class based on user roles, not user 'permissions'.
49

50
    There are a number of ways that the permissions can be specified for a view:
51

52
    1.  Specify the 'role_required' attribute (e.g. part.change)
53
    2.  Specify the 'permission_required' attribute (e.g. part.change_bomitem)
54
        (Note: This is the "normal" django-esque way of doing this)
55
    3.  Do nothing. The mixin will attempt to "guess" what permission you require:
56
        a) If there is a queryset associated with the View, we have the model!
57
        b) The *type* of View tells us the permission level (e.g. AjaxUpdateView = change)
58
        c) 1 + 1 = 3
59
        d) Use the combination of model + permission as we would in 2)
60

61
    1.  Specify the 'role_required' attribute
62
        =====================================
63
        To specify which role is required for the mixin,
64
        set the class attribute 'role_required' to something like the following:
65

66
        role_required = 'part.add'
67
        role_required = [
68
            'part.change',
69
            'build.add',
70
        ]
71

72
    2.  Specify the 'permission_required' attribute
73
        ===========================================
74
        To specify a particular low-level permission,
75
        set the class attribute 'permission_required' to something like:
76

77
        permission_required = 'company.delete_company'
78

79
    3.  Do Nothing
80
        ==========
81

82
        See above.
83
    """
84

85
    # By default, no roles are required
86
    # Roles must be specified
87
    role_required = None
1✔
88

89
    def has_permission(self):
1✔
90
        """Determine if the current user has specified permissions."""
91
        roles_required = []
92

93
        if type(self.role_required) is str:
94
            roles_required.append(self.role_required)
95
        elif type(self.role_required) in [list, tuple]:
96
            roles_required = self.role_required
97

98
        user = self.request.user
99

100
        # Superuser can have any permissions they desire
101
        if user.is_superuser:
102
            return True
103

104
        for required in roles_required:
105
            (role, permission) = required.split('.')
106

107
            if role not in RuleSet.RULESET_NAMES:
108
                raise ValueError(f"Role '{role}' is not a valid role")
109

110
            if permission not in RuleSet.RULESET_PERMISSIONS:
111
                raise ValueError(f"Permission '{permission}' is not a valid permission")
112

113
            # Return False if the user does not have *any* of the required roles
114
            if not check_user_role(user, role, permission):
115
                return False
116

117
        # If a permission_required is specified, use that!
118
        if self.permission_required:
119
            # Ignore role-based permissions
120
            return super().has_permission()
121

122
        # Ok, so at this point we have not explicitly require a "role" or a "permission"
123
        # Instead, we will use the model to introspect the data we need
124

125
        model = getattr(self, 'model', None)
126

127
        if not model:
128
            queryset = getattr(self, 'queryset', None)
129

130
            if queryset is not None:
131
                model = queryset.model
132

133
        # We were able to introspect a database model
134
        if model is not None:
135
            app_label = model._meta.app_label
136
            model_name = model._meta.model_name
137

138
            table = f'{app_label}_{model_name}'
139

140
            permission = self.get_permission_class()
141

142
            if not permission:
143
                raise AttributeError(
144
                    f'permission_class not defined for {type(self).__name__}'
145
                )
146

147
            # Check if the user has the required permission
148
            return RuleSet.check_table_permission(user, table, permission)
149

150
        # We did not fail any required checks
151
        return True
152

153
    def get_permission_class(self):
154
        """Return the 'permission_class' required for the current View.
155

156
        Must be one of:
157

158
        - view
159
        - change
160
        - add
161
        - delete
162

163
        This can either be explicitly defined, by setting the
164
        'permission_class' attribute,
165
        or it can be "guessed" by looking at the type of class
166
        """
167
        perm = getattr(self, 'permission_class', None)
×
168

169
        # Permission is specified by the class itself
170
        if perm:
×
171
            return perm
×
172

173
        # Otherwise, we will need to have a go at guessing...
174
        permission_map = {
175
            AjaxView: 'view',
176
            ListView: 'view',
177
            DetailView: 'view',
178
            UpdateView: 'change',
179
            DeleteView: 'delete',
180
            AjaxUpdateView: 'change',
181
        }
182

183
        for view_class in permission_map.keys():
×
184
            if issubclass(type(self), view_class):
×
185
                return permission_map[view_class]
×
186

187
        return None
×
188

189

190
class AjaxMixin(InvenTreeRoleMixin):
1✔
191
    """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.
192

193
    Any view which inherits the AjaxMixin will need
194
    correct permissions set using the 'role_required' attribute
195
    """
196

197
    # By default, allow *any* role
198
    role_required = None
1✔
199

200
    # By default, point to the modal_form template
201
    # (this can be overridden by a child class)
202
    ajax_template_name = 'modal_form.html'
1✔
203

204
    ajax_form_title = ''
1✔
205

206
    def get_form_title(self):
1✔
207
        """Default implementation - return the ajax_form_title variable."""
208
        return self.ajax_form_title
209

210
    def get_param(self, name, method='GET'):
211
        """Get a request query parameter value from URL e.g. ?part=3.
212

213
        Args:
214
            name: Variable name e.g. 'part'
215
            method: Request type ('GET' or 'POST')
216

217
        Returns:
218
            Value of the supplier parameter or None if parameter is not available
219
        """
220
        if method == 'POST':
×
221
            return self.request.POST.get(name, None)
×
222
        return self.request.GET.get(name, None)
×
223

224
    def get_data(self):
1✔
225
        """Get extra context data (default implementation is empty dict).
226

227
        Returns:
228
            dict object (empty)
229
        """
230
        return {}
×
231

232
    def validate(self, obj, form, **kwargs):
1✔
233
        """Hook for performing custom form validation steps.
234

235
        If a form error is detected, add it to the form,
236
        with 'form.add_error()'
237

238
        Ref: https://docs.djangoproject.com/en/dev/topics/forms/
239
        """
240
        # Do nothing by default
241
        pass
×
242

243
    def renderJsonResponse(self, request, form=None, data=None, context=None):
1✔
244
        """Render a JSON response based on specific class context.
245

246
        Args:
247
            request: HTTP request object (e.g. GET / POST)
248
            form: Django form object (may be None)
249
            data: Extra JSON data to pass to client
250
            context: Extra context data to pass to template rendering
251

252
        Returns:
253
            JSON response object
254
        """
255
        # a empty dict as default can be dangerous - set it here if empty
256
        if not data:
×
257
            data = {}
×
258

259
        if not is_ajax(request):
×
260
            return HttpResponseRedirect('/')
×
261

262
        if context is None:
×
263
            try:
×
264
                context = self.get_context_data()
×
265
            except AttributeError:
×
266
                context = {}
×
267

268
        # If no 'form' argument is supplied, look at the underlying class
269
        if form is None:
×
270
            try:
×
271
                form = self.get_form()
×
272
            except AttributeError:
×
273
                pass
×
274

275
        if form:
×
276
            context['form'] = form
×
277
        else:
×
278
            context['form'] = None
×
279

280
        data['title'] = self.get_form_title()
×
281

282
        data['html_form'] = render_to_string(
283
            self.ajax_template_name, context, request=request
284
        )
285

286
        # Custom feedback`data
287
        fb = self.get_data()
×
288

289
        for key in fb.keys():
×
290
            data[key] = fb[key]
×
291

292
        return JsonResponse(data, safe=False)
×
293

294

295
class AjaxView(AjaxMixin, View):
1✔
296
    """An 'AJAXified' View for displaying an object."""
297

298
    def post(self, request, *args, **kwargs):
299
        """Return a json formatted response.
300

301
        This renderJsonResponse function must be supplied by your function.
302
        """
303
        return self.renderJsonResponse(request)
×
304

305
    def get(self, request, *args, **kwargs):
1✔
306
        """Return a json formatted response.
307

308
        This renderJsonResponse function must be supplied by your function.
309
        """
310
        return self.renderJsonResponse(request)
×
311

312

313
class AjaxUpdateView(AjaxMixin, UpdateView):
1✔
314
    """An 'AJAXified' UpdateView for updating an object in the db.
315

316
    - Returns form in JSON format (for delivery to a modal window)
317
    - Handles repeated form validation (via AJAX) until the form is valid
318
    """
319

320
    def get(self, request, *args, **kwargs):
1✔
321
        """Respond to GET request.
322

323
        - Populates form with object data
324
        - Renders form to JSON and returns to client
325
        """
326
        super(UpdateView, self).get(request, *args, **kwargs)
×
327

328
        return self.renderJsonResponse(
329
            request, self.get_form(), context=self.get_context_data()
330
        )
331

332
    def save(self, object, form, **kwargs):
1✔
333
        """Method for updating the object in the database. Default implementation is very simple, but can be overridden if required.
334

335
        Args:
336
            object: The current object, to be updated
337
            form: The validated form
338

339
        Returns:
340
            object instance for supplied form
341
        """
342
        self.object = form.save()
×
343

344
        return self.object
×
345

346
    def post(self, request, *args, **kwargs):
1✔
347
        """Respond to POST request.
348

349
        - Updates model with POST field data
350
        - Performs form and object validation
351
        - If errors exist, re-render the form
352
        - Otherwise, return success status
353
        """
354
        self.request = request
×
355

356
        # Make sure we have an object to point to
357
        self.object = self.get_object()
×
358

359
        form = self.get_form()
×
360

361
        # Perform initial form validation
362
        form.is_valid()
×
363

364
        # Perform custom validation
365
        self.validate(self.object, form)
×
366

367
        valid = form.is_valid()
×
368

369
        data = {
370
            'form_valid': valid,
371
            'form_errors': form.errors.as_json(),
372
            'non_field_errors': form.non_field_errors().as_json(),
373
        }
374

375
        # Add in any extra class data
376
        for value, key in enumerate(self.get_data()):
×
377
            data[key] = value
×
378

379
        if valid:
×
380
            # Save the updated object to the database
381
            self.save(self.object, form)
×
382

383
            self.object = self.get_object()
×
384

385
            # Include context data about the updated object
386
            data['pk'] = self.object.pk
×
387

388
            try:
×
389
                data['url'] = self.object.get_absolute_url()
×
390
            except AttributeError:
×
391
                pass
×
392

393
        return self.renderJsonResponse(request, form, data)
×
394

395

396
class EditUserView(AjaxUpdateView):
1✔
397
    """View for editing user information."""
398

399
    ajax_template_name = 'modal_form.html'
400
    ajax_form_title = _('Edit User Information')
401
    form_class = EditUserForm
402

403
    def get_object(self):
404
        """Set form to edit current user."""
405
        return self.request.user
×
406

407

408
class SetPasswordView(AjaxUpdateView):
1✔
409
    """View for setting user password."""
410

411
    ajax_template_name = 'InvenTree/password.html'
412
    ajax_form_title = _('Set Password')
413
    form_class = SetPasswordForm
414

415
    def get_object(self):
416
        """Set form to edit current user."""
417
        return self.request.user
×
418

419
    def post(self, request, *args, **kwargs):
1✔
420
        """Validate inputs and change password."""
421
        form = self.get_form()
422

423
        valid = form.is_valid()
424

425
        p1 = request.POST.get('enter_password', '')
426
        p2 = request.POST.get('confirm_password', '')
427
        old_password = request.POST.get('old_password', '')
428
        user = self.request.user
429

430
        if valid:
431
            # Passwords must match
432

433
            if p1 != p2:
434
                error = _('Password fields must match')
435
                form.add_error('enter_password', error)
436
                form.add_error('confirm_password', error)
437
                valid = False
438

439
        if valid:
440
            # Old password must be correct
441
            if user.has_usable_password() and not user.check_password(old_password):
442
                form.add_error('old_password', _('Wrong password provided'))
443
                valid = False
444

445
        if valid:
446
            try:
447
                # Validate password
448
                password_validation.validate_password(p1, user)
449

450
                # Update the user
451
                user.set_password(p1)
452
                user.save()
453
            except ValidationError as error:
454
                form.add_error('confirm_password', str(error))
455
                valid = False
456

457
        return self.renderJsonResponse(request, form, data={'form_valid': valid})
458

459

460
class IndexView(TemplateView):
461
    """View for InvenTree index page."""
462

463
    template_name = 'InvenTree/index.html'
1✔
464

465

466
class SearchView(TemplateView):
1✔
467
    """View for InvenTree search page.
468

469
    Displays results of search query
470
    """
471

472
    template_name = 'InvenTree/search.html'
1✔
473

474
    def post(self, request, *args, **kwargs):
1✔
475
        """Handle POST request (which contains search query).
476

477
        Pass the search query to the page template
478
        """
479
        context = self.get_context_data()
×
480

481
        query = request.POST.get('search', '')
×
482

NEW
483
        query = strip_html_tags(query, raise_error=False)
×
NEW
484
        query = remove_non_printable_characters(query)
×
485

486
        context['query'] = query
×
487

488
        return super(TemplateView, self).render_to_response(context)
×
489

490

491
class DynamicJsView(TemplateView):
1✔
492
    """View for returning javacsript files, which instead of being served dynamically, are passed through the django translation engine!"""
493

494
    template_name = ''
495
    content_type = 'text/javascript'
496

497

498
class SettingsView(TemplateView):
499
    """View for configuring User settings."""
500

501
    template_name = 'InvenTree/settings/settings.html'
1✔
502

503
    def get_context_data(self, **kwargs):
1✔
504
        """Add data for template."""
505
        ctx = super().get_context_data(**kwargs).copy()
506

507
        ctx['settings'] = common_models.InvenTreeSetting.objects.all().order_by('key')
508

509
        ctx['base_currency'] = common_settings.currency_code_default()
510
        ctx['currencies'] = common_settings.currency_codes
511

512
        ctx['rates'] = Rate.objects.filter(backend='InvenTreeExchange')
513

514
        ctx['categories'] = PartCategory.objects.all().order_by(
515
            'tree_id', 'lft', 'name'
516
        )
517

518
        # When were the rates last updated?
519
        try:
520
            backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
521
            if backend.exists():
522
                backend = backend.first()
523
                ctx['rates_updated'] = backend.last_update
524
        except Exception:
525
            ctx['rates_updated'] = None
526

527
        # Forms and context for allauth
528
        ctx['add_email_form'] = AddEmailForm
529
        ctx['can_add_email'] = EmailAddress.objects.can_add_email(self.request.user)
530

531
        # Form and context for allauth social-accounts
532
        ctx['request'] = self.request
533
        ctx['social_form'] = DisconnectForm(request=self.request)
534

535
        # user db sessions
536
        ctx['session_key'] = self.request.session.session_key
537
        ctx['session_list'] = self.request.user.session_set.filter(
538
            expire_date__gt=now()
539
        ).order_by('-last_activity')
540

541
        return ctx
542

543

544
class AllauthOverrides(LoginRequiredMixin):
545
    """Override allauths views to always redirect to success_url."""
546

547
    def get(self, request, *args, **kwargs):
1✔
548
        """Always redirect to success_url (set to settings)."""
549
        return HttpResponseRedirect(self.success_url)
550

551

552
class CustomEmailView(AllauthOverrides, EmailView):
553
    """Override of allauths EmailView to always show the settings but leave the functions allow."""
554

555
    success_url = reverse_lazy('settings')
1✔
556

557

558
class CustomConnectionsView(AllauthOverrides, ConnectionsView):
1✔
559
    """Override of allauths ConnectionsView to always show the settings but leave the functions allow."""
560

561
    success_url = reverse_lazy('settings')
562

563

564
class CustomPasswordResetFromKeyView(PasswordResetFromKeyView):
565
    """Override of allauths PasswordResetFromKeyView to always show the settings but leave the functions allow."""
566

567
    success_url = reverse_lazy('account_login')
1✔
568

569

570
class UserSessionOverride:
1✔
571
    """Overrides sucessurl to lead to settings."""
572

573
    def get_success_url(self):
574
        """Revert to settings page after success."""
575
        return str(reverse_lazy('settings'))
×
576

577

578
class CustomSessionDeleteView(UserSessionOverride, SessionDeleteView):
1✔
579
    """Revert to settings after session delete."""
580

581
    pass
582

583

584
class CustomSessionDeleteOtherView(UserSessionOverride, SessionDeleteOtherView):
585
    """Revert to settings after session delete."""
586

587
    pass
1✔
588

589

590
class CustomLoginView(LoginView):
1✔
591
    """Custom login view that allows login with urlargs."""
592

593
    def get(self, request, *args, **kwargs):
594
        """Extendend get to allow for auth via url args."""
595
        # Check if login is present
596
        if 'login' in request.GET:
×
597
            # Initiate form
598
            form = self.get_form_class()(request.GET.dict(), request=request)
×
599

600
            # Validate form data
601
            form.is_valid()
×
602

603
            # Try to login
604
            form.full_clean()
×
605
            return form.login(request)
×
606

607
        return super().get(request, *args, **kwargs)
×
608

609

610
class AppearanceSelectView(RedirectView):
1✔
611
    """View for selecting a color theme."""
612

613
    def get_user_theme(self):
614
        """Get current user color theme."""
615
        try:
×
616
            user_theme = common_models.ColorTheme.objects.filter(
617
                user=self.request.user
618
            ).get()
619
        except common_models.ColorTheme.DoesNotExist:
×
620
            user_theme = None
×
621

622
        return user_theme
×
623

624
    def post(self, request, *args, **kwargs):
1✔
625
        """Save user color theme selection."""
626
        theme = request.POST.get('theme', None)
627

628
        # Get current user theme
629
        user_theme = self.get_user_theme()
630

631
        # Create theme entry if user did not select one yet
632
        if not user_theme:
633
            user_theme = common_models.ColorTheme()
634
            user_theme.user = request.user
635

636
        if theme:
637
            try:
638
                user_theme.name = theme
639
                user_theme.save()
640
            except Exception:
641
                pass
642

643
        return redirect(reverse_lazy('settings'))
644

645

646
class DatabaseStatsView(AjaxView):
647
    """View for displaying database statistics."""
648

649
    ajax_template_name = 'stats.html'
1✔
650
    ajax_form_title = _('System Information')
1✔
651

652

653
class AboutView(AjaxView):
1✔
654
    """A view for displaying InvenTree version information."""
655

656
    ajax_template_name = 'about.html'
657
    ajax_form_title = _('About InvenTree')
658

659

660
class NotificationsView(TemplateView):
661
    """View for showing notifications."""
662

663
    template_name = 'InvenTree/notifications/notifications.html'
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc