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

inventree / InvenTree / 4495584363

pending completion
4495584363

push

github

GitHub
Catch TemplateDoesNotExist error (#4518)

5 of 5 new or added lines in 1 file covered. (100.0%)

25664 of 29153 relevant lines covered (88.03%)

0.88 hits per line

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

85.47
/InvenTree/report/api.py
1
"""API functionality for the 'report' app"""
2

3
from django.core.exceptions import FieldError, ValidationError
1✔
4
from django.core.files.base import ContentFile
1✔
5
from django.http import HttpResponse
1✔
6
from django.template.exceptions import TemplateDoesNotExist
1✔
7
from django.urls import include, path, re_path
1✔
8
from django.utils.decorators import method_decorator
1✔
9
from django.utils.translation import gettext_lazy as _
1✔
10
from django.views.decorators.cache import cache_page, never_cache
1✔
11

12
from django_filters.rest_framework import DjangoFilterBackend
1✔
13
from rest_framework import filters
1✔
14
from rest_framework.response import Response
1✔
15

16
import build.models
1✔
17
import common.models
1✔
18
import InvenTree.helpers
1✔
19
import order.models
1✔
20
import part.models
1✔
21
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
1✔
22
from stock.models import StockItem, StockItemAttachment
1✔
23

24
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
1✔
25
                     SalesOrderReport, TestReport)
26
from .serializers import (BOMReportSerializer, BuildReportSerializer,
1✔
27
                          PurchaseOrderReportSerializer,
28
                          SalesOrderReportSerializer, TestReportSerializer)
29

30

31
class ReportListView(ListAPI):
1✔
32
    """Generic API class for report templates."""
33

34
    filter_backends = [
1✔
35
        DjangoFilterBackend,
36
        filters.SearchFilter,
37
    ]
38

39
    filterset_fields = [
1✔
40
        'enabled',
41
    ]
42

43
    search_fields = [
1✔
44
        'name',
45
        'description',
46
    ]
47

48

49
class ReportFilterMixin:
1✔
50
    """Mixin for extracting multiple objects from query params.
51

52
    Each subclass *must* have an attribute called 'ITEM_KEY',
53
    which is used to determine what 'key' is used in the query parameters.
54

55
    This mixin defines a 'get_items' method which provides a generic implementation
56
    to return a list of matching database model instances
57
    """
58

59
    # Database model for instances to actually be "printed" against this report template
60
    ITEM_MODEL = None
1✔
61

62
    # Default key for looking up database model instances
63
    ITEM_KEY = 'item'
1✔
64

65
    def get_items(self):
1✔
66
        """Return a list of database objects from query parameters"""
67

68
        if not self.ITEM_MODEL:
1✔
69
            raise NotImplementedError(f"ITEM_MODEL attribute not defined for {__class__}")
×
70

71
        ids = []
1✔
72

73
        # Construct a list of possible query parameter value options
74
        # e.g. if self.ITEM_KEY = 'order' -> ['order', 'order[]', 'orders', 'orders[]']
75
        for k in [self.ITEM_KEY + x for x in ['', '[]', 's', 's[]']]:
1✔
76
            if ids := self.request.query_params.getlist(k, []):
1✔
77
                # Return the first list of matches
78
                break
1✔
79

80
        # Next we must validated each provided object ID
81
        valid_ids = []
1✔
82

83
        for id in ids:
1✔
84
            try:
1✔
85
                valid_ids.append(int(id))
1✔
86
            except ValueError:
×
87
                pass
×
88

89
        # Filter queryset by matching ID values
90
        return self.ITEM_MODEL.objects.filter(pk__in=valid_ids)
1✔
91

92
    def filter_queryset(self, queryset):
1✔
93
        """Filter the queryset based on the provided report ID values.
94

95
        As each 'report' instance may optionally define its own filters,
96
        the resulting queryset is the 'union' of the two
97
        """
98

99
        queryset = super().filter_queryset(queryset)
1✔
100

101
        items = self.get_items()
1✔
102

103
        if len(items) > 0:
1✔
104
            """At this point, we are basically forced to be inefficient:
105

106
            We need to compare the 'filters' string of each report template,
107
            and see if it matches against each of the requested items.
108

109
            In practice, this is not too bad.
110
            """
111

112
            valid_report_ids = set()
1✔
113

114
            for report in queryset.all():
