diff --git a/app.py b/app.py index c546d32..7078a87 100644 --- a/app.py +++ b/app.py @@ -13340,6 +13340,436 @@ def api_geoip_stats(): db.close() +# ============================================================ +# ANNOUNCEMENTS (Ogłoszenia dla członków) +# ============================================================ + +def generate_slug(title): + """ + Generate URL-friendly slug from title. + Uses unidecode for proper Polish character handling. + """ + import re + try: + from unidecode import unidecode + text = unidecode(title.lower()) + except ImportError: + # Fallback without unidecode + text = title.lower() + replacements = { + 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', + 'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z' + } + for pl, en in replacements.items(): + text = text.replace(pl, en) + + # Remove special characters, replace spaces with hyphens + text = re.sub(r'[^\w\s-]', '', text) + text = re.sub(r'[-\s]+', '-', text).strip('-') + return text[:200] # Limit slug length + + +@app.route('/admin/announcements') +@login_required +def admin_announcements(): + """Admin panel - lista ogłoszeń""" + if not current_user.is_admin: + flash('Brak uprawnień do tej strony.', 'error') + return redirect(url_for('dashboard')) + + from database import Announcement + + db = SessionLocal() + try: + # Filters + status_filter = request.args.get('status', 'all') + category_filter = request.args.get('category', 'all') + + query = db.query(Announcement) + + if status_filter != 'all': + query = query.filter(Announcement.status == status_filter) + if category_filter != 'all': + query = query.filter(Announcement.category == category_filter) + + # Sort: pinned first, then by created_at desc + query = query.order_by( + Announcement.is_pinned.desc(), + Announcement.created_at.desc() + ) + + announcements = query.all() + + return render_template('admin/announcements.html', + announcements=announcements, + now=datetime.now(), + status_filter=status_filter, + category_filter=category_filter, + categories=Announcement.CATEGORIES, + category_labels=Announcement.CATEGORY_LABELS, + statuses=Announcement.STATUSES, + status_labels=Announcement.STATUS_LABELS) + + finally: + db.close() + + +@app.route('/admin/announcements/new', methods=['GET', 'POST']) +@login_required +def admin_announcements_new(): + """Admin panel - nowe ogłoszenie""" + if not current_user.is_admin: + flash('Brak uprawnień do tej strony.', 'error') + return redirect(url_for('dashboard')) + + from database import Announcement + + if request.method == 'POST': + db = SessionLocal() + try: + title = request.form.get('title', '').strip() + excerpt = request.form.get('excerpt', '').strip() + content = request.form.get('content', '').strip() + category = request.form.get('category', 'general') + image_url = request.form.get('image_url', '').strip() or None + external_link = request.form.get('external_link', '').strip() or None + is_featured = 'is_featured' in request.form + is_pinned = 'is_pinned' in request.form + + # Handle expires_at + expires_at_str = request.form.get('expires_at', '').strip() + expires_at = None + if expires_at_str: + try: + expires_at = datetime.strptime(expires_at_str, '%Y-%m-%dT%H:%M') + except ValueError: + pass + + # Generate unique slug + base_slug = generate_slug(title) + slug = base_slug + counter = 1 + while db.query(Announcement).filter(Announcement.slug == slug).first(): + slug = f"{base_slug}-{counter}" + counter += 1 + + # Determine status based on button clicked + action = request.form.get('action', 'draft') + status = 'published' if action == 'publish' else 'draft' + published_at = datetime.now() if status == 'published' else None + + announcement = Announcement( + title=title, + slug=slug, + excerpt=excerpt or None, + content=content, + category=category, + image_url=image_url, + external_link=external_link, + status=status, + published_at=published_at, + expires_at=expires_at, + is_featured=is_featured, + is_pinned=is_pinned, + created_by=current_user.id + ) + + db.add(announcement) + db.commit() + + flash(f'Ogłoszenie zostało {"opublikowane" if status == "published" else "zapisane jako szkic"}.', 'success') + return redirect(url_for('admin_announcements')) + + except Exception as e: + db.rollback() + logger.error(f"Error creating announcement: {e}") + flash(f'Błąd podczas tworzenia ogłoszenia: {e}', 'error') + finally: + db.close() + + # GET request - show form + from database import Announcement + return render_template('admin/announcements_form.html', + announcement=None, + categories=Announcement.CATEGORIES, + category_labels=Announcement.CATEGORY_LABELS) + + +@app.route('/admin/announcements//edit', methods=['GET', 'POST']) +@login_required +def admin_announcements_edit(id): + """Admin panel - edycja ogłoszenia""" + if not current_user.is_admin: + flash('Brak uprawnień do tej strony.', 'error') + return redirect(url_for('dashboard')) + + from database import Announcement + + db = SessionLocal() + try: + announcement = db.query(Announcement).filter(Announcement.id == id).first() + if not announcement: + flash('Nie znaleziono ogłoszenia.', 'error') + return redirect(url_for('admin_announcements')) + + if request.method == 'POST': + announcement.title = request.form.get('title', '').strip() + announcement.excerpt = request.form.get('excerpt', '').strip() or None + announcement.content = request.form.get('content', '').strip() + announcement.category = request.form.get('category', 'general') + announcement.image_url = request.form.get('image_url', '').strip() or None + announcement.external_link = request.form.get('external_link', '').strip() or None + announcement.is_featured = 'is_featured' in request.form + announcement.is_pinned = 'is_pinned' in request.form + + # Handle expires_at + expires_at_str = request.form.get('expires_at', '').strip() + if expires_at_str: + try: + announcement.expires_at = datetime.strptime(expires_at_str, '%Y-%m-%dT%H:%M') + except ValueError: + pass + else: + announcement.expires_at = None + + # Regenerate slug if title changed significantly + new_slug = generate_slug(announcement.title) + if new_slug != announcement.slug.split('-')[0]: # Check if base changed + base_slug = new_slug + slug = base_slug + counter = 1 + while db.query(Announcement).filter( + Announcement.slug == slug, + Announcement.id != id + ).first(): + slug = f"{base_slug}-{counter}" + counter += 1 + announcement.slug = slug + + # Handle status change + action = request.form.get('action', 'save') + if action == 'publish' and announcement.status != 'published': + announcement.status = 'published' + announcement.published_at = datetime.now() + elif action == 'archive': + announcement.status = 'archived' + elif action == 'draft': + announcement.status = 'draft' + + announcement.updated_at = datetime.now() + db.commit() + + flash('Zmiany zostały zapisane.', 'success') + return redirect(url_for('admin_announcements')) + + # GET request - show form + return render_template('admin/announcements_form.html', + announcement=announcement, + categories=Announcement.CATEGORIES, + category_labels=Announcement.CATEGORY_LABELS) + + except Exception as e: + db.rollback() + logger.error(f"Error editing announcement {id}: {e}") + flash(f'Błąd: {e}', 'error') + return redirect(url_for('admin_announcements')) + + finally: + db.close() + + +@app.route('/admin/announcements//publish', methods=['POST']) +@login_required +def admin_announcements_publish(id): + """Publikacja ogłoszenia""" + if not current_user.is_admin: + return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 + + from database import Announcement + + db = SessionLocal() + try: + announcement = db.query(Announcement).filter(Announcement.id == id).first() + if not announcement: + return jsonify({'success': False, 'error': 'Nie znaleziono ogłoszenia'}), 404 + + announcement.status = 'published' + if not announcement.published_at: + announcement.published_at = datetime.now() + announcement.updated_at = datetime.now() + db.commit() + + return jsonify({'success': True, 'message': 'Ogłoszenie zostało opublikowane'}) + + except Exception as e: + db.rollback() + logger.error(f"Error publishing announcement {id}: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + finally: + db.close() + + +@app.route('/admin/announcements//archive', methods=['POST']) +@login_required +def admin_announcements_archive(id): + """Archiwizacja ogłoszenia""" + if not current_user.is_admin: + return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 + + from database import Announcement + + db = SessionLocal() + try: + announcement = db.query(Announcement).filter(Announcement.id == id).first() + if not announcement: + return jsonify({'success': False, 'error': 'Nie znaleziono ogłoszenia'}), 404 + + announcement.status = 'archived' + announcement.updated_at = datetime.now() + db.commit() + + return jsonify({'success': True, 'message': 'Ogłoszenie zostało zarchiwizowane'}) + + except Exception as e: + db.rollback() + logger.error(f"Error archiving announcement {id}: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + finally: + db.close() + + +@app.route('/admin/announcements//delete', methods=['POST']) +@login_required +def admin_announcements_delete(id): + """Usunięcie ogłoszenia""" + if not current_user.is_admin: + return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 + + from database import Announcement + + db = SessionLocal() + try: + announcement = db.query(Announcement).filter(Announcement.id == id).first() + if not announcement: + return jsonify({'success': False, 'error': 'Nie znaleziono ogłoszenia'}), 404 + + db.delete(announcement) + db.commit() + + return jsonify({'success': True, 'message': 'Ogłoszenie zostało usunięte'}) + + except Exception as e: + db.rollback() + logger.error(f"Error deleting announcement {id}: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + finally: + db.close() + + +# ============================================================ +# PUBLIC ANNOUNCEMENTS PAGE +# ============================================================ + +@app.route('/ogloszenia') +@login_required +@limiter.limit("60 per minute") +def announcements_list(): + """Strona z listą ogłoszeń dla zalogowanych członków""" + from database import Announcement + from sqlalchemy import or_, desc + + db = SessionLocal() + try: + page = request.args.get('page', 1, type=int) + category = request.args.get('category', '') + per_page = 12 + + # Base query: published and not expired + query = db.query(Announcement).filter( + Announcement.status == 'published', + or_( + Announcement.expires_at.is_(None), + Announcement.expires_at > datetime.now() + ) + ) + + # Filter by category + if category and category in Announcement.CATEGORIES: + query = query.filter(Announcement.category == category) + + # Sort: pinned first, then by published_at desc + query = query.order_by( + desc(Announcement.is_pinned), + desc(Announcement.published_at) + ) + + # Pagination + total = query.count() + total_pages = (total + per_page - 1) // per_page + announcements = query.offset((page - 1) * per_page).limit(per_page).all() + + return render_template('announcements/list.html', + announcements=announcements, + current_category=category, + categories=Announcement.CATEGORIES, + category_labels=Announcement.CATEGORY_LABELS, + page=page, + total_pages=total_pages, + total=total) + + finally: + db.close() + + +@app.route('/ogloszenia/') +@login_required +@limiter.limit("60 per minute") +def announcement_detail(slug): + """Szczegóły ogłoszenia dla zalogowanych członków""" + from database import Announcement + from sqlalchemy import or_, desc + + db = SessionLocal() + try: + announcement = db.query(Announcement).filter( + Announcement.slug == slug, + Announcement.status == 'published', + or_( + Announcement.expires_at.is_(None), + Announcement.expires_at > datetime.now() + ) + ).first() + + if not announcement: + flash('Nie znaleziono ogłoszenia lub zostało usunięte.', 'error') + return redirect(url_for('announcements_list')) + + # Increment views counter + announcement.views_count = (announcement.views_count or 0) + 1 + db.commit() + + # Get other recent announcements for sidebar + other_announcements = db.query(Announcement).filter( + Announcement.status == 'published', + Announcement.id != announcement.id, + or_( + Announcement.expires_at.is_(None), + Announcement.expires_at > datetime.now() + ) + ).order_by(desc(Announcement.published_at)).limit(5).all() + + return render_template('announcements/detail.html', + announcement=announcement, + other_announcements=other_announcements, + category_labels=Announcement.CATEGORY_LABELS) + + finally: + db.close() + + # ============================================================ # HONEYPOT ENDPOINTS (trap for malicious bots) # ============================================================ diff --git a/database.py b/database.py index 4696d1a..bd87e82 100644 --- a/database.py +++ b/database.py @@ -17,6 +17,7 @@ Models: - ITAudit: IT infrastructure audit results - ITCollaborationMatch: IT collaboration matches between companies - AIUsageLog, AIUsageDaily, AIRateLimit: AI API usage monitoring +- Announcement: Ogłoszenia i aktualności dla członków Author: Norda Biznes Development Team Created: 2025-11-23 @@ -3000,6 +3001,91 @@ class SecurityAlert(Base): return f"" +# ============================================================ +# ANNOUNCEMENTS (Ogłoszenia dla członków) +# ============================================================ + +class Announcement(Base): + """ + Ogłoszenia i aktualności dla członków Norda Biznes. + Obsługuje różne kategorie: ogólne, wydarzenia, okazje biznesowe, od członków. + """ + __tablename__ = 'announcements' + + id = Column(Integer, primary_key=True) + title = Column(String(300), nullable=False) + slug = Column(String(300), unique=True, index=True) + excerpt = Column(String(500)) # Krótki opis do listy + content = Column(Text, nullable=False) # Pełna treść (HTML) + + # Kategoryzacja + category = Column(String(50), default='general') + # Wartości: general, event, opportunity, member_news, partnership + + # Media + image_url = Column(String(1000)) + external_link = Column(String(1000)) # Link do zewnętrznego źródła + + # Publikacja + status = Column(String(20), default='draft', index=True) + # Wartości: draft, published, archived + published_at = Column(DateTime) + expires_at = Column(DateTime) # Opcjonalne wygaśnięcie + + # Wyróżnienie + is_featured = Column(Boolean, default=False) + is_pinned = Column(Boolean, default=False) + + # Statystyki + views_count = Column(Integer, default=0) + + # Audyt + created_by = Column(Integer, ForeignKey('users.id')) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # Relationships + author = relationship('User', foreign_keys=[created_by]) + + # Constants + CATEGORIES = ['general', 'event', 'opportunity', 'member_news', 'partnership'] + CATEGORY_LABELS = { + 'general': 'Ogólne', + 'event': 'Wydarzenie', + 'opportunity': 'Okazja biznesowa', + 'member_news': 'Od członka', + 'partnership': 'Partnerstwo' + } + STATUSES = ['draft', 'published', 'archived'] + STATUS_LABELS = { + 'draft': 'Szkic', + 'published': 'Opublikowane', + 'archived': 'Zarchiwizowane' + } + + @property + def category_label(self): + """Zwraca polską etykietę kategorii""" + return self.CATEGORY_LABELS.get(self.category, self.category) + + @property + def status_label(self): + """Zwraca polską etykietę statusu""" + return self.STATUS_LABELS.get(self.status, self.status) + + @property + def is_active(self): + """Sprawdza czy ogłoszenie jest aktywne (opublikowane i nie wygasło)""" + if self.status != 'published': + return False + if self.expires_at and self.expires_at < datetime.now(): + return False + return True + + def __repr__(self): + return f"" + + # ============================================================ # ZOPK MILESTONES (Timeline) # ============================================================ diff --git a/database/migrations/018_announcements.sql b/database/migrations/018_announcements.sql new file mode 100644 index 0000000..829b13a --- /dev/null +++ b/database/migrations/018_announcements.sql @@ -0,0 +1,177 @@ +-- ============================================================ +-- Migration: 018_announcements.sql +-- Description: Rozszerzenie tabeli ogłoszeń o nowe pola +-- Author: Claude +-- Date: 2026-01-26 +-- Note: Tabela announcements już istnieje - rozszerzamy ją +-- ============================================================ + +-- Dodanie nowych kolumn (jeśli nie istnieją) +-- slug: URL-friendly identyfikator +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'slug') THEN + ALTER TABLE announcements ADD COLUMN slug VARCHAR(300); + END IF; +END $$; + +-- excerpt: Krótki opis do listy +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'excerpt') THEN + ALTER TABLE announcements ADD COLUMN excerpt VARCHAR(500); + END IF; +END $$; + +-- category: Nowa kategoryzacja (general, event, opportunity, member_news, partnership) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'category') THEN + ALTER TABLE announcements ADD COLUMN category VARCHAR(50) DEFAULT 'general'; + END IF; +END $$; + +-- image_url: Obrazek ogłoszenia +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'image_url') THEN + ALTER TABLE announcements ADD COLUMN image_url VARCHAR(1000); + END IF; +END $$; + +-- external_link: Link do zewnętrznego źródła +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'external_link') THEN + ALTER TABLE announcements ADD COLUMN external_link VARCHAR(1000); + END IF; +END $$; + +-- status: draft, published, archived (zastępuje is_published) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'status') THEN + ALTER TABLE announcements ADD COLUMN status VARCHAR(20) DEFAULT 'draft'; + END IF; +END $$; + +-- published_at: Data publikacji +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'published_at') THEN + ALTER TABLE announcements ADD COLUMN published_at TIMESTAMP; + END IF; +END $$; + +-- expires_at: Data wygaśnięcia (zastępuje expire_date) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'expires_at') THEN + ALTER TABLE announcements ADD COLUMN expires_at TIMESTAMP; + END IF; +END $$; + +-- is_featured: Wyróżnienie +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'is_featured') THEN + ALTER TABLE announcements ADD COLUMN is_featured BOOLEAN DEFAULT FALSE; + END IF; +END $$; + +-- views_count: Licznik wyświetleń +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'views_count') THEN + ALTER TABLE announcements ADD COLUMN views_count INTEGER DEFAULT 0; + END IF; +END $$; + +-- created_by: ID użytkownika (mapowanie z author_id) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'announcements' AND column_name = 'created_by') THEN + ALTER TABLE announcements ADD COLUMN created_by INTEGER REFERENCES users(id) ON DELETE SET NULL; + END IF; +END $$; + +-- Migracja danych ze starych kolumn do nowych +-- Mapowanie announcement_type -> category +UPDATE announcements +SET category = CASE + WHEN announcement_type = 'fees' THEN 'general' + WHEN announcement_type = 'important' THEN 'general' + WHEN announcement_type = 'urgent' THEN 'general' + WHEN announcement_type = 'event' THEN 'event' + ELSE COALESCE(announcement_type, 'general') +END +WHERE category IS NULL OR category = ''; + +-- Mapowanie is_published -> status +UPDATE announcements +SET status = CASE + WHEN is_published = true THEN 'published' + ELSE 'draft' +END +WHERE status IS NULL OR status = ''; + +-- Mapowanie expire_date -> expires_at +UPDATE announcements +SET expires_at = expire_date +WHERE expires_at IS NULL AND expire_date IS NOT NULL; + +-- Mapowanie publish_date -> published_at +UPDATE announcements +SET published_at = COALESCE(publish_date, created_at) +WHERE published_at IS NULL AND is_published = true; + +-- Mapowanie author_id -> created_by +UPDATE announcements +SET created_by = author_id +WHERE created_by IS NULL AND author_id IS NOT NULL; + +-- Generowanie slugów dla istniejących ogłoszeń +UPDATE announcements +SET slug = CONCAT( + LOWER(REGEXP_REPLACE( + REGEXP_REPLACE(title, '[^a-zA-Z0-9\s]', '', 'g'), + '\s+', '-', 'g' + )), + '-', id +) +WHERE slug IS NULL OR slug = ''; + +-- Tworzenie indeksów (jeśli nie istnieją) +CREATE INDEX IF NOT EXISTS idx_announcements_status ON announcements(status); +CREATE INDEX IF NOT EXISTS idx_announcements_slug ON announcements(slug); +CREATE INDEX IF NOT EXISTS idx_announcements_published_at ON announcements(published_at DESC); +CREATE INDEX IF NOT EXISTS idx_announcements_category ON announcements(category); + +-- Tworzenie unikalności na slug +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_announcements_slug_unique') THEN + CREATE UNIQUE INDEX idx_announcements_slug_unique ON announcements(slug) WHERE slug IS NOT NULL; + END IF; +END $$; + +-- Uprawnienia +GRANT ALL ON TABLE announcements TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE announcements_id_seq TO nordabiz_app; + +-- Komentarze +COMMENT ON COLUMN announcements.slug IS 'URL-friendly slug (np. baza-noclegowa-arp-choczewo)'; +COMMENT ON COLUMN announcements.excerpt IS 'Krótki opis do listy (max 500 znaków)'; +COMMENT ON COLUMN announcements.category IS 'Kategoria: general, event, opportunity, member_news, partnership'; +COMMENT ON COLUMN announcements.status IS 'Status: draft, published, archived'; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8c02f12..0fa300e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -114,6 +114,255 @@ Główne instrukcje znajdują się w [CLAUDE.md](../CLAUDE.md). --- +## Priorytet 6: System powiadomień + +**Status:** Planowane +**Cel:** Powiadamianie użytkowników o istotnych zdarzeniach bez konieczności logowania +**Źródło:** Feedback od członków Norda Biznes (Janusz Masiak, Angelika Piechocka) - 2026-01-22 + +### Kanały powiadomień + +| Kanał | Zastosowanie | Technologia | Koszt | +|-------|--------------|-------------|-------| +| **Email** | Standardowe powiadomienia | SendGrid / Mailgun / SMTP | ~$15/mies (10k maili) | +| **SMS** | Pilne/ważne powiadomienia | SMSAPI.pl | ~0.07 zł/SMS | +| **Push** | Natychmiastowe (PWA) | Web Push API | Darmowe | + +### Typy powiadomień + +| Typ | Email | SMS | Push | Domyślnie | +|-----|:-----:|:---:|:----:|:---------:| +| Nowe wydarzenia Norda Biznes | ✅ | ⚪ | ✅ | Email+Push | +| Nowe aktualności i newsy | ✅ | ❌ | ✅ | Email | +| Wiadomości od innych firm | ✅ | ⚪ | ✅ | Email+Push | +| Przypomnienia o spotkaniach | ✅ | ✅ | ✅ | Wszystkie | +| Newsletter tygodniowy | ✅ | ❌ | ❌ | Wyłączone | + +**Legenda:** ✅ dostępne domyślnie, ⚪ dostępne opcjonalnie, ❌ niedostępne + +### Panel preferencji powiadomień + +Użytkownik w profilu będzie mógł: +- Włączyć/wyłączyć poszczególne typy powiadomień +- Wybrać preferowane kanały (email, SMS, push) +- Ustawić "ciche godziny" (np. brak SMS po 21:00) +- Zapisać numer telefonu do SMS + +### Wymagania techniczne + +```python +# Nowa tabela: notification_preferences +class NotificationPreference(Base): + __tablename__ = 'notification_preferences' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id'), unique=True) + + # Kanały + email_enabled = Column(Boolean, default=True) + sms_enabled = Column(Boolean, default=False) + push_enabled = Column(Boolean, default=True) + phone_number = Column(String(15)) # Format: +48XXXXXXXXX + + # Typy powiadomień + events_email = Column(Boolean, default=True) + events_sms = Column(Boolean, default=False) + news_email = Column(Boolean, default=True) + messages_email = Column(Boolean, default=True) + messages_push = Column(Boolean, default=True) + reminders_sms = Column(Boolean, default=True) + newsletter = Column(Boolean, default=False) + + # Ciche godziny + quiet_hours_start = Column(Time) # np. 21:00 + quiet_hours_end = Column(Time) # np. 08:00 + +# Nowa tabela: notification_log (audit) +class NotificationLog(Base): + __tablename__ = 'notification_log' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id')) + channel = Column(String(10)) # email, sms, push + type = Column(String(30)) # event, news, message, reminder + subject = Column(String(200)) + sent_at = Column(DateTime, default=datetime.utcnow) + status = Column(String(20)) # sent, failed, bounced +``` + +### Integracje + +- **Email:** Flask-Mail + SendGrid API (fallback: SMTP) +- **SMS:** SMSAPI.pl (polska firma, dobra dokumentacja) +- **Push:** Web Push API + pywebpush (wymaga PWA - Priorytet 7) + +--- + +## Priorytet 7: PWA (Progressive Web App) + +**Status:** Planowane +**Cel:** Umożliwienie "instalacji" NordaBiznes na telefonie jako aplikacji +**Źródło:** Feedback od Angeliki Piechockiej - 2026-01-22 + +### Korzyści PWA vs natywna aplikacja + +| Aspekt | PWA | Natywna aplikacja | +|--------|-----|-------------------| +| Koszt rozwoju | Niski | Wysoki (iOS + Android) | +| Czas wdrożenia | 2-4 tygodnie | 3-6 miesięcy | +| Aktualizacje | Natychmiastowe | Wymaga publikacji w Store | +| Instalacja | "Dodaj do ekranu" | App Store / Google Play | +| Push notifications | ✅ (Android, iOS 16.4+) | ✅ | +| Dostęp offline | Ograniczony | Pełny | +| Kamera/GPS | ✅ | ✅ | + +### Wymagania techniczne + +1. **manifest.json** - metadane aplikacji (nazwa, ikony, kolory) +2. **Service Worker** - cache, offline, push notifications +3. **HTTPS** - już mamy (Let's Encrypt) +4. **Ikony** - różne rozmiary (192x192, 512x512) + +### Przykład manifest.json + +```json +{ + "name": "NordaBiznes - Katalog Firm", + "short_name": "NordaBiznes", + "description": "Platforma networkingowa członków Norda Biznes", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#6c5ce7", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} +``` + +### Fazy wdrożenia + +1. **Faza 1:** Podstawowy PWA (manifest + service worker cache) +2. **Faza 2:** Push notifications (integracja z Priorytetem 6) +3. **Faza 3:** Offline mode (cache kluczowych stron) + +### Zależności + +- Push notifications w PWA wymagają Service Worker +- Priorytet 6 (System powiadomień) i Priorytet 7 (PWA) są powiązane +- Rekomendacja: wdrażać równolegle + +--- + +## Priorytet 8: System ogłoszeń i aktualności + +**Status:** Planowane +**Cel:** Umożliwienie komunikacji z członkami Norda Biznes - ogłoszenia, aktualności, ważne informacje +**Źródło:** Potrzeba rozesłania informacji o bazie noclegowej ARP dla elektrowni jądrowej - 2026-01-26 + +### Problem + +Obecnie brak możliwości: +- Publikowania ogłoszeń dla członków na stronie www +- Wysyłania masowych komunikatów do wszystkich członków +- Targetowania komunikatów (np. tylko branża noclegowa) + +### Funkcjonalności + +#### Panel admina (`/admin/announcements`) +- Tworzenie nowych ogłoszeń (tytuł, treść, obrazek, link) +- Ustawianie daty publikacji i wygaśnięcia +- Wybór grupy docelowej (wszyscy / wybrane kategorie firm) +- Podgląd przed publikacją +- Historia wysłanych ogłoszeń + +#### Strona publiczna (`/aktualnosci`) +- Lista aktualnych ogłoszeń +- Archiwum starszych ogłoszeń +- Filtrowanie po kategorii +- RSS feed (opcjonalnie) + +#### Integracja z powiadomieniami (Priorytet 6) +- Automatyczne wysyłanie email do członków przy nowym ogłoszeniu +- Push notification (PWA) +- Opcja "wyślij teraz" vs "tylko opublikuj na stronie" + +### Wymagania techniczne + +```python +# Nowa tabela: announcements +class Announcement(Base): + __tablename__ = 'announcements' + + id = Column(Integer, primary_key=True) + title = Column(String(200), nullable=False) + slug = Column(String(200), unique=True) + content = Column(Text, nullable=False) # Markdown lub HTML + excerpt = Column(String(500)) # Krótki opis do listy + image_url = Column(String(500)) + external_link = Column(String(500)) # Link do zewnętrznego źródła + + # Publikacja + status = Column(String(20), default='draft') # draft, published, archived + published_at = Column(DateTime) + expires_at = Column(DateTime) # Opcjonalna data wygaśnięcia + + # Targetowanie + target_audience = Column(String(50), default='all') # all, category:IT, category:Services + + # Powiadomienia + send_email = Column(Boolean, default=True) + send_push = Column(Boolean, default=True) + notification_sent_at = Column(DateTime) + + # Meta + created_by = Column(Integer, ForeignKey('users.id')) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, onupdate=datetime.utcnow) + view_count = Column(Integer, default=0) + +# Tabela śledzenia kto widział ogłoszenie +class AnnouncementView(Base): + __tablename__ = 'announcement_views' + + id = Column(Integer, primary_key=True) + announcement_id = Column(Integer, ForeignKey('announcements.id')) + user_id = Column(Integer, ForeignKey('users.id')) + viewed_at = Column(DateTime, default=datetime.utcnow) +``` + +### Przykłady użycia + +| Scenariusz | Target | Email | Push | +|------------|--------|:-----:|:----:| +| Baza noclegowa ARP (elektrownia) | Wszystkie firmy | ✅ | ✅ | +| Szkolenie IT | category:IT | ✅ | ❌ | +| Spotkanie networkingowe | Wszystkie firmy | ✅ | ✅ | +| Aktualizacja regulaminu | Wszystkie firmy | ✅ | ❌ | + +### Zależności + +- **Priorytet 6 (Powiadomienia)** - do wysyłania email/push +- **Priorytet 7 (PWA)** - do push notifications +- Może działać samodzielnie (tylko strona www) bez powiadomień + +### Fazy wdrożenia + +1. **Faza 1:** Strona `/aktualnosci` + panel admina (bez powiadomień) +2. **Faza 2:** Integracja z email (gdy Priorytet 6 gotowy) +3. **Faza 3:** Push notifications (gdy Priorytet 7 gotowy) + +--- + ## Notatki implementacyjne - Scraper powinien deduplikować wydarzenia (hash tytułu + daty) @@ -170,6 +419,10 @@ Najwyższy poziom służy jako **kotwica** - sprawia że środkowy wydaje się a | Forum | ❌ | ✅ | ✅ | | Kalendarz wydarzeń | ❌ | ✅ | ✅ | | Chat AI (NordaGPT) | ❌ | ✅ | ✅ | +| **Powiadomienia email** | ✅ | ✅ | ✅ | +| **Powiadomienia SMS** | ❌ | ✅ | ✅ | +| **Powiadomienia push (PWA)** | ❌ | ✅ | ✅ | +| **Aktualności i ogłoszenia** | ✅ | ✅ | ✅ | | **Raporty podstawowe** | ❌ | ✅ | ✅ | | **Raporty zaawansowane** | ❌ | ❌ | ✅ | | Eksport danych (CSV/PDF) | ❌ | ❌ | ✅ | diff --git a/static/announcements/tytani-harmonogram.pdf b/static/announcements/tytani-harmonogram.pdf new file mode 100644 index 0000000..2affcde Binary files /dev/null and b/static/announcements/tytani-harmonogram.pdf differ diff --git a/static/announcements/tytani-regulamin.pdf b/static/announcements/tytani-regulamin.pdf new file mode 100644 index 0000000..5b9204b Binary files /dev/null and b/static/announcements/tytani-regulamin.pdf differ diff --git a/templates/admin/announcements.html b/templates/admin/announcements.html index 2c1f6cd..eeaff46 100755 --- a/templates/admin/announcements.html +++ b/templates/admin/announcements.html @@ -9,6 +9,8 @@ display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; + gap: var(--spacing-md); } .admin-header h1 { @@ -16,6 +18,34 @@ color: var(--text-primary); } + .filters-row { + display: flex; + gap: var(--spacing-md); + margin-bottom: var(--spacing-lg); + flex-wrap: wrap; + align-items: center; + } + + .filter-group { + display: flex; + align-items: center; + gap: var(--spacing-xs); + } + + .filter-group label { + font-weight: 500; + color: var(--text-secondary); + font-size: var(--font-size-sm); + } + + .filter-group select { + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + font-size: var(--font-size-sm); + } + .section { background: var(--surface); padding: var(--spacing-xl); @@ -57,9 +87,10 @@ .status-published { background: var(--success-bg); color: var(--success); } .status-draft { background: var(--warning-bg); color: var(--warning); } - .status-expired { background: var(--surface-secondary); color: var(--text-secondary); } + .status-archived { background: var(--surface-secondary); color: var(--text-secondary); } + .status-expired { background: var(--error-bg); color: var(--error); } - .type-badge { + .category-badge { display: inline-block; padding: var(--spacing-xs) var(--spacing-sm); border-radius: var(--radius-sm); @@ -69,15 +100,21 @@ color: var(--primary); } - .type-fees { background: var(--warning-bg); color: var(--warning); } - .type-important { background: var(--error-bg); color: var(--error); } - .type-urgent { background: var(--error); color: white; } + .category-event { background: #e0f2fe; color: #0369a1; } + .category-opportunity { background: #dcfce7; color: #15803d; } + .category-member_news { background: #fef3c7; color: #b45309; } + .category-partnership { background: #f3e8ff; color: #7c3aed; } .pinned-icon { color: var(--warning); margin-left: var(--spacing-xs); } + .featured-icon { + color: var(--primary); + margin-left: var(--spacing-xs); + } + .btn-small { padding: var(--spacing-xs) var(--spacing-sm); font-size: var(--font-size-xs); @@ -85,6 +122,8 @@ .actions-cell { white-space: nowrap; + display: flex; + gap: var(--spacing-xs); } .empty-state { @@ -92,6 +131,64 @@ padding: var(--spacing-2xl); color: var(--text-secondary); } + + .stats-row { + display: flex; + gap: var(--spacing-lg); + margin-bottom: var(--spacing-lg); + flex-wrap: wrap; + } + + .stat-card { + background: var(--surface); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + min-width: 120px; + } + + .stat-value { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--primary); + } + + .stat-label { + font-size: var(--font-size-sm); + color: var(--text-secondary); + } + + .title-cell { + max-width: 300px; + } + + .title-cell a { + color: var(--text-primary); + text-decoration: none; + } + + .title-cell a:hover { + color: var(--primary); + } + + .views-count { + color: var(--text-secondary); + font-size: var(--font-size-sm); + } + + @media (max-width: 768px) { + .announcements-table { + font-size: var(--font-size-sm); + } + .announcements-table th:nth-child(4), + .announcements-table td:nth-child(4), + .announcements-table th:nth-child(5), + .announcements-table td:nth-child(5) { + display: none; + } + } {% endblock %} @@ -104,41 +201,67 @@ + +
+
+ + +
+
+ + +
+
+
{% if announcements %} - + + {% for ann in announcements %} - +
TytulTypKategoria Status Autor UtworzonoWyswietlenia Akcje
- {{ ann.title }} - {% if ann.is_pinned %}📌{% endif %} + + + {{ ann.title }} + + {% if ann.is_pinned %}📌{% endif %} + {% if ann.is_featured %}{% endif %} - - {% if ann.announcement_type == 'general' %}Ogolne - {% elif ann.announcement_type == 'fees' %}Skladki - {% elif ann.announcement_type == 'event' %}Wydarzenie - {% elif ann.announcement_type == 'important' %}Wazne - {% elif ann.announcement_type == 'urgent' %}Pilne - {% else %}{{ ann.announcement_type }} - {% endif %} + + {{ category_labels.get(ann.category, ann.category) }} - {% if not ann.is_published %} - Wersja robocza - {% elif ann.expire_date and ann.expire_date < now %} + {% if ann.status == 'draft' %} + Szkic + {% elif ann.status == 'archived' %} + Zarchiwizowane + {% elif ann.expires_at and ann.expires_at < now %} Wygaslo {% else %} Opublikowane @@ -146,10 +269,20 @@ {{ ann.author.name if ann.author else '-' }} {{ ann.created_at.strftime('%Y-%m-%d %H:%M') if ann.created_at else '-' }}{{ ann.views_count or 0 }} Edytuj + {% if ann.status == 'draft' %} + + {% elif ann.status == 'published' %} + + {% endif %} @@ -160,7 +293,7 @@
{% else %}
-

Brak ogloszen. Utworz pierwsze ogloszenie klikajac przycisk powyzej.

+

Brak ogloszen{% if status_filter != 'all' or category_filter != 'all' %} pasujacych do filtrow{% endif %}. Utworz pierwsze ogloszenie klikajac przycisk powyzej.

{% endif %}
@@ -170,7 +303,7 @@