fix: ispravi prikaz računa u admin/frontendu i smanji PDF slike
- admin.py: thumbnail_preview u WorkOrderInvoiceInline i WorkOrderInvoiceAdmin prikazuje slike/PDF veze putem optimiziranog image endpointa - serializers.py: image_url sada uključuje ?w=1280&q=80&fmt=jpeg za ne-PDF slike - views.py: doda _compress_image_for_pdf() helper, zamijeni ImageReader(photo.image) s kompresiranom JPEG verzijom na oba mjesta u PDF generiranju - tasks.py: kompresija invoice slike u JPEG u memoriji prije ImageReader poziva - WorkOrderInvoicesPdfPage.jsx: * InvoiceImagePreview prikazuje 'Učitavanje...' dok se blob dohvaća (ne 401 fallback) * uklonjen resolveMediaUrl fallback koji uzrokuje 401 greške * svaka invoice kartica je klikabilna i otvara InvoiceDetailModal * InvoiceDetailModal prikazuje detalje i uvećanu sliku/PDF vezu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
from pathlib import Path
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.admin import ModelAdmin
|
from django.contrib.admin import ModelAdmin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
@@ -61,8 +62,27 @@ class ServiceRecordInline(admin.TabularInline):
|
|||||||
class WorkOrderInvoiceInline(admin.TabularInline):
|
class WorkOrderInvoiceInline(admin.TabularInline):
|
||||||
model = WorkOrderInvoice
|
model = WorkOrderInvoice
|
||||||
extra = 0
|
extra = 0
|
||||||
fields = ('naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
|
fields = ('thumbnail_preview', 'naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
|
||||||
readonly_fields = ('created_at',)
|
readonly_fields = ('thumbnail_preview', 'created_at')
|
||||||
|
|
||||||
|
@admin.display(description="Pregled")
|
||||||
|
def thumbnail_preview(self, obj):
|
||||||
|
if not obj.pk or not obj.image:
|
||||||
|
return "—"
|
||||||
|
suffix = Path(obj.image.name or '').suffix.lower()
|
||||||
|
if suffix == '.pdf':
|
||||||
|
return format_html(
|
||||||
|
'<a href="/api/fleet/work-order-invoices/{}/image/" target="_blank">📄 PDF</a>',
|
||||||
|
obj.pk,
|
||||||
|
)
|
||||||
|
return format_html(
|
||||||
|
'<a href="/api/fleet/work-order-invoices/{}/image/?w=1600&q=90&fmt=jpeg" target="_blank">'
|
||||||
|
'<img src="/api/fleet/work-order-invoices/{}/image/?w=200&q=75&fmt=jpeg" '
|
||||||
|
'style="height:64px;width:auto;border-radius:4px;object-fit:cover;" />'
|
||||||
|
'</a>',
|
||||||
|
obj.pk,
|
||||||
|
obj.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WorkOrderPhotoInline(admin.TabularInline):
|
class WorkOrderPhotoInline(admin.TabularInline):
|
||||||
@@ -193,10 +213,30 @@ class WorkOrderAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(WorkOrderInvoice)
|
@admin.register(WorkOrderInvoice)
|
||||||
class WorkOrderInvoiceAdmin(admin.ModelAdmin):
|
class WorkOrderInvoiceAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active')
|
list_display = ('id', 'image_preview', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active')
|
||||||
list_filter = ('datum', 'is_active', 'work_order')
|
list_filter = ('datum', 'is_active', 'work_order')
|
||||||
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
|
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
|
||||||
ordering = ('-datum', '-created_at')
|
ordering = ('-datum', '-created_at')
|
||||||
|
readonly_fields = ('image_preview', 'created_at')
|
||||||
|
|
||||||
|
@admin.display(description="Slika")
|
||||||
|
def image_preview(self, obj):
|
||||||
|
if not obj.pk or not obj.image:
|
||||||
|
return "—"
|
||||||
|
suffix = Path(obj.image.name or '').suffix.lower()
|
||||||
|
if suffix == '.pdf':
|
||||||
|
return format_html(
|
||||||
|
'<a href="/api/fleet/work-order-invoices/{}/image/" target="_blank">📄 PDF</a>',
|
||||||
|
obj.pk,
|
||||||
|
)
|
||||||
|
return format_html(
|
||||||
|
'<a href="/api/fleet/work-order-invoices/{}/image/?w=1600&q=90&fmt=jpeg" target="_blank">'
|
||||||
|
'<img src="/api/fleet/work-order-invoices/{}/image/?w=200&q=75&fmt=jpeg" '
|
||||||
|
'style="height:80px;width:auto;border-radius:4px;object-fit:cover;" />'
|
||||||
|
'</a>',
|
||||||
|
obj.pk,
|
||||||
|
obj.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(WorkOrderAdditionalCostsTable)
|
@admin.register(WorkOrderAdditionalCostsTable)
|
||||||
|
|||||||
@@ -373,9 +373,13 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
|||||||
return None
|
return None
|
||||||
if request is None:
|
if request is None:
|
||||||
return None
|
return None
|
||||||
return request.build_absolute_uri(
|
base = request.build_absolute_uri(
|
||||||
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
|
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
|
||||||
)
|
)
|
||||||
|
suffix = Path(obj.image.name or '').suffix.lower()
|
||||||
|
if suffix == '.pdf':
|
||||||
|
return base
|
||||||
|
return f"{base}?w=1280&q=80&fmt=jpeg"
|
||||||
|
|
||||||
def get_image_content_type(self, obj):
|
def get_image_content_type(self, obj):
|
||||||
if not obj.image:
|
if not obj.image:
|
||||||
|
|||||||
@@ -645,10 +645,19 @@ def _build_work_order_invoices_pdf(work_order):
|
|||||||
y = content_top
|
y = content_top
|
||||||
max_height = y - content_bottom
|
max_height = y - content_bottom
|
||||||
|
|
||||||
|
# Kompresiraj u JPEG u memoriji radi manje veličine PDF-a
|
||||||
|
compress_w = min(image.width, 1280)
|
||||||
|
if image.width > compress_w:
|
||||||
|
ratio_c = compress_w / float(image.width)
|
||||||
|
image = image.resize((compress_w, max(1, int(image.height * ratio_c))), Image.LANCZOS)
|
||||||
|
buf = BytesIO()
|
||||||
|
image.save(buf, format='JPEG', quality=75, optimize=True)
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
|
ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
|
||||||
draw_width = max(1, int(image.width * ratio))
|
draw_width = max(1, int(image.width * ratio))
|
||||||
draw_height = max(1, int(image.height * ratio))
|
draw_height = max(1, int(image.height * ratio))
|
||||||
image_reader = ImageReader(image)
|
image_reader = ImageReader(buf)
|
||||||
pdf.drawImage(
|
pdf.drawImage(
|
||||||
image_reader,
|
image_reader,
|
||||||
margin,
|
margin,
|
||||||
|
|||||||
@@ -208,6 +208,32 @@ def _docx_filename(work_order, doc_type):
|
|||||||
return f"{display_code}.work-order.docx"
|
return f"{display_code}.work-order.docx"
|
||||||
|
|
||||||
|
|
||||||
|
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
||||||
|
"""
|
||||||
|
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
||||||
|
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
image_field.open('rb')
|
||||||
|
with Image.open(image_field) as src:
|
||||||
|
img = src.convert('RGB')
|
||||||
|
if img.width > max_width:
|
||||||
|
ratio = max_width / float(img.width)
|
||||||
|
new_h = max(1, int(img.height * ratio))
|
||||||
|
img = img.resize((max_width, new_h), Image.LANCZOS)
|
||||||
|
buf = BytesIO()
|
||||||
|
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||||
|
buf.seek(0)
|
||||||
|
return ImageReader(buf)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
image_field.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _get_cached_pdf(work_order, pdf_type):
|
def _get_cached_pdf(work_order, pdf_type):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
expected_filename = _pdf_filename(work_order, pdf_type)
|
expected_filename = _pdf_filename(work_order, pdf_type)
|
||||||
@@ -1166,7 +1192,9 @@ def _build_work_order_service_records_pdf(work_order):
|
|||||||
prepared = []
|
prepared = []
|
||||||
for photo in row_photos:
|
for photo in row_photos:
|
||||||
try:
|
try:
|
||||||
image_reader = ImageReader(photo.image)
|
image_reader = _compress_image_for_pdf(photo.image)
|
||||||
|
if image_reader is None:
|
||||||
|
continue
|
||||||
source_w, source_h = image_reader.getSize()
|
source_w, source_h = image_reader.getSize()
|
||||||
if not source_w or not source_h:
|
if not source_w or not source_h:
|
||||||
continue
|
continue
|
||||||
@@ -1390,7 +1418,9 @@ def _build_service_record_pdf(service_record):
|
|||||||
prepared = []
|
prepared = []
|
||||||
for photo in row_photos:
|
for photo in row_photos:
|
||||||
try:
|
try:
|
||||||
image_reader = ImageReader(photo.image)
|
image_reader = _compress_image_for_pdf(photo.image)
|
||||||
|
if image_reader is None:
|
||||||
|
continue
|
||||||
source_w, source_h = image_reader.getSize()
|
source_w, source_h = image_reader.getSize()
|
||||||
if not source_w or not source_h:
|
if not source_w or not source_h:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/a
|
|||||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||||
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
import { showToast } from '../../stores/toastStore';
|
import { showToast } from '../../stores/toastStore';
|
||||||
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
||||||
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
||||||
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
||||||
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
|
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
|
||||||
@@ -32,9 +32,11 @@ function readWorkOrderIdFromQuery() {
|
|||||||
return params.get('workOrderId') || '';
|
return params.get('workOrderId') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function InvoiceImagePreview({ imageUrl, alt, contentType }) {
|
function InvoiceImagePreview({ imageUrl, rawUrl, alt, contentType, onClick }) {
|
||||||
const [broken, setBroken] = useState(false);
|
const [broken, setBroken] = useState(false);
|
||||||
const isPdf = String(contentType || '').toLowerCase() === 'application/pdf';
|
const isPdf = String(contentType || '').toLowerCase() === 'application/pdf';
|
||||||
|
const isProtected = typeof rawUrl === 'string' && /\/api\/fleet\//i.test(rawUrl);
|
||||||
|
const isLoading = isProtected && !imageUrl && !broken;
|
||||||
|
|
||||||
if (isPdf && imageUrl) {
|
if (isPdf && imageUrl) {
|
||||||
return (
|
return (
|
||||||
@@ -49,6 +51,14 @@ function InvoiceImagePreview({ imageUrl, alt, contentType }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-muted">
|
||||||
|
<span className="animate-pulse">Učitavanje…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!imageUrl || broken) {
|
if (!imageUrl || broken) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-muted">
|
<div className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-muted">
|
||||||
@@ -58,14 +68,76 @@ function InvoiceImagePreview({ imageUrl, alt, contentType }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="inline-flex">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex cursor-pointer focus:outline-none"
|
||||||
|
aria-label={`Otvori pregled: ${alt}`}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
onError={() => setBroken(true)}
|
onError={() => setBroken(true)}
|
||||||
className="h-28 w-28 rounded-lg border border-border-hairline object-cover"
|
className="h-28 w-28 rounded-lg border border-border-hairline object-cover hover:opacity-90 transition-opacity"
|
||||||
/>
|
/>
|
||||||
</a>
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceDetailModal({ invoice, imageUrl, contentType, onClose }) {
|
||||||
|
const isPdf = String(contentType || '').toLowerCase() === 'application/pdf';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div className="relative flex max-h-[95vh] w-full max-w-3xl flex-col overflow-auto rounded-xl bg-canvas-base shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-3">
|
||||||
|
<h2 className="text-base font-semibold text-text-main">{invoice.naziv_racuna || 'Račun'}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded p-1 text-text-muted hover:text-text-main focus:outline-none"
|
||||||
|
aria-label="Zatvori"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 px-5 py-3 text-sm text-text-main border-b border-border-hairline">
|
||||||
|
<div><span className="font-semibold">Naziv:</span> {invoice.naziv_racuna || '-'}</div>
|
||||||
|
<div><span className="font-semibold">Lokacija:</span> {invoice.lokacija || '-'}</div>
|
||||||
|
<div><span className="font-semibold">Datum:</span> {invoice.datum || '-'}</div>
|
||||||
|
<div><span className="font-semibold">Opis:</span> {invoice.opis || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center p-5">
|
||||||
|
{isPdf && imageUrl ? (
|
||||||
|
<a
|
||||||
|
href={imageUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Otvori PDF u novom tabu
|
||||||
|
</a>
|
||||||
|
) : imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={invoice.naziv_racuna}
|
||||||
|
className="max-h-[60vh] max-w-full rounded-lg object-contain shadow"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-text-muted">Slika nije dostupna.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +158,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
const [editingServiceReportNote, setEditingServiceReportNote] = useState(false);
|
const [editingServiceReportNote, setEditingServiceReportNote] = useState(false);
|
||||||
const [savingServiceReportNote, setSavingServiceReportNote] = useState(false);
|
const [savingServiceReportNote, setSavingServiceReportNote] = useState(false);
|
||||||
const [serviceReportNoteError, setServiceReportNoteError] = useState('');
|
const [serviceReportNoteError, setServiceReportNoteError] = useState('');
|
||||||
|
const [invoiceDetailModal, setInvoiceDetailModal] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hydrateAuthFromStorage();
|
hydrateAuthFromStorage();
|
||||||
@@ -408,25 +481,38 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
)}
|
)}
|
||||||
{!loading && !error && invoices.length > 0 && (
|
{!loading && !error && invoices.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{invoices.map((invoice) => (
|
{invoices.map((invoice) => {
|
||||||
<article key={invoice.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
const rawUrl = invoice.image_url || invoice.image || '';
|
||||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
|
const blobUrl = getAuthenticatedMediaSrc(rawUrl);
|
||||||
<div className="space-y-1 text-sm text-text-main">
|
return (
|
||||||
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
|
<article
|
||||||
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
|
key={invoice.id}
|
||||||
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
|
className="cursor-pointer rounded-lg border border-border-hairline bg-canvas-base p-3 hover:bg-canvas-deep transition-colors"
|
||||||
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
|
onClick={() => setInvoiceDetailModal(invoice)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setInvoiceDetailModal(invoice); }}
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
|
||||||
|
<div className="space-y-1 text-sm text-text-main">
|
||||||
|
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
|
||||||
|
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
|
||||||
|
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
|
||||||
|
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<InvoiceImagePreview
|
||||||
|
imageUrl={blobUrl}
|
||||||
|
rawUrl={rawUrl}
|
||||||
|
alt={`Račun ${invoice.naziv_racuna}`}
|
||||||
|
contentType={invoice.image_content_type}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setInvoiceDetailModal(invoice); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<InvoiceImagePreview
|
)}
|
||||||
imageUrl={getAuthenticatedMediaSrc(invoice.image_url || invoice.image) || resolveMediaUrl(invoice.image_url || invoice.image)}
|
|
||||||
alt={`Račun ${invoice.naziv_racuna}`}
|
|
||||||
contentType={invoice.image_content_type}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<TaskWorkHoursTableModal
|
<TaskWorkHoursTableModal
|
||||||
open={!!editingTask}
|
open={!!editingTask}
|
||||||
@@ -455,6 +541,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
}}
|
}}
|
||||||
onSave={handleSaveServiceReportNote}
|
onSave={handleSaveServiceReportNote}
|
||||||
/>
|
/>
|
||||||
|
{invoiceDetailModal && (
|
||||||
|
<InvoiceDetailModal
|
||||||
|
invoice={invoiceDetailModal}
|
||||||
|
imageUrl={getAuthenticatedMediaSrc(invoiceDetailModal.image_url || invoiceDetailModal.image || '')}
|
||||||
|
contentType={invoiceDetailModal.image_content_type}
|
||||||
|
onClose={() => setInvoiceDetailModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user