diff --git a/app.py b/app.py index 11d0496..76056b7 100644 --- a/app.py +++ b/app.py @@ -97,6 +97,7 @@ from database import ( AIAPICostLog, ForumTopic, ForumReply, + ForumAttachment, NordaEvent, EventAttendee, PrivateMessage, @@ -112,6 +113,7 @@ import gemini_service from nordabiz_chat import NordaBizChatEngine from search_service import search_companies import krs_api_service +from file_upload_service import FileUploadService # News service for fetching company news try: @@ -812,14 +814,25 @@ def events(): @app.route('/forum') @login_required def forum_index(): - """Forum - list of topics""" + """Forum - list of topics with category/status filters""" page = request.args.get('page', 1, type=int) per_page = 20 + category_filter = request.args.get('category', '') + status_filter = request.args.get('status', '') db = SessionLocal() try: - # Get topics ordered by pinned first, then by last activity - query = db.query(ForumTopic).order_by( + # Build query with optional filters + query = db.query(ForumTopic) + + if category_filter and category_filter in ForumTopic.CATEGORIES: + query = query.filter(ForumTopic.category == category_filter) + + if status_filter and status_filter in ForumTopic.STATUSES: + query = query.filter(ForumTopic.status == status_filter) + + # Order by pinned first, then by last activity + query = query.order_by( ForumTopic.is_pinned.desc(), ForumTopic.updated_at.desc() ) @@ -833,7 +846,13 @@ def forum_index(): page=page, per_page=per_page, total_topics=total_topics, - total_pages=(total_topics + per_page - 1) // per_page + total_pages=(total_topics + per_page - 1) // per_page, + category_filter=category_filter, + status_filter=status_filter, + categories=ForumTopic.CATEGORIES, + statuses=ForumTopic.STATUSES, + category_labels=ForumTopic.CATEGORY_LABELS, + status_labels=ForumTopic.STATUS_LABELS ) finally: db.close() @@ -842,36 +861,70 @@ def forum_index(): @app.route('/forum/nowy', methods=['GET', 'POST']) @login_required def forum_new_topic(): - """Create new forum topic""" + """Create new forum topic with category and attachments""" if request.method == 'POST': title = sanitize_input(request.form.get('title', ''), 255) content = request.form.get('content', '').strip() + category = request.form.get('category', 'question') + + # Validate category + if category not in ForumTopic.CATEGORIES: + category = 'question' if not title or len(title) < 5: flash('Tytuł musi mieć co najmniej 5 znaków.', 'error') - return render_template('forum/new_topic.html') + return render_template('forum/new_topic.html', + categories=ForumTopic.CATEGORIES, + category_labels=ForumTopic.CATEGORY_LABELS) if not content or len(content) < 10: flash('Treść musi mieć co najmniej 10 znaków.', 'error') - return render_template('forum/new_topic.html') + return render_template('forum/new_topic.html', + categories=ForumTopic.CATEGORIES, + category_labels=ForumTopic.CATEGORY_LABELS) db = SessionLocal() try: topic = ForumTopic( title=title, content=content, - author_id=current_user.id + author_id=current_user.id, + category=category ) db.add(topic) db.commit() db.refresh(topic) + # Handle file upload + if 'attachment' in request.files: + file = request.files['attachment'] + if file and file.filename: + is_valid, error_msg = FileUploadService.validate_file(file) + if is_valid: + stored_filename, rel_path, file_size, mime_type = FileUploadService.save_file(file, 'topic') + attachment = ForumAttachment( + attachment_type='topic', + topic_id=topic.id, + original_filename=file.filename, + stored_filename=stored_filename, + file_extension=stored_filename.rsplit('.', 1)[-1], + file_size=file_size, + mime_type=mime_type, + uploaded_by=current_user.id + ) + db.add(attachment) + db.commit() + else: + flash(f'Załącznik: {error_msg}', 'warning') + flash('Temat został utworzony.', 'success') return redirect(url_for('forum_topic', topic_id=topic.id)) finally: db.close() - return render_template('forum/new_topic.html') + return render_template('forum/new_topic.html', + categories=ForumTopic.CATEGORIES, + category_labels=ForumTopic.CATEGORY_LABELS) @app.route('/forum/') @@ -890,7 +943,10 @@ def forum_topic(topic_id): topic.views_count += 1 db.commit() - return render_template('forum/topic.html', topic=topic) + return render_template('forum/topic.html', + topic=topic, + category_labels=ForumTopic.CATEGORY_LABELS, + status_labels=ForumTopic.STATUS_LABELS) finally: db.close() @@ -898,7 +954,7 @@ def forum_topic(topic_id): @app.route('/forum//odpowiedz', methods=['POST']) @login_required def forum_reply(topic_id): - """Add reply to forum topic""" + """Add reply to forum topic with optional attachment""" content = request.form.get('content', '').strip() if not content or len(content) < 3: @@ -923,6 +979,44 @@ def forum_reply(topic_id): content=content ) db.add(reply) + db.commit() + db.refresh(reply) + + # Handle multiple file uploads (max 10) + MAX_ATTACHMENTS = 10 + files = request.files.getlist('attachments[]') + if not files: + # Fallback for single file upload (backward compatibility) + files = request.files.getlist('attachment') + + uploaded_count = 0 + errors = [] + + for file in files[:MAX_ATTACHMENTS]: + if file and file.filename: + is_valid, error_msg = FileUploadService.validate_file(file) + if is_valid: + stored_filename, rel_path, file_size, mime_type = FileUploadService.save_file(file, 'reply') + attachment = ForumAttachment( + attachment_type='reply', + reply_id=reply.id, + original_filename=file.filename, + stored_filename=stored_filename, + file_extension=stored_filename.rsplit('.', 1)[-1], + file_size=file_size, + mime_type=mime_type, + uploaded_by=current_user.id + ) + db.add(attachment) + uploaded_count += 1 + else: + errors.append(f'{file.filename}: {error_msg}') + + if uploaded_count > 0: + db.commit() + + if errors: + flash(f'Niektóre załączniki nie zostały dodane: {"; ".join(errors)}', 'warning') # Update topic updated_at topic.updated_at = datetime.now() @@ -964,6 +1058,15 @@ def admin_forum(): pinned_count = sum(1 for t in topics if t.is_pinned) locked_count = sum(1 for t in topics if t.is_locked) + # Category and status stats + category_counts = {} + status_counts = {} + for t in topics: + cat = t.category or 'question' + status = t.status or 'new' + category_counts[cat] = category_counts.get(cat, 0) + 1 + status_counts[status] = status_counts.get(status, 0) + 1 + return render_template( 'admin/forum.html', topics=topics, @@ -971,7 +1074,13 @@ def admin_forum(): total_topics=total_topics, total_replies=total_replies, pinned_count=pinned_count, - locked_count=locked_count + locked_count=locked_count, + category_counts=category_counts, + status_counts=status_counts, + categories=ForumTopic.CATEGORIES, + statuses=ForumTopic.STATUSES, + category_labels=ForumTopic.CATEGORY_LABELS, + status_labels=ForumTopic.STATUS_LABELS ) finally: db.close() @@ -1081,6 +1190,45 @@ def admin_forum_delete_reply(reply_id): db.close() +@app.route('/admin/forum/topic//status', methods=['POST']) +@login_required +def admin_forum_change_status(topic_id): + """Change topic status (admin only)""" + if not current_user.is_admin: + return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403 + + data = request.get_json() or {} + new_status = data.get('status') + note = data.get('note', '').strip() + + if not new_status or new_status not in ForumTopic.STATUSES: + return jsonify({'success': False, 'error': 'Nieprawidłowy status'}), 400 + + db = SessionLocal() + try: + topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first() + if not topic: + return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404 + + old_status = topic.status + topic.status = new_status + topic.status_changed_by = current_user.id + topic.status_changed_at = datetime.now() + if note: + topic.status_note = note + db.commit() + + logger.info(f"Admin {current_user.email} changed topic #{topic_id} status: {old_status} -> {new_status}") + return jsonify({ + 'success': True, + 'status': new_status, + 'status_label': ForumTopic.STATUS_LABELS.get(new_status, new_status), + 'message': f"Status zmieniony na: {ForumTopic.STATUS_LABELS.get(new_status, new_status)}" + }) + finally: + db.close() + + # ============================================================ # RECOMMENDATIONS ADMIN ROUTES # ============================================================ diff --git a/database.py b/database.py index da3c40d..9ca547d 100644 --- a/database.py +++ b/database.py @@ -153,7 +153,7 @@ class User(Base, UserMixin): # Relationships conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan') - forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan') + forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumTopic.author_id') forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan') def __repr__(self): @@ -791,7 +791,14 @@ class ForumTopic(Base): content = Column(Text, nullable=False) author_id = Column(Integer, ForeignKey('users.id'), nullable=False) - # Status + # Category and Status (for feedback tracking) + category = Column(String(50), default='question') # feature_request, bug, question, announcement + status = Column(String(50), default='new') # new, in_progress, resolved, rejected + status_changed_by = Column(Integer, ForeignKey('users.id')) + status_changed_at = Column(DateTime) + status_note = Column(Text) + + # Moderation flags is_pinned = Column(Boolean, default=False) is_locked = Column(Boolean, default=False) views_count = Column(Integer, default=0) @@ -800,9 +807,30 @@ class ForumTopic(Base): created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + # Constants for validation + CATEGORIES = ['feature_request', 'bug', 'question', 'announcement'] + STATUSES = ['new', 'in_progress', 'resolved', 'rejected'] + + CATEGORY_LABELS = { + 'feature_request': 'Propozycja funkcji', + 'bug': 'Błąd', + 'question': 'Pytanie', + 'announcement': 'Ogłoszenie' + } + + STATUS_LABELS = { + 'new': 'Nowy', + 'in_progress': 'W realizacji', + 'resolved': 'Rozwiązany', + 'rejected': 'Odrzucony' + } + # Relationships - author = relationship('User', back_populates='forum_topics') + author = relationship('User', foreign_keys=[author_id], back_populates='forum_topics') + status_changer = relationship('User', foreign_keys=[status_changed_by]) replies = relationship('ForumReply', back_populates='topic', cascade='all, delete-orphan', order_by='ForumReply.created_at') + attachments = relationship('ForumAttachment', back_populates='topic', cascade='all, delete-orphan', + primaryjoin="and_(ForumAttachment.topic_id==ForumTopic.id, ForumAttachment.attachment_type=='topic')") @property def reply_count(self): @@ -814,6 +842,14 @@ class ForumTopic(Base): return max(r.created_at for r in self.replies) return self.created_at + @property + def category_label(self): + return self.CATEGORY_LABELS.get(self.category, self.category) + + @property + def status_label(self): + return self.STATUS_LABELS.get(self.status, self.status) + class ForumReply(Base): """Forum replies to topics""" @@ -831,6 +867,64 @@ class ForumReply(Base): # Relationships topic = relationship('ForumTopic', back_populates='replies') author = relationship('User', back_populates='forum_replies') + attachments = relationship('ForumAttachment', back_populates='reply', cascade='all, delete-orphan', + primaryjoin="and_(ForumAttachment.reply_id==ForumReply.id, ForumAttachment.attachment_type=='reply')") + + +class ForumAttachment(Base): + """Forum file attachments for topics and replies""" + __tablename__ = 'forum_attachments' + + id = Column(Integer, primary_key=True) + + # Polymorphic relationship (topic or reply) + attachment_type = Column(String(20), nullable=False) # 'topic' or 'reply' + topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE')) + reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE')) + + # File metadata + original_filename = Column(String(255), nullable=False) + stored_filename = Column(String(255), nullable=False, unique=True) + file_extension = Column(String(10), nullable=False) + file_size = Column(Integer, nullable=False) # in bytes + mime_type = Column(String(100), nullable=False) + + # Uploader + uploaded_by = Column(Integer, ForeignKey('users.id'), nullable=False) + + # Timestamps + created_at = Column(DateTime, default=datetime.now) + + # Relationships + topic = relationship('ForumTopic', back_populates='attachments', foreign_keys=[topic_id]) + reply = relationship('ForumReply', back_populates='attachments', foreign_keys=[reply_id]) + uploader = relationship('User') + + # Allowed file types + ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} + MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB + + @property + def url(self): + """Get the URL to serve this file""" + date = self.created_at or datetime.now() + subdir = 'topics' if self.attachment_type == 'topic' else 'replies' + return f"/static/uploads/forum/{subdir}/{date.year}/{date.month:02d}/{self.stored_filename}" + + @property + def is_image(self): + """Check if this is an image file""" + return self.mime_type.startswith('image/') + + @property + def size_display(self): + """Human-readable file size""" + if self.file_size < 1024: + return f"{self.file_size} B" + elif self.file_size < 1024 * 1024: + return f"{self.file_size / 1024:.1f} KB" + else: + return f"{self.file_size / (1024 * 1024):.1f} MB" class AIAPICostLog(Base): diff --git a/database/forum_categories_attachments.sql b/database/forum_categories_attachments.sql new file mode 100644 index 0000000..3a16955 --- /dev/null +++ b/database/forum_categories_attachments.sql @@ -0,0 +1,90 @@ +-- Migration: Forum Categories, Statuses, and Attachments +-- Date: 2026-01-10 +-- Description: Extends forum with categories, status tracking, and file attachments + +-- ============================================ +-- PHASE 1: Categories and Statuses for Topics +-- ============================================ + +-- Add category column (default: question) +ALTER TABLE forum_topics +ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'question'; + +-- Add status column (default: new) +ALTER TABLE forum_topics +ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'new'; + +-- Add admin tracking for status changes +ALTER TABLE forum_topics +ADD COLUMN IF NOT EXISTS status_changed_by INTEGER REFERENCES users(id); + +ALTER TABLE forum_topics +ADD COLUMN IF NOT EXISTS status_changed_at TIMESTAMP; + +ALTER TABLE forum_topics +ADD COLUMN IF NOT EXISTS status_note TEXT; + +-- Create indexes for filtering +CREATE INDEX IF NOT EXISTS idx_forum_topics_category ON forum_topics(category); +CREATE INDEX IF NOT EXISTS idx_forum_topics_status ON forum_topics(status); + +-- ============================================ +-- PHASE 2: File Attachments +-- ============================================ + +-- Create forum_attachments table +CREATE TABLE IF NOT EXISTS forum_attachments ( + id SERIAL PRIMARY KEY, + + -- Polymorphic relationship (topic or reply) + attachment_type VARCHAR(20) NOT NULL, -- 'topic' or 'reply' + topic_id INTEGER REFERENCES forum_topics(id) ON DELETE CASCADE, + reply_id INTEGER REFERENCES forum_replies(id) ON DELETE CASCADE, + + -- File metadata + original_filename VARCHAR(255) NOT NULL, + stored_filename VARCHAR(255) NOT NULL UNIQUE, + file_extension VARCHAR(10) NOT NULL, + file_size INTEGER NOT NULL, -- in bytes + mime_type VARCHAR(100) NOT NULL, + + -- Uploader + uploaded_by INTEGER REFERENCES users(id) NOT NULL, + + -- Timestamps + created_at TIMESTAMP DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_attachment_type CHECK (attachment_type IN ('topic', 'reply')), + CONSTRAINT chk_file_size CHECK (file_size <= 5242880), -- 5MB max + CONSTRAINT chk_attachment_target CHECK ( + (attachment_type = 'topic' AND topic_id IS NOT NULL AND reply_id IS NULL) OR + (attachment_type = 'reply' AND reply_id IS NOT NULL AND topic_id IS NULL) + ) +); + +-- Indexes for attachments +CREATE INDEX IF NOT EXISTS idx_forum_attachments_topic ON forum_attachments(topic_id) WHERE topic_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_forum_attachments_reply ON forum_attachments(reply_id) WHERE reply_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_forum_attachments_uploaded_by ON forum_attachments(uploaded_by); + +-- ============================================ +-- PERMISSIONS +-- ============================================ + +-- Grant permissions to app user +GRANT ALL ON TABLE forum_attachments TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE forum_attachments_id_seq TO nordabiz_app; + +-- ============================================ +-- VERIFICATION +-- ============================================ + +-- Check columns were added +SELECT column_name, data_type, column_default +FROM information_schema.columns +WHERE table_name = 'forum_topics' +AND column_name IN ('category', 'status', 'status_changed_by', 'status_changed_at', 'status_note'); + +-- Check attachments table exists +SELECT table_name FROM information_schema.tables WHERE table_name = 'forum_attachments'; diff --git a/file_upload_service.py b/file_upload_service.py new file mode 100644 index 0000000..8ff4f45 --- /dev/null +++ b/file_upload_service.py @@ -0,0 +1,288 @@ +""" +Forum File Upload Service +========================= + +Secure file upload handling for forum attachments. +Supports JPG, PNG, GIF images up to 5MB. + +Features: +- File type validation (magic bytes + extension) +- Size limits +- EXIF data stripping for privacy +- UUID-based filenames for security +- Date-organized storage structure + +Author: Norda Biznes Development Team +Created: 2026-01-10 +""" + +import os +import uuid +import logging +from datetime import datetime +from typing import Tuple, Optional +from werkzeug.datastructures import FileStorage + +logger = logging.getLogger(__name__) + +# Configuration +ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} +ALLOWED_MIME_TYPES = {'image/jpeg', 'image/png', 'image/gif'} +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB +MAX_IMAGE_DIMENSIONS = (4096, 4096) # Max 4K resolution +UPLOAD_BASE_PATH = 'static/uploads/forum' + +# Magic bytes for image validation +IMAGE_SIGNATURES = { + b'\xff\xd8\xff': 'jpg', # JPEG + b'\x89PNG\r\n\x1a\n': 'png', # PNG + b'GIF87a': 'gif', # GIF87a + b'GIF89a': 'gif', # GIF89a +} + + +class FileUploadService: + """Secure file upload service for forum attachments""" + + @staticmethod + def validate_file(file: FileStorage) -> Tuple[bool, str]: + """ + Validate uploaded file. + + Args: + file: Werkzeug FileStorage object + + Returns: + Tuple of (is_valid, error_message) + """ + # Check if file exists + if not file or file.filename == '': + return False, 'Nie wybrano pliku' + + # Check extension + ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else '' + if ext not in ALLOWED_EXTENSIONS: + return False, f'Niedozwolony format pliku. Dozwolone: {", ".join(sorted(ALLOWED_EXTENSIONS))}' + + # Check file size + file.seek(0, 2) # Seek to end + size = file.tell() + file.seek(0) # Reset to beginning + + if size > MAX_FILE_SIZE: + return False, f'Plik jest za duży (max {MAX_FILE_SIZE // 1024 // 1024}MB)' + + if size == 0: + return False, 'Plik jest pusty' + + # Verify magic bytes (actual file type) + header = file.read(16) + file.seek(0) + + detected_type = None + for signature, file_type in IMAGE_SIGNATURES.items(): + if header.startswith(signature): + detected_type = file_type + break + + if not detected_type: + return False, 'Plik nie jest prawidłowym obrazem' + + # Check if extension matches detected type + if ext == 'jpg': + ext = 'jpeg' # Normalize + if detected_type == 'jpg': + detected_type = 'jpeg' + + if detected_type not in (ext, 'jpeg' if ext == 'jpg' else ext): + # Allow jpg/jpeg mismatch + if not (detected_type == 'jpeg' and ext in ('jpg', 'jpeg')): + return False, f'Rozszerzenie pliku ({ext}) nie odpowiada zawartości ({detected_type})' + + # Validate image dimensions using PIL (if available) + try: + from PIL import Image + img = Image.open(file) + width, height = img.size + file.seek(0) + + if width > MAX_IMAGE_DIMENSIONS[0] or height > MAX_IMAGE_DIMENSIONS[1]: + return False, f'Obraz jest za duży (max {MAX_IMAGE_DIMENSIONS[0]}x{MAX_IMAGE_DIMENSIONS[1]}px)' + + except ImportError: + # PIL not available, skip dimension check + logger.warning("PIL not available, skipping image dimension validation") + except Exception as e: + file.seek(0) + return False, f'Nie można odczytać obrazu: {str(e)}' + + return True, '' + + @staticmethod + def generate_stored_filename(original_filename: str) -> str: + """ + Generate secure UUID-based filename preserving extension. + + Args: + original_filename: Original filename from upload + + Returns: + UUID-based filename with original extension + """ + ext = original_filename.rsplit('.', 1)[-1].lower() if '.' in original_filename else 'bin' + if ext == 'jpeg': + ext = 'jpg' # Normalize to jpg + return f"{uuid.uuid4()}.{ext}" + + @staticmethod + def get_upload_path(attachment_type: str) -> str: + """ + Get upload directory path with date-based organization. + + Args: + attachment_type: 'topic' or 'reply' + + Returns: + Full path to upload directory + """ + now = datetime.now() + subdir = 'topics' if attachment_type == 'topic' else 'replies' + path = os.path.join(UPLOAD_BASE_PATH, subdir, str(now.year), f"{now.month:02d}") + os.makedirs(path, exist_ok=True) + return path + + @staticmethod + def save_file(file: FileStorage, attachment_type: str) -> Tuple[str, str, int, str]: + """ + Save file securely with EXIF stripping. + + Args: + file: Werkzeug FileStorage object + attachment_type: 'topic' or 'reply' + + Returns: + Tuple of (stored_filename, relative_path, file_size, mime_type) + """ + stored_filename = FileUploadService.generate_stored_filename(file.filename) + upload_dir = FileUploadService.get_upload_path(attachment_type) + file_path = os.path.join(upload_dir, stored_filename) + + # Determine mime type + ext = stored_filename.rsplit('.', 1)[-1].lower() + mime_types = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif' + } + mime_type = mime_types.get(ext, 'application/octet-stream') + + try: + from PIL import Image + + # Open and process image + img = Image.open(file) + + # For GIF, preserve animation + if ext == 'gif' and getattr(img, 'is_animated', False): + # Save animated GIF without modification + file.seek(0) + file.save(file_path) + else: + # Strip EXIF data by creating new image + if img.mode in ('RGBA', 'LA', 'P'): + # Keep transparency for PNG + clean_img = Image.new(img.mode, img.size) + clean_img.putdata(list(img.getdata())) + else: + # Convert to RGB for JPEG + if img.mode != 'RGB': + img = img.convert('RGB') + clean_img = Image.new('RGB', img.size) + clean_img.putdata(list(img.getdata())) + + # Save with optimization + save_kwargs = {'optimize': True} + if ext in ('jpg', 'jpeg'): + save_kwargs['quality'] = 85 + elif ext == 'png': + save_kwargs['compress_level'] = 6 + + clean_img.save(file_path, **save_kwargs) + + file_size = os.path.getsize(file_path) + relative_path = os.path.relpath(file_path, 'static') + + logger.info(f"Saved forum attachment: {stored_filename} ({file_size} bytes)") + return stored_filename, relative_path, file_size, mime_type + + except ImportError: + # PIL not available, save without processing + logger.warning("PIL not available, saving file without EXIF stripping") + file.seek(0) + file.save(file_path) + file_size = os.path.getsize(file_path) + relative_path = os.path.relpath(file_path, 'static') + return stored_filename, relative_path, file_size, mime_type + + @staticmethod + def delete_file(stored_filename: str, attachment_type: str, created_at: Optional[datetime] = None) -> bool: + """ + Delete file from storage. + + Args: + stored_filename: UUID-based filename + attachment_type: 'topic' or 'reply' + created_at: Creation timestamp to determine path + + Returns: + True if deleted, False otherwise + """ + subdir = 'topics' if attachment_type == 'topic' else 'replies' + + if created_at: + # Try exact path first + path = os.path.join( + UPLOAD_BASE_PATH, subdir, + str(created_at.year), f"{created_at.month:02d}", + stored_filename + ) + if os.path.exists(path): + try: + os.remove(path) + logger.info(f"Deleted forum attachment: {stored_filename}") + return True + except OSError as e: + logger.error(f"Failed to delete {stored_filename}: {e}") + return False + + # Search in all date directories + base_path = os.path.join(UPLOAD_BASE_PATH, subdir) + for root, dirs, files in os.walk(base_path): + if stored_filename in files: + try: + os.remove(os.path.join(root, stored_filename)) + logger.info(f"Deleted forum attachment: {stored_filename}") + return True + except OSError as e: + logger.error(f"Failed to delete {stored_filename}: {e}") + return False + + logger.warning(f"Attachment not found for deletion: {stored_filename}") + return False + + @staticmethod + def get_file_url(stored_filename: str, attachment_type: str, created_at: datetime) -> str: + """ + Get URL for serving the file. + + Args: + stored_filename: UUID-based filename + attachment_type: 'topic' or 'reply' + created_at: Creation timestamp + + Returns: + URL path to the file + """ + subdir = 'topics' if attachment_type == 'topic' else 'replies' + return f"/static/uploads/forum/{subdir}/{created_at.year}/{created_at.month:02d}/{stored_filename}" diff --git a/templates/admin/forum.html b/templates/admin/forum.html index cc0e472..4061e97 100755 --- a/templates/admin/forum.html +++ b/templates/admin/forum.html @@ -15,7 +15,7 @@ .stats-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: var(--spacing-lg); margin-bottom: var(--spacing-2xl); } @@ -40,6 +40,16 @@ margin-top: var(--spacing-xs); } + .stat-card.category-feature_request .stat-value { color: #1e40af; } + .stat-card.category-bug .stat-value { color: #991b1b; } + .stat-card.category-question .stat-value { color: #166534; } + .stat-card.category-announcement .stat-value { color: #92400e; } + + .stat-card.status-new .stat-value { color: #374151; } + .stat-card.status-in_progress .stat-value { color: #1e40af; } + .stat-card.status-resolved .stat-value { color: #166534; } + .stat-card.status-rejected .stat-value { color: #991b1b; } + .section { background: var(--surface); padding: var(--spacing-xl); @@ -105,7 +115,6 @@ border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 500; - text-transform: uppercase; } .badge-pinned { @@ -118,6 +127,65 @@ color: white; } + /* Category badges */ + .badge-category { + border: 1px solid; + } + + .badge-feature_request { + background: #dbeafe; + color: #1e40af; + border-color: #93c5fd; + } + + .badge-bug { + background: #fee2e2; + color: #991b1b; + border-color: #fca5a5; + } + + .badge-question { + background: #dcfce7; + color: #166534; + border-color: #86efac; + } + + .badge-announcement { + background: #fef3c7; + color: #92400e; + border-color: #fcd34d; + } + + /* Status badges */ + .badge-status { + cursor: pointer; + transition: var(--transition); + } + + .badge-status:hover { + opacity: 0.8; + } + + .badge-new { + background: #f3f4f6; + color: #374151; + } + + .badge-in_progress { + background: #dbeafe; + color: #1e40af; + } + + .badge-resolved { + background: #dcfce7; + color: #166534; + } + + .badge-rejected { + background: #fee2e2; + color: #991b1b; + } + .action-buttons { display: flex; gap: var(--spacing-xs); @@ -193,6 +261,75 @@ color: var(--text-secondary); } + /* Status change modal */ + .modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + justify-content: center; + align-items: center; + } + + .modal-overlay.active { + display: flex; + } + + .modal-content { + background: var(--surface); + border-radius: var(--radius-lg); + padding: var(--spacing-xl); + max-width: 400px; + width: 90%; + box-shadow: var(--shadow-lg); + } + + .modal-header { + font-size: var(--font-size-lg); + font-weight: 600; + margin-bottom: var(--spacing-lg); + color: var(--text-primary); + } + + .modal-body { + margin-bottom: var(--spacing-lg); + } + + .form-group { + margin-bottom: var(--spacing-md); + } + + .form-label { + display: block; + font-weight: 500; + margin-bottom: var(--spacing-sm); + color: var(--text-primary); + } + + .form-select, .form-input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: var(--font-size-base); + background: var(--surface); + } + + .form-select:focus, .form-input:focus { + outline: none; + border-color: var(--primary); + } + + .modal-footer { + display: flex; + gap: var(--spacing-md); + justify-content: flex-end; + } + @media (max-width: 768px) { .topics-table { font-size: var(--font-size-sm); @@ -200,8 +337,8 @@ .topics-table th:nth-child(3), .topics-table td:nth-child(3), - .topics-table th:nth-child(4), - .topics-table td:nth-child(4) { + .topics-table th:nth-child(5), + .topics-table td:nth-child(5) { display: none; } } @@ -234,6 +371,46 @@ + +
+
+
{{ status_counts.get('new', 0) }}
+
Nowych
+
+
+
{{ status_counts.get('in_progress', 0) }}
+
W realizacji
+
+
+
{{ status_counts.get('resolved', 0) }}
+
Rozwiazanych
+
+
+
{{ status_counts.get('rejected', 0) }}
+
Odrzuconych
+
+
+ + +
+
+
{{ category_counts.get('feature_request', 0) }}
+
Propozycji
+
+
+
{{ category_counts.get('bug', 0) }}
+
Bledow
+
+
+
{{ category_counts.get('question', 0) }}
+
Pytan
+
+
+
{{ category_counts.get('announcement', 0) }}
+
Ogloszen
+
+
+

Tematy

@@ -242,10 +419,10 @@ Tytul + Kategoria Autor - Odpowiedzi - Data Status + Data Akcje @@ -256,11 +433,6 @@ - - {{ topic.author.name or topic.author.email.split('@')[0] }} - {{ topic.reply_count }} - {{ topic.created_at.strftime('%d.%m.%Y') }} - {% if topic.is_pinned %} Przypiety {% endif %} @@ -268,6 +440,20 @@ Zamkniety {% endif %} + + + {{ category_labels.get(topic.category, 'Pytanie') }} + + + {{ topic.author.name or topic.author.email.split('@')[0] }} + + + {{ status_labels.get(topic.status, 'Nowy') }} + + + {{ topic.created_at.strftime('%d.%m.%Y') }}
+ + + {% endblock %} {% block extra_js %} const csrfToken = '{{ csrf_token() }}'; + let currentTopicId = null; function showMessage(message, type) { - // Simple alert for now - could be improved with toast notifications alert(message); } + // Status modal functions + function openStatusModal(topicId, topicTitle, currentStatus) { + currentTopicId = topicId; + document.getElementById('modalTopicTitle').textContent = topicTitle; + document.getElementById('newStatus').value = currentStatus; + document.getElementById('statusNote').value = ''; + document.getElementById('statusModal').classList.add('active'); + } + + function closeStatusModal() { + document.getElementById('statusModal').classList.remove('active'); + currentTopicId = null; + } + + async function saveStatus() { + if (!currentTopicId) return; + + const newStatus = document.getElementById('newStatus').value; + const statusNote = document.getElementById('statusNote').value; + + try { + const response = await fetch(`/admin/forum/topic/${currentTopicId}/status`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + status: newStatus, + note: statusNote + }) + }); + + const data = await response.json(); + if (data.success) { + location.reload(); + } else { + showMessage(data.error || 'Wystapil blad', 'error'); + } + } catch (error) { + showMessage('Blad polaczenia', 'error'); + } + + closeStatusModal(); + } + + // Close modal on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeStatusModal(); + } + }); + + // Close modal on overlay click + document.getElementById('statusModal').addEventListener('click', (e) => { + if (e.target.id === 'statusModal') { + closeStatusModal(); + } + }); + async function togglePin(topicId) { try { const response = await fetch(`/admin/forum/topic/${topicId}/pin`, { diff --git a/templates/forum/index.html b/templates/forum/index.html index 5fd48b6..8e7e8fb 100755 --- a/templates/forum/index.html +++ b/templates/forum/index.html @@ -21,6 +21,51 @@ font-size: var(--font-size-sm); } + /* Filters bar */ + .filters-bar { + 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-sm); + } + + .filter-label { + font-size: var(--font-size-sm); + color: var(--text-secondary); + font-weight: 500; + } + + .filter-select { + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: var(--font-size-sm); + background: var(--surface); + cursor: pointer; + } + + .filter-select:focus { + outline: none; + border-color: var(--primary); + } + + .filter-reset { + font-size: var(--font-size-sm); + color: var(--text-secondary); + text-decoration: none; + } + + .filter-reset:hover { + color: var(--primary); + } + .topics-list { display: flex; flex-direction: column; @@ -65,6 +110,7 @@ display: flex; align-items: center; gap: var(--spacing-sm); + flex-wrap: wrap; } .topic-title:hover { @@ -72,10 +118,11 @@ } .topic-badge { - font-size: var(--font-size-sm); + font-size: var(--font-size-xs); padding: 2px 8px; border-radius: var(--radius-sm); font-weight: 500; + white-space: nowrap; } .badge-pinned { @@ -88,6 +135,62 @@ color: white; } + /* Category badges */ + .badge-category { + border: 1px solid; + } + + .badge-feature_request { + background: #dbeafe; + color: #1e40af; + border-color: #93c5fd; + } + + .badge-bug { + background: #fee2e2; + color: #991b1b; + border-color: #fca5a5; + } + + .badge-question { + background: #dcfce7; + color: #166534; + border-color: #86efac; + } + + .badge-announcement { + background: #fef3c7; + color: #92400e; + border-color: #fcd34d; + } + + /* Status badges */ + .badge-status { + font-size: var(--font-size-xs); + padding: 2px 6px; + border-radius: var(--radius-sm); + } + + .badge-new { + background: #f3f4f6; + color: #374151; + } + + .badge-in_progress { + background: #dbeafe; + color: #1e40af; + } + + .badge-resolved { + background: #dcfce7; + color: #166534; + } + + .badge-rejected { + background: #fee2e2; + color: #991b1b; + } + .topic-meta { display: flex; gap: var(--spacing-lg); @@ -170,6 +273,19 @@ gap: var(--spacing-md); } + .filters-bar { + flex-direction: column; + align-items: stretch; + } + + .filter-group { + width: 100%; + } + + .filter-select { + flex: 1; + } + .topic-card { grid-template-columns: 1fr; } @@ -193,6 +309,35 @@
+ +
+
+ Kategoria: + +
+
+ Status: + +
+ {% if category_filter or status_filter %} + Wyczysc filtry + {% endif %} +
+ {% if topics %}
{% for topic in topics %} @@ -205,6 +350,12 @@ {% if topic.is_locked %} Zamkniety {% endif %} + + {{ category_labels.get(topic.category, 'Pytanie') }} + + + {{ status_labels.get(topic.status, 'Nowy') }} + {{ topic.title }}
@@ -246,21 +397,21 @@ {% if total_pages > 1 %} {% endif %} @@ -278,3 +429,22 @@
{% endif %} {% endblock %} + +{% block extra_js %} + function applyFilters() { + const category = document.getElementById('categoryFilter').value; + const status = document.getElementById('statusFilter').value; + + let url = '{{ url_for("forum_index") }}'; + const params = new URLSearchParams(); + + if (category) params.set('category', category); + if (status) params.set('status', status); + + if (params.toString()) { + url += '?' + params.toString(); + } + + window.location.href = url; + } +{% endblock %} diff --git a/templates/forum/new_topic.html b/templates/forum/new_topic.html index 0f9d75c..4fc34a6 100755 --- a/templates/forum/new_topic.html +++ b/templates/forum/new_topic.html @@ -60,7 +60,7 @@ color: var(--error); } - .form-input { + .form-input, .form-select { width: 100%; padding: var(--spacing-md); border: 1px solid var(--border); @@ -68,9 +68,10 @@ font-size: var(--font-size-base); font-family: var(--font-family); transition: var(--transition); + background: var(--surface); } - .form-input:focus { + .form-input:focus, .form-select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); @@ -119,11 +120,102 @@ margin-bottom: var(--spacing-xs); } + /* Category select styles */ + .category-group { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-md); + } + + /* Upload dropzone */ + .upload-dropzone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: var(--spacing-xl); + text-align: center; + background: var(--background); + transition: var(--transition); + cursor: pointer; + } + + .upload-dropzone:hover, .upload-dropzone.drag-over { + border-color: var(--primary); + background: rgba(37, 99, 235, 0.05); + } + + .upload-dropzone svg { + width: 48px; + height: 48px; + color: var(--text-secondary); + margin-bottom: var(--spacing-md); + } + + .upload-dropzone p { + color: var(--text-secondary); + margin-bottom: var(--spacing-sm); + } + + .upload-dropzone .upload-hint { + font-size: var(--font-size-sm); + color: var(--text-tertiary); + } + + .upload-preview { + display: none; + margin-top: var(--spacing-md); + padding: var(--spacing-md); + background: var(--background); + border-radius: var(--radius); + border: 1px solid var(--border); + } + + .upload-preview.active { + display: flex; + align-items: center; + gap: var(--spacing-md); + } + + .upload-preview img { + max-width: 120px; + max-height: 80px; + border-radius: var(--radius-sm); + object-fit: cover; + } + + .upload-preview .file-info { + flex: 1; + } + + .upload-preview .file-name { + font-weight: 500; + color: var(--text-primary); + } + + .upload-preview .file-size { + font-size: var(--font-size-sm); + color: var(--text-secondary); + } + + .upload-preview .remove-file { + color: var(--error); + cursor: pointer; + padding: var(--spacing-sm); + } + + .upload-preview .remove-file:hover { + background: rgba(239, 68, 68, 0.1); + border-radius: var(--radius); + } + @media (max-width: 768px) { .new-topic-form { padding: var(--spacing-lg); } + .category-group { + grid-template-columns: 1fr; + } + .form-actions { flex-direction: column; } @@ -157,25 +249,41 @@
-
+ -
- - -

Minimum 5 znakow. Dobry tytul zacheca do dyskusji.

+
+
+ + +

Wybierz typ tematu

+
+ +
+ + +

Minimum 5 znakow

+
@@ -193,6 +301,33 @@

Minimum 10 znakow. Im wiecej szczegolow, tym lepsze odpowiedzi.

+
+ +
+ + + +

Przeciagnij obraz lub kliknij tutaj

+ Mozesz tez wkleic ze schowka (Ctrl+V) + JPG, PNG, GIF do 5MB + +
+
+ Preview +
+
+
+
+
+ + + +
+
+
+
@@ -258,6 +568,20 @@
{{ topic.content }}
+ + {% if topic.attachments %} + {% for attachment in topic.attachments %} +
+ {{ attachment.original_filename }} +
+ {{ attachment.original_filename }} ({{ (attachment.file_size / 1024)|int }} KB) +
+
+ {% endfor %} + {% endif %}
@@ -279,6 +603,23 @@ {{ reply.created_at.strftime('%d.%m.%Y %H:%M') }}
{{ reply.content }}
+ + {% if reply.attachments %} +
+
+ {% for attachment in reply.attachments %} +
+ {{ attachment.original_filename }} +
+ {{ attachment.original_filename|truncate(20) }} ({{ (attachment.file_size / 1024)|int }} KB) +
+
+ {% endfor %} +
+
+ {% endif %} {% endfor %} @@ -294,11 +635,221 @@ Ten temat jest zamkniety. Nie mozna dodawac nowych odpowiedzi. {% else %} - +

Dodaj odpowiedz

- - + + +
+
+
+

Przeciagnij obrazy lub kliknij tutaj (max 10 plikow, mozesz tez wkleic Ctrl+V)

+ +
+ +
+ +
{% endif %} + + + +{% endblock %} + +{% block extra_js %} + // Lightbox functions + function openLightbox(src) { + document.getElementById('lightboxImage').src = src; + document.getElementById('lightbox').classList.add('active'); + } + + function closeLightbox() { + document.getElementById('lightbox').classList.remove('active'); + } + + // Close lightbox with Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeLightbox(); + } + }); + + // Multi-file upload handling (only if form exists) + const dropzone = document.getElementById('dropzone'); + if (dropzone) { + const fileInput = document.getElementById('attachmentInput'); + const previewsContainer = document.getElementById('previewsContainer'); + const uploadCounter = document.getElementById('uploadCounter'); + const replyContent = document.getElementById('replyContent'); + + const MAX_FILES = 10; + const MAX_SIZE = 5 * 1024 * 1024; // 5MB + const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif']; + + // Store files in a Map for easy removal + let filesMap = new Map(); + let fileIdCounter = 0; + + // Click to upload + dropzone.addEventListener('click', () => fileInput.click()); + + // Drag and drop + dropzone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropzone.classList.add('drag-over'); + }); + + dropzone.addEventListener('dragleave', () => { + dropzone.classList.remove('drag-over'); + }); + + dropzone.addEventListener('drop', (e) => { + e.preventDefault(); + dropzone.classList.remove('drag-over'); + const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')); + addFiles(droppedFiles); + }); + + // File input change + fileInput.addEventListener('change', (e) => { + const selectedFiles = Array.from(e.target.files); + addFiles(selectedFiles); + // Reset input to allow selecting same files again + fileInput.value = ''; + }); + + // Paste from clipboard (Ctrl+V) + document.addEventListener('paste', (e) => { + // Only handle paste if reply textarea is focused + if (document.activeElement !== replyContent && !replyContent.contains(document.activeElement)) { + return; + } + + const items = e.clipboardData?.items; + if (!items) return; + + const pastedFiles = []; + for (let i = 0; i < items.length; i++) { + if (items[i].type.startsWith('image/')) { + e.preventDefault(); + const file = items[i].getAsFile(); + if (file) { + pastedFiles.push(file); + } + } + } + if (pastedFiles.length > 0) { + addFiles(pastedFiles); + } + }); + + function addFiles(newFiles) { + const currentCount = filesMap.size; + const availableSlots = MAX_FILES - currentCount; + + if (availableSlots <= 0) { + alert('Osiagnieto limit ' + MAX_FILES + ' plikow'); + return; + } + + const filesToAdd = newFiles.slice(0, availableSlots); + const errors = []; + + filesToAdd.forEach(file => { + // Validate size + if (file.size > MAX_SIZE) { + errors.push(file.name + ': za duzy (max 5MB)'); + return; + } + + // Validate type + if (!ALLOWED_TYPES.includes(file.type)) { + errors.push(file.name + ': niedozwolony format'); + return; + } + + const fileId = 'file_' + (fileIdCounter++); + filesMap.set(fileId, file); + createPreview(fileId, file); + }); + + if (errors.length > 0) { + alert('Bledy:\n' + errors.join('\n')); + } + + updateCounter(); + syncFilesToInput(); + } + + function createPreview(fileId, file) { + const preview = document.createElement('div'); + preview.className = 'upload-preview-item'; + preview.dataset.fileId = fileId; + + const img = document.createElement('img'); + const info = document.createElement('div'); + info.className = 'preview-info'; + info.textContent = file.name.substring(0, 15) + (file.name.length > 15 ? '...' : '') + ' (' + formatFileSize(file.size) + ')'; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove-preview'; + removeBtn.innerHTML = '×'; + removeBtn.title = 'Usun'; + removeBtn.onclick = () => removeFile(fileId); + + preview.appendChild(img); + preview.appendChild(info); + preview.appendChild(removeBtn); + previewsContainer.appendChild(preview); + + // Load image preview + const reader = new FileReader(); + reader.onload = (e) => { + img.src = e.target.result; + }; + reader.readAsDataURL(file); + } + + function removeFile(fileId) { + filesMap.delete(fileId); + const preview = previewsContainer.querySelector('[data-file-id="' + fileId + '"]'); + if (preview) { + preview.remove(); + } + updateCounter(); + syncFilesToInput(); + } + + function updateCounter() { + const count = filesMap.size; + if (count === 0) { + uploadCounter.textContent = ''; + uploadCounter.classList.remove('limit-reached'); + dropzone.style.display = 'block'; + } else { + uploadCounter.textContent = 'Wybrano: ' + count + '/' + MAX_FILES + ' plikow'; + uploadCounter.classList.toggle('limit-reached', count >= MAX_FILES); + dropzone.style.display = count >= MAX_FILES ? 'none' : 'block'; + } + } + + function syncFilesToInput() { + // Create DataTransfer and add all files from Map + const dataTransfer = new DataTransfer(); + filesMap.forEach(file => { + dataTransfer.items.add(file); + }); + fileInput.files = dataTransfer.files; + } + + function formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + } {% endblock %}