feat(notifications): D.2+D.3 — forum, broadcasty Izby, wydarzenia, cron 24h
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
Rozszerzenie powiadomień o kolejne typy zdarzeń, z symetrycznymi togglami e-mail i push w /konto/prywatnosc. Migracje 103 + 104 — 6 nowych kolumn preferencji e-mail + NordaEvent.reminder_24h_sent_at. Triggery: - Forum odpowiedź → push do autora wątku (notify_push_forum_reply) - Forum cytat (> **Imię** napisał(a):) → push + email do cytowanego (notify_push/email_forum_quote) - Admin publikuje aktualność → broadcast push (ON) + email (OFF) do aktywnych członków (notify_push/email_announcements) - Board: utworzenie / publikacja programu / publikacja protokołu → broadcast push + opt-in email (notify_push/email_board_meetings) - Nowe wydarzenie w kalendarzu → broadcast push + email (oba ON) (notify_push/email_event_invites) - Cron scripts/event_reminders_cron.py co godzinę — wydarzenia za 23-25h, dla zapisanych (EventAttendee.status != 'declined') push + email, znacznik NordaEvent.reminder_24h_sent_at żeby nie dublować. Email defaults dobrane, by nie zalać inbox: broadcast OFF (announcements, board, forum_reply), personalne/actionable ON (forum_quote, event_invites, event_reminders). Wszystkie nowe e-maile mają jednym-kliknięciem unsubscribe (RFC 8058 + link w stopce) — unsubscribe_tokens.py rozszerzony o nowe typy. Cron entry do dodania na prod (osobny krok, bo to edycja crontaba): 0 * * * * cd /var/www/nordabiznes && venv/bin/python3 scripts/event_reminders_cron.py Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
425f38d0fd
commit
c9985ba51a
@ -33,6 +33,72 @@ import gemini_service
|
||||
# Logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _broadcast_event_created(db, event):
|
||||
"""Push + opt-in email broadcast gdy admin utworzył nowe wydarzenie w kalendarzu Izby."""
|
||||
try:
|
||||
from blueprints.push.push_service import send_push
|
||||
from email_service import send_email, _email_v3_wrap
|
||||
except ImportError:
|
||||
return 0
|
||||
|
||||
url_path = f'/kalendarz/{event.id}'
|
||||
title = 'Nowe wydarzenie w kalendarzu Izby'
|
||||
date_str = event.event_date.strftime('%d.%m.%Y') if event.event_date else ''
|
||||
time_str = str(event.time_start)[:5] if event.time_start else ''
|
||||
body = f'{event.title} · {date_str}' + (f' {time_str}' if time_str else '')
|
||||
|
||||
try:
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
except Exception as e:
|
||||
logger.warning(f"event broadcast: fetch users failed: {e}")
|
||||
return 0
|
||||
|
||||
publisher_id = getattr(current_user, 'id', None)
|
||||
push_sent = 0
|
||||
for u in users:
|
||||
if publisher_id and u.id == publisher_id:
|
||||
continue
|
||||
try:
|
||||
if getattr(u, 'notify_push_event_invites', True) is not False:
|
||||
send_push(
|
||||
user_id=u.id,
|
||||
title=title,
|
||||
body=body,
|
||||
url=url_path,
|
||||
tag=f'event-{event.id}',
|
||||
)
|
||||
push_sent += 1
|
||||
except Exception as e:
|
||||
logger.debug(f"event push err user={u.id}: {e}")
|
||||
try:
|
||||
if u.email and getattr(u, 'notify_email_event_invites', False) is True:
|
||||
subject = f"Nowe wydarzenie: {event.title[:60]}"
|
||||
body_text = f"{title}\n\n{event.title}\nTermin: {date_str} {time_str}\nMiejsce: {event.location or '-'}\n\nZobacz: https://nordabiznes.pl{url_path}"
|
||||
content = (
|
||||
f'<p style="margin:0 0 16px;color:#1e293b;font-size:16px;">Cześć <strong>{u.name or u.email}</strong>!</p>'
|
||||
f'<p style="margin:0 0 20px;color:#475569;font-size:15px;">{title}:</p>'
|
||||
f'<h3 style="color:#1e3a8a;">{event.title}</h3>'
|
||||
f'<p style="color:#475569;line-height:1.6">Termin: <b>{date_str} {time_str}</b><br>'
|
||||
f'Miejsce: {event.location or "-"}</p>'
|
||||
f'<p style="margin:24px 0;"><a href="https://nordabiznes.pl{url_path}" '
|
||||
f'style="background:#1e3a8a;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none">Zapisz się / szczegóły</a></p>'
|
||||
)
|
||||
body_html = _email_v3_wrap(title, 'Norda Biznes Partner', content)
|
||||
send_email(
|
||||
to=[u.email],
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
email_type='event_broadcast',
|
||||
user_id=u.id,
|
||||
recipient_name=u.name,
|
||||
notification_type='event_invites',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"event email err user={u.id}: {e}")
|
||||
return push_sent
|
||||
|
||||
# Polish month names for fees
|
||||
MONTHS_PL = [
|
||||
(1, 'Styczen'), (2, 'Luty'), (3, 'Marzec'), (4, 'Kwiecien'),
|
||||
@ -1335,6 +1401,11 @@ def admin_calendar_new():
|
||||
db.add(event)
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
_broadcast_event_created(db, event)
|
||||
except Exception as e:
|
||||
logger.warning(f"event broadcast failed: {e}")
|
||||
|
||||
flash('Wydarzenie zostało dodane.', 'success')
|
||||
return redirect(url_for('.admin_calendar'))
|
||||
except Exception as e:
|
||||
|
||||
@ -13,13 +13,86 @@ 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, Announcement, SystemRole
|
||||
from database import SessionLocal, Announcement, SystemRole, User
|
||||
from utils.decorators import role_required
|
||||
from utils.helpers import sanitize_html
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _broadcast_announcement(db, announcement):
|
||||
"""Broadcast push (+ optional e-mail) do aktywnych użytkowników.
|
||||
|
||||
Każdy user jest filtrowany po własnych flagach:
|
||||
- notify_push_announcements (default TRUE — push)
|
||||
- notify_email_announcements (default FALSE — e-mail, opt-in)
|
||||
Autor (publishing admin) nie dostaje powiadomienia o sobie.
|
||||
"""
|
||||
push_sent = 0
|
||||
try:
|
||||
from blueprints.push.push_service import send_push
|
||||
from email_service import send_email, _email_v3_wrap
|
||||
except ImportError:
|
||||
return 0
|
||||
|
||||
url_path = f'/aktualnosci/{announcement.id}' if hasattr(announcement, 'slug') else f'/aktualnosci/{announcement.id}'
|
||||
title = announcement.title or 'Nowa aktualność'
|
||||
excerpt = (announcement.excerpt or '')[:160] if hasattr(announcement, 'excerpt') else ''
|
||||
|
||||
try:
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
except Exception as e:
|
||||
logger.warning(f"broadcast: fetch users failed: {e}")
|
||||
return 0
|
||||
|
||||
publisher_id = getattr(current_user, 'id', None)
|
||||
|
||||
for u in users:
|
||||
if publisher_id and u.id == publisher_id:
|
||||
continue
|
||||
# Push
|
||||
try:
|
||||
if getattr(u, 'notify_push_announcements', True) is not False:
|
||||
send_push(
|
||||
user_id=u.id,
|
||||
title=f'Nowa aktualność Izby',
|
||||
body=title[:120] if title else 'Opublikowano nową aktualność',
|
||||
url=url_path,
|
||||
tag=f'announcement-{announcement.id}',
|
||||
)
|
||||
push_sent += 1
|
||||
except Exception as e:
|
||||
logger.debug(f"broadcast push err user={u.id}: {e}")
|
||||
# Email (opt-in)
|
||||
try:
|
||||
if u.email and getattr(u, 'notify_email_announcements', False) is True:
|
||||
subject = f"Aktualność Izby: {title[:60]}"
|
||||
body_text = f"Nowa aktualność na portalu:\n\n{title}\n\n{excerpt}\n\nZobacz: https://nordabiznes.pl{url_path}"
|
||||
content = (
|
||||
f'<p style="margin:0 0 16px;color:#1e293b;font-size:16px;">Cześć <strong>{u.name or u.email}</strong>!</p>'
|
||||
f'<p style="margin:0 0 20px;color:#475569;font-size:15px;">Biuro Izby opublikowało nową aktualność:</p>'
|
||||
f'<h3 style="color:#1e3a8a;">{title}</h3>'
|
||||
f'<p style="color:#475569;line-height:1.6">{excerpt}</p>'
|
||||
f'<p style="margin:24px 0;"><a href="https://nordabiznes.pl{url_path}" '
|
||||
f'style="background:#1e3a8a;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none">Zobacz aktualność</a></p>'
|
||||
)
|
||||
body_html = _email_v3_wrap('Nowa aktualność Izby', 'Norda Biznes Partner', content)
|
||||
send_email(
|
||||
to=[u.email],
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
email_type='announcement_broadcast',
|
||||
user_id=u.id,
|
||||
recipient_name=u.name,
|
||||
notification_type='announcements',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"broadcast email err user={u.id}: {e}")
|
||||
|
||||
return push_sent
|
||||
|
||||
|
||||
def generate_slug(title):
|
||||
"""
|
||||
Generate URL-friendly slug from title.
|
||||
@ -275,8 +348,13 @@ def admin_announcements_publish(id):
|
||||
title=announcement.title,
|
||||
category=announcement.category
|
||||
)
|
||||
logger.info(f"Sent {notify_count} notifications for announcement: {announcement.title}")
|
||||
return jsonify({'success': True, 'message': f'Ogłoszenie zostało opublikowane. Wysłano {notify_count} powiadomień.'})
|
||||
logger.info(f"Sent {notify_count} in-app notifications for announcement: {announcement.title}")
|
||||
|
||||
# Broadcast push/e-mail do aktywnych użytkowników (opt-in per flaga)
|
||||
push_count = _broadcast_announcement(db, announcement)
|
||||
logger.info(f"Sent {push_count} push notifications for announcement: {announcement.title}")
|
||||
|
||||
return jsonify({'success': True, 'message': f'Ogłoszenie zostało opublikowane. Wysłano {notify_count} powiadomień w portalu, {push_count} na urządzenia.'})
|
||||
except ImportError:
|
||||
return jsonify({'success': True, 'message': 'Ogłoszenie zostało opublikowane.'})
|
||||
|
||||
|
||||
@ -917,6 +917,12 @@ def konto_prywatnosc():
|
||||
user.notify_email_classified_question = request.form.get('notify_email_classified_question') == 'on'
|
||||
user.notify_email_classified_answer = request.form.get('notify_email_classified_answer') == 'on'
|
||||
user.notify_email_classified_expiry = request.form.get('notify_email_classified_expiry') == 'on'
|
||||
user.notify_email_forum_reply = request.form.get('notify_email_forum_reply') == 'on'
|
||||
user.notify_email_forum_quote = request.form.get('notify_email_forum_quote') == 'on'
|
||||
user.notify_email_announcements = request.form.get('notify_email_announcements') == 'on'
|
||||
user.notify_email_board_meetings = request.form.get('notify_email_board_meetings') == 'on'
|
||||
user.notify_email_event_invites = request.form.get('notify_email_event_invites') == 'on'
|
||||
user.notify_email_event_reminders = request.form.get('notify_email_event_reminders') == 'on'
|
||||
|
||||
# Web Push preferences per event type
|
||||
user.notify_push_messages = request.form.get('notify_push_messages') == 'on'
|
||||
|
||||
@ -277,6 +277,11 @@ def meeting_publish_agenda(meeting_id):
|
||||
meeting.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
_broadcast_board_meeting(db, meeting, kind='agenda')
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"board broadcast (agenda) failed: {e}")
|
||||
|
||||
flash(f'Program posiedzenia {meeting.meeting_identifier} został opublikowany.', 'success')
|
||||
current_app.logger.info(
|
||||
f"Meeting agenda published: {meeting.meeting_identifier} by user {current_user.id}"
|
||||
@ -327,6 +332,11 @@ def meeting_publish_protocol(meeting_id):
|
||||
meeting.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
_broadcast_board_meeting(db, meeting, kind='protocol')
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"board broadcast (protocol) failed: {e}")
|
||||
|
||||
# Trigger admission workflow in background
|
||||
user_id = current_user.id
|
||||
import threading
|
||||
@ -794,6 +804,11 @@ def _handle_meeting_form(db, meeting=None):
|
||||
db.add(new_meeting)
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
_broadcast_board_meeting(db, new_meeting, kind='created')
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"board broadcast (created) failed: {e}")
|
||||
|
||||
flash(f'Posiedzenie {new_meeting.meeting_identifier} zostało utworzone.', 'success')
|
||||
return redirect(url_for('board.meeting_view', meeting_id=new_meeting.id))
|
||||
|
||||
@ -856,7 +871,7 @@ def board_fees():
|
||||
'has_data': False, 'is_child': is_child,
|
||||
'child_brands': [], 'child_count': 0,
|
||||
'expected_fees': {}, 'rate_change_month': None,
|
||||
'parent_months': {},
|
||||
'parent_months': {}, 'reminder': None,
|
||||
}
|
||||
|
||||
for m in range(1, 13):
|
||||
@ -893,6 +908,27 @@ def board_fees():
|
||||
for m in range(1, 13):
|
||||
company_data['parent_months'][m] = fees.get((company.parent_company_id, m))
|
||||
|
||||
# Find last reminder for this company
|
||||
if not is_child and company_data['has_data']:
|
||||
has_unpaid = any(
|
||||
company_data['months'].get(m) and company_data['months'][m].status in ('pending', 'partial', 'overdue')
|
||||
for m in range(1, 13)
|
||||
)
|
||||
if has_unpaid:
|
||||
from database import PrivateMessage, UserCompany as UC2
|
||||
company_user_ids = [cu.user_id for cu in db.query(UC2).filter(UC2.company_id == company.id).all()]
|
||||
if company_user_ids:
|
||||
last_reminder = db.query(PrivateMessage).filter(
|
||||
PrivateMessage.recipient_id.in_(company_user_ids),
|
||||
PrivateMessage.subject.ilike('%przypomnienie o składce%'),
|
||||
).order_by(PrivateMessage.created_at.desc()).first()
|
||||
if last_reminder:
|
||||
company_data['reminder'] = {
|
||||
'sent_at': last_reminder.created_at,
|
||||
'is_read': last_reminder.is_read,
|
||||
'read_at': last_reminder.read_at,
|
||||
}
|
||||
|
||||
parent_fees_data.append(company_data)
|
||||
|
||||
# Sort: non-children with data first, then without data, children will be inserted after parents
|
||||
@ -953,3 +989,88 @@ def board_fees():
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BROADCAST HELPERS — Web Push + opt-in e-mail dla posiedzeń Rady
|
||||
# =============================================================================
|
||||
|
||||
def _broadcast_board_meeting(db, meeting, kind: str):
|
||||
"""Broadcast o zdarzeniu Rady do aktywnych członków.
|
||||
|
||||
kind:
|
||||
'created' - utworzono nowe posiedzenie
|
||||
'agenda' - opublikowano program
|
||||
'protocol' - opublikowano protokół
|
||||
"""
|
||||
titles = {
|
||||
'created': 'Nowe posiedzenie Rady Izby',
|
||||
'agenda': 'Opublikowano program posiedzenia Rady',
|
||||
'protocol': 'Opublikowano protokół z posiedzenia Rady',
|
||||
}
|
||||
subj_titles = {
|
||||
'created': 'Nowe posiedzenie Rady',
|
||||
'agenda': 'Program posiedzenia Rady',
|
||||
'protocol': 'Protokół z posiedzenia Rady',
|
||||
}
|
||||
title = titles.get(kind, 'Aktualizacja posiedzenia Rady')
|
||||
subject_prefix = subj_titles.get(kind, 'Posiedzenie Rady')
|
||||
url_path = f'/rada/posiedzenia/{meeting.id}'
|
||||
body = f'{meeting.meeting_identifier}' if hasattr(meeting, 'meeting_identifier') else f'Posiedzenie #{meeting.id}'
|
||||
if meeting.meeting_date:
|
||||
body += f' — {meeting.meeting_date.strftime("%d.%m.%Y")}'
|
||||
|
||||
try:
|
||||
from blueprints.push.push_service import send_push
|
||||
from email_service import send_email, _email_v3_wrap
|
||||
except ImportError:
|
||||
return 0
|
||||
|
||||
try:
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"board broadcast: fetch users failed: {e}")
|
||||
return 0
|
||||
|
||||
publisher_id = getattr(current_user, 'id', None)
|
||||
push_sent = 0
|
||||
for u in users:
|
||||
if publisher_id and u.id == publisher_id:
|
||||
continue
|
||||
try:
|
||||
if getattr(u, 'notify_push_board_meetings', True) is not False:
|
||||
send_push(
|
||||
user_id=u.id,
|
||||
title=title,
|
||||
body=body,
|
||||
url=url_path,
|
||||
tag=f'board-{meeting.id}-{kind}',
|
||||
)
|
||||
push_sent += 1
|
||||
except Exception as e:
|
||||
current_app.logger.debug(f"board push err user={u.id}: {e}")
|
||||
try:
|
||||
if u.email and getattr(u, 'notify_email_board_meetings', False) is True:
|
||||
subject = f"{subject_prefix}: {body}"
|
||||
body_text = f"{title}\n\n{body}\n\nZobacz: https://nordabiznes.pl{url_path}"
|
||||
content = (
|
||||
f'<p style="margin:0 0 16px;color:#1e293b;font-size:16px;">Cześć <strong>{u.name or u.email}</strong>!</p>'
|
||||
f'<p style="margin:0 0 20px;color:#475569;font-size:15px;">{title}.</p>'
|
||||
f'<h3 style="color:#1e3a8a;">{body}</h3>'
|
||||
f'<p style="margin:24px 0;"><a href="https://nordabiznes.pl{url_path}" '
|
||||
f'style="background:#1e3a8a;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none">Zobacz posiedzenie</a></p>'
|
||||
)
|
||||
body_html = _email_v3_wrap(title, 'Norda Biznes Partner', content)
|
||||
send_email(
|
||||
to=[u.email],
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
email_type='board_broadcast',
|
||||
user_id=u.id,
|
||||
recipient_name=u.name,
|
||||
notification_type='board_meetings',
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.debug(f"board email err user={u.id}: {e}")
|
||||
return push_sent
|
||||
|
||||
@ -473,6 +473,71 @@ def forum_reply(topic_id):
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse mentions: {e}")
|
||||
|
||||
# Web Push: autor wątku (forum_reply) + cytowani (forum_quote)
|
||||
try:
|
||||
from blueprints.push.push_service import send_push
|
||||
import re as _re
|
||||
|
||||
topic_url = f'/forum/{topic_id}#reply-{reply.id}'
|
||||
plain = _re.sub(r'<[^>]+>', '', content or '').strip()
|
||||
# Usuń cytat bloki z podglądu (linie zaczynające się od '>')
|
||||
plain_lines = [ln for ln in plain.splitlines() if not ln.lstrip().startswith('>')]
|
||||
clean = ' '.join(plain_lines).strip()
|
||||
preview = (clean[:80] + '…') if len(clean) > 80 else clean
|
||||
|
||||
notified_user_ids = set()
|
||||
|
||||
# Autor wątku (o ile nie jest sam replierem)
|
||||
if topic.author_id and topic.author_id != current_user.id:
|
||||
t_author = db.query(User).filter(User.id == topic.author_id).first()
|
||||
if t_author and t_author.notify_push_forum_reply is not False:
|
||||
send_push(
|
||||
user_id=t_author.id,
|
||||
title=f'Nowa odpowiedź: {topic.title[:60]}',
|
||||
body=f'{replier_name}: {preview}' if preview else f'{replier_name} odpowiedział',
|
||||
url=topic_url,
|
||||
tag=f'forum-reply-{topic_id}',
|
||||
)
|
||||
notified_user_ids.add(t_author.id)
|
||||
|
||||
# Cytowani autorzy — pattern "> **Imię Nazwisko** napisał(a):"
|
||||
quoted_names = set(_re.findall(r'>\s*\*\*([^*\n]+?)\*\*\s*napisał', content))
|
||||
for qname in quoted_names:
|
||||
qname = qname.strip()
|
||||
if not qname:
|
||||
continue
|
||||
quoted_users = db.query(User).filter(User.name == qname).all()
|
||||
if len(quoted_users) != 1:
|
||||
continue # niejednoznaczne — pomijamy
|
||||
quoted = quoted_users[0]
|
||||
if quoted.id == current_user.id or quoted.id in notified_user_ids:
|
||||
continue
|
||||
if quoted.notify_push_forum_quote is not False:
|
||||
send_push(
|
||||
user_id=quoted.id,
|
||||
title=f'Zacytowano Twoją wypowiedź: {topic.title[:50]}',
|
||||
body=f'{replier_name}: {preview}' if preview else f'{replier_name} zacytował',
|
||||
url=topic_url,
|
||||
tag=f'forum-quote-{reply.id}-{quoted.id}',
|
||||
)
|
||||
notified_user_ids.add(quoted.id)
|
||||
# Email dla cytowanego (jeśli flag=True, default TRUE dla quote)
|
||||
if quoted.email and quoted.notify_email_forum_quote is not False:
|
||||
try:
|
||||
from utils.notifications import send_forum_reply_email
|
||||
send_forum_reply_email(
|
||||
topic_id=topic_id,
|
||||
topic_title=topic.title,
|
||||
replier_name=replier_name,
|
||||
reply_content=content,
|
||||
subscriber_emails=[{'email': quoted.email, 'name': quoted.name or quoted.email}],
|
||||
reply_id=reply.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send forum quote email: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Forum push trigger error: {e}")
|
||||
|
||||
flash('Odpowiedź dodana.', 'success')
|
||||
return redirect(url_for('.forum_topic', topic_id=topic_id))
|
||||
finally:
|
||||
|
||||
@ -342,6 +342,12 @@ class User(Base, UserMixin):
|
||||
notify_email_classified_question = Column(Boolean, default=True) # Pytanie pod moim ogłoszeniem B2B
|
||||
notify_email_classified_answer = Column(Boolean, default=True) # Odpowiedź pod moim pytaniem B2B
|
||||
notify_email_classified_expiry = Column(Boolean, default=True) # Moje ogłoszenie wygasa za 3 dni
|
||||
notify_email_forum_reply = Column(Boolean, default=False) # Odpowiedź w moim wątku forum (default OFF — forum subs duplikuje)
|
||||
notify_email_forum_quote = Column(Boolean, default=True) # Cytat mojego wpisu forum
|
||||
notify_email_announcements = Column(Boolean, default=False) # Nowa aktualność Izby (broadcast)
|
||||
notify_email_board_meetings = Column(Boolean, default=False) # Posiedzenia Rady (broadcast)
|
||||
notify_email_event_invites = Column(Boolean, default=True) # Nowe wydarzenie w kalendarzu
|
||||
notify_email_event_reminders = Column(Boolean, default=True) # Przypomnienie 24h przed wydarzeniem
|
||||
|
||||
# Web Push notification preferences (per event type)
|
||||
notify_push_messages = Column(Boolean, default=True) # Prywatna wiadomość
|
||||
@ -2252,6 +2258,7 @@ class NordaEvent(Base):
|
||||
max_attendees = Column(Integer)
|
||||
created_by = Column(Integer, ForeignKey('users.id'))
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
reminder_24h_sent_at = Column(DateTime) # Cron znacznik — nie wysyłaj przypomnienia 24h dwa razy
|
||||
|
||||
# Źródło danych (tracking)
|
||||
source = Column(String(255)) # np. 'kalendarz_norda_2026', 'manual', 'api'
|
||||
@ -2382,6 +2389,7 @@ class EventAttendee(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
event_id = Column(Integer, ForeignKey('norda_events.id'), nullable=False)
|
||||
# (właściwość poniżej)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
status = Column(String(20), default='confirmed') # confirmed, maybe, declined
|
||||
registered_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
13
database/migrations/103_add_d2_email_preferences.sql
Normal file
13
database/migrations/103_add_d2_email_preferences.sql
Normal file
@ -0,0 +1,13 @@
|
||||
-- Migration 103: preferencje e-mail dla zdarzeń D.2 (forum + broadcast)
|
||||
--
|
||||
-- Defaults dobrane tak, żeby nie zalać inbox użytkowników:
|
||||
-- forum_reply = FALSE (forum_topic_subscriptions już wysyła maile subskrybentom)
|
||||
-- forum_quote = TRUE (bezpośrednie, personalne, rzadkie)
|
||||
-- announcements = FALSE (broadcast informacyjny — user zajrzy na portal)
|
||||
-- board_meetings = FALSE (broadcast, preferowany push)
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS notify_email_forum_reply BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS notify_email_forum_quote BOOLEAN DEFAULT TRUE,
|
||||
ADD COLUMN IF NOT EXISTS notify_email_announcements BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS notify_email_board_meetings BOOLEAN DEFAULT FALSE;
|
||||
12
database/migrations/104_add_event_email_preferences.sql
Normal file
12
database/migrations/104_add_event_email_preferences.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- Migration 104: email preferences dla wydarzeń (D.3)
|
||||
--
|
||||
-- Defaults TRUE bo oba zdarzenia są actionable (wymagają reakcji —
|
||||
-- zapis, przygotowanie, pamiętanie o terminie).
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS notify_email_event_invites BOOLEAN DEFAULT TRUE,
|
||||
ADD COLUMN IF NOT EXISTS notify_email_event_reminders BOOLEAN DEFAULT TRUE;
|
||||
|
||||
-- Pole na tracking wysyłki przypomnienia 24h (żeby cron nie wysyłał dwa razy)
|
||||
ALTER TABLE norda_events
|
||||
ADD COLUMN IF NOT EXISTS reminder_24h_sent_at TIMESTAMP;
|
||||
135
scripts/event_reminders_cron.py
Executable file
135
scripts/event_reminders_cron.py
Executable file
@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Event reminders cron — wysyła przypomnienia push/email 24h przed wydarzeniem.
|
||||
|
||||
Uruchomienie (cron co godzinę):
|
||||
0 * * * * cd /var/www/nordabiznes && venv/bin/python3 scripts/event_reminders_cron.py
|
||||
|
||||
Logika:
|
||||
- Szuka wydarzeń, których event_date+time_start mieści się między now()+23h a now()+25h
|
||||
- Które jeszcze nie mają reminder_24h_sent_at ustawionego
|
||||
- Dla każdego: pobiera zapisanych (EventAttendee.status != 'declined')
|
||||
- Dla każdego zapisanego: sprawdza notify_push_event_reminders i notify_email_event_reminders
|
||||
- Wysyła push i/lub email
|
||||
- Oznacza event.reminder_24h_sent_at = now()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from datetime import datetime, timedelta, time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from database import SessionLocal, NordaEvent, EventAttendee, User
|
||||
|
||||
|
||||
def _combine_event_datetime(event):
|
||||
"""Zwraca datetime startu wydarzenia (data + time_start lub 09:00 jeśli brak)."""
|
||||
t = event.time_start if event.time_start else time(9, 0)
|
||||
return datetime.combine(event.event_date, t)
|
||||
|
||||
|
||||
def main():
|
||||
from email_service import init_email_service
|
||||
init_email_service()
|
||||
from blueprints.push.push_service import send_push
|
||||
from email_service import send_email, _email_v3_wrap
|
||||
|
||||
now = datetime.now()
|
||||
window_start = now + timedelta(hours=23)
|
||||
window_end = now + timedelta(hours=25)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
candidates = db.query(NordaEvent).filter(
|
||||
NordaEvent.event_date >= window_start.date(),
|
||||
NordaEvent.event_date <= window_end.date(),
|
||||
NordaEvent.reminder_24h_sent_at.is_(None),
|
||||
).all()
|
||||
|
||||
fired = 0
|
||||
for event in candidates:
|
||||
event_dt = _combine_event_datetime(event)
|
||||
if not (window_start <= event_dt <= window_end):
|
||||
continue
|
||||
|
||||
attendees = db.query(EventAttendee).filter(
|
||||
EventAttendee.event_id == event.id,
|
||||
EventAttendee.status != 'declined',
|
||||
).all()
|
||||
|
||||
if not attendees:
|
||||
event.reminder_24h_sent_at = now
|
||||
continue
|
||||
|
||||
time_str = event.time_start.strftime('%H:%M') if event.time_start else ''
|
||||
date_str = event.event_date.strftime('%d.%m.%Y')
|
||||
url_path = f'/kalendarz/{event.id}'
|
||||
title = f'Jutro wydarzenie: {event.title[:60]}'
|
||||
body = f'{date_str} {time_str} · {event.location or "Miejsce w szczegółach"}'
|
||||
|
||||
push_count = 0
|
||||
email_count = 0
|
||||
for a in attendees:
|
||||
user = db.query(User).filter(User.id == a.user_id).first()
|
||||
if not user or not user.is_active:
|
||||
continue
|
||||
try:
|
||||
if getattr(user, 'notify_push_event_reminders', True) is not False:
|
||||
send_push(
|
||||
user_id=user.id,
|
||||
title=title,
|
||||
body=body,
|
||||
url=url_path,
|
||||
tag=f'event-reminder-{event.id}',
|
||||
)
|
||||
push_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"push err user={user.id}: {e}")
|
||||
|
||||
try:
|
||||
if user.email and getattr(user, 'notify_email_event_reminders', True) is not False:
|
||||
subject = f"Przypomnienie: {event.title[:60]} jutro o {time_str}"
|
||||
body_text = f"Przypominamy o wydarzeniu na które jesteś zapisany:\n\n{event.title}\nTermin: {date_str} {time_str}\nMiejsce: {event.location or '-'}\n\nSzczegóły: https://nordabiznes.pl{url_path}"
|
||||
content = (
|
||||
f'<p style="margin:0 0 16px;color:#1e293b;font-size:16px;">Cześć <strong>{user.name or user.email}</strong>!</p>'
|
||||
f'<p style="margin:0 0 20px;color:#475569;font-size:15px;">Przypominamy o wydarzeniu na które jesteś zapisany:</p>'
|
||||
f'<h3 style="color:#1e3a8a;">{event.title}</h3>'
|
||||
f'<p style="color:#475569;line-height:1.6">Termin: <b>{date_str} {time_str}</b><br>'
|
||||
f'Miejsce: {event.location or "-"}</p>'
|
||||
f'<p style="margin:24px 0;"><a href="https://nordabiznes.pl{url_path}" '
|
||||
f'style="background:#1e3a8a;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none">Zobacz szczegóły</a></p>'
|
||||
)
|
||||
body_html = _email_v3_wrap('Przypomnienie o wydarzeniu', 'Norda Biznes Partner', content)
|
||||
send_email(
|
||||
to=[user.email],
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
email_type='event_reminder',
|
||||
user_id=user.id,
|
||||
recipient_name=user.name,
|
||||
notification_type='event_reminders',
|
||||
)
|
||||
email_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"email err user={user.id}: {e}")
|
||||
|
||||
event.reminder_24h_sent_at = now
|
||||
fired += 1
|
||||
logger.info(f"event={event.id} '{event.title}' — push={push_count}, email={email_count}")
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Done. Fired reminders for {fired} event(s).")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -400,6 +400,72 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Odpowiedź w Twoim wątku forum</div>
|
||||
<div class="setting-description">E-mail gdy ktoś odpowie w temacie, który założyłeś. Domyślnie wyłączone — forum ma już osobny system „Obserwuj temat" który wysyła maile subskrybentom.</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="notify_email_forum_reply" {% if user.notify_email_forum_reply %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Cytat Twojego wpisu forum</div>
|
||||
<div class="setting-description">E-mail gdy ktoś cytuje lub odpowiada bezpośrednio pod Twoim wpisem</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="notify_email_forum_quote" {% if user.notify_email_forum_quote != False %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Nowa aktualność / ogłoszenie Izby</div>
|
||||
<div class="setting-description">E-mail z każdą nową aktualnością publikowaną przez Biuro Izby. Domyślnie wyłączone — aktualności są powiadomieniem push i widoczne w portalu.</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="notify_email_announcements" {% if user.notify_email_announcements %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Posiedzenia Rady Izby</div>
|
||||
<div class="setting-description">E-mail gdy Rada tworzy posiedzenie, publikuje program lub protokół. Domyślnie wyłączone — powiadomienia push i kalendarz wystarczą.</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="notify_email_board_meetings" {% if user.notify_email_board_meetings %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Nowe wydarzenia w kalendarzu</div>
|
||||
<div class="setting-description">E-mail gdy w kalendarzu Izby pojawi się nowe wydarzenie</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="notify_email_event_invites" {% if user.notify_email_event_invites != False %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Przypomnienie 24h przed wydarzeniem</div>
|
||||
<div class="setting-description">E-mail dzień przed wydarzeniem, na które jesteś zapisany</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="notify_email_event_reminders" {% if user.notify_email_event_reminders != False %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
|
||||
@ -15,8 +15,12 @@ NOTIFICATION_TYPE_TO_COLUMN = {
|
||||
'classified_question': 'notify_email_classified_question',
|
||||
'classified_answer': 'notify_email_classified_answer',
|
||||
'classified_expiry': 'notify_email_classified_expiry',
|
||||
# D.2/D.3 dorzucą: forum_reply, forum_quote, announcements,
|
||||
# board_meetings, event_invites, event_reminders
|
||||
'forum_reply': 'notify_email_forum_reply',
|
||||
'forum_quote': 'notify_email_forum_quote',
|
||||
'announcements': 'notify_email_announcements',
|
||||
'board_meetings': 'notify_email_board_meetings',
|
||||
'event_invites': 'notify_email_event_invites',
|
||||
'event_reminders': 'notify_email_event_reminders',
|
||||
}
|
||||
|
||||
# Friendly labels dla strony potwierdzenia
|
||||
@ -25,6 +29,12 @@ NOTIFICATION_TYPE_LABELS = {
|
||||
'classified_question': 'Pytanie do Twojego ogłoszenia B2B',
|
||||
'classified_answer': 'Odpowiedź na Twoje pytanie B2B',
|
||||
'classified_expiry': 'Przypomnienie o wygasającym ogłoszeniu B2B',
|
||||
'forum_reply': 'Odpowiedź w Twoim wątku forum',
|
||||
'forum_quote': 'Cytat Twojego wpisu na forum',
|
||||
'announcements': 'Aktualności Izby',
|
||||
'board_meetings': 'Posiedzenia Rady Izby',
|
||||
'event_invites': 'Nowe wydarzenia w kalendarzu Izby',
|
||||
'event_reminders': 'Przypomnienia 24h przed wydarzeniem',
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user