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
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:
parent
7073a56dc3
commit
2bf5c780e2
@ -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()
|
||||
|
||||
109
scripts/classified_expiry_notifier.py
Normal file
109
scripts/classified_expiry_notifier.py
Normal 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()
|
||||
@ -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);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user