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 . import bp
from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, ClassifiedAttachment, User from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, ClassifiedAttachment, User
from sqlalchemy import desc 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.decorators import member_required
from utils.notifications import ( from utils.notifications import (
create_classified_question_notification, create_classified_question_notification,
@ -87,7 +87,7 @@ def new():
listing_type = request.form.get('listing_type', '') listing_type = request.form.get('listing_type', '')
category = request.form.get('category', '') category = request.form.get('category', '')
title = sanitize_input(request.form.get('title', ''), 255) 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) budget_info = sanitize_input(request.form.get('budget_info', ''), 255)
location_info = sanitize_input(request.form.get('location_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.listing_type = request.form.get('listing_type', classified.listing_type)
classified.category = request.form.get('category', classified.category) classified.category = request.form.get('category', classified.category)
classified.title = sanitize_input(request.form.get('title', ''), 255) 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.budget_info = sanitize_input(request.form.get('budget_info', ''), 255)
classified.location_info = sanitize_input(request.form.get('location_info', ''), 255) classified.location_info = sanitize_input(request.form.get('location_info', ''), 255)
classified.updated_at = datetime.now() 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 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 %} {% block extra_css %}
<style> <style>
.form-container { max-width: 700px; margin: 0 auto; } .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 { 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; } .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 attachments */
.existing-attachment { position: relative; border: 1px solid var(--border); border-radius: var(--radius); padding: var(--spacing-xs); background: var(--surface); } .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 img { width: 100%; height: 80px; object-fit: cover; border-radius: var(--radius-sm); }
@ -108,8 +118,9 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description">Opis *</label> <label>Opis *</label>
<textarea id="description" name="description" rows="6" required>{{ classified.description }}</textarea> <div id="quill-editor" class="quill-container"></div>
<textarea id="description" name="description" style="display:none;" required></textarea>
</div> </div>
<div class="form-row"> <div class="form-row">
@ -160,6 +171,26 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% 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) { function toggleDeleteAttachment(attId) {
var el = document.getElementById('existing-' + attId); var el = document.getElementById('existing-' + attId);
var cb = document.getElementById('del-' + attId); var cb = document.getElementById('del-' + attId);

View File

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

View File

@ -2,8 +2,29 @@
{% block title %}Nowe ogłoszenie - Norda Biznes Partner{% endblock %} {% 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 %} {% block extra_css %}
<style> <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 { .form-container {
max-width: 700px; max-width: 700px;
margin: 0 auto; margin: 0 auto;
@ -278,8 +299,9 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description">Opis *</label> <label>Opis *</label>
<textarea id="description" name="description" rows="6" required placeholder="Opisz szczegółowo czego szukasz lub co oferujesz..."></textarea> <div id="quill-editor" class="quill-container"></div>
<textarea id="description" name="description" style="display:none;" required></textarea>
</div> </div>
<div class="form-row"> <div class="form-row">
@ -315,6 +337,26 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% 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() { (function() {
const dropzone = document.getElementById('dropzone'); const dropzone = document.getElementById('dropzone');
if (!dropzone) return; if (!dropzone) return;

View File

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