feat: Quill rich text editor in B2B classifieds + expiry email notifier
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

- Replace textarea with Quill editor in new/edit classified forms
- Sanitize HTML with sanitize_html() on save (XSS prevention)
- Render HTML in classified detail view, strip tags in list view
- New script: classified_expiry_notifier.py sends email 3 days before
  expiry with link to extend. Run daily via cron at 8:00.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-10 10:53:54 +02:00
parent 7073a56dc3
commit 2bf5c780e2
6 changed files with 191 additions and 9 deletions

View File

@ -12,7 +12,7 @@ from flask_login import login_required, current_user
from . import bp
from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, ClassifiedAttachment, User
from sqlalchemy import desc
from utils.helpers import sanitize_input
from utils.helpers import sanitize_input, sanitize_html
from utils.decorators import member_required
from utils.notifications import (
create_classified_question_notification,
@ -87,7 +87,7 @@ def new():
listing_type = request.form.get('listing_type', '')
category = request.form.get('category', '')
title = sanitize_input(request.form.get('title', ''), 255)
description = request.form.get('description', '').strip()
description = sanitize_html(request.form.get('description', '').strip())
budget_info = sanitize_input(request.form.get('budget_info', ''), 255)
location_info = sanitize_input(request.form.get('location_info', ''), 255)
@ -259,7 +259,7 @@ def edit(classified_id):
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.description = sanitize_html(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)
classified.updated_at = datetime.now()

View File

@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Classified Expiry Notifier
==========================
Sends email notifications to classified authors 3 days before expiry.
Run daily via cron: 0 8 * * * cd /var/www/nordabiznes && venv/bin/python3 scripts/classified_expiry_notifier.py
Author: Maciej Pienczyn, InPi sp. z o.o.
"""
import os
import sys
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from datetime import datetime, timedelta
from database import SessionLocal, Classified, User
def main():
# Initialize email service
from dotenv import load_dotenv
load_dotenv()
from email_service import init_email_service, send_email
init_email_service()
db = SessionLocal()
try:
# Find classifieds expiring in exactly 3 days
target_date = datetime.now().date() + timedelta(days=3)
next_day = target_date + timedelta(days=1)
expiring = db.query(Classified).filter(
Classified.is_active == True,
Classified.expires_at >= datetime.combine(target_date, datetime.min.time()),
Classified.expires_at < datetime.combine(next_day, datetime.min.time())
).all()
if not expiring:
print(f"[{datetime.now()}] Brak ogłoszeń wygasających {target_date}")
return
print(f"[{datetime.now()}] Znaleziono {len(expiring)} ogłoszeń wygasających {target_date}")
for classified in expiring:
author = db.query(User).filter(User.id == classified.author_id).first()
if not author or not author.email:
continue
author_name = author.name or author.email.split('@')[0]
expire_date = classified.expires_at.strftime('%d.%m.%Y')
extend_url = f"https://nordabiznes.pl/tablica/{classified.id}"
subject = f"Twoje ogłoszenie wygasa za 3 dni: {classified.title}"
body_text = f"""Cześć {author_name},
Twoje ogłoszenie na portalu NordaBiznes.pl wygasa {expire_date}:
{classified.title}"
Jeśli chcesz je przedłużyć o kolejne 30 dni, wejdź na stronę ogłoszenia i kliknij przycisk Przedłuż o 30 dni":
{extend_url}
Jeśli ogłoszenie jest już nieaktualne, nie musisz nic robić wygaśnie automatycznie.
Pozdrawiam,
Portal NordaBiznes.pl"""
body_html = f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto;">
<p>Cześć {author_name},</p>
<p>Twoje ogłoszenie na portalu NordaBiznes.pl wygasa <strong>{expire_date}</strong>:</p>
<div style="background: #f8fafc; border-left: 4px solid #2E4872; padding: 16px; margin: 16px 0; border-radius: 4px;">
<strong>{classified.title}</strong>
</div>
<p>Jeśli chcesz je przedłużyć o kolejne 30 dni, kliknij poniższy przycisk:</p>
<p style="text-align: center; margin: 24px 0;">
<a href="{extend_url}" style="background: #2E4872; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600;">
Przedłuż ogłoszenie
</a>
</p>
<p style="color: #64748b; font-size: 14px;">Jeśli ogłoszenie jest już nieaktualne, nie musisz nic robić wygaśnie automatycznie.</p>
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 12px;">Portal NordaBiznes.pl Izba Gospodarcza Norda Biznes</p>
</div>"""
success = send_email(
to=[author.email],
subject=subject,
body_text=body_text,
body_html=body_html,
email_type='classified_expiry',
user_id=author.id,
recipient_name=author_name
)
status = "wysłano" if success else "BŁĄD"
print(f" [{status}] {classified.title} -> {author.email} (wygasa {expire_date})")
finally:
db.close()
if __name__ == '__main__':
main()

View File

@ -2,6 +2,11 @@
{% block title %}Edycja ogloszenia - Norda Biznes Partner{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
{% endblock %}
{% block extra_css %}
<style>
.form-container { max-width: 700px; margin: 0 auto; }
@ -42,6 +47,11 @@
.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; }
.quill-container { border: 1px solid var(--border); border-radius: var(--radius); }
.quill-container .ql-toolbar { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.quill-container .ql-container { border-bottom-left-radius: var(--radius); border-bottom-right-radius: var(--radius); font-size: var(--font-size-base); }
.quill-container .ql-editor { min-height: 150px; }
/* 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); }
@ -108,8 +118,9 @@
</div>
<div class="form-group">
<label for="description">Opis *</label>
<textarea id="description" name="description" rows="6" required>{{ classified.description }}</textarea>
<label>Opis *</label>
<div id="quill-editor" class="quill-container"></div>
<textarea id="description" name="description" style="display:none;" required></textarea>
</div>
<div class="form-row">
@ -160,6 +171,26 @@
{% endblock %}
{% block extra_js %}
var quill = new Quill('#quill-editor', {
theme: 'snow',
placeholder: 'Opisz szczegółowo czego szukasz lub co oferujesz...',
modules: {
toolbar: [
['bold', 'italic'],
[{'list': 'ordered'}, {'list': 'bullet'}],
['link'],
['clean']
]
}
});
quill.root.innerHTML = {{ classified.description|tojson }};
document.querySelector('form').addEventListener('submit', function() {
var html = quill.root.innerHTML;
if (html === '<p><br></p>') html = '';
document.getElementById('description').value = html;
});
function toggleDeleteAttachment(attId) {
var el = document.getElementById('existing-' + attId);
var cb = document.getElementById('del-' + attId);

View File

@ -278,7 +278,7 @@
{% endif %}
</div>
<div class="classified-description">
{{ classified.description[:200] }}{% if classified.description|length > 200 %}...{% endif %}
{{ classified.description|striptags|truncate(200) }}
</div>
<div class="classified-meta">
<div class="classified-author">

View File

@ -2,8 +2,29 @@
{% block title %}Nowe ogłoszenie - Norda Biznes Partner{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
{% endblock %}
{% block extra_css %}
<style>
.quill-container {
border: 1px solid var(--border);
border-radius: var(--radius);
}
.quill-container .ql-toolbar {
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
}
.quill-container .ql-container {
border-bottom-left-radius: var(--radius);
border-bottom-right-radius: var(--radius);
font-size: var(--font-size-base);
}
.quill-container .ql-editor {
min-height: 150px;
}
.form-container {
max-width: 700px;
margin: 0 auto;
@ -278,8 +299,9 @@
</div>
<div class="form-group">
<label for="description">Opis *</label>
<textarea id="description" name="description" rows="6" required placeholder="Opisz szczegółowo czego szukasz lub co oferujesz..."></textarea>
<label>Opis *</label>
<div id="quill-editor" class="quill-container"></div>
<textarea id="description" name="description" style="display:none;" required></textarea>
</div>
<div class="form-row">
@ -315,6 +337,26 @@
{% endblock %}
{% block extra_js %}
var quill = new Quill('#quill-editor', {
theme: 'snow',
placeholder: 'Opisz szczegółowo czego szukasz lub co oferujesz...',
modules: {
toolbar: [
['bold', 'italic'],
[{'list': 'ordered'}, {'list': 'bullet'}],
['link'],
['clean']
]
}
});
// Sync Quill content to hidden textarea on form submit
document.querySelector('form').addEventListener('submit', function() {
var html = quill.root.innerHTML;
if (html === '<p><br></p>') html = '';
document.getElementById('description').value = html;
});
(function() {
const dropzone = document.getElementById('dropzone');
if (!dropzone) return;

View File

@ -718,7 +718,7 @@
<h1 class="classified-title">{{ classified.title }}</h1>
<div class="classified-description">{{ classified.description }}</div>
<div class="classified-description">{{ classified.description|safe }}</div>
{% if classified.attachments %}
<div class="classified-gallery">