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

inventree / InvenTree / 4499739823

pending completion
4499739823

push

github

GitHub
Add Metadata to further models (#4410)

247 of 247 new or added lines in 30 files covered. (100.0%)

25882 of 29374 relevant lines covered (88.11%)

0.88 hits per line

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

84.97
/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, RetrieveUpdateAPI,
1✔
22
                              RetrieveUpdateDestroyAPI)
23
from plugin.serializers import MetadataSerializer
1✔
24
from stock.models import StockItem, StockItemAttachment
1✔
25

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

32

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

36
    filter_backends = [
1✔
37
        DjangoFilterBackend,
38
        filters.SearchFilter,
39
    ]
40

41
    filterset_fields = [
1✔
42
        'enabled',
43
    ]
44

45
    search_fields = [
1✔
46
        'name',
47
        'description',
48
    ]
49

50

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

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

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

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

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

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

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

73
        ids = []
1✔
74

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

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

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

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

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

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

101
        queryset = super().filter_queryset(queryset)
1✔
102

103
        items = self.get_items()
1✔
104

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

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

111
            In practice, this is not too bad.
112
            """
113

114
            valid_report_ids = set()
1✔
115

116
            for report in queryset.all():
1✔
117
                matches = True
1✔
118

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

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

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

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

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

142
        return queryset
1✔
143

144

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

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

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

157
        Allows functionality to be performed before returning the consolidated PDF
158

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

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

174
            return Response(data, status=400)
1✔
175

176
        outputs = []
1✔
177

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

181
        # Start with a default report name
182
        report_name = "report.pdf"
1✔
183

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

189
            report_name = report.generate_filename(request)
1✔
190
            output = report.render(request)
1✔
191

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

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

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

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

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

218
            html = "\n".join(outputs)
×
219

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

224
            pages = []
1✔
225

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

232
                pdf = outputs[0].get_document().copy(pages).write_pdf()
1✔
233

234
            except TemplateDoesNotExist as e:
×
235

236
                template = str(e)
×
237

238
                if not template:
×
239
                    template = report.template
×
240

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

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

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

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

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

265

266
class StockItemTestReportMixin(ReportFilterMixin):
1✔
267
    """Mixin for StockItemTestReport report template"""
268

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

274

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

278
    Filterable by:
279

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

285

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

290

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

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

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

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

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

313

314
class BOMReportMixin(ReportFilterMixin):
1✔
315
    """Mixin for BillOfMaterialsReport report template"""
316

317
    ITEM_MODEL = part.models.Part
1✔
318
    ITEM_KEY = 'part'
1✔
319

320
    queryset = BillOfMaterialsReport.objects.all()
1✔
321
    serializer_class = BOMReportSerializer
1✔
322

323

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

327
    Filterably by:
328

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

334

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

339

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

344

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

348
    ITEM_MODEL = build.models.Build
1✔
349
    ITEM_KEY = 'build'
1✔
350

351
    queryset = BuildReport.objects.all()
1✔
352
    serializer_class = BuildReportSerializer
1✔
353

354

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

358
    Can be filtered by:
359

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

365

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

370

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

375

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

379
    ITEM_MODEL = order.models.PurchaseOrder
1✔
380
    ITEM_KEY = 'order'
1✔
381

382
    queryset = PurchaseOrderReport.objects.all()
1✔
383
    serializer_class = PurchaseOrderReportSerializer
1✔
384

385

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

390

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

395

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

400

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

404
    ITEM_MODEL = order.models.SalesOrder
1✔
405
    ITEM_KEY = 'order'
1✔
406

407
    queryset = SalesOrderReport.objects.all()
1✔
408
    serializer_class = SalesOrderReportSerializer
1✔
409

410

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

415

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

420

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

425

426
class ReportMetadata(RetrieveUpdateAPI):
1✔
427
    """API endpoint for viewing / updating Report metadata."""
428
    MODEL_REF = 'reportmodel'
1✔
429

430
    def _get_model(self, *args, **kwargs):
1✔
431
        """Return model depending on which report type is requested in get_view constructor."""
432
        reportmodel = self.kwargs.get(self.MODEL_REF, PurchaseOrderReport)
1✔
433

434
        if reportmodel not in [PurchaseOrderReport, SalesOrderReport, BuildReport, BillOfMaterialsReport, TestReport]:
1✔
435
            raise ValidationError("Invalid report model")
×
436
        return reportmodel
1✔
437

438
        # Return corresponding Serializer
439
    def get_serializer(self, *args, **kwargs):
1✔
440
        """Return correct MetadataSerializer instance depending on which model is requested"""
441
        # Get type of report, make sure its one of the allowed values
442
        UseModel = self._get_model(*args, **kwargs)
1✔
443
        return MetadataSerializer(UseModel, *args, **kwargs)
1✔
444

445
    def get_queryset(self, *args, **kwargs):
1✔
446
        """Return correct queryset depending on which model is requested"""
447
        UseModel = self._get_model(*args, **kwargs)
×
448
        return UseModel.objects.all()
×
449

450

451
report_api_urls = [
1✔
452

453
    # Purchase order reports
454
    re_path(r'po/', include([
455
        # Detail views
456
        re_path(r'^(?P<pk>\d+)/', include([
457
            re_path(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'),
458
            re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: PurchaseOrderReport}, name='api-po-report-metadata'),
459
            path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'),
460
        ])),
461

462
        # List view
463
        path('', PurchaseOrderReportList.as_view(), name='api-po-report-list'),
464
    ])),
465

466
    # Sales order reports
467
    re_path(r'so/', include([
468
        # Detail views
469
        re_path(r'^(?P<pk>\d+)/', include([
470
            re_path(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'),
471
            re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: SalesOrderReport}, name='api-so-report-metadata'),
472
            path('', SalesOrderReportDetail.as_view(), name='api-so-report-detail'),
473
        ])),
474

475
        path('', SalesOrderReportList.as_view(), name='api-so-report-list'),
476
    ])),
477

478
    # Build reports
479
    re_path(r'build/', include([
480
        # Detail views
481
        re_path(r'^(?P<pk>\d+)/', include([
482
            re_path(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'),
483
            re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BuildReport}, name='api-build-report-metadata'),
484
            re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'),
485
        ])),
486

487
        # List view
488
        re_path(r'^.*$', BuildReportList.as_view(), name='api-build-report-list'),
489
    ])),
490

491
    # Bill of Material reports
492
    re_path(r'bom/', include([
493

494
        # Detail views
495
        re_path(r'^(?P<pk>\d+)/', include([
496
            re_path(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'),
497
            re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BillOfMaterialsReport}, name='api-bom-report-metadata'),
498
            re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'),
499
        ])),
500

501
        # List view
502
        re_path(r'^.*$', BOMReportList.as_view(), name='api-bom-report-list'),
503
    ])),
504

505
    # Stock item test reports
506
    re_path(r'test/', include([
507
        # Detail views
508
        re_path(r'^(?P<pk>\d+)/', include([
509
            re_path(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'),
510
            re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: TestReport}, name='api-stockitem-testreport-metadata'),
511
            re_path(r'^.*$', StockItemTestReportDetail.as_view(), name='api-stockitem-testreport-detail'),
512
        ])),
513

514
        # List view
515
        re_path(r'^.*$', StockItemTestReportList.as_view(), name='api-stockitem-testreport-list'),
516
    ])),
517
]
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