Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@ See also [Creating new releases] for instructions on how to create a new release
### Added
- Added REST API endpoints for print previews [#178]
- Added configurable printer display order in the Django admin panel. [#181]
<<<<<<< HEAD
- Added configuration environment variables used when Gutenberg is deployed behind a reverse proxy:
- `GUTENBERG_TRUST_X_FORWARDED_HOST`, `GUTENBERG_TRUST_X_FORWARDED_PROTO` and `GUTENBERG_TRUST_X_REAL_IP` [#175]
- `GUTENBERG_TRUSTED_PROXY_IPS` [#190]
- Docker Images are now published in the GitHub Container Registry [#199]
=======
- Modified nginx Docker image config to correctly pass the `X-Forwarded-Host` header [#175]
- Changed default nginx location filenames [#175]
- Added `GUTENBERG_TRUST_X_FORWARDED_HOST`, `GUTENBERG_TRUST_X_FORWARDED_PROTO` and `GUTENBERG_TRUST_X_REAL_IP` environment variables used to configure the nginx container [#175]
### Added
- Added `GUTENBERG_TRUSTED_PROXY_IPS` environment variable for configuring trusted reverse proxy IP addresses when using `GUTENBERG_TRUST_X_FORWARDED_*` or `GUTENBERG_TRUST_X_REAL_IP` variables [#190]
- Added 4 test print buttons to the Printer admin page. Buttons: Simplex white and black, simplex color,duplex black and white, duplex color.[#196]
### Security
- Nginx now requires explicit `GUTENBERG_TRUSTED_PROXY_IPS` configuration when `GUTENBERG_TRUST_X_FORWARDED_*` or `GUTENBERG_TRUST_X_REAL_IP` variables are enabled, preventing `X-Forwarded-*` header injection attacks [#190]
### Added
- Added `GUTENBERG_TRUST_X_FORWARDED_HOST`, `GUTENBERG_TRUST_X_FORWARDED_PROTO` and `GUTENBERG_TRUST_X_REAL_IP` environment variables used to configure the nginx container [#175]
- Added asynchronous REST API endpoints for generating, retrieving and canceling print previews [#178]
- Added preview status, page metadata and print-job configuration version tracking [#178]
>>>>>>> c62f3aa (feature:add test print button using JS fetch and refactor print services)

### Changed
- Modified nginx Docker image config to correctly pass the `X-Forwarded-Host` header [#175]
Expand Down Expand Up @@ -75,7 +90,10 @@ The previous significant commit was made on [2022-08-26](https://github.com/KSIU
[#178]: https://github.com/KSIUJ/gutenberg/pull/178
[#190]: https://github.com/KSIUJ/gutenberg/pull/190
[#194]: https://github.com/KSIUJ/gutenberg/pull/194
- [#199]: https://github.com/KSIUJ/gutenberg/pull/199
[#196]: https://github.com/KSIUJ/gutenberg/pull/196
[#199]: https://github.com/KSIUJ/gutenberg/pull/199



[keep a changelog]: https://keepachangelog.com/en/1.1.0/
[OpenID Connect chapter]: https://ksiuj.github.io/gutenberg/admin/openid-connect.html
Expand Down
66 changes: 24 additions & 42 deletions backend/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
import os
import tempfile
from secrets import token_urlsafe

from django.core.exceptions import ValidationError, ObjectDoesNotExist
from printing.services import (
create_printing_job as create_printing_job_helper,
validate_properties as validate_properties_helper,
run_job as run_job_helper,
)
from celery import current_app
from django.contrib.auth import authenticate, login
from django.db import transaction
Expand Down Expand Up @@ -164,25 +169,26 @@ 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,
# Delegation to services.py
try:
job = create_printing_job_helper(
user=self.request.user,
printer_with_perms=printer_with_perms,
name='webrequest',
copies=copies,
pages_to_print=pages_to_print,
color=color,
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
except ObjectDoesNotExist:
raise exceptions.NotFound("Selected printer does not exist")
except ValidationError as ex:
raise exceptions.ValidationError(detail=getattr(ex, 'messages', [str(ex)]))

def _change_properties(
self,
Expand Down Expand Up @@ -279,39 +285,15 @@ def _change_order(self, new_order):
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)
logger.info('User %s submitted job: %s', self.request.user.username, job.id)
return job
return run_job_helper(job, request_user=self.request.user)

def _validate_properties(self, printer_id: int, properties, job):
if job.status != JobStatus.INCOMING:
raise InvalidStatus("Invalid job status for this request", additional_info="current status: {}".format(job.status))
printer_with_perms = Printer.get_printer_for_user(user=self.request.user,
printer_id=printer_id)
if not printer_with_perms:
try:
validate_properties_helper(user=self.request.user, printer_id=printer_id, properties=properties, job=job)
except ObjectDoesNotExist:
raise exceptions.NotFound("Selected printer does not exist")
if properties.color and not printer_with_perms.color_allowed:
raise exceptions.ValidationError("Color printing is not allowed on the selected printer")
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")

except ValidationError as ex:
raise exceptions.ValidationError(detail=getattr(ex, 'messages', [str(ex)]))
@action(
detail=True,
methods=['get'],
Expand Down
67 changes: 62 additions & 5 deletions backend/control/admin.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from django.contrib import admin
from django.urls import path, reverse
from django.utils.html import format_html

from control.forms import LocalPrinterParamsForm
# Register your models here.
from control.models import GutenbergJob, PrintingProperties, PrinterPermissions, LocalPrinterParams, Printer, \
JobArtefact

from control.models import GutenbergJob, PrintingProperties, PrinterPermissions, \
LocalPrinterParams, Printer, JobArtefact
from control.views import trigger_test_print_view
from django.utils.safestring import mark_safe

class PrintingPropertiesInline(admin.TabularInline):
model = PrintingProperties
Expand Down Expand Up @@ -32,8 +34,63 @@ class PrinterPermissionsAdmin(admin.TabularInline):

class PrinterAdmin(admin.ModelAdmin):
inlines = [LocalPrinterParamsInline, PrinterPermissionsAdmin]
list_display = ('name', 'display_order')
list_display = ('name', 'display_order','test_print_controls')
ordering = ('display_order', 'name')
readonly_fields = ('test_print_controls',)

def get_urls(self):
"""Returns HTML for Django admin list view"""
urls = super().get_urls()
custom_urls = [
path(
'<path:printer_id>/test-print/',
self.admin_site.admin_view(trigger_test_print_view),
name='control_printer_test_print',
),
]
return custom_urls + urls

def test_print_controls(self, obj):
"""
Renders action buttons for test prints in different configurations (one-sided/two-sided, colored/grayscale).
"""
if not obj or not obj.pk:
return "-"

url = reverse('admin:control_printer_test_print', args=[obj.pk])

test_variants = [
('Grayscale one-sided', False, False, 'btn-grayscale-simplex-one-sided'),
('Colored one-sided', True, False, 'btn-colored-one-sided'),
('Grayscale two-sided', False, True, 'btn-grayscale-two-sided'),
('Colored two-sided', True, True, 'btn-colored-two-sided'),
]
Comment on lines +53 to +67

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The term simplex is not used anywhere else in the UI or code. I would also avoid the term duplex - most of the code and the entire web UI use the terms one-sided and two-sided.

I would suggest sticking to one naming convention (both in the UI and in code) and in my opinion one-sided/two-sided is easier to understand for unfamiliar users.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The term black and white is incorrect here.
We support two color modes:

  1. colored,
  2. grayscale (monochrome in the Internet Printing Protocol)

grayscale (monochrome) is different from black and white (IPP: bi-level) - the former can print a spectrum of colors from white to black, while the latter can only print two colors

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current webapp UI:
Image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also use this opportunity to adjust these terms in the admin panel:

Image


buttons_html = '<div style="display: flex; gap: 8px; flex-wrap: wrap;">'

for label, color, duplex, css_class in test_variants:
btn_html = (
f'<button type="button" class="admin-test-print-btn {css_class}" '
f'data-url="{url}" data-color="{str(color).lower()}" data-duplex="{str(duplex).lower()}" '
f'style="padding: 8px 12px; font-size: 12px; font-weight: bold; '
f'border: none; border-radius: 4px; cursor: pointer; '
f'background-color: #417690; color: white; transition: all 0.2s;" '
f'onmouseover="this.style.backgroundColor=\'#2a4d63\';" '
f'onmouseout="this.style.backgroundColor=\'#417690\';">'
f'{label}'
f'</button>'
)
buttons_html += btn_html

buttons_html += '</div>'

return mark_safe(buttons_html)

test_print_controls.short_description = 'Test Print Options'

class Media:
"""Class for loading js script"""
js = ('js/admin_test_print.js',)


admin.site.register(Printer, PrinterAdmin)
Expand Down
4 changes: 2 additions & 2 deletions backend/control/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ class OrientationRequested(models.TextChoices):
class Printer(models.Model):
name = models.CharField(max_length=64)
printer_type = models.CharField(max_length=10, default=PrinterType.DISABLED, choices=PrinterType.choices)
color_supported = models.BooleanField(default=False)
duplex_supported = models.BooleanField(default=False)
color_supported = models.BooleanField(default=False,verbose_name="Colored printing supported")
duplex_supported = models.BooleanField(default=False, verbose_name="Two-sided printing supported")
display_order = 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."
Expand Down
Binary file not shown.
Binary file not shown.
86 changes: 86 additions & 0 deletions backend/control/static/js/admin_test_print.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Admin Test Print Handler
*
* Handles click events for test print buttons in the Django Admin interface.
* Sends a POST request with a JSON payload containing 'color' and 'duplex' parameters.
*/

document.addEventListener('DOMContentLoaded', function() {
// Select all test print action buttons
const testPrintButtons = document.querySelectorAll('.admin-test-print-btn');

/**
* Helper function to retrieve a cookie value by name (used for CSRF token).
*
* @param {string} name - The name of the cookie.
* @returns {string|null} The cookie value or null if not found.
*/
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}

testPrintButtons.forEach(button => {
button.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();

const url = this.getAttribute('data-url');
const color = this.getAttribute('data-color') === 'true';
const duplex = this.getAttribute('data-duplex') === 'true';

const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]');
const csrftoken = csrfInput ? csrfInput.value : getCookie('csrftoken');

const payload = {
color: color,
duplex: duplex
};

console.log('Sending test print request:', { url, payload });

fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
body: JSON.stringify(payload)
})
.then(response => {
console.log('Response status:', response.status);
return response.json();
})
.then(data => {
console.log('Response data:', data);

if (data.status === 'ok') {
// Success — display alert and reload page
alert(`Test print job #${data.job_id} successfully dispatched!`);
setTimeout(() => {
window.location.reload();
}, 500);
} else {
// Error response — display error details
const errorMessage = data.message || 'Unknown error';
const details = data.details ? `\n\nDetails: ${data.details}` : '';
alert(`Test print failed: ${errorMessage}${details}`);
}
})
.catch(error => {
console.error('Error during test print request:', error);
alert(`Network error: ${error.message}`);
});
});
});
});
Loading
Loading