1✔
115
                matches = True
1✔
116

117
                try:
1✔
118
                    filters = InvenTree.helpers.validateFilterString(report.filters)
1✔
119
                except ValidationError:
×
120
                    continue
×
121

122
                for item in items:
1✔
123
                    item_query = self.ITEM_MODEL.objects.filter(pk=item.pk)
1✔
124

125
                    try:
1✔
126
                        if not item_query.filter(**filters).exists():
1✔
127
                            matches = False
×
128
                            break
×
129
                    except FieldError:
×
130
                        matches = False
×
131
                        break
×
132

133
                # Matched all items
134
                if matches:
1✔
135
                    valid_report_ids.add(report.pk)
1✔
136

137
            # Reduce queryset to only valid matches
138
            queryset = queryset.filter(pk__in=[pk for pk in valid_report_ids])
1✔
139

140
        return queryset
1✔
141

142

143
@method_decorator(cache_page(5), name='dispatch')
1✔
144
class ReportPrintMixin:
1✔
145
    """Mixin for printing reports."""
146

147
    @method_decorator(never_cache)
1✔
148
    def dispatch(self, *args, **kwargs):
1✔
149
        """Prevent caching when printing report templates"""
150
        return super().dispatch(*args, **kwargs)
1✔
151

152
    def report_callback(self, object, report, request):
1✔
153
        """Callback function for each object/report combination.
154

155
        Allows functionality to be performed before returning the consolidated PDF
156

157
        Arguments:
158
            object: The model instance to be printed
159
            report: The individual PDF file object
160
            request: The request instance associated with this print call
161
        """
162
        ...
1✔
163

164
    def print(self, request, items_to_print):
1✔
165
        """Print this report template against a number of pre-validated items."""
166
        if len(items_to_print) == 0:
1✔
167
            # No valid items provided, return an error message
168
            data = {
1✔
169
                'error': _('No valid objects provided to template'),
170
            }
171

172
            return Response(data, status=400)
1✔
173

174
        outputs = []
1✔
175

176
        # In debug mode, generate single HTML output, rather than PDF
177
        debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE', cache=False)
1✔
178

179
        # Start with a default report name
180
        report_name = "report.pdf"
1✔
181

182
        # Merge one or more PDF files into a single download
183
        for item in items_to_print:
1✔
184
            report = self.get_object()
1✔
185
            report.object_to_print = item
1✔
186

187
            report_name = report.generate_filename(request)
1✔
188
            output = report.render(request)
1✔
189

190
            # Run report callback for each generated report
191
            self.report_callback(item, output, request)
1✔
192

193
            try:
1✔
194
                if debug_mode:
1✔
195
                    outputs.append(report.render_as_string(request))
×
196
                else:
197
                    outputs.append(output)
1✔
198
            except TemplateDoesNotExist as e:
×
199
                template = str(e)
×
200
                if not template:
×
201
                    template = report.template
×
202

203
                return Response(
×
204
                    {
205
                        'error': _(f"Template file '{template}' is missing or does not exist"),
206
                    },
207
                    status=400,
208
                )
209

210
        if not report_name.endswith('.pdf'):
1✔
211
            report_name += '.pdf'
×
212

213
        if debug_mode:
1✔
214
            """Contatenate all rendered templates into a single HTML string, and return the string as a HTML response."""
215

216
            html = "\n".join(outputs)
×
217

218
            return HttpResponse(html)
×
219
        else:
220
            """Concatenate all rendered pages into a single PDF object, and return the resulting document!"""
221

222
            pages = []
1✔
223

224
            try:
1✔
225
                for output in outputs:
1✔
226
                    doc = output.get_document()
1✔
227
                    for page in doc.pages:
1✔
228
                        pages.append(page)
1✔
229

230
                pdf = outputs[0].get_document().copy(pages).write_pdf()
1✔
231

232
            except TemplateDoesNotExist as e:
×
233

234
                template = str(e)
×
235

236
                if not template:
×
237
                    template = report.template
×
238

239
                return Response(
×
240
                    {
241
                        'error': _(f"Template file '{template}' is missing or does not exist"),
242
                    },
243
                    status=400,
244
                )
245

246
            inline = common.models.InvenTreeUserSetting.get_setting('REPORT_INLINE', user=request.user, cache=False)
1✔
247

