feat: add edit functionality for 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
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 edit route with form pre-filled with existing data - Edit existing attachments (mark for deletion) + add new ones - Edit button visible to classified author on detail view Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
699af41efa
commit
d10c6620d8
@ -229,6 +229,82 @@ def view(classified_id):
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/<int:classified_id>/edytuj', methods=['GET', 'POST'], endpoint='classifieds_edit')
|
||||
@login_required
|
||||
@member_required
|
||||
def edit(classified_id):
|
||||
"""Edytuj ogłoszenie"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
classified = db.query(Classified).filter(
|
||||
Classified.id == classified_id,
|
||||
Classified.author_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not classified:
|
||||
flash('Ogłoszenie nie istnieje lub brak uprawnień.', 'error')
|
||||
return redirect(url_for('.classifieds_index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
classified.listing_type = request.form.get('listing_type', classified.listing_type)
|
||||
classified.category = request.form.get('category', classified.category)
|
||||
classified.title = sanitize_input(request.form.get('title', ''), 255)
|
||||
classified.description = request.form.get('description', '').strip()
|
||||
classified.budget_info = sanitize_input(request.form.get('budget_info', ''), 255)
|
||||
classified.location_info = sanitize_input(request.form.get('location_info', ''), 255)
|
||||
|
||||
if not classified.title or not classified.description:
|
||||
flash('Tytuł i opis są wymagane.', 'error')
|
||||
return render_template('classifieds/edit.html', classified=classified)
|
||||
|
||||
# Handle deleted attachments
|
||||
delete_ids = request.form.getlist('delete_attachments[]')
|
||||
if delete_ids:
|
||||
from file_upload_service import FileUploadService
|
||||
for att in classified.attachments[:]:
|
||||
if str(att.id) in delete_ids:
|
||||
FileUploadService.delete_file(att.stored_filename, 'classified', att.created_at)
|
||||
db.delete(att)
|
||||
|
||||
# Handle new file uploads
|
||||
files = request.files.getlist('attachments[]')
|
||||
if files:
|
||||
try:
|
||||
from file_upload_service import FileUploadService
|
||||
current_count = len([a for a in classified.attachments if str(a.id) not in (delete_ids if delete_ids else [])])
|
||||
max_new = 10 - current_count
|
||||
for file in files[:max_new]:
|
||||
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)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Upload error: {e}")
|
||||
|
||||
db.commit()
|
||||
flash('Ogłoszenie zaktualizowane.', 'success')
|
||||
return redirect(url_for('.classifieds_view', classified_id=classified.id))
|
||||
|
||||
return render_template('classifieds/edit.html', classified=classified)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/<int:classified_id>/zakoncz', methods=['POST'], endpoint='classifieds_close')
|
||||
@login_required
|
||||
@member_required
|
||||
|
||||
272
templates/classifieds/edit.html
Normal file
272
templates/classifieds/edit.html
Normal file
@ -0,0 +1,272 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edycja ogloszenia - Norda Biznes Partner{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.form-container { max-width: 700px; margin: 0 auto; }
|
||||
.form-header { margin-bottom: var(--spacing-xl); }
|
||||
.form-header h1 { font-size: var(--font-size-3xl); color: var(--text-primary); }
|
||||
.form-card { background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl); box-shadow: var(--shadow); }
|
||||
.form-group { margin-bottom: var(--spacing-lg); }
|
||||
.form-group label { display: block; font-weight: 500; margin-bottom: var(--spacing-xs); color: var(--text-primary); }
|
||||
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: var(--spacing-sm) var(--spacing-md); border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-base); transition: var(--transition); }
|
||||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); }
|
||||
.form-actions { display: flex; gap: var(--spacing-md); margin-top: var(--spacing-xl); }
|
||||
.back-link { display: inline-flex; align-items: center; gap: var(--spacing-xs); color: var(--text-secondary); text-decoration: none; margin-bottom: var(--spacing-lg); }
|
||||
.back-link:hover { color: var(--primary); }
|
||||
.type-selector { display: flex; gap: var(--spacing-md); }
|
||||
.type-option { flex: 1; position: relative; }
|
||||
.type-option input { position: absolute; opacity: 0; }
|
||||
.type-option label { display: flex; flex-direction: column; align-items: center; padding: var(--spacing-lg); border: 2px solid var(--border); border-radius: var(--radius-lg); cursor: pointer; transition: var(--transition); }
|
||||
.type-option input:checked + label { border-color: var(--primary); background: rgba(37, 99, 235, 0.05); }
|
||||
.type-option label:hover { border-color: var(--primary); }
|
||||
.type-icon { font-size: 24px; margin-bottom: var(--spacing-sm); }
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* Existing attachments */
|
||||
.existing-attachment { position: relative; border: 1px solid var(--border); border-radius: var(--radius); padding: var(--spacing-xs); background: var(--surface); }
|
||||
.existing-attachment img { width: 100%; height: 80px; object-fit: cover; border-radius: var(--radius-sm); }
|
||||
.existing-attachment .preview-info { font-size: 10px; color: var(--text-secondary); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.existing-attachment .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; }
|
||||
.existing-attachment .remove-preview:hover { background: #c53030; }
|
||||
.existing-attachment.marked-for-delete { opacity: 0.3; }
|
||||
.existing-attachment.marked-for-delete::after { content: 'Usunięte'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: var(--error); font-weight: 700; font-size: var(--font-size-sm); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<a href="{{ url_for('classifieds.classifieds_view', classified_id=classified.id) }}" class="back-link">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Powrot do ogloszenia
|
||||
</a>
|
||||
|
||||
<div class="form-header">
|
||||
<h1>Edytuj ogloszenie</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<form method="POST" action="{{ url_for('classifieds.classifieds_edit', classified_id=classified.id) }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Typ ogloszenia *</label>
|
||||
<div class="type-selector">
|
||||
<div class="type-option">
|
||||
<input type="radio" id="type_szukam" name="listing_type" value="szukam" required {% if classified.listing_type == 'szukam' %}checked{% endif %}>
|
||||
<label for="type_szukam">
|
||||
<span class="type-icon">🔍</span>
|
||||
<strong>Szukam</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="type-option">
|
||||
<input type="radio" id="type_oferuje" name="listing_type" value="oferuje" required {% if classified.listing_type == 'oferuje' %}checked{% endif %}>
|
||||
<label for="type_oferuje">
|
||||
<span class="type-icon">✨</span>
|
||||
<strong>Oferuje</strong>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="category">Kategoria *</label>
|
||||
<select id="category" name="category" required>
|
||||
<option value="">Wybierz kategorie...</option>
|
||||
<option value="uslugi" {% if classified.category == 'uslugi' %}selected{% endif %}>Uslugi profesjonalne</option>
|
||||
<option value="produkty" {% if classified.category == 'produkty' %}selected{% endif %}>Produkty, materialy</option>
|
||||
<option value="wspolpraca" {% if classified.category == 'wspolpraca' %}selected{% endif %}>Propozycje wspolpracy</option>
|
||||
<option value="praca" {% if classified.category == 'praca' %}selected{% endif %}>Oferty pracy, zlecenia</option>
|
||||
<option value="inne" {% if classified.category == 'inne' %}selected{% endif %}>Inne</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Tytul ogloszenia *</label>
|
||||
<input type="text" id="title" name="title" required maxlength="255" value="{{ classified.title }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Opis *</label>
|
||||
<textarea id="description" name="description" rows="6" required>{{ classified.description }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="budget_info">Budzet / Cena</label>
|
||||
<input type="text" id="budget_info" name="budget_info" maxlength="255" value="{{ classified.budget_info or '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="location_info">Lokalizacja</label>
|
||||
<input type="text" id="location_info" name="location_info" maxlength="255" value="{{ classified.location_info or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Zdjecia</label>
|
||||
|
||||
{% if classified.attachments %}
|
||||
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-sm);">Istniejace zdjecia (kliknij X aby usunac):</div>
|
||||
<div class="upload-previews-container" id="existingAttachments">
|
||||
{% for att in classified.attachments %}
|
||||
<div class="existing-attachment" id="existing-{{ att.id }}">
|
||||
<img src="{{ att.url }}" alt="{{ att.original_filename }}">
|
||||
<div class="preview-info">{{ att.original_filename[:20] }}</div>
|
||||
<button type="button" class="remove-preview" onclick="toggleDeleteAttachment({{ att.id }})">×</button>
|
||||
<input type="checkbox" name="delete_attachments[]" value="{{ att.id }}" style="display:none;" id="del-{{ att.id }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-sm); margin-top: var(--spacing-md);">Dodaj nowe zdjecia:</div>
|
||||
<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">Przeciagnij obrazy lub kliknij tutaj (max 10 plikow, JPG/PNG/GIF do 5MB)</p>
|
||||
<p class="mobile-only">📷 Dodaj zdjecie 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">Zapisz zmiany</button>
|
||||
<a href="{{ url_for('classifieds.classifieds_view', classified_id=classified.id) }}" class="btn btn-secondary">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
function toggleDeleteAttachment(attId) {
|
||||
var el = document.getElementById('existing-' + attId);
|
||||
var cb = document.getElementById('del-' + attId);
|
||||
if (cb.checked) {
|
||||
cb.checked = false;
|
||||
el.classList.remove('marked-for-delete');
|
||||
} else {
|
||||
cb.checked = true;
|
||||
el.classList.add('marked-for-delete');
|
||||
}
|
||||
}
|
||||
|
||||
(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 = ''; });
|
||||
|
||||
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 = '×';
|
||||
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 = '';
|
||||
dropzone.style.display = 'block';
|
||||
} else {
|
||||
uploadCounter.textContent = 'Nowych: ' + 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) {}
|
||||
}
|
||||
|
||||
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 %}
|
||||
@ -685,6 +685,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if classified.author_id == current_user.id %}
|
||||
<a href="{{ url_for('classifieds.classifieds_edit', classified_id=classified.id) }}" class="btn btn-primary btn-sm">Edytuj</a>
|
||||
<button class="btn btn-secondary btn-sm close-btn" onclick="closeClassified()">Zamknij ogloszenie</button>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated and current_user.can_access_admin_panel() %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user