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

inventree / InvenTree / 4548642839

pending completion
4548642839

push

github

GitHub
[Feature] Add RMA support (#4488)

1082 of 1082 new or added lines in 49 files covered. (100.0%)

26469 of 30045 relevant lines covered (88.1%)

0.88 hits per line

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

70.22
/InvenTree/report/models.py
1
"""Report template model definitions."""
2

3
import datetime
1✔
4
import logging
1✔
5
import os
1✔
6
import sys
1✔
7

8
from django.conf import settings
1✔
9
from django.core.cache import cache
1✔
10
from django.core.exceptions import FieldError, ValidationError
1✔
11
from django.core.validators import FileExtensionValidator
1✔
12
from django.db import models
1✔
13
from django.template import Context, Template
1✔
14
from django.template.loader import render_to_string
1✔
15
from django.urls import reverse
1✔
16
from django.utils.translation import gettext_lazy as _
1✔
17

18
import build.models
1✔
19
import common.models
1✔
20
import order.models
1✔
21
import part.models
1✔
22
import stock.models
1✔
23
from InvenTree.helpers import validateFilterString
1✔
24
from plugin.models import MetadataMixin
1✔
25

26
try:
1✔
27
    from django_weasyprint import WeasyTemplateResponseMixin
1✔
28
except OSError as err:  # pragma: no cover
29
    print("OSError: {e}".format(e=err))
30
    print("You may require some further system packages to be installed.")
31
    sys.exit(1)
32

33

34
logger = logging.getLogger("inventree")
1✔
35

36

37
def rename_template(instance, filename):
1✔
38
    """Helper function for 'renaming' uploaded report files.
39

40
    Pass responsibility back to the calling class,
41
    to ensure that files are uploaded to the correct directory.
42
    """
43
    return instance.rename_file(filename)
×
44

45

46
def validate_stock_item_report_filters(filters):
1✔
47
    """Validate filter string against StockItem model."""
48
    return validateFilterString(filters, model=stock.models.StockItem)
×
49

50

51
def validate_part_report_filters(filters):
1✔
52
    """Validate filter string against Part model."""
53
    return validateFilterString(filters, model=part.models.Part)
×
54

55

56
def validate_build_report_filters(filters):
1✔
57
    """Validate filter string against Build model."""
58
    return validateFilterString(filters, model=build.models.Build)
×
59

60

61
def validate_purchase_order_filters(filters):
1✔
62
    """Validate filter string against PurchaseOrder model."""
63
    return validateFilterString(filters, model=order.models.PurchaseOrder)
×
64

65

66
def validate_sales_order_filters(filters):
1✔
67
    """Validate filter string against SalesOrder model."""
68
    return validateFilterString(filters, model=order.models.SalesOrder)
×
69

70

71
def validate_return_order_filters(filters):
1✔
72
    """Validate filter string against ReturnOrder model"""
73
    return validateFilterString(filters, model=order.models.ReturnOrder)
×
74

75

76
class WeasyprintReportMixin(WeasyTemplateResponseMixin):
1✔
77
    """Class for rendering a HTML template to a PDF."""
78

79
    pdf_filename = 'report.pdf'
1✔
80
    pdf_attachment = True
1✔
81

82
    def __init__(self, request, template, **kwargs):
1✔
83
        """Initialize the report mixin with some standard attributes"""
84
        self.request = request
1✔
85
        self.template_name = template
1✔
86
        self.pdf_filename = kwargs.get('filename', 'report.pdf')
1✔
87

88

89
class ReportBase(models.Model):
1✔
90
    """Base class for uploading html templates."""
91

92
    class Meta:
1✔
93
        """Metaclass options. Abstract ensures no database table is created."""
94

95
        abstract = True
1✔
96

97
    def save(self, *args, **kwargs):
1✔
98
        """Perform additional actions when the report is saved"""
99
        # Increment revision number
100
        self.revision += 1
1✔
101

102
        super().save()
1✔
103

104
    def __str__(self):
1✔
105
        """Format a string representation of a report instance"""
106
        return "{n} - {d}".format(n=self.name, d=self.description)
×
107

108
    @classmethod
1✔
109
    def getSubdir(cls):
1✔
110
        """Return the subdirectory where template files for this report model will be located."""
111
        return ''
×
112

113
    def rename_file(self, filename):
1✔
114
        """Function for renaming uploaded file"""
115

116
        filename = os.path.basename(filename)
×
117

118
        path = os.path.join('report', 'report_template', self.getSubdir(), filename)
×
119

120
        fullpath = settings.MEDIA_ROOT.joinpath(path).resolve()
×
121

122
        # If the report file is the *same* filename as the one being uploaded,
123
        # remove the original one from the media directory
124
        if str(filename) == str(self.template):
×
125

126
            if fullpath.exists():
×
127
                logger.info(f"Deleting existing report template: '{filename}'")
×
128
                os.remove(fullpath)
×
129

130
        # Ensure that the cache is cleared for this template!
131
        cache.delete(fullpath)
×
132

133
        return path
×
134

135
    @property
1✔
136
    def extension(self):
1✔
137
        """Return the filename extension of the associated template file"""
138
        return os.path.splitext(self.template.name)[1].lower()
×
139

140
    @property
1✔
141
    def template_name(self):
1✔
142
        """Returns the file system path to the template file.
143

144
        Required for passing the file to an external process
145
        """
146
        template = self.template.name
1✔
147

148
        # TODO @matmair change to using new file objects
149
        template = template.replace('/', os.path.sep)
1✔
150
        template = template.replace('\\', os.path.sep)
1✔
151

152
        template = settings.MEDIA_ROOT.joinpath(template)
1✔
153

154
        return template
1✔
155

156
    name = models.CharField(
1✔
157
        blank=False, max_length=100,
158
        verbose_name=_('Name'),
159
        help_text=_('Template name'),
160
    )
161

162
    template = models.FileField(
1✔
163
        upload_to=rename_template,
164
        verbose_name=_('Template'),
165
        help_text=_("Report template file"),
166
        validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])],
167
    )
168

169
    description = models.CharField(
1✔
170
        max_length=250,
171
        verbose_name=_('Description'),
172
        help_text=_("Report template description")
173
    )
174

175
    revision = models.PositiveIntegerField(
1✔
176
        default=1,
177
        verbose_name=_("Revision"),
178
        help_text=_("Report revision number (auto-increments)"),
179
        editable=False,
180
    )
181

182

183
class ReportTemplateBase(MetadataMixin, ReportBase):
1✔
184
    """Reporting template model.
185

186
    Able to be passed context data
187
    """
188

189
    class Meta:
1✔
190
        """Metaclass options. Abstract ensures no database table is created."""
191
        abstract = True
1✔
192

193
    # Pass a single top-level object to the report template
194
    object_to_print = None
1✔
195

196
    def get_context_data(self, request):
1✔
197
        """Supply context data to the template for rendering."""
198
        return {}
×
199

200
    def context(self, request):
1✔
201
        """All context to be passed to the renderer."""
202
        # Generate custom context data based on the particular report subclass
203
        context = self.get_context_data(request)
1✔
204

205
        context['base_url'] = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
1✔
206
        context['date'] = datetime.datetime.now().date()
1✔
207
        context['datetime'] = datetime.datetime.now()
1✔
208
        context['default_page_size'] = common.models.InvenTreeSetting.get_setting('REPORT_DEFAULT_PAGE_SIZE')
1✔
209
        context['report_description'] = self.description
1✔
210
        context['report_name'] = self.name
1✔
211
        context['report_revision'] = self.revision
1✔
212
        context['request'] = request
1✔
213
        context['user'] = request.user
1✔
214

215
        return context
1✔
216

217
    def generate_filename(self, request, **kwargs):
1✔
218
        """Generate a filename for this report."""
219
        template_string = Template(self.filename_pattern)
1✔
220

221
        ctx = self.context(request)
1✔
222

223
        context = Context(ctx)
1✔
224

225
        return template_string.render(context)
1✔
226

227
    def render_as_string(self, request, **kwargs):
1✔
228
        """Render the report to a HTML string.
229

230
        Useful for debug mode (viewing generated code)
231
        """
232
        return render_to_string(self.template_name, self.context(request), request)
×
233

234
    def render(self, request, **kwargs):
1✔
235
        """Render the template to a PDF file.
236

237
        Uses django-weasyprint plugin to render HTML template against Weasyprint
238
        """
239
        # TODO: Support custom filename generation!
240
        # filename = kwargs.get('filename', 'report.pdf')
241

242
        # Render HTML template to PDF
243
        wp = WeasyprintReportMixin(
1✔
244
            request,
245
            self.template_name,
246
            base_url=request.build_absolute_uri("/"),
247
            presentational_hints=True,
248
            filename=self.generate_filename(request),
249
            **kwargs)
250

251
        return wp.render_to_response(
1✔
252
            self.context(request),
253
            **kwargs)
254

255
    filename_pattern = models.CharField(
1✔
256
        default="report.pdf",
257
        verbose_name=_('Filename Pattern'),
258
        help_text=_('Pattern for generating report filenames'),
259
        max_length=100,
260
    )
261

262
    enabled = models.BooleanField(
1✔
263
        default=True,
264
        verbose_name=_('Enabled'),
265
        help_text=_('Report template is enabled'),
266
    )
267

268

269
class TestReport(ReportTemplateBase):
1✔
270
    """Render a TestReport against a StockItem object."""
271

272
    @staticmethod
1✔
273
    def get_api_url():
1✔
274
        """Return the API URL associated with the TestReport model"""
275
        return reverse('api-stockitem-testreport-list')
×
276

277
    @classmethod
1✔
278
    def getSubdir(cls):
1✔
279
        """Return the subdirectory where TestReport templates are located"""
280
        return 'test'
1✔
281

282
    filters = models.CharField(
1✔
283
        blank=True,
284
        max_length=250,
285
        verbose_name=_('Filters'),
286
        help_text=_("StockItem query filters (comma-separated list of key=value pairs)"),
287
        validators=[
288
            validate_stock_item_report_filters
289
        ]
290
    )
291

292
    include_installed = models.BooleanField(
1✔
293
        default=False,
294
        verbose_name=_('Include Installed Tests'),
295
        help_text=_('Include test results for stock items installed inside assembled item')
296
    )
297

298
    def matches_stock_item(self, item):
1✔
299
        """Test if this report template matches a given StockItem objects."""
300
        try:
×
301
            filters = validateFilterString(self.filters)
×
302
            items = stock.models.StockItem.objects.filter(**filters)
×
303
        except (ValidationError, FieldError):
×
304
            return False
×
305

306
        # Ensure the provided StockItem object matches the filters
307
        items = items.filter(pk=item.pk)
×
308

309
        return items.exists()
×
310

311
    def get_test_keys(self, stock_item):
1✔
312
        """Construct a flattened list of test 'keys' for this StockItem:
313

314
        - First, any 'required' tests
315
        - Second, any 'non required' tests
316
        - Finally, any test results which do not match a test
317
        """
318

319
        keys = []
1✔
320

321
        for test in stock_item.part.getTestTemplates(required=True):
1✔
322
            if test.key not in keys:
×
323
                keys.append(test.key)
×
324

325
        for test in stock_item.part.getTestTemplates(required=False):
1✔
326
            if test.key not in keys:
×
327
                keys.append(test.key)
×
328

329
        for result in stock_item.testResultList(include_installed=self.include_installed):
1✔
330
            if result.key not in keys:
×
331
                keys.append(result.key)
×
332

333
        return list(keys)
1✔
334

335
    def get_context_data(self, request):
1✔
336
        """Return custom context data for the TestReport template"""
337
        stock_item = self.object_to_print
1✔
338

339
        return {
1✔
340
            'stock_item': stock_item,
341
            'serial': stock_item.serial,
342
            'part': stock_item.part,
343
            'parameters': stock_item.part.parameters_map(),
344
            'test_keys': self.get_test_keys(stock_item),
345
            'test_template_list': stock_item.part.getTestTemplates(),
346
            'test_template_map': stock_item.part.getTestTemplateMap(),
347
            'results': stock_item.testResultMap(include_installed=self.include_installed),
348
            'result_list': stock_item.testResultList(include_installed=self.include_installed),
349
            'installed_items': stock_item.get_installed_items(cascade=True),
350
        }
351

352

353
class BuildReport(ReportTemplateBase):
1✔
354
    """Build order / work order report."""
355

356
    @staticmethod
1✔
357
    def get_api_url():
1✔
358
        """Return the API URL associated with the BuildReport model"""
359
        return reverse('api-build-report-list')
×
360

361
    @classmethod
1✔
362
    def getSubdir(cls):
1✔
363
        """Return the subdirectory where BuildReport templates are located"""
364
        return 'build'
1✔
365

366
    filters = models.CharField(
1✔
367
        blank=True,
368
        max_length=250,
369
        verbose_name=_('Build Filters'),
370
        help_text=_('Build query filters (comma-separated list of key=value pairs'),
371
        validators=[
372
            validate_build_report_filters,
373
        ]
374
    )
375

376
    def get_context_data(self, request):
1✔
377
        """Custom context data for the build report."""
378
        my_build = self.object_to_print
1✔
379

380
        if type(my_build) != build.models.Build:
1✔
381
            raise TypeError('Provided model is not a Build object')
×
382

383
        return {
1✔
384
            'build': my_build,
385
            'part': my_build.part,
386
            'bom_items': my_build.part.get_bom_items(),
387
            'reference': my_build.reference,
388
            'quantity': my_build.quantity,
389
            'title': str(my_build),
390
        }
391

392

393
class BillOfMaterialsReport(ReportTemplateBase):
1✔
394
    """Render a Bill of Materials against a Part object."""
395

396
    @staticmethod
1✔
397
    def get_api_url():
1✔
398
        """Return the API URL associated with the BillOfMaterialsReport model"""
399
        return reverse('api-bom-report-list')
×
400

401
    @classmethod
1✔
402
    def getSubdir(cls):
1✔
403
        """Retun the directory where BillOfMaterialsReport templates are located"""
404
        return 'bom'
1✔
405

406
    filters = models.CharField(
1✔
407
        blank=True,
408
        max_length=250,
409
        verbose_name=_('Part Filters'),
410
        help_text=_('Part query filters (comma-separated list of key=value pairs'),
411
        validators=[
412
            validate_part_report_filters
413
        ]
414
    )
415

416
    def get_context_data(self, request):
1✔
417
        """Return custom context data for the BillOfMaterialsReport template"""
418
        part = self.object_to_print
×
419

420
        return {
×
421
            'part': part,
422
            'category': part.category,
423
            'bom_items': part.get_bom_items(),
424
        }
425

426

427
class PurchaseOrderReport(ReportTemplateBase):
1✔
428
    """Render a report against a PurchaseOrder object."""
429

430
    @staticmethod
1✔
431
    def get_api_url():
1✔
432
        """Return the API URL associated with the PurchaseOrderReport model"""
433
        return reverse('api-po-report-list')
×
434

435
    @classmethod
1✔
436
    def getSubdir(cls):
1✔
437
        """Return the directory where PurchaseOrderReport templates are stored"""
438
        return 'purchaseorder'
1✔
439

440
    filters = models.CharField(
1✔
441
        blank=True,
442
        max_length=250,
443
        verbose_name=_('Filters'),
444
        help_text=_('Purchase order query filters'),
445
        validators=[
446
            validate_purchase_order_filters,
447
        ]
448
    )
449

450
    def get_context_data(self, request):
1✔
451
        """Return custom context data for the PurchaseOrderReport template"""
452
        order = self.object_to_print
×
453

454
        return {
×
455
            'description': order.description,
456
            'lines': order.lines,
457
            'extra_lines': order.extra_lines,
458
            'order': order,
459
            'reference': order.reference,
460
            'supplier': order.supplier,
461
            'title': str(order),
462
        }
463

464

465
class SalesOrderReport(ReportTemplateBase):
1✔
466
    """Render a report against a SalesOrder object."""
467

468
    @staticmethod
1✔
469
    def get_api_url():
1✔
470
        """Return the API URL associated with the SalesOrderReport model"""
471
        return reverse('api-so-report-list')
×
472

473
    @classmethod
1✔
474
    def getSubdir(cls):
1✔
475
        """Retun the subdirectory where SalesOrderReport templates are located"""
476
        return 'salesorder'
1✔
477

478
    filters = models.CharField(
1✔
479
        blank=True,
480
        max_length=250,
481
        verbose_name=_('Filters'),
482
        help_text=_('Sales order query filters'),
483
        validators=[
484
            validate_sales_order_filters
485
        ]
486
    )
487

488
    def get_context_data(self, request):
1✔
489
        """Return custom context data for a SalesOrderReport template"""
490
        order = self.object_to_print
×
491

492
        return {
×
493
            'customer': order.customer,
494
            'description': order.description,
495
            'lines': order.lines,
496
            'extra_lines': order.extra_lines,
497
            'order': order,
498
            'reference': order.reference,
499
            'title': str(order),
500
        }
501

502

503
class ReturnOrderReport(ReportTemplateBase):
1✔
504
    """Render a custom report against a ReturnOrder object"""
505

506
    @staticmethod
1✔
507
    def get_api_url():
1✔
508
        """Return the API URL associated with the ReturnOrderReport model"""
509
        return reverse('api-return-order-report-list')
×
510

511
    @classmethod
1✔
512
    def getSubdir(cls):
1✔
513
        """Return the directory where the ReturnOrderReport templates are stored"""
514
        return 'returnorder'
1✔
515

516
    filters = models.CharField(
1✔
517
        blank=True,
518
        max_length=250,
519
        verbose_name=_('Filters'),
520
        help_text=_('Return order query filters'),
521
        validators=[
522
            validate_return_order_filters,
523
        ]
524
    )
525

526
    def get_context_data(self, request):
1✔
527
        """Return custom context data for the ReturnOrderReport template"""
528

529
        order = self.object_to_print
×
530

531
        return {
×
532
            'order': order,
533
            'description': order.description,
534
            'reference': order.reference,
535
            'customer': order.customer,
536
            'lines': order.lines,
537
            'extra_lines': order.extra_lines,
538
            'title': str(order),
539
        }
540

541

542
def rename_snippet(instance, filename):
1✔
543
    """Function to rename a report snippet once uploaded"""
544

545
    filename = os.path.basename(filename)
×
546

547
    path = os.path.join('report', 'snippets', filename)
×
548

549
    fullpath = settings.MEDIA_ROOT.joinpath(path).resolve()
×
550

551
    # If the snippet file is the *same* filename as the one being uploaded,
552
    # delete the original one from the media directory
553
    if str(filename) == str(instance.snippet):
×
554

555
        if fullpath.exists():
×
556
            logger.info(f"Deleting existing snippet file: '{filename}'")
×
557
            os.remove(fullpath)
×
558

559
    # Ensure that the cache is deleted for this snippet
560
    cache.delete(fullpath)
×
561

562
    return path
×
563

564

565
class ReportSnippet(models.Model):
1✔
566
    """Report template 'snippet' which can be used to make templates that can then be included in other reports.
567

568
    Useful for 'common' template actions, sub-templates, etc
569
    """
570

571
    snippet = models.FileField(
1✔
572
        upload_to=rename_snippet,
573
        verbose_name=_('Snippet'),
574
        help_text=_('Report snippet file'),
575
        validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])],
576
    )
577

578
    description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_("Snippet file description"))
1✔
579

580

581
def rename_asset(instance, filename):
1✔
582
    """Function to rename an asset file when uploaded"""
583

584
    filename = os.path.basename(filename)
×
585

586
    path = os.path.join('report', 'assets', filename)
×
587

588
    # If the asset file is the *same* filename as the one being uploaded,
589
    # delete the original one from the media directory
590
    if str(filename) == str(instance.asset):
×
591
        fullpath = settings.MEDIA_ROOT.joinpath(path).resolve()
×
592

593
        if fullpath.exists():
×
594
            logger.info(f"Deleting existing asset file: '{filename}'")
×
595
            os.remove(fullpath)
×
596

597
    return path
×
598

599

600
class ReportAsset(models.Model):
1✔
601
    """Asset file for use in report templates.
602

603
    For example, an image to use in a header file.
604
    Uploaded asset files appear in MEDIA_ROOT/report/assets,
605
    and can be loaded in a template using the {% report_asset <filename> %} tag.
606
    """
607

608
    def __str__(self):
1✔
609
        """String representation of a ReportAsset instance"""
610
        return os.path.basename(self.asset.name)
×
611

612
    # Asset file
613
    asset = models.FileField(
1✔
614
        upload_to=rename_asset,
615
        verbose_name=_('Asset'),
616
        help_text=_("Report asset file"),
617
    )
618

619
    # Asset description (user facing string, not used internally)
620
    description = models.CharField(
1✔
621
        max_length=250,
622
        verbose_name=_('Description'),
623
        help_text=_("Asset file description")
624
    )
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