248
            return InvenTree.helpers.DownloadFile(
1✔
249
                pdf,
250
                report_name,
251
                content_type='application/pdf',
252
                inline=inline,
253
            )
254

255
    def get(self, request, *args, **kwargs):
1✔
256
        """Default implementation of GET for a print endpoint.
257

258
        Note that it expects the class has defined a get_items() method
259
        """
260
        items = self.get_items()
1✔
261
        return self.print(request, items)
1✔
262

263

264
class StockItemTestReportMixin(ReportFilterMixin):
1✔
265
    """Mixin for StockItemTestReport report template"""
266

267
    ITEM_MODEL = StockItem
1✔
268
    ITEM_KEY = 'item'
1✔
269
    queryset = TestReport.objects.all()
1✔
270
    serializer_class = TestReportSerializer
1✔
271

272

273
class StockItemTestReportList(StockItemTestReportMixin, ReportListView):
1✔
274
    """API endpoint for viewing list of TestReport objects.
275

276
    Filterable by:
277

278
    - enabled: Filter by enabled / disabled status
279
    - item: Filter by stock item(s)
280
    """
281
    pass
1✔
282

283

284
class StockItemTestReportDetail(StockItemTestReportMixin, RetrieveUpdateDestroyAPI):
1✔
285
    """API endpoint for a single TestReport object."""
286
    pass
1✔
287

288

289
class StockItemTestReportPrint(StockItemTestReportMixin, ReportPrintMixin, RetrieveAPI):
1✔
290
    """API endpoint for printing a TestReport object."""
291

292
    def report_callback(self, item, report, request):
1✔
293
        """Callback to (optionally) save a copy of the generated report"""
294

295
        if common.models.InvenTreeSetting.get_setting('REPORT_ATTACH_TEST_REPORT', cache=False):
1✔
296

297
            # Construct a PDF file object
298
            try:
1✔
299
                pdf = report.get_document().write_pdf()
1✔
300
                pdf_content = ContentFile(pdf, "test_report.pdf")
1✔
301
            except TemplateDoesNotExist:
×
302
                return
×
303

304
            StockItemAttachment.objects.create(
1✔
305
                attachment=pdf_content,
306
                stock_item=item,
307
                user=request.user,
308
                comment=_("Test report")
309
            )
310

311

312
class BOMReportMixin(ReportFilterMixin):
1✔
313
    """Mixin for BillOfMaterialsReport report template"""
314

315
    ITEM_MODEL = part.models.Part
1✔
316
    ITEM_KEY = 'part'
1✔
317

318
    queryset = BillOfMaterialsReport.objects.all()
1✔
319
    serializer_class = BOMReportSerializer
1✔
320

321

322
class BOMReportList(BOMReportMixin, ReportListView):
1✔
323
    """API endpoint for viewing a list of BillOfMaterialReport objects.
324

325
    Filterably by:
326

327
    - enabled: Filter by enabled / disabled status
328
    - part: Filter by part(s)
329
    """
330
    pass
1✔
331

332

333
class BOMReportDetail(BOMReportMixin, RetrieveUpdateDestroyAPI):
1✔
334
    """API endpoint for a single BillOfMaterialReport object."""
335
    pass
1✔
336

337

338
class BOMReportPrint(BOMReportMixin, ReportPrintMixin, RetrieveAPI):
1✔
339
    """API endpoint for printing a BillOfMaterialReport object."""
340
    pass
1✔
341

342

343
class BuildReportMixin(ReportFilterMixin):
1✔
344
    """Mixin for the BuildReport report template"""
345

346
    ITEM_MODEL = build.models.Build
1✔
347
    ITEM_KEY = 'build'
1✔
348

349
    queryset = BuildReport.objects.all()
1✔
350
    serializer_class = BuildReportSerializer
1✔
351

352

353
class BuildReportList(BuildReportMixin, ReportListView):
1✔
354
    """API endpoint for viewing a list of BuildReport objects.
355

356
    Can be filtered by:
357

358
    - enabled: Filter by enabled / disabled status
359
    - build: Filter by Build object
360
    """
361
    pass
1✔
362

363

364
class BuildReportDetail(BuildReportMixin, RetrieveUpdateDestroyAPI):
1✔
365
    """API endpoint for a single BuildReport object."""
366
    pass
1✔
367

368

