feat: add image attachments to B2B classifieds
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

- New ClassifiedAttachment model with migration
- FileUploadService extended with 'classified' type
- Dropzone with drag & drop, paste, multi-file preview in creation form
- Image gallery with lightbox in classified detail view
- Max 10 files, 5MB each, JPG/PNG/GIF

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-10 07:13:24 +02:00
parent 5a51142cfe
commit 699af41efa
6 changed files with 393 additions and 16 deletions

View File

@ -10,7 +10,7 @@ from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, User
from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, ClassifiedAttachment, User
from sqlalchemy import desc
from utils.helpers import sanitize_input
from utils.decorators import member_required
@ -106,6 +106,41 @@ def new():
expires_at=expires
)
db.add(classified)
db.flush() # Get classified.id before commit
# Handle file uploads
files = request.files.getlist('attachments[]')
if files:
try:
from file_upload_service import FileUploadService
saved_count = 0
for file in files[:10]: # Max 10 files
if not file or file.filename == '':
continue
is_valid, error_msg = FileUploadService.validate_file(file)
if not is_valid:
flash(f'Plik {file.filename}: {error_msg}', 'warning')
continue
stored_filename, rel_path, file_size, mime_type = FileUploadService.save_file(file, 'classified')
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
attachment = ClassifiedAttachment(
classified_id=classified.id,
original_filename=file.filename,
stored_filename=stored_filename,
file_extension=ext,
file_size=file_size,
mime_type=mime_type,
uploaded_by=current_user.id
)
db.add(attachment)
saved_count += 1
if saved_count > 0:
flash(f'Dodano {saved_count} zdjęć.', 'info')
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Upload error: {e}")
flash('Nie udało się zapisać niektórych plików.', 'warning')
db.commit()
flash('Ogłoszenie dodane.', 'success')

View File

@ -2565,6 +2565,7 @@ class Classified(Base):
author = relationship('User', backref='classifieds')
company = relationship('Company')
attachments = relationship('ClassifiedAttachment', back_populates='classified', cascade='all, delete-orphan', order_by='ClassifiedAttachment.created_at')
@property
def is_expired(self):
@ -2573,6 +2574,43 @@ class Classified(Base):
return False
class ClassifiedAttachment(Base):
"""File attachments for B2B classifieds"""
__tablename__ = 'classified_attachments'
id = Column(Integer, primary_key=True)
classified_id = Column(Integer, ForeignKey('classifieds.id', ondelete='CASCADE'), nullable=False, index=True)
# File metadata
original_filename = Column(String(255), nullable=False)
stored_filename = Column(String(255), nullable=False, unique=True)
file_extension = Column(String(10), nullable=False)
file_size = Column(Integer, nullable=False) # in bytes
mime_type = Column(String(100), nullable=False)
# Uploader
uploaded_by = Column(Integer, ForeignKey('users.id'), nullable=False)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
# Relationships
classified = relationship('Classified', back_populates='attachments')
uploader = relationship('User')
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
@property
def url(self):
date = self.created_at or datetime.now()
return f"/static/uploads/classifieds/{date.year}/{date.month:02d}/{self.stored_filename}"
@property
def is_image(self):
return self.file_extension.lower() in ('jpg', 'jpeg', 'png', 'gif')
class ClassifiedRead(Base):
"""
Śledzenie odczytów ogłoszeń B2B (seen by).

View File

@ -0,0 +1,19 @@
-- Migration: Add classified_attachments table for B2B image uploads
-- Date: 2026-04-10
CREATE TABLE IF NOT EXISTS classified_attachments (
id SERIAL PRIMARY KEY,
classified_id INTEGER NOT NULL REFERENCES classifieds(id) ON DELETE CASCADE,
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL UNIQUE,
file_extension VARCHAR(10) NOT NULL,
file_size INTEGER NOT NULL,
mime_type VARCHAR(100) NOT NULL,
uploaded_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_classified_attachments_classified_id ON classified_attachments(classified_id);
GRANT ALL ON TABLE classified_attachments TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE classified_attachments_id_seq TO nordabiz_app;

View File

@ -1,8 +1,8 @@
"""
Forum File Upload Service
=========================
File Upload Service
===================
Secure file upload handling for forum attachments.
Secure file upload handling for forum and classified attachments.
Supports JPG, PNG, GIF images up to 5MB.
Features:
@ -34,6 +34,7 @@ MAX_IMAGE_DIMENSIONS = (4096, 4096) # Max 4K resolution
# Get absolute path based on this file's location
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_BASE_PATH = os.path.join(_BASE_DIR, 'static', 'uploads', 'forum')
UPLOAD_CLASSIFIEDS_PATH = os.path.join(_BASE_DIR, 'static', 'uploads', 'classifieds')
# Magic bytes for image validation
IMAGE_SIGNATURES = {
@ -149,8 +150,11 @@ class FileUploadService:
Full path to upload directory
"""
now = datetime.now()
subdir = 'topics' if attachment_type == 'topic' else 'replies'
path = os.path.join(UPLOAD_BASE_PATH, subdir, str(now.year), f"{now.month:02d}")
if attachment_type == 'classified':
path = os.path.join(UPLOAD_CLASSIFIEDS_PATH, str(now.year), f"{now.month:02d}")
else:
subdir = 'topics' if attachment_type == 'topic' else 'replies'
path = os.path.join(UPLOAD_BASE_PATH, subdir, str(now.year), f"{now.month:02d}")
os.makedirs(path, exist_ok=True)
return path
@ -241,15 +245,19 @@ class FileUploadService:
Returns:
True if deleted, False otherwise
"""
subdir = 'topics' if attachment_type == 'topic' else 'replies'
if attachment_type == 'classified':
base_path = UPLOAD_CLASSIFIEDS_PATH
subdir = None
else:
base_path = UPLOAD_BASE_PATH
subdir = 'topics' if attachment_type == 'topic' else 'replies'
if created_at:
# Try exact path first
path = os.path.join(
UPLOAD_BASE_PATH, subdir,
str(created_at.year), f"{created_at.month:02d}",
stored_filename
)
if subdir:
path = os.path.join(base_path, subdir, str(created_at.year), f"{created_at.month:02d}", stored_filename)
else:
path = os.path.join(base_path, str(created_at.year), f"{created_at.month:02d}", stored_filename)
if os.path.exists(path):
try:
os.remove(path)
@ -260,8 +268,8 @@ class FileUploadService:
return False
# Search in all date directories
base_path = os.path.join(UPLOAD_BASE_PATH, subdir)
for root, dirs, files in os.walk(base_path):
search_path = os.path.join(base_path, subdir) if subdir else base_path
for root, dirs, files in os.walk(search_path):
if stored_filename in files:
try:
os.remove(os.path.join(root, stored_filename))
@ -287,5 +295,7 @@ class FileUploadService:
Returns:
URL path to the file
"""
if attachment_type == 'classified':
return f"/static/uploads/classifieds/{created_at.year}/{created_at.month:02d}/{stored_filename}"
subdir = 'topics' if attachment_type == 'topic' else 'replies'
return f"/static/uploads/forum/{subdir}/{created_at.year}/{created_at.month:02d}/{stored_filename}"

View File

@ -134,6 +134,85 @@
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* Upload dropzone */
.upload-dropzone-mini {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: var(--spacing-md);
text-align: center;
background: var(--background);
transition: var(--transition);
cursor: pointer;
margin-bottom: var(--spacing-md);
}
.upload-dropzone-mini:hover,
.upload-dropzone-mini.drag-over {
border-color: var(--primary);
background: rgba(37, 99, 235, 0.05);
}
.upload-dropzone-mini p {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin: 0;
}
.upload-dropzone-mini .mobile-only { display: none; }
@media (max-width: 768px) {
.upload-dropzone-mini .desktop-only { display: none; }
.upload-dropzone-mini .mobile-only { display: block; font-size: var(--font-size-base); padding: var(--spacing-sm) 0; }
}
.upload-previews-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.upload-preview-item {
position: relative;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-xs);
background: var(--surface);
}
.upload-preview-item img {
width: 100%;
height: 80px;
object-fit: cover;
border-radius: var(--radius-sm);
}
.upload-preview-item .preview-info {
font-size: 10px;
color: var(--text-secondary);
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upload-preview-item .remove-preview {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
background: var(--error);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
}
.upload-preview-item .remove-preview:hover { background: #c53030; }
.upload-counter {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.upload-counter.limit-reached { color: var(--error); font-weight: 600; }
</style>
{% endblock %}
@ -156,7 +235,7 @@
Ogloszenie bedzie widoczne przez 30 dni. Po tym czasie wygasnie automatycznie.
</div>
<form method="POST" action="{{ url_for('classifieds.classifieds_new') }}">
<form method="POST" action="{{ url_for('classifieds.classifieds_new') }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
@ -215,11 +294,149 @@
</div>
</div>
<div class="form-group">
<label>Zdjęcia (opcjonalnie)</label>
<div class="upload-counter" id="uploadCounter"></div>
<div class="upload-previews-container" id="previewsContainer"></div>
<div class="upload-dropzone-mini" id="dropzone">
<p class="desktop-only">Przeciągnij obrazy lub kliknij tutaj (max 10 plików, JPG/PNG/GIF do 5MB)</p>
<p class="mobile-only">📷 Dodaj zdjęcie z galerii</p>
<input type="file" id="attachmentInput" name="attachments[]" accept="image/jpeg,image/png,image/gif" multiple style="display: none;">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Dodaj ogloszenie</button>
<button type="submit" class="btn btn-primary" id="submitBtn">Dodaj ogloszenie</button>
<a href="{{ url_for('classifieds.classifieds_index') }}" class="btn btn-secondary">Anuluj</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
(function() {
const dropzone = document.getElementById('dropzone');
if (!dropzone) return;
const fileInput = document.getElementById('attachmentInput');
const previewsContainer = document.getElementById('previewsContainer');
const uploadCounter = document.getElementById('uploadCounter');
const MAX_FILES = 10;
const MAX_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
let filesMap = new Map();
let fileIdCounter = 0;
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('drag-over');
});
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over'));
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('drag-over');
addFiles(Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')));
});
fileInput.addEventListener('change', (e) => {
addFiles(Array.from(e.target.files));
fileInput.value = '';
});
// Paste from clipboard
document.addEventListener('paste', (e) => {
const desc = document.getElementById('description');
if (document.activeElement !== desc) return;
const items = e.clipboardData?.items;
if (!items) return;
const pastedFiles = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) addFiles(pastedFiles);
});
function addFiles(newFiles) {
const availableSlots = MAX_FILES - filesMap.size;
if (availableSlots <= 0) return;
newFiles.slice(0, availableSlots).forEach(file => {
if (file.size > MAX_SIZE || !ALLOWED_TYPES.includes(file.type)) return;
const fileId = 'file_' + (fileIdCounter++);
filesMap.set(fileId, file);
createPreview(fileId, file);
});
updateCounter();
syncFilesToInput();
}
function createPreview(fileId, file) {
const preview = document.createElement('div');
preview.className = 'upload-preview-item';
preview.dataset.fileId = fileId;
const img = document.createElement('img');
const info = document.createElement('div');
info.className = 'preview-info';
info.textContent = file.name.substring(0, 15) + (file.name.length > 15 ? '...' : '') + ' (' + formatFileSize(file.size) + ')';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-preview';
removeBtn.innerHTML = '&times;';
removeBtn.onclick = () => { filesMap.delete(fileId); preview.remove(); updateCounter(); syncFilesToInput(); };
preview.appendChild(img);
preview.appendChild(info);
preview.appendChild(removeBtn);
previewsContainer.appendChild(preview);
const reader = new FileReader();
reader.onload = (e) => { img.src = e.target.result; };
reader.readAsDataURL(file);
}
function updateCounter() {
const count = filesMap.size;
if (count === 0) {
uploadCounter.textContent = '';
uploadCounter.classList.remove('limit-reached');
dropzone.style.display = 'block';
} else {
uploadCounter.textContent = 'Wybrano: ' + count + '/' + MAX_FILES + ' plików';
uploadCounter.classList.toggle('limit-reached', count >= MAX_FILES);
dropzone.style.display = count >= MAX_FILES ? 'none' : 'block';
}
}
function syncFilesToInput() {
try {
const dataTransfer = new DataTransfer();
filesMap.forEach(file => dataTransfer.items.add(file));
fileInput.files = dataTransfer.files;
} catch (e) {}
}
// FormData fallback for mobile
const form = dropzone.closest('form');
form.addEventListener('submit', function(e) {
if (filesMap.size === 0) return;
if (fileInput.files && fileInput.files.length > 0) return;
e.preventDefault();
const formData = new FormData(form);
formData.delete('attachments[]');
filesMap.forEach(file => formData.append('attachments[]', file));
fetch(form.action, { method: 'POST', body: formData })
.then(resp => { if (resp.redirected) { window.location.href = resp.url; } else { window.location.reload(); } })
.catch(() => alert('Błąd wysyłania'));
});
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
})();
{% endblock %}

View File

@ -630,6 +630,39 @@
padding: var(--spacing-xl);
color: var(--text-secondary);
}
/* Classified image gallery */
.classified-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--spacing-sm);
margin: var(--spacing-lg) 0;
}
.classified-gallery img {
width: 100%;
height: 140px;
object-fit: cover;
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
border: 1px solid var(--border);
}
.classified-gallery img:hover {
transform: scale(1.03);
box-shadow: var(--shadow-md);
}
.lightbox {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
justify-content: center;
align-items: center;
cursor: pointer;
}
.lightbox.active { display: flex; }
.lightbox img { max-width: 90%; max-height: 90%; object-fit: contain; }
</style>
{% endblock %}
@ -680,6 +713,16 @@
<div class="classified-description">{{ classified.description }}</div>
{% if classified.attachments %}
<div class="classified-gallery">
{% for att in classified.attachments %}
{% if att.is_image %}
<img src="{{ att.url }}" alt="{{ att.original_filename }}" onclick="openLightbox(this.src)" loading="lazy">
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if classified.budget_info or classified.location_info %}
<div class="classified-details">
{% if classified.budget_info %}
@ -860,6 +903,10 @@
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightboxImage" src="" alt="Powiększony obraz">
</div>
<!-- Interests Modal -->
<div class="modal-overlay" id="interestsModal">
<div class="modal" style="max-width: 500px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
@ -1182,4 +1229,15 @@ async function toggleQuestionVisibility(questionId) {
showToast('Błąd połączenia', 'error');
}
}
function openLightbox(src) {
document.getElementById('lightboxImage').src = src;
document.getElementById('lightbox').classList.add('active');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeLightbox();
});
{% endblock %}