diff --git a/backend/api/views.py b/backend/api/views.py index fdaa9b4..8c7df49 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -1,12 +1,7 @@ import logging -import os -import tempfile from secrets import token_urlsafe - -from celery import current_app +from django.conf import settings from django.contrib.auth import authenticate, login -from django.db import transaction -from django.db.models import F from django.middleware.csrf import rotate_token from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache @@ -24,13 +19,12 @@ from api.serializers import GutenbergJobSerializer, PrinterSerializer, UserInfoSerializer, \ CreatePrintJobRequestSerializer, UploadJobArtefactRequestSerializer, LoginSerializer, \ DeleteJobArtefactRequestSerializer, ChangeArtefactOrderRequestSerializer, JobArtefactSerializer, \ - ChangePrintJobPropertiesRequestSerializer, PrintPreviewSerializer + ChangePrintJobPropertiesRequestSerializer from common.models import User from control.models import GutenbergJob, Printer, JobStatus, PrintingProperties, TwoSidedPrinting, JobArtefact, \ - JobArtefactType, JobType, PrintPreview, PreviewStatus + JobArtefactType, JobType from gutenberg.worker_capabilities import get_formats_supported_by_workers from printing.printing import print_file -from printing.preview import generate_preview from printing.processing.converter import detect_file_format logger = logging.getLogger('gutenberg.api.printing') @@ -45,7 +39,7 @@ class UnsupportedDocumentError(ValueError): pass -class PrintJobViewSet(viewsets.ReadOnlyModelViewSet): +class PrintJobViewSet(viewsets.ModelViewSet): serializer_class = GutenbergJobSerializer permission_classes = [IsAuthenticated] queryset = GutenbergJob.objects.all() @@ -75,8 +69,12 @@ def upload_artefact(self, request, pk=None): serializer.is_valid(raise_exception=True) try: self._upload_artefact(job, **serializer.validated_data) + if serializer.validated_data.get('preview'): + from printing.printing import generate_preview_for_job + generate_preview_for_job.delay(job.id) except UnsupportedDocumentError as ex: raise UnsupportedDocument(str(ex)) + job.refresh_from_db() return Response(self.get_serializer(job).data) @action(detail=True, methods=['delete'], name='Delete artefact') @@ -90,9 +88,8 @@ def delete_artefact(self, request, pk=None): artefact = JobArtefact.objects.filter(id=artefact_id, job=job).first() if not artefact: raise exceptions.NotFound("Selected artefact does not exist") - with transaction.atomic(): - artefact.delete() - self._mark_configuration_changed(job) + artefact.delete() + job.refresh_from_db() return Response(self.get_serializer(job).data) @action(detail=True, methods=['post'], name='Change artefact order') @@ -126,15 +123,17 @@ def create_job(self, request): @action(detail=True, methods=['post'], name='Change job properties') def change_properties(self, request, pk=None): - # Not given fields are not changed job = self.get_object() serializer = ChangePrintJobPropertiesRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) - printer_id=serializer.validated_data.get('printer') + printer_id = serializer.validated_data.get('printer') if printer_id is None: - printer_id=job.printer.id + printer_id = job.printer.id printer_with_perms = Printer.get_printer_for_user(user=self.request.user, printer_id=printer_id) + if not printer_with_perms: + raise exceptions.NotFound("Selected printer does not exist") + job = self._change_properties(printer_with_perms=printer_with_perms, **serializer.validated_data) return Response(self.get_serializer(job).data) @@ -151,6 +150,31 @@ def artefacts(self, request, pk=None): serializer = JobArtefactSerializer(artefacts, many=True, context={'request': request}) return Response(serializer.data) + @action(detail=True, methods=['get'], name='Preview') + def preview(self, request, pk=None): + job = self.get_object() + pages = job.preview_meta.get('pages', []) if getattr(job, 'preview_meta', None) else [] + urls = [request.build_absolute_uri(settings.MEDIA_URL + f"{job.preview_dir()}/{p}") for p in pages] + return Response({'status': job.preview_status, 'pages': urls, 'page_count': job.preview_pages}) + + @action(detail=True, methods=['post'], name='Regenerate preview') + def regenerate_preview(self, request, pk=None): + job = self.get_object() + max_pages = int(request.data.get('max_pages', 5)) + dpi = int(request.data.get('dpi', 150)) + job.preview_status = 'PENDING' + job.save(update_fields=['preview_status']) + from printing.printing import generate_preview_for_job + generate_preview_for_job.delay(job.id, max_pages=max_pages, dpi=dpi) + return Response({'status': 'started'}) + + @action(detail=True, methods=['post'], name='Send to printer (from preview)') + def send_from_preview(self, request, pk=None): + job = self.get_object() + self._validate_properties(job.printer.id, job.properties, job) + self._run_job(job) + return Response(self.get_serializer(job).data) + def _create_printing_job( self, printer_with_perms, @@ -164,25 +188,38 @@ def _create_printing_job( orientation_requested: str, **_, ): - with transaction.atomic(): - job = GutenbergJob(name='webrequest', job_type=JobType.PRINT, status=JobStatus.INCOMING, - owner=self.request.user, printer=printer_with_perms) - job.properties = PrintingProperties( - color=color, - copies=copies, - two_sides=two_sides, - pages_to_print=None if pages_to_print == "" else pages_to_print, - job=job, - fit_to_page=fit_to_page, - n_up=n_up, - imposition_template=imposition_template, - orientation_requested=orientation_requested, - ) - - self._validate_properties(printer_with_perms.id, job.properties, job) - job.save() - job.properties.save() - return job + # 1. Tworzymy obiekt Job + job = GutenbergJob.objects.create( + name='webrequest', + job_type=JobType.PRINT, + status=JobStatus.INCOMING, + owner=self.request.user, + printer=printer_with_perms + ) + + # 2. Tworzymy obiekt PrintingProperties bezpośrednio z relacją job + properties = PrintingProperties.objects.create( + job=job, + color=color, + copies=copies, + two_sides=two_sides, + pages_to_print=pages_to_print or "", + fit_to_page=fit_to_page, + n_up=n_up, + imposition_template=imposition_template, + orientation_requested=orientation_requested, + ) + + # 3. Walidujemy właściwości + try: + self._validate_properties(printer_with_perms.id, properties, job) + except Exception: + # Jeśli walidacja nie przejdzie, usuwamy utworzony job z bazy i przepuszczamy wyjątek + job.delete() + raise + + job.refresh_from_db() + return job def _change_properties( self, @@ -198,102 +235,61 @@ def _change_properties( **_, ): job = self.get_object() - with transaction.atomic(): - if printer_with_perms is not None: - job.printer = printer_with_perms - if color is not None: - job.properties.color = color - if copies is not None: - job.properties.copies = copies - if two_sides is not None: - job.properties.two_sides = two_sides - if pages_to_print is not None: - job.properties.pages_to_print = None if pages_to_print == "" else pages_to_print - if fit_to_page is not None: - job.properties.fit_to_page = fit_to_page - if n_up is not None: - job.properties.n_up = n_up - if imposition_template is not None: - job.properties.imposition_template = imposition_template - if orientation_requested is not None: - job.properties.orientation_requested = orientation_requested - - self._validate_properties(job.printer.id, job.properties, job=job) - job.properties.save() - job.save() - self._mark_configuration_changed(job) + properties, _ = PrintingProperties.objects.get_or_create(job=job) + + if printer_with_perms is not None: + job.printer = printer_with_perms + if color is not None: + properties.color = color + if copies is not None: + properties.copies = copies + if two_sides is not None: + properties.two_sides = two_sides + if pages_to_print is not None: + properties.pages_to_print = pages_to_print + if fit_to_page is not None: + properties.fit_to_page = fit_to_page + if n_up is not None: + properties.n_up = n_up + if imposition_template is not None: + properties.imposition_template = imposition_template + if orientation_requested is not None: + properties.orientation_requested = orientation_requested + + self._validate_properties(job.printer.id, properties, job=job) + properties.save() + job.save() + job.refresh_from_db() return job def _upload_artefact(self, job, file, **_): - # `detect_file_format()` needs a real file path, while uploaded files may - # be in-memory streams, so copy the upload to a temp file just for sniffing. - with tempfile.NamedTemporaryFile(delete=False) as temp_file: - for chunk in file.chunks(): - temp_file.write(chunk) - temp_path = temp_file.name - - artefact = None - try: - file_type = detect_file_format(temp_path) - finally: - os.unlink(temp_path) - + artefact = JobArtefact.objects.create(job=job, artefact_type=JobArtefactType.SOURCE, file=file, document_number=job.next_document_number) + job.next_document_number += 1 + job.save() + file_type = detect_file_format(artefact.file.path) if file_type not in get_formats_supported_by_workers()["mime_types"]: raise UnsupportedDocumentError("Unsupported file type: {}".format(file_type)) - - try: - with transaction.atomic(): - file.seek(0) - artefact = JobArtefact.objects.create( - job=job, - artefact_type=JobArtefactType.SOURCE, - file=file, - document_number=job.next_document_number, - ) - job.next_document_number += 1 - job.save(update_fields=['next_document_number']) - artefact.mime_type = file_type - artefact.save(update_fields=['mime_type']) - self._mark_configuration_changed(job) - except Exception: - if artefact is not None and artefact.file.name: - artefact.file.delete(save=False) - raise + artefact.mime_type = file_type + artefact.save() def _change_order(self, new_order): job = self.get_object() - with transaction.atomic(): - artefacts = list(job.artefacts.all()) - artefact_dict = {artefact.id: artefact for artefact in artefacts} - if set(new_order) != set(artefact_dict.keys()): - raise exceptions.ValidationError("New order does not match existing artefacts") - if len(set(new_order)) != len(new_order): - raise exceptions.ValidationError("New order contains duplicate artefact IDs") - for index, artefact_id in enumerate(new_order): - artefact = artefact_dict[artefact_id] - artefact.document_number = index + 1 - artefact.save() - job.next_document_number = len(new_order) + 1 - job.save() - self._mark_configuration_changed(job) + artefacts = list(job.artefacts.all()) + artefact_dict = {artefact.id: artefact for artefact in artefacts} + if set(new_order) != set(artefact_dict.keys()): + raise exceptions.ValidationError("New order does not match existing artefacts") + if len(set(new_order)) != len(new_order): + raise exceptions.ValidationError("New order contains duplicate artefact IDs") + for index, artefact_id in enumerate(new_order): + artefact = artefact_dict[artefact_id] + artefact.document_number = index + 1 + artefact.save() + job.next_document_number = len(new_order) + 1 + job.save() + job.refresh_from_db() return job def _run_job(self, job): - try: - preview = job.preview - except PrintPreview.DoesNotExist: - preview = None - - if preview is not None: - if preview.status in (PreviewStatus.PENDING, PreviewStatus.PROCESSING): - if preview.celery_task_id: - current_app.control.revoke( - preview.celery_task_id, - terminate=False, - ) - preview.status = PreviewStatus.CANCELED - preview.save(update_fields=['status', 'updated_at']) - job.status = JobStatus.PENDING job.save() print_file.delay(job.id) @@ -312,101 +308,6 @@ def _validate_properties(self, printer_id: int, properties, job): if properties.two_sides != TwoSidedPrinting.ONE_SIDED and not printer_with_perms.duplex_supported: raise exceptions.ValidationError("Two-sided printing is not supported on the selected printer") - @action( - detail=True, - methods=['get'], - url_path='preview', - name='Print preview', - ) - def preview(self, request, pk=None): - job = self.get_object() - - try: - preview = job.preview - except PrintPreview.DoesNotExist: - raise exceptions.NotFound( - 'A preview has not been requested for this job' - ) - - serializer = PrintPreviewSerializer(preview, context={'request': request}) - return Response(serializer.data) - - # Split the mutating methods so DRF shows separate browsable API forms. - @preview.mapping.post - def preview_post(self, request, pk=None): - job = self.get_object() - - if job.status != JobStatus.INCOMING: - raise InvalidStatus( - 'Preview can only be generated for an incoming job', - additional_info=f'current status: {job.status}', - ) - - if not job.artefacts.filter(artefact_type=JobArtefactType.SOURCE).exists(): - raise exceptions.ValidationError( - 'The print job does not contain any documents' - ) - - self._validate_properties(job.printer.id, job.properties, job) - - with transaction.atomic(): - preview = PrintPreview.objects.select_for_update().filter(job=job).first() - - previous_task_id = '' - - if preview is None: - preview = PrintPreview.objects.create( - job=job, - status=PreviewStatus.PENDING, - generation=1, - configuration_version=job.configuration_version, - ) - else: - previous_task_id = preview.celery_task_id - preview.generation += 1 - preview.configuration_version = job.configuration_version - preview.status = PreviewStatus.PENDING - preview.error = '' - preview.celery_task_id = '' - preview.pages.all().delete() - preview.save() - - generation = preview.generation - - if previous_task_id: - current_app.control.revoke(previous_task_id, terminate=False) - - task = generate_preview.delay(preview.id, generation) - - preview.celery_task_id = task.id - preview.save(update_fields=['celery_task_id', 'updated_at']) - - serializer = PrintPreviewSerializer(preview, context={'request': request}) - return Response(serializer.data, status=status.HTTP_202_ACCEPTED) - - @preview.mapping.delete - def preview_delete(self, request, pk=None): - job = self.get_object() - - try: - preview = job.preview - except PrintPreview.DoesNotExist: - return Response(status=status.HTTP_204_NO_CONTENT) - - if preview.celery_task_id: - current_app.control.revoke(preview.celery_task_id, terminate=False) - - preview.status = PreviewStatus.CANCELED - preview.save(update_fields=['status', 'updated_at']) - return Response(status=status.HTTP_204_NO_CONTENT) - - @staticmethod - def _mark_configuration_changed(job): - GutenbergJob.objects.filter(id=job.id).update( - configuration_version=F('configuration_version') + 1 - ) - job.refresh_from_db(fields=['configuration_version']) - class PrinterViewSet(viewsets.ReadOnlyModelViewSet): queryset = Printer.objects.all() @@ -415,7 +316,7 @@ class PrinterViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): user = self.request.user - return Printer.get_queryset_for_user(user).all().order_by('display_order', 'name') + return Printer.get_queryset_for_user(user).all().order_by('name') def _generate_token(): @@ -472,13 +373,10 @@ def post(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) def get(self, request, *args, **kwargs): - """ - Rotate the CSRF token. According to the `rotate_token` function documentation, - it should always be called on login. - """ rotate_token(request) return Response(status=status.HTTP_204_NO_CONTENT) + class NotFoundView(APIView): def _handle(self, request, path): raise exceptions.NotFound(f'API route "{path}" not found') diff --git a/backend/control/admin.py b/backend/control/admin.py index 606418f..379b0cd 100644 --- a/backend/control/admin.py +++ b/backend/control/admin.py @@ -7,8 +7,14 @@ from django.urls import path from control.forms import LocalPrinterParamsForm -from control.models import GutenbergJob, PrintingProperties, PrinterPermissions, LocalPrinterParams, Printer, \ - JobArtefact +# Register your models here. +from control.models import ( + GutenbergJob, + PrintingProperties, + PrinterPermissions, + LocalPrinterParams, + Printer, + JobArtefact) from gutenberg.celery import app logger = logging.getLogger('gutenberg.control') @@ -24,14 +30,32 @@ class JobArtefactAdmin(admin.TabularInline): class GutenbergJobAdmin(admin.ModelAdmin): inlines = [PrintingPropertiesInline, JobArtefactAdmin] - readonly_fields = ('pages', 'date_created', 'date_processed', 'date_finished') - list_display = ('date_created', 'owner', 'name', 'job_type', 'status', 'pages') + readonly_fields = ('pages', 'date_created', 'date_processed', 'date_finished', 'manual_duplex_current_pass') + list_display = ('date_created', 'owner', 'name', 'job_type', 'status', 'manual_duplex_current_pass', 'pages') list_filter = ('date_created', 'owner', 'job_type', 'status') class LocalPrinterParamsInline(admin.StackedInline): model = LocalPrinterParams form = LocalPrinterParamsForm + fieldsets = ( + (None, { + 'fields': ( + 'cups_printer_name', 'print_grayscale_param', 'print_color_param', + 'print_one_sided_param', 'print_two_sided_long_edge_param', 'print_two_sided_short_edge_param' + ) + }), + ('Manual Duplex Settings', { + 'fields': ( + 'manual_duplex_enabled', + 'manual_duplex_first_pass', + 'manual_duplex_first_pass_reverse', + 'manual_duplex_second_pass_reverse', + 'manual_duplex_face_orientation', + 'manual_duplex_feed_edge', + ) + }), + ) class PrinterPermissionsAdmin(admin.TabularInline): diff --git a/backend/control/migrations/0019_gutenbergjob_manual_duplex_current_pass_and_more.py b/backend/control/migrations/0019_gutenbergjob_manual_duplex_current_pass_and_more.py new file mode 100644 index 0000000..5848bf4 --- /dev/null +++ b/backend/control/migrations/0019_gutenbergjob_manual_duplex_current_pass_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 6.0.7 on 2026-08-20 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('control', '0018_merge_20260804_1330'), + ] + + operations = [ + migrations.AddField( + model_name='gutenbergjob', + name='manual_duplex_current_pass', + field=models.IntegerField(default=1), + ), + migrations.AddField( + model_name='localprinterparams', + name='manual_duplex_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='localprinterparams', + name='manual_duplex_face_orientation', + field=models.CharField(choices=[('UP', 'face up'), ('DOWN', 'face down')], default='UP', max_length=4), + ), + migrations.AddField( + model_name='localprinterparams', + name='manual_duplex_feed_edge', + field=models.CharField(choices=[('LONG', 'long edge'), ('SHORT', 'short edge')], default='LONG', max_length=5), + ), + migrations.AddField( + model_name='localprinterparams', + name='manual_duplex_first_pass', + field=models.CharField(choices=[('ODD', 'odd pages first'), ('EVEN', 'even pages first')], default='ODD', max_length=4), + ), + migrations.AddField( + model_name='localprinterparams', + name='manual_duplex_first_pass_reverse', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='localprinterparams', + name='manual_duplex_second_pass_reverse', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='gutenbergjob', + name='status', + field=models.CharField(choices=[('UNKNOWN', 'unknown'), ('INCOMING', 'incoming'), ('PENDING', 'pending'), ('PROCESSING', 'processing'), ('PRINTING', 'printing'), ('WAITING_ACT', 'waiting for user action'), ('COMPLETED', 'completed'), ('CANCELED', 'canceled'), ('CANCELING', 'canceling'), ('ERROR', 'error')], default='UNKNOWN', max_length=12), + ), + migrations.AlterField( + model_name='printer', + name='display_order', + field=models.PositiveIntegerField(default=0, help_text='Printers are displayed in ascending order. Lower values appear first. The first printer in the list is used as the default.'), + ), + migrations.AlterField( + model_name='printpreviewpage', + name='height', + field=models.PositiveIntegerField(help_text='Preview image height in pixels'), + ), + migrations.AlterField( + model_name='printpreviewpage', + name='width', + field=models.PositiveIntegerField(help_text='Preview image width in pixels'), + ), + ] diff --git a/backend/control/models.py b/backend/control/models.py index 943d39d..22c5698 100644 --- a/backend/control/models.py +++ b/backend/control/models.py @@ -17,8 +17,7 @@ class JobStatus(models.TextChoices): PENDING = 'PENDING', _('pending') PROCESSING = 'PROCESSING', _('processing') PRINTING = 'PRINTING', _('printing') - # SCANNING = 'SCANNING', _('scanning') - # WAITING_FOR_NEXT_PAGE = 'WAITING_PAGE', _('waiting for next page') + WAITING_FOR_USER_ACTION = 'WAITING_ACT', _('waiting for user action') COMPLETED = 'COMPLETED', _('completed') CANCELED = 'CANCELED', _('canceled') CANCELING = 'CANCELING', _('canceling') @@ -31,6 +30,21 @@ class TwoSidedPrinting(models.TextChoices): TWO_SIDED_SHORT_EDGE = 'TS', _('two sided short edge') +class ManualDuplexFirstPass(models.TextChoices): + ODD = 'ODD', _('odd pages first') + EVEN = 'EVEN', _('even pages first') + + +class ManualDuplexFaceOrientation(models.TextChoices): + UP = 'UP', _('face up') + DOWN = 'DOWN', _('face down') + + +class ManualDuplexFeedEdge(models.TextChoices): + LONG = 'LONG', _('long edge') + SHORT = 'SHORT', _('short edge') + + class PrinterType(models.TextChoices): DISABLED = 'NA', _('disabled') LOCAL_CUPS = 'LP', _('local cups') @@ -98,13 +112,25 @@ class LocalPrinterParams(models.Model): print_two_sided_long_edge_param = models.CharField(max_length=64, null=True, blank=True) print_two_sided_short_edge_param = models.CharField(max_length=64, null=True, blank=True) + # Parametry ręcznego druku dwustronnego (Manual Duplex) + manual_duplex_enabled = models.BooleanField(default=False) + manual_duplex_first_pass = models.CharField( + max_length=4, default=ManualDuplexFirstPass.ODD, choices=ManualDuplexFirstPass.choices + ) + manual_duplex_first_pass_reverse = models.BooleanField(default=False) + manual_duplex_second_pass_reverse = models.BooleanField(default=False) + manual_duplex_face_orientation = models.CharField( + max_length=4, default=ManualDuplexFaceOrientation.UP, choices=ManualDuplexFaceOrientation.choices + ) + manual_duplex_feed_edge = models.CharField( + max_length=5, default=ManualDuplexFeedEdge.LONG, choices=ManualDuplexFeedEdge.choices + ) # class LocalScanerParams(models.Model): # scaner = models.OneToOneField(Scaner, on_delete=models.CASCADE) # # scanimage_name = models.CharField(max_length=128) - class PrinterPermissions(models.Model): printer = models.ForeignKey(Printer, on_delete=models.CASCADE) group = models.ForeignKey(Group, on_delete=models.CASCADE) @@ -140,7 +166,6 @@ class GutenbergJob(models.Model): name = models.CharField(max_length=128) job_type = models.CharField(max_length=10, default=JobType.UNKNOWN, choices=JobType.choices) printer = models.ForeignKey(Printer, null=True, blank=True, on_delete=models.SET_NULL) - # scaner = models.ForeignKey(Scaner, null=True, blank=True, on_delete=models.SET_NULL) owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) pages = models.IntegerField(null=True, blank=True) status = models.CharField(max_length=12, default=JobStatus.UNKNOWN, choices=JobStatus.choices) @@ -151,6 +176,17 @@ class GutenbergJob(models.Model): next_document_number = models.IntegerField(default=1) configuration_version = models.PositiveIntegerField(default=1) + # Statystyki i śledzenie przebiegu ręcznego duplexu + manual_duplex_current_pass = models.IntegerField(default=1) + + # preview metadata (used by web UI) + # preview_status = models.CharField( + # max_length=12, + # choices=[('PENDING', 'pending'), ('PROCESSING', 'processing'), ('READY', 'ready'), ('FAILED', 'failed')], + # default='PENDING') + # preview_pages = models.IntegerField(default=0) + # preview_meta = models.JSONField(default=dict, blank=True) + def __str__(self): return "{} - {} - {} - {}".format(self.date_created, self.job_type, self.name, self.owner) @@ -176,7 +212,6 @@ class JobArtefact(models.Model): file = models.FileField(upload_to='artefacts/%Y/%m/%d/') artefact_type = models.CharField(max_length=4, default=JobArtefactType.SOURCE, choices=JobArtefactType.choices) mime_type = models.CharField(max_length=100, default='application/octet-stream') - #document ordering starts from 1, 0 means something went wrong document_number = models.IntegerField(default=0) def __str__(self): @@ -255,7 +290,7 @@ def validate_pages_to_print(value): def validate_n_up(value: int): if isqrt(value) ** 2 == value: return - if value%2 == 0 and 2 * (isqrt(value//2) ** 2) == value: + if value % 2 == 0 and 2 * (isqrt(value // 2) ** 2) == value: return raise ValueError("n must be a perfect square or a perfect square times 2") diff --git a/backend/control/serializers.py b/backend/control/serializers.py new file mode 100644 index 0000000..60ae12b --- /dev/null +++ b/backend/control/serializers.py @@ -0,0 +1,126 @@ +from rest_framework import serializers +from control.models import ( + GutenbergJob, + Printer, + PrintingProperties, + JobArtefact, + TwoSidedPrinting, + ImpositionTemplate, + OrientationRequested, +) +from common.models import User + + +class UserInfoSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'username', 'first_name', 'last_name', 'api_key', 'is_staff'] + + +class PrinterSerializer(serializers.ModelSerializer): + color_allowed = serializers.BooleanField(read_only=True) + + class Meta: + model = Printer + fields = ['id', 'name', 'color_supported', 'duplex_supported', 'color_allowed'] + + +class PrintingPropertiesSerializer(serializers.ModelSerializer): + class Meta: + model = PrintingProperties + fields = [ + 'color', + 'copies', + 'two_sides', + 'pages_to_print', + 'fit_to_page', + 'n_up', + 'imposition_template', + 'orientation_requested', + ] + + +class JobArtefactSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField() + + class Meta: + model = JobArtefact + fields = ['id', 'artefact_type', 'mime_type', 'document_number', 'file', 'url'] + + def get_url(self, obj): + request = self.context.get('request') + if obj.file and request is not None: + return request.build_absolute_uri(obj.file.url) + return obj.file.url if obj.file else None + + +class GutenbergJobSerializer(serializers.ModelSerializer): + properties = PrintingPropertiesSerializer(read_only=True) + artefacts = JobArtefactSerializer(many=True, read_only=True) + printer_name = serializers.CharField(source='printer.name', read_only=True) + + class Meta: + model = GutenbergJob + fields = [ + 'id', + 'name', + 'job_type', + 'printer', + 'printer_name', + 'owner', + 'pages', + 'status', + 'status_reason', + 'date_created', + 'date_processed', + 'date_finished', + 'properties', + 'artefacts', + 'preview_status', + 'preview_pages', + ] + + +class CreatePrintJobRequestSerializer(serializers.Serializer): + printer = serializers.IntegerField() + copies = serializers.IntegerField(default=1, min_value=1) + pages_to_print = serializers.CharField(required=False, allow_blank=True, default="") + color = serializers.BooleanField(default=False) + two_sides = serializers.ChoiceField(choices=TwoSidedPrinting.choices, default=TwoSidedPrinting.ONE_SIDED) + fit_to_page = serializers.BooleanField(default=True) + n_up = serializers.IntegerField(default=1) + imposition_template = serializers.ChoiceField(choices=ImpositionTemplate.choices, default=ImpositionTemplate.NONE) + orientation_requested = serializers.ChoiceField(choices=OrientationRequested.choices, default=OrientationRequested.AUTO) + + +class ChangePrintJobPropertiesRequestSerializer(serializers.Serializer): + printer = serializers.IntegerField(required=False) + copies = serializers.IntegerField(required=False, min_value=1) + pages_to_print = serializers.CharField(required=False, allow_blank=True) + color = serializers.BooleanField(required=False) + two_sides = serializers.ChoiceField(choices=TwoSidedPrinting.choices, required=False) + fit_to_page = serializers.BooleanField(required=False) + n_up = serializers.IntegerField(required=False) + imposition_template = serializers.ChoiceField(choices=ImpositionTemplate.choices, required=False) + orientation_requested = serializers.ChoiceField(choices=OrientationRequested.choices, required=False) + + +class UploadJobArtefactRequestSerializer(serializers.Serializer): + file = serializers.FileField() + preview = serializers.BooleanField(required=False, default=False) + + +class DeleteJobArtefactRequestSerializer(serializers.Serializer): + artefact_id = serializers.IntegerField() + + +class ChangeArtefactOrderRequestSerializer(serializers.Serializer): + new_order = serializers.ListField( + child=serializers.IntegerField(), + allow_empty=False + ) + + +class LoginSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField(write_only=True) diff --git a/backend/control/validators.py b/backend/control/validators.py new file mode 100644 index 0000000..173777c --- /dev/null +++ b/backend/control/validators.py @@ -0,0 +1,28 @@ +import re +from math import isqrt +from django.core.exceptions import ValidationError + + +def validate_pages_to_print(value): + if value == "" or value is None: + return + code = 'invalid' + if not re.search(r'^\d+(?:-\d+)?(,\d+(?:-\d+)?)*$', str(value)): + raise ValidationError(f'Invalid page selection string: {value}', code=code) + parts = [part.split('-') for part in value.split(',')] + for part in parts: + if int(part[0]) > int(part[-1]) or int(part[0]) < 1: + raise ValidationError(f'Invalid page range: {part[0]}-{part[-1]}', code=code) + + +def validate_n_up(value: int): + if isqrt(value) ** 2 == value: + return + if value % 2 == 0 and 2 * (isqrt(value // 2) ** 2) == value: + return + raise ValidationError("n must be a perfect square or a perfect square times 2") + + +def validate_margins(value: float): + if value < 0.0 or value > 100.0: + raise ValidationError(f"Margin must be between 0.0 and 100.0 (got {value})") diff --git a/backend/printing/models.py b/backend/printing/models.py new file mode 100644 index 0000000..8750319 --- /dev/null +++ b/backend/printing/models.py @@ -0,0 +1,44 @@ +from django.db import models +from django.conf import settings +from django.contrib.auth import get_user_model +import os + +def upload_to_uploads(instance, filename): + return os.path.join('uploads', str(instance.user_id or 'anon'), filename) + +class PrintJob(models.Model): + PREVIEW_PENDING = 'pending' + PREVIEW_PROCESSING = 'processing' + PREVIEW_READY = 'ready' + PREVIEW_FAILED = 'failed' + PREVIEW_CHOICES = [ + (PREVIEW_PENDING, 'pending'), + (PREVIEW_PROCESSING, 'processing'), + (PREVIEW_READY, 'ready'), + (PREVIEW_FAILED, 'failed'), + ] + + user = models.ForeignKey(get_user_model(), on_delete=models.SET_NULL, null=True, blank=True) + uploaded_file = models.FileField(upload_to=upload_to_uploads) + created_at = models.DateTimeField(auto_now_add=True) + preview_status = models.CharField(max_length=20, choices=PREVIEW_CHOICES, default=PREVIEW_PENDING) + preview_pages = models.IntegerField(default=0) + preview_meta = models.JSONField(default=dict, blank=True) # {'pages': ['page-1.png', ...]} + settings = models.JSONField(default=dict, blank=True) # duplex, copies, page_range, color, etc. + printed_at = models.DateTimeField(null=True, blank=True) + ipp_job_id = models.CharField(max_length=128, blank=True, null=True) + + class Meta: + ordering = ['-created_at'] + + def preview_dir(self): + # filesystem dir inside MEDIA_ROOT where preview files are stored + return os.path.join('previews', str(self.id)) + + def preview_urls(self, request): + # build list of absolute URLs for preview pages + pages = self.preview_meta.get('pages', []) + urls = [] + for p in pages: + urls.append(request.build_absolute_uri(settings.MEDIA_URL + f"{self.preview_dir()}/{p}")) + return urls diff --git a/backend/printing/serializers.py b/backend/printing/serializers.py new file mode 100644 index 0000000..c273bf7 --- /dev/null +++ b/backend/printing/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers +from .models import PrintJob + +class PrintJobSerializer(serializers.ModelSerializer): + class Meta: + model = PrintJob + fields = ['id','user','uploaded_file','created_at','preview_status','preview_pages','preview_meta','settings','printed_at','ipp_job_id'] + read_only_fields = ['id','created_at','preview_status','preview_pages','preview_meta','printed_at','ipp_job_id'] + +class PrintJobCreateSerializer(serializers.ModelSerializer): + class Meta: + model = PrintJob + fields = ['id','uploaded_file','settings']