369
class BuildReportPrint(BuildReportMixin, ReportPrintMixin, RetrieveAPI):
1✔
370
    """API endpoint for printing a BuildReport."""
371
    pass
1✔
372

373

374
class PurchaseOrderReportMixin(ReportFilterMixin):
1✔
375
    """Mixin for the PurchaseOrderReport report template"""
376

377
    ITEM_MODEL = order.models.PurchaseOrder
1✔
378
    ITEM_KEY = 'order'
1✔
379

380
    queryset = PurchaseOrderReport.objects.all()
1✔
381
    serializer_class = PurchaseOrderReportSerializer
1✔
382

383

384
class PurchaseOrderReportList(PurchaseOrderReportMixin, ReportListView):
1✔
385
    """API list endpoint for the PurchaseOrderReport model"""
386
    pass
1✔
387

388

389
class PurchaseOrderReportDetail(PurchaseOrderReportMixin, RetrieveUpdateDestroyAPI):
1✔
390
    """API endpoint for a single PurchaseOrderReport object."""
391
    pass
1✔
392

393

394
class PurchaseOrderReportPrint(PurchaseOrderReportMixin, ReportPrintMixin, RetrieveAPI):
1✔
395
    """API endpoint for printing a PurchaseOrderReport object."""
396
    pass
1✔
397

398

399
class SalesOrderReportMixin(ReportFilterMixin):
1✔
400
    """Mixin for the SalesOrderReport report template"""
401

402
    ITEM_MODEL = order.models.SalesOrder
1✔
403
    ITEM_KEY = 'order'
1✔
404

405
    queryset = SalesOrderReport.objects.all()
1✔
406
    serializer_class = SalesOrderReportSerializer
1✔
407

408

409
class SalesOrderReportList(SalesOrderReportMixin, ReportListView):
1✔
410
    """API list endpoint for the SalesOrderReport model"""
411
    pass
1✔
412

413

414
class SalesOrderReportDetail(SalesOrderReportMixin, RetrieveUpdateDestroyAPI):
1✔
415
    """API endpoint for a single SalesOrderReport object."""
416
    pass
1✔
417

418

419
class SalesOrderReportPrint(SalesOrderReportMixin, ReportPrintMixin, RetrieveAPI):
1✔
420
    """API endpoint for printing a PurchaseOrderReport object."""
421
    pass
1✔
422

423

424
report_api_urls = [
1✔
425

426
    # Purchase order reports
427
    re_path(r'po/', include([
428
        # Detail views
429
        re_path(r'^(?P<pk>\d+)/', include([
430
            re_path(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'),
431
            path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'),
432
        ])),
433

434
        # List view
435
        path('', PurchaseOrderReportList.as_view(), name='api-po-report-list'),
436
    ])),
437

438
    # Sales order reports
439
    re_path(r'so/', include([
440
        # Detail views
441
        re_path(r'^(?P<pk>\d+)/', include([
442
            re_path(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'),
443
            path('', SalesOrderReportDetail.as_view(), name='api-so-report-detail'),
444
        ])),
445

446
        path('', SalesOrderReportList.as_view(), name='api-so-report-list'),
447
    ])),
448

449
    # Build reports
450
    re_path(r'build/', include([
451
        # Detail views
452
        re_path(r'^(?P<pk>\d+)/', include([
453
            re_path(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'),
454
            re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'),
455
        ])),
456

457
        # List view
458
        re_path(r'^.*$', BuildReportList.as_view(), name='api-build-report-list'),
459
    ])),
460

461
    # Bill of Material reports
462
    re_path(r'bom/', include([
463

464
        # Detail views
465
        re_path(r'^(?P<pk>\d+)/', include([
466
            re_path(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'),
467
            re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'),
468
        ])),
469

470
        # List view
471
        re_path(r'^.*$', BOMReportList.as_view(), name='api-bom-report-list'),
472
    ])),
473

474
    # Stock item test reports
475
    re_path(r'test/', include([
476
        # Detail views
477
        re_path(r'^(?P<pk>\d+)/', include([
478
            re_path(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'),
479
            re_path(r'^.*$', StockItemTestReportDetail.as_view(), name='api-stockitem-testreport-detail'),
480
        ])),
481

482
        # List view
483
        re_path(r'^.*$', StockItemTestReportList.as_view(), name='api-stockitem-testreport-list'),
484
    ])),
485
]
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