Coverage for src/backend/InvenTree/plugin/builtin/labels/label_sheet.py: 25%

108 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 17:47 +0000

1"""Label printing plugin which supports printing multiple labels on a single page.""" 

2 

3import math 

4 

5from django.core.exceptions import ValidationError 

6from django.core.files.base import ContentFile 

7from django.utils.translation import gettext_lazy as _ 

8 

9import structlog 

10import weasyprint 

11from rest_framework import serializers 

12 

13import report.helpers 

14from common.models import DataOutput 

15from InvenTree.helpers import str2bool 

16from plugin import InvenTreePlugin 

17from plugin.mixins import LabelPrintingMixin, SettingsMixin 

18from report.fetcher import InvenTreeURLFetcher 

19from report.models import LabelTemplate 

20 

21logger = structlog.get_logger('inventree') 

22 

23 

24class LabelPrintingOptionsSerializer(serializers.Serializer): 

25 """Custom printing options for the label sheet plugin.""" 

26 

27 page_size = serializers.ChoiceField( 

28 choices=report.helpers.report_page_size_options(), 

29 default='A4', 

30 label=_('Page Size'), 

31 help_text=_('Page size for the label sheet'), 

32 ) 

33 

34 skip = serializers.IntegerField( 

35 default=0, 

36 label=_('Skip Labels'), 

37 help_text=_('Skip this number of labels when printing label sheets'), 

38 min_value=0, 

39 max_value=500, 

40 ) 

41 

42 border = serializers.BooleanField( 

43 default=False, 

44 label=_('Border'), 

45 help_text=_('Print a border around each label'), 

46 ) 

47 

48 landscape = serializers.BooleanField( 

49 default=False, 

50 label=_('Landscape'), 

51 help_text=_('Print the label sheet in landscape mode'), 

52 ) 

53 

54 margin = serializers.IntegerField( 

55 default=10, 

56 label=_('Page Margin'), 

57 help_text=_('Margin around the page in mm'), 

58 min_value=0, 

59 ) 

60 

61 

62class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): 

63 """Builtin plugin for label printing. 

64 

65 This plugin arrays multiple labels onto a single larger sheet, 

66 and returns the resulting PDF file. 

67 """ 

68 

69 NAME = 'InvenTreeLabelSheet' 

70 TITLE = _('InvenTree Label Sheet Printer') 

71 DESCRIPTION = _('Arrays multiple labels onto a single sheet') 

72 VERSION = '1.0.1' 

73 AUTHOR = _('InvenTree contributors') 

74 

75 BLOCKING_PRINT = True 

76 

77 SETTINGS = { 

78 'DEBUG': { 

79 'name': _('Debug mode'), 

80 'description': _('Enable debug mode - returns raw HTML instead of PDF'), 

81 'validator': bool, 

82 'default': False, 

83 } 

84 } 

85 

86 PrintingOptionsSerializer = LabelPrintingOptionsSerializer 

87 

88 def print_labels( 

89 self, label: LabelTemplate, output: DataOutput, items: list, request, **kwargs 

90 ): 

91 """Handle printing of the provided labels. 

92 

93 Note that we override the entire print_labels method for this plugin. 

94 """ 

95 printing_options = kwargs['printing_options'] 

96 

97 # Extract page size for the label sheet 

98 page_size_code = printing_options.get('page_size', 'A4') 

99 landscape = printing_options.get('landscape', False) 

100 border = printing_options.get('border', False) 

101 skip = int(printing_options.get('skip', 0)) 

102 

103 # Get margin from printing options 

104 margin = printing_options.get('margin', 10) 

105 

106 # Extract size of page 

107 page_size = report.helpers.page_size(page_size_code) 

108 page_width, page_height = page_size 

109 

110 if landscape: 

111 page_width, page_height = page_height, page_width 

112 

113 # Calculate available space after margins 

114 available_width = page_width - (2 * margin) 

115 available_height = page_height - (2 * margin) 

116 

117 # Calculate number of rows and columns based on available space 

118 n_cols = math.floor(available_width / label.width) 

119 n_rows = math.floor(available_height / label.height) 

120 n_cells = n_cols * n_rows 

121 

122 if n_cells == 0: 

123 raise ValidationError(_('Label is too large for page size')) 

124 

125 # Prepend the required number of skipped null labels 

126 items = [None] * skip + list(items) 

127 

128 n_labels = len(items) 

129 

130 # Data to pass through to each page 

131 document_data = { 

132 'border': border, 

133 'landscape': landscape, 

134 'page_width': page_width, 

135 'page_height': page_height, 

136 'label_width': label.width, 

137 'label_height': label.height, 

138 'n_labels': n_labels, 

139 'n_pages': math.ceil(n_labels / n_cells), 

140 'n_cols': n_cols, 

141 'n_rows': n_rows, 

142 'margin': margin, 

143 } 

144 

145 pages = [] 

146 

147 idx = 0 

148 

149 while idx < n_labels: 

150 if page := self.print_page( 

151 label, items[idx : idx + n_cells], request, **document_data 

152 ): 

153 pages.append(page) 

154 

155 idx += n_cells 

156 

157 # Update printing progress 

158 output.progress += 1 

159 output.save() 

160 

161 if len(pages) == 0: 

162 raise ValidationError(_('No labels were generated')) 

163 

164 # Render to a single HTML document 

165 html_data = self.wrap_pages(pages, **document_data) 

166 

167 if str2bool(self.get_setting('DEBUG')): 

168 # In debug mode return with the raw HTML 

169 generated_file = ContentFile(html_data, 'labels.html') 

170 else: 

171 # Render HTML to PDF 

172 html = weasyprint.HTML(string=html_data, url_fetcher=InvenTreeURLFetcher()) 

173 document = html.render().write_pdf() 

174 generated_file = ContentFile(document, 'labels.pdf') 

175 

176 output.mark_complete(progress=n_labels, output=generated_file) 

177 

178 def print_page(self, label: LabelTemplate, items: list, request, **kwargs): 

179 """Generate a single page of labels. 

180 

181 For a single page, generate a simple table grid of labels. 

182 Styling of the table is handled by the higher level label template 

183 

184 Arguments: 

185 label: The LabelTemplate object to use for printing 

186 items: The list of database items to print (e.g. StockItem instances) 

187 request: The HTTP request object which triggered this print job 

188 

189 Kwargs: 

190 n_cols: Number of columns 

191 n_rows: Number of rows 

192 """ 

193 n_cols = kwargs['n_cols'] 

194 n_rows = kwargs['n_rows'] 

195 

196 # Generate a table of labels 

197 html = """<table class='label-sheet-table'>""" 

198 

199 for row in range(n_rows): 

200 html += "<tr class='label-sheet-row'>" 

201 

202 for col in range(n_cols): 

203 # Cell index 

204 idx = row * n_cols + col 

205 

206 if idx >= len(items): 

207 break 

208 

209 html += f"<td class='label-sheet-cell label-sheet-row-{row} label-sheet-col-{col}'>" 

210 

211 # If the label is empty (skipped), render an empty cell 

212 if items[idx] is None: 

213 html += """<div class='label-sheet-cell-skip'></div>""" 

214 else: 

215 try: 

216 # Render the individual label template 

217 # Note that we disable @page styling for this 

218 cell = label.render_as_string( 

219 items[idx], request, insert_page_style=False 

220 ) 

221 html += cell 

222 except Exception as exc: 

223 logger.exception('Error rendering label: %s', exc) 

224 html += """ 

225 <div class='label-sheet-cell-error'></div> 

226 """ 

227 

228 html += '</td>' 

229 

230 html += '</tr>' 

231 

232 html += '</table>' 

233 

234 return html 

235 

236 def wrap_pages(self, pages, **kwargs): 

237 """Wrap the generated pages into a single document.""" 

238 border = kwargs['border'] 

239 

240 page_width = kwargs['page_width'] 

241 page_height = kwargs['page_height'] 

242 

243 label_width = kwargs['label_width'] 

244 label_height = kwargs['label_height'] 

245 

246 n_rows = kwargs['n_rows'] 

247 n_cols = kwargs['n_cols'] 

248 

249 margin = kwargs.get('margin', 0) 

250 

251 inner = ''.join(pages) 

252 

253 # Generate styles for individual cells (on each page) 

254 cell_styles = [] 

255 

256 for row in range(n_rows): 

257 cell_styles.append( 

258 f""" 

259 .label-sheet-row-{row} {{ 

260 top: {row * label_height}mm; 

261 }} 

262 """ 

263 ) 

264 

265 for col in range(n_cols): 

266 cell_styles.append( 

267 f""" 

268 .label-sheet-col-{col} {{ 

269 left: {col * label_width}mm; 

270 }} 

271 """ 

272 ) 

273 

274 cell_styles = '\n'.join(cell_styles) 

275 

276 return f""" 

277 <head> 

278 <style> 

279 @page {{ 

280 size: {page_width}mm {page_height}mm; 

281 margin: {margin}mm; 

282 padding: 0mm; 

283 }} 

284 

285 .label-sheet-table {{ 

286 page-break-after: always; 

287 table-layout: fixed; 

288 width: {page_width}mm; 

289 border-spacing: 0mm 0mm; 

290 }} 

291 

292 .label-sheet-cell-error {{ 

293 background-color: #F00; 

294 }} 

295 

296 .label-sheet-cell {{ 

297 border: {'1px solid #000;' if border else '0mm;'} 

298 width: {label_width}mm; 

299 height: {label_height}mm; 

300 padding: 0mm; 

301 position: absolute; 

302 }} 

303 

304 {cell_styles} 

305 

306 body {{ 

307 margin: 0mm !important; 

308 }} 

309 </style> 

310 </head> 

311 <body> 

312 {inner} 

313 </body> 

314 </html> 

315 """