diff --git a/blueprints/admin/routes.py b/blueprints/admin/routes.py index 90b5f99..e8344af 100644 --- a/blueprints/admin/routes.py +++ b/blueprints/admin/routes.py @@ -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'

Cześć {u.name or u.email}!

' + f'

{title}:

' + f'

{event.title}

' + f'

Termin: {date_str} {time_str}
' + f'Miejsce: {event.location or "-"}

' + f'

Zapisz się / szczegóły

' + ) + 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: diff --git a/blueprints/admin/routes_announcements.py b/blueprints/admin/routes_announcements.py index ebe8370..cf64b11 100644 --- a/blueprints/admin/routes_announcements.py +++ b/blueprints/admin/routes_announcements.py @@ -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'

Cześć {u.name or u.email}!

' + f'

Biuro Izby opublikowało nową aktualność:

' + f'

{title}

' + f'

{excerpt}

' + f'

Zobacz aktualność

' + ) + 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.'}) diff --git a/blueprints/auth/routes.py b/blueprints/auth/routes.py index 49dbabd..0d3520d 100644 --- a/blueprints/auth/routes.py +++ b/blueprints/auth/routes.py @@ -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' diff --git a/blueprints/board/routes.py b/blueprints/board/routes.py index a25bc17..c6f28a3 100644 --- a/blueprints/board/routes.py +++ b/blueprints/board/routes.py @@ -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'

Cześć {u.name or u.email}!

' + f'

{title}.

' + f'

{body}

' + f'

Zobacz posiedzenie

' + ) + 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 diff --git a/blueprints/forum/routes.py b/blueprints/forum/routes.py index eb59f3d..5528a77 100644 --- a/blueprints/forum/routes.py +++ b/blueprints/forum/routes.py @@ -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: diff --git a/database.py b/database.py index 3194940..4a45b88 100644 --- a/database.py +++ b/database.py @@ -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) diff --git a/database/migrations/103_add_d2_email_preferences.sql b/database/migrations/103_add_d2_email_preferences.sql new file mode 100644 index 0000000..2af3d05 --- /dev/null +++ b/database/migrations/103_add_d2_email_preferences.sql @@ -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; diff --git a/database/migrations/104_add_event_email_preferences.sql b/database/migrations/104_add_event_email_preferences.sql new file mode 100644 index 0000000..a112e05 --- /dev/null +++ b/database/migrations/104_add_event_email_preferences.sql @@ -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; diff --git a/scripts/event_reminders_cron.py b/scripts/event_reminders_cron.py new file mode 100755 index 0000000..3f7f9ee --- /dev/null +++ b/scripts/event_reminders_cron.py @@ -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'

Cześć {user.name or user.email}!

' + f'

Przypominamy o wydarzeniu na które jesteś zapisany:

' + f'

{event.title}

' + f'

Termin: {date_str} {time_str}
' + f'Miejsce: {event.location or "-"}

' + f'

Zobacz szczegóły

' + ) + 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() diff --git a/templates/konto/prywatnosc.html b/templates/konto/prywatnosc.html index 1c79eb9..111fb61 100644 --- a/templates/konto/prywatnosc.html +++ b/templates/konto/prywatnosc.html @@ -400,6 +400,72 @@ + +
+
+
Odpowiedź w Twoim wątku forum
+
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.
+
+ +
+ +
+
+
Cytat Twojego wpisu forum
+
E-mail gdy ktoś cytuje lub odpowiada bezpośrednio pod Twoim wpisem
+
+ +
+ +
+
+
Nowa aktualność / ogłoszenie Izby
+
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.
+
+ +
+ +
+
+
Posiedzenia Rady Izby
+
E-mail gdy Rada tworzy posiedzenie, publikuje program lub protokół. Domyślnie wyłączone — powiadomienia push i kalendarz wystarczą.
+
+ +
+ +
+
+
Nowe wydarzenia w kalendarzu
+
E-mail gdy w kalendarzu Izby pojawi się nowe wydarzenie
+
+ +
+ +
+
+
Przypomnienie 24h przed wydarzeniem
+
E-mail dzień przed wydarzeniem, na które jesteś zapisany
+
+ +
diff --git a/utils/unsubscribe_tokens.py b/utils/unsubscribe_tokens.py index cc48259..d62bd7a 100644 --- a/utils/unsubscribe_tokens.py +++ b/utils/unsubscribe_tokens.py @@ -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', }