Coverage for src/backend/InvenTree/report/api.py: 62%

172 statements  

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

1"""API functionality for the 'report' app.""" 

2 

3from django.core.exceptions import ValidationError 

4from django.urls import include, path 

5from django.utils.decorators import method_decorator 

6from django.utils.translation import gettext_lazy as _ 

7from django.views.decorators.cache import never_cache 

8 

9import django_filters.rest_framework.filters as rest_filters 

10from django_filters.rest_framework.filterset import FilterSet 

11from rest_framework.exceptions import PermissionDenied 

12from rest_framework.generics import GenericAPIView 

13from rest_framework.response import Response 

14 

15import InvenTree.permissions 

16import report.helpers 

17import report.models 

18import report.serializers 

19import users.permissions 

20from common.models import DataOutput 

21from common.serializers import DataOutputSerializer 

22from InvenTree.api import meta_path 

23from InvenTree.filters import SEARCH_ORDER_FILTER 

24from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI 

25from plugin import PluginMixinEnum 

26from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin 

27 

28 

29class TemplatePermissionMixin: 

30 """Permission mixin for report and label templates.""" 

31 

32 # Read only for non-staff users 

33 permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope] 

34 

35 

36class ReportFilterBase(FilterSet): 

37 """Base filter class for label and report templates.""" 

38 

39 enabled = rest_filters.BooleanFilter() 

40 

41 model_type = rest_filters.ChoiceFilter( 

42 choices=report.helpers.report_model_options(), label=_('Model Type') 

43 ) 

44 

45 items = rest_filters.CharFilter(method='filter_items', label=_('Items')) 

46 

47 def filter_items(self, queryset, name, values): 

48 """Filter against a comma-separated list of provided items. 

49 

50 Note: This filter is only applied if the 'model_type' is also provided. 

51 """ 

52 model_type = self.data.get('model_type', None) 

53 values = values.strip().split(',') 

54 

55 if model_class := report.helpers.report_model_from_name(model_type): 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true

56 model_items = model_class.objects.filter(pk__in=values) 

57 

58 # Ensure that we have already filtered by model_type 

59 queryset = queryset.filter(model_type=model_type) 

60 

61 # Construct a list of templates which match the list of provided IDs 

62 matching_template_ids = [] 

63 

64 for template in queryset.all(): 

65 filters = template.get_filters() 

66 results = model_items.filter(**filters) 

67 # If the resulting queryset is *shorter* than the provided items, then this template does not match 

68 if results.count() == model_items.count(): 

69 matching_template_ids.append(template.pk) 

70 

71 queryset = queryset.filter(pk__in=matching_template_ids) 

72 

73 return queryset 

74 

75 

76class ReportFilter(ReportFilterBase): 

77 """Filter class for report template list.""" 

78 

79 class Meta: 

80 """Filter options.""" 

81 

82 model = report.models.ReportTemplate 

83 fields = ['landscape', 'merge', 'attach_to_model', 'enabled', 'model_type'] 

84 

85 

86class LabelFilter(ReportFilterBase): 

87 """Filter class for label template list.""" 

88 

89 class Meta: 

90 """Filter options.""" 

91 

92 model = report.models.LabelTemplate 

93 fields = ['enabled'] 

94 

95 

96class LabelPrint(GenericAPIView): 

97 """API endpoint for printing labels.""" 

98 

99 # Any authenticated user can print labels 

100 permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope] 

101 serializer_class = report.serializers.LabelPrintSerializer 

102 

103 def get_plugin_class(self, plugin_slug: str, raise_error=False): 

104 """Return the plugin class for the given plugin key.""" 

105 from plugin import registry 

106 

107 if not plugin_slug: 

108 # Use the default label printing plugin 

109 plugin_slug = InvenTreeLabelPlugin.NAME.lower() 

110 

111 plugin = registry.get_plugin(plugin_slug, active=True) 

112 

113 error = None 

114 

115 if not plugin: 

116 error = _('Plugin not found') 

117 elif not plugin.mixin_enabled(PluginMixinEnum.LABELS): 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true

118 error = _('Plugin does not support label printing') 

119 

120 if error: 

121 plugin = None 

122 

123 if raise_error: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true

124 raise ValidationError({'plugin': error}) 

125 

126 return plugin 

127 

128 def get_plugin_serializer(self, plugin): 

129 """Return the serializer for the given plugin.""" 

130 if plugin and hasattr(plugin, 'get_printing_options_serializer'): 

131 return plugin.get_printing_options_serializer( 

132 self.request, 

133 data=self.request.data, 

134 context=self.get_serializer_context(), 

135 ) 

136 

137 return None 

138 

139 def get_serializer(self, *args, **kwargs): 

140 """Return serializer information for the label print endpoint.""" 

141 plugin = None 

142 

143 # Plugin information provided? 

144 if self.request: 144 ↛ 155line 144 didn't jump to line 155 because the condition on line 144 was always true

145 plugin_key = self.request.data.get('plugin', '') 

146 # Legacy url based lookup 

147 if not plugin_key: 

148 plugin_key = self.request.query_params.get('plugin', '') 

149 plugin = self.get_plugin_class(plugin_key) 

150 plugin_serializer = self.get_plugin_serializer(plugin) 

151 

152 if plugin_serializer: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true

153 kwargs['plugin_serializer'] = plugin_serializer 

154 

155 return super().get_serializer(*args, **kwargs) 

156 

157 @method_decorator(never_cache) 

158 def post(self, request, *args, **kwargs): 

159 """POST action for printing labels.""" 

160 serializer = self.get_serializer(data=request.data) 

161 serializer.is_valid(raise_exception=True) 

162 

163 template = serializer.validated_data['template'] 

164 

165 model_class = template.get_model() 

166 if model_class and not users.permissions.check_user_permission( 

167 request.user, model_class, 'view' 

168 ): 

169 raise PermissionDenied( 

170 _('You do not have permission to view this model type') 

171 ) 

172 

173 if template.width <= 0 or template.height <= 0: 

174 raise ValidationError({'template': _('Invalid label dimensions')}) 

175 

176 items = serializer.validated_data['items'] 

177 

178 # Default to the InvenTreeLabelPlugin 

179 plugin_key = InvenTreeLabelPlugin.NAME.lower() 

180 

181 if plugin_config := serializer.validated_data.get('plugin', None): 

182 plugin_key = plugin_config.key 

183 

184 plugin = self.get_plugin_class(plugin_key, raise_error=True) 

185 

186 instances = model_class.objects.filter(pk__in=items) 

187 

188 # Sort the instances by the order of the provided items 

189 instances = sorted(instances, key=lambda item: items.index(item.pk)) 

190 

191 if len(instances) == 0: 

192 raise ValidationError(_('No valid items provided to template')) 

193 

194 return self.print(template, instances, plugin, request) 

195 

196 def print(self, template, items_to_print, plugin, request): 

197 """Print this label template against a number of provided items.""" 

198 import report.tasks 

199 from InvenTree.tasks import offload_task 

200 

201 if plugin_serializer := plugin.get_printing_options_serializer( 

202 request, data=request.data, context=self.get_serializer_context() 

203 ): 

204 plugin_serializer.is_valid(raise_exception=True) 

205 

206 user = getattr(request, 'user', None) 

207 

208 # Generate a new DataOutput object to print against 

209 output = DataOutput.objects.create( 

210 user=user if user and user.is_authenticated else None, 

211 total=len(items_to_print), 

212 progress=0, 

213 complete=False, 

214 output_type=DataOutput.DataOutputTypes.LABEL, 

215 plugin=plugin.slug, 

216 template_name=template.name, 

217 output=None, 

218 ) 

219 

220 output.refresh_from_db() 

221 

222 offload_task( 

223 report.tasks.print_labels, 

224 template.pk, 

225 [item.pk for item in items_to_print], 

226 output.pk, 

227 user.pk if user else None, 

228 plugin.slug, 

229 options=(plugin_serializer.data if plugin_serializer else {}), 

230 ) 

231 

232 output.refresh_from_db() 

233 

234 return Response(DataOutputSerializer(output).data, status=201) 

235 

236 

237class LabelTemplateMixin: 

238 """Mixin class for label template API views.""" 

239 

240 queryset = report.models.LabelTemplate.objects.all().prefetch_related('updated_by') 

241 serializer_class = report.serializers.LabelTemplateSerializer 

242 

243 

244class LabelTemplateList(TemplatePermissionMixin, LabelTemplateMixin, ListCreateAPI): 

245 """API endpoint for viewing list of LabelTemplate objects.""" 

246 

247 filterset_class = LabelFilter 

248 filter_backends = SEARCH_ORDER_FILTER 

249 search_fields = ['name', 'description'] 

250 ordering_fields = ['name', 'enabled', 'width', 'height'] 

251 

252 

253class LabelTemplateDetail( 

254 TemplatePermissionMixin, LabelTemplateMixin, RetrieveUpdateDestroyAPI 

255): 

256 """Detail API endpoint for label template model.""" 

257 

258 

259class ReportPrint(GenericAPIView): 

260 """API endpoint for printing reports.""" 

261 

262 # Any authenticated user can print reports 

263 permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope] 

264 serializer_class = report.serializers.ReportPrintSerializer 

265 

266 @method_decorator(never_cache) 

267 def post(self, request, *args, **kwargs): 

268 """POST action for printing a report.""" 

269 serializer = self.get_serializer(data=request.data) 

270 serializer.is_valid(raise_exception=True) 

271 

272 template = serializer.validated_data['template'] 

273 

274 model_class = template.get_model() 

275 if model_class and not users.permissions.check_user_permission( 

276 request.user, model_class, 'view' 

277 ): 

278 raise PermissionDenied( 

279 _('You do not have permission to view this model type') 

280 ) 

281 

282 items = serializer.validated_data['items'] 

283 

284 instances = model_class.objects.filter(pk__in=items) 

285 

286 # Sort the instances by the order of the provided items 

287 instances = sorted(instances, key=lambda item: items.index(item.pk)) 

288 

289 if len(instances) == 0: 

290 raise ValidationError(_('No valid items provided to template')) 

291 

292 return self.print(template, instances, request) 

293 

294 def print(self, template, items_to_print, request): 

295 """Print this report template against a number of provided items. 

296 

297 This functionality is offloaded to the background worker process, 

298 which will update the status of the DataOutput object as it progresses. 

299 """ 

300 import report.tasks 

301 from InvenTree.tasks import offload_task 

302 

303 user = getattr(request, 'user', None) 

304 

305 # Generate a new DataOutput object 

306 output = DataOutput.objects.create( 

307 user=user if user and user.is_authenticated else None, 

308 total=len(items_to_print), 

309 progress=0, 

310 complete=False, 

311 output_type=DataOutput.DataOutputTypes.REPORT, 

312 template_name=template.name, 

313 output=None, 

314 ) 

315 

316 item_ids = [item.pk for item in items_to_print] 

317 

318 # Offload the task to the background worker 

319 offload_task( 

320 report.tasks.print_reports, 

321 template.pk, 

322 item_ids, 

323 output.pk, 

324 user.pk if user else None, 

325 ) 

326 

327 output.refresh_from_db() 

328 

329 return Response(DataOutputSerializer(output).data, status=201) 

330 

331 

332class ReportTemplateMixin: 

333 """Mixin class for report template API views.""" 

334 

335 queryset = report.models.ReportTemplate.objects.all().prefetch_related('updated_by') 

336 serializer_class = report.serializers.ReportTemplateSerializer 

337 

338 

339class ReportTemplateList(TemplatePermissionMixin, ReportTemplateMixin, ListCreateAPI): 

340 """API endpoint for viewing list of ReportTemplate objects.""" 

341 

342 filterset_class = ReportFilter 

343 filter_backends = SEARCH_ORDER_FILTER 

344 search_fields = ['name', 'description'] 

345 ordering_fields = ['name', 'enabled'] 

346 

347 

348class ReportTemplateDetail( 

349 TemplatePermissionMixin, ReportTemplateMixin, RetrieveUpdateDestroyAPI 

350): 

351 """Detail API endpoint for report template model.""" 

352 

353 

354class ReportSnippetList(TemplatePermissionMixin, ListCreateAPI): 

355 """API endpoint for listing ReportSnippet objects.""" 

356 

357 queryset = report.models.ReportSnippet.objects.all() 

358 serializer_class = report.serializers.ReportSnippetSerializer 

359 

360 

361class ReportSnippetDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI): 

362 """API endpoint for a single ReportSnippet object.""" 

363 

364 queryset = report.models.ReportSnippet.objects.all() 

365 serializer_class = report.serializers.ReportSnippetSerializer 

366 

367 

368class ReportAssetList(TemplatePermissionMixin, ListCreateAPI): 

369 """API endpoint for listing ReportAsset objects.""" 

370 

371 queryset = report.models.ReportAsset.objects.all() 

372 serializer_class = report.serializers.ReportAssetSerializer 

373 

374 

375class ReportAssetDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI): 

376 """API endpoint for a single ReportAsset object.""" 

377 

378 queryset = report.models.ReportAsset.objects.all() 

379 serializer_class = report.serializers.ReportAssetSerializer 

380 

381 

382label_api_urls = [ 

383 # Printing endpoint 

384 path('print/', LabelPrint.as_view(), name='api-label-print'), 

385 # Label templates 

386 path( 

387 'template/', 

388 include([ 

389 path( 

390 '<int:pk>/', 

391 include([ 

392 meta_path(report.models.LabelTemplate), 

393 path( 

394 '', 

395 LabelTemplateDetail.as_view(), 

396 name='api-label-template-detail', 

397 ), 

398 ]), 

399 ), 

400 path('', LabelTemplateList.as_view(), name='api-label-template-list'), 

401 ]), 

402 ), 

403] 

404 

405report_api_urls = [ 

406 # Printing endpoint 

407 path('print/', ReportPrint.as_view(), name='api-report-print'), 

408 # Report templates 

409 path( 

410 'template/', 

411 include([ 

412 path( 

413 '<int:pk>/', 

414 include([ 

415 meta_path(report.models.ReportTemplate), 

416 path( 

417 '', 

418 ReportTemplateDetail.as_view(), 

419 name='api-report-template-detail', 

420 ), 

421 ]), 

422 ), 

423 path('', ReportTemplateList.as_view(), name='api-report-template-list'), 

424 ]), 

425 ), 

426 # Report assets 

427 path( 

428 'asset/', 

429 include([ 

430 path( 

431 '<int:pk>/', ReportAssetDetail.as_view(), name='api-report-asset-detail' 

432 ), 

433 path('', ReportAssetList.as_view(), name='api-report-asset-list'), 

434 ]), 

435 ), 

436 # Report snippets 

437 path( 

438 'snippet/', 

439 include([ 

440 path( 

441 '<int:pk>/', 

442 ReportSnippetDetail.as_view(), 

443 name='api-report-snippet-detail', 

444 ), 

445 path('', ReportSnippetList.as_view(), name='api-report-snippet-list'), 

446 ]), 

447 ), 

448]