From 650c0d576053c7917fc8054c9f0a01db1b806ce1 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Tue, 3 Feb 2026 18:41:12 +0100 Subject: [PATCH] feat: Add Strefa RADA - closed section for Board Council members - Add @rada_member_required decorator for access control - Add BoardDocument model for storing protocols and documents - Create document upload service (PDF, DOCX, DOC up to 50MB) - Add /rada/ blueprint with list, upload, download endpoints - Add "Rada" link in navigation (visible only for board members) - Add "Rada" badge and toggle button in admin user management - Create SQL migration to set up board_documents table and assign is_rada_member=True to 16 board members by email Storage: /data/board-docs/ (outside webroot for security) Access: is_rada_member=True OR role >= OFFICE_MANAGER Co-Authored-By: Claude Opus 4.5 --- blueprints/__init__.py | 8 + blueprints/admin/routes.py | 25 ++ blueprints/board/__init__.py | 16 + blueprints/board/routes.py | 258 +++++++++++++ database.py | 69 ++++ database/migrations/048_board_documents.sql | 74 ++++ services/__init__.py | 1 + services/document_upload_service.py | 237 ++++++++++++ templates/admin/users.html | 41 ++ templates/base.html | 5 + templates/board/index.html | 398 ++++++++++++++++++++ templates/board/upload.html | 382 +++++++++++++++++++ utils/decorators.py | 27 ++ 13 files changed, 1541 insertions(+) create mode 100644 blueprints/board/__init__.py create mode 100644 blueprints/board/routes.py create mode 100644 database/migrations/048_board_documents.sql create mode 100644 services/__init__.py create mode 100644 services/document_upload_service.py create mode 100644 templates/board/index.html create mode 100644 templates/board/upload.html diff --git a/blueprints/__init__.py b/blueprints/__init__.py index 224240a..24f1b1f 100644 --- a/blueprints/__init__.py +++ b/blueprints/__init__.py @@ -70,6 +70,14 @@ def register_blueprints(app): except ImportError as e: logger.debug(f"Blueprint education not yet available: {e}") + # Board blueprint (Rada Izby) + try: + from blueprints.board import bp as board_bp + app.register_blueprint(board_bp) + logger.info("Registered blueprint: board (Rada Izby)") + except ImportError as e: + logger.debug(f"Blueprint board not yet available: {e}") + # IT Audit blueprint try: from blueprints.it_audit import bp as it_audit_bp diff --git a/blueprints/admin/routes.py b/blueprints/admin/routes.py index 965e777..8bb09ca 100644 --- a/blueprints/admin/routes.py +++ b/blueprints/admin/routes.py @@ -286,6 +286,31 @@ def admin_user_toggle_verified(user_id): db.close() +@bp.route('/users//toggle-rada-member', methods=['POST']) +@login_required +@role_required(SystemRole.ADMIN) +def admin_user_toggle_rada_member(user_id): + """Toggle Rada Izby (Board Council) membership for a user""" + db = SessionLocal() + try: + user = db.query(User).filter(User.id == user_id).first() + if not user: + return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404 + + user.is_rada_member = not user.is_rada_member + db.commit() + + logger.info(f"Admin {current_user.email} {'added' if user.is_rada_member else 'removed'} user {user.email} {'to' if user.is_rada_member else 'from'} Rada Izby") + + return jsonify({ + 'success': True, + 'is_rada_member': user.is_rada_member, + 'message': f"Użytkownik {'dodany do' if user.is_rada_member else 'usunięty z'} Rady Izby" + }) + finally: + db.close() + + @bp.route('/users//update', methods=['POST']) @login_required @role_required(SystemRole.ADMIN) diff --git a/blueprints/board/__init__.py b/blueprints/board/__init__.py new file mode 100644 index 0000000..fc0672a --- /dev/null +++ b/blueprints/board/__init__.py @@ -0,0 +1,16 @@ +""" +Board Blueprint (Rada Izby) +=========================== + +Strefa RADA - zamknięta sekcja dla członków Rady Izby. +Protokoły, uchwały i dokumenty z posiedzeń Rady. + +URL prefix: /rada +Access: is_rada_member = True OR role >= OFFICE_MANAGER +""" + +from flask import Blueprint + +bp = Blueprint('board', __name__, url_prefix='/rada') + +from . import routes # noqa: F401, E402 diff --git a/blueprints/board/routes.py b/blueprints/board/routes.py new file mode 100644 index 0000000..0b8cdba --- /dev/null +++ b/blueprints/board/routes.py @@ -0,0 +1,258 @@ +""" +Board Routes (Rada Izby) +======================== + +Routes for board document management. + +Endpoints: +- GET /rada/ - Document list +- GET/POST /rada/upload - Upload new document (office_manager+) +- GET /rada//download - Download document +- POST /rada//delete - Soft delete document (office_manager+) +""" + +import os +from datetime import datetime +from flask import ( + render_template, request, redirect, url_for, flash, + send_file, abort, current_app +) +from flask_login import login_required, current_user +from sqlalchemy import desc + +from . import bp +from database import db_session, BoardDocument, SystemRole +from utils.decorators import rada_member_required, office_manager_required +from services.document_upload_service import DocumentUploadService + + +@bp.route('/') +@login_required +@rada_member_required +def index(): + """Display list of board documents with filtering""" + # Get filter parameters + year = request.args.get('year', type=int) + month = request.args.get('month', type=int) + doc_type = request.args.get('type', '') + + # Build query + query = db_session.query(BoardDocument).filter(BoardDocument.is_active == True) + + if year: + from sqlalchemy import extract + query = query.filter(extract('year', BoardDocument.meeting_date) == year) + if month: + from sqlalchemy import extract + query = query.filter(extract('month', BoardDocument.meeting_date) == month) + if doc_type and doc_type in BoardDocument.DOCUMENT_TYPES: + query = query.filter(BoardDocument.document_type == doc_type) + + # Order by meeting date descending + documents = query.order_by(desc(BoardDocument.meeting_date)).all() + + # Get available years for filter + years_query = db_session.query( + db_session.query(BoardDocument.meeting_date).distinct() + ).filter(BoardDocument.is_active == True).all() + + available_years = sorted(set( + doc.meeting_date.year for doc in + db_session.query(BoardDocument).filter(BoardDocument.is_active == True).all() + if doc.meeting_date + ), reverse=True) + + # Check if user can upload (office_manager or admin) + can_upload = current_user.has_role(SystemRole.OFFICE_MANAGER) + + return render_template( + 'board/index.html', + documents=documents, + available_years=available_years, + document_types=BoardDocument.DOCUMENT_TYPES, + type_labels=BoardDocument.DOCUMENT_TYPE_LABELS, + current_year=year, + current_month=month, + current_type=doc_type, + can_upload=can_upload + ) + + +@bp.route('/upload', methods=['GET', 'POST']) +@login_required +@office_manager_required +def upload(): + """Upload new board document""" + if request.method == 'POST': + # Get form data + title = request.form.get('title', '').strip() + description = request.form.get('description', '').strip() + document_type = request.form.get('document_type', 'protocol') + meeting_date_str = request.form.get('meeting_date', '') + meeting_number = request.form.get('meeting_number', type=int) + + # Validate required fields + errors = [] + if not title: + errors.append('Tytuł dokumentu jest wymagany.') + if not meeting_date_str: + errors.append('Data posiedzenia jest wymagana.') + + # Parse meeting date + meeting_date = None + if meeting_date_str: + try: + meeting_date = datetime.strptime(meeting_date_str, '%Y-%m-%d').date() + except ValueError: + errors.append('Nieprawidłowy format daty.') + + # Validate document type + if document_type not in BoardDocument.DOCUMENT_TYPES: + document_type = 'other' + + # Get uploaded file + file = request.files.get('document') + if not file or file.filename == '': + errors.append('Plik dokumentu jest wymagany.') + + # Validate file + if file and file.filename: + is_valid, error_msg = DocumentUploadService.validate_file(file) + if not is_valid: + errors.append(error_msg) + + if errors: + for error in errors: + flash(error, 'error') + return render_template( + 'board/upload.html', + document_types=BoardDocument.DOCUMENT_TYPES, + type_labels=BoardDocument.DOCUMENT_TYPE_LABELS, + form_data={ + 'title': title, + 'description': description, + 'document_type': document_type, + 'meeting_date': meeting_date_str, + 'meeting_number': meeting_number + } + ) + + # Save file + try: + stored_filename, file_path, file_size, mime_type = DocumentUploadService.save_file(file) + except Exception as e: + current_app.logger.error(f"Failed to save board document: {e}") + flash('Błąd podczas zapisywania pliku. Spróbuj ponownie.', 'error') + return redirect(url_for('board.upload')) + + # Get file extension + file_extension = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else '' + + # Create database record + document = BoardDocument( + title=title, + description=description if description else None, + document_type=document_type, + meeting_date=meeting_date, + meeting_number=meeting_number if meeting_number else None, + original_filename=file.filename, + stored_filename=stored_filename, + file_extension=file_extension, + file_size=file_size, + mime_type=mime_type, + uploaded_by=current_user.id + ) + + try: + db_session.add(document) + db_session.commit() + flash(f'Dokument "{title}" został dodany.', 'success') + current_app.logger.info(f"Board document uploaded: {title} by user {current_user.id}") + return redirect(url_for('board.index')) + except Exception as e: + db_session.rollback() + # Clean up uploaded file + DocumentUploadService.delete_file(stored_filename) + current_app.logger.error(f"Failed to create board document record: {e}") + flash('Błąd podczas tworzenia rekordu. Spróbuj ponownie.', 'error') + return redirect(url_for('board.upload')) + + # GET request - show upload form + return render_template( + 'board/upload.html', + document_types=BoardDocument.DOCUMENT_TYPES, + type_labels=BoardDocument.DOCUMENT_TYPE_LABELS, + form_data={} + ) + + +@bp.route('//download') +@login_required +@rada_member_required +def download(doc_id): + """Download board document (secure, authenticated)""" + document = db_session.query(BoardDocument).filter( + BoardDocument.id == doc_id, + BoardDocument.is_active == True + ).first() + + if not document: + flash('Dokument nie został znaleziony.', 'error') + return redirect(url_for('board.index')) + + # Get file path + file_path = DocumentUploadService.get_file_path( + document.stored_filename, + document.uploaded_at + ) + + if not os.path.exists(file_path): + current_app.logger.error(f"Board document file not found: {file_path}") + flash('Plik dokumentu nie został znaleziony.', 'error') + return redirect(url_for('board.index')) + + # Log download + current_app.logger.info( + f"Board document downloaded: {document.title} (ID: {doc_id}) by user {current_user.id}" + ) + + # Send file with original filename + return send_file( + file_path, + as_attachment=True, + download_name=document.original_filename, + mimetype=document.mime_type + ) + + +@bp.route('//delete', methods=['POST']) +@login_required +@office_manager_required +def delete(doc_id): + """Soft delete board document""" + document = db_session.query(BoardDocument).filter( + BoardDocument.id == doc_id, + BoardDocument.is_active == True + ).first() + + if not document: + flash('Dokument nie został znaleziony.', 'error') + return redirect(url_for('board.index')) + + try: + # Soft delete + document.is_active = False + document.updated_at = datetime.now() + document.updated_by = current_user.id + db_session.commit() + + flash(f'Dokument "{document.title}" został usunięty.', 'success') + current_app.logger.info( + f"Board document deleted: {document.title} (ID: {doc_id}) by user {current_user.id}" + ) + except Exception as e: + db_session.rollback() + current_app.logger.error(f"Failed to delete board document: {e}") + flash('Błąd podczas usuwania dokumentu.', 'error') + + return redirect(url_for('board.index')) diff --git a/database.py b/database.py index 0527293..0e5fcc9 100644 --- a/database.py +++ b/database.py @@ -1515,6 +1515,75 @@ class ForumAttachment(Base): return f"{self.file_size / (1024 * 1024):.1f} MB" +class BoardDocument(Base): + """Documents for Rada Izby (Board Council) - protocols, minutes, resolutions""" + __tablename__ = 'board_documents' + + id = Column(Integer, primary_key=True) + + # Document metadata + title = Column(String(255), nullable=False) + description = Column(Text) + document_type = Column(String(50), default='protocol') # protocol, minutes, resolution, report, other + + # Meeting reference + meeting_date = Column(Date, nullable=False) + meeting_number = Column(Integer) # Sequential meeting number (optional) + + # 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) + + # Upload tracking + uploaded_by = Column(Integer, ForeignKey('users.id'), nullable=False) + uploaded_at = Column(DateTime, default=datetime.now) + + # Audit fields + updated_at = Column(DateTime, onupdate=datetime.now) + updated_by = Column(Integer, ForeignKey('users.id')) + is_active = Column(Boolean, default=True) # Soft delete + + # Relationships + uploader = relationship('User', foreign_keys=[uploaded_by]) + editor = relationship('User', foreign_keys=[updated_by]) + + # Constants + DOCUMENT_TYPES = ['protocol', 'minutes', 'resolution', 'report', 'other'] + DOCUMENT_TYPE_LABELS = { + 'protocol': 'Protokół', + 'minutes': 'Notatki', + 'resolution': 'Uchwała', + 'report': 'Raport', + 'other': 'Inny' + } + ALLOWED_EXTENSIONS = {'pdf', 'docx', 'doc'} + MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB + + @property + def type_label(self): + """Get Polish label for document type""" + return self.DOCUMENT_TYPE_LABELS.get(self.document_type, 'Dokument') + + @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" + + @property + def file_path(self): + """Get the full path to the stored file""" + date = self.uploaded_at or datetime.now() + return f"/data/board-docs/{date.year}/{date.month:02d}/{self.stored_filename}" + + class ForumTopicSubscription(Base): """Forum topic subscriptions for notifications""" __tablename__ = 'forum_topic_subscriptions' diff --git a/database/migrations/048_board_documents.sql b/database/migrations/048_board_documents.sql new file mode 100644 index 0000000..bef8e35 --- /dev/null +++ b/database/migrations/048_board_documents.sql @@ -0,0 +1,74 @@ +-- Migration: 048_board_documents.sql +-- Date: 2026-02-03 +-- Description: Board documents table for Rada Izby (protocols, minutes, resolutions) +-- Author: Claude Code + +-- Create board_documents table +CREATE TABLE IF NOT EXISTS board_documents ( + id SERIAL PRIMARY KEY, + + -- Document metadata + title VARCHAR(255) NOT NULL, + description TEXT, + document_type VARCHAR(50) DEFAULT 'protocol', + + -- Meeting reference + meeting_date DATE NOT NULL, + meeting_number INTEGER, + + -- 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, + mime_type VARCHAR(100) NOT NULL, + + -- Upload tracking + uploaded_by INTEGER NOT NULL REFERENCES users(id), + uploaded_at TIMESTAMP DEFAULT NOW(), + + -- Audit fields + updated_at TIMESTAMP, + updated_by INTEGER REFERENCES users(id), + is_active BOOLEAN DEFAULT TRUE +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_board_documents_meeting_date ON board_documents(meeting_date); +CREATE INDEX IF NOT EXISTS idx_board_documents_document_type ON board_documents(document_type); +CREATE INDEX IF NOT EXISTS idx_board_documents_is_active ON board_documents(is_active); + +-- Grant permissions to application user +GRANT ALL ON TABLE board_documents TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE board_documents_id_seq TO nordabiz_app; + +-- Set is_rada_member for known board members +-- Note: Only updates users that exist in the database +UPDATE users SET is_rada_member = TRUE +WHERE email IN ( + 'leszek@rotor.pl', + 'artur.wiertel@norda-biznes.info', + 'pawel.kwidzinski@norda-biznes.info', + 'jm@hebel-masiak.pl', + 'iwonamusial@cristap.pl', + 'andrzej.gorczycki@zukwejherowo.pl', + 'dariusz.schmidtke@tkchopin.pl', + 'a.jedrzejewski@scrol.pl', + 'krzysztof.kubis@sibuk.pl', + 'info@greenhousesystems.pl', + 'kuba@bormax.com.pl', + 'pawel.piechota@norda-biznes.info', + 'jacek.pomieczynski@eura-tech.eu', + 'radoslaw@skwarlo.pl', + 'roman@sigmabudownictwo.pl', + 'mjwesierski@gmail.com' +); + +-- Log how many users were updated +DO $$ +DECLARE + updated_count INTEGER; +BEGIN + SELECT COUNT(*) INTO updated_count FROM users WHERE is_rada_member = TRUE; + RAISE NOTICE 'Board members set: % users have is_rada_member = TRUE', updated_count; +END $$; diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..a70b302 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/services/document_upload_service.py b/services/document_upload_service.py new file mode 100644 index 0000000..e472c75 --- /dev/null +++ b/services/document_upload_service.py @@ -0,0 +1,237 @@ +""" +Board Document Upload Service +============================= + +Secure file upload handling for Rada Izby (Board Council) documents. +Supports PDF, DOCX, DOC files up to 50MB. + +Features: +- File type validation (magic bytes + extension) +- Size limits +- UUID-based filenames for security +- Date-organized storage structure +- Protected storage outside webroot + +Author: Norda Biznes Development Team +Created: 2026-02-03 +""" + +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 = {'pdf', 'docx', 'doc'} +ALLOWED_MIME_TYPES = { + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' +} +MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB + +# Storage path - OUTSIDE webroot for security +UPLOAD_BASE_PATH = '/data/board-docs' + +# Magic bytes for document validation +DOCUMENT_SIGNATURES = { + b'%PDF': 'pdf', # PDF files + b'PK\x03\x04': 'docx', # DOCX (ZIP-based Office format) + b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1': 'doc', # DOC (OLE Compound Document) +} + +# MIME type mapping +MIME_TYPES = { + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' +} + + +class DocumentUploadService: + """Secure file upload service for board documents""" + + @staticmethod + def validate_file(file: FileStorage) -> Tuple[bool, str]: + """ + Validate uploaded document 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 DOCUMENT_SIGNATURES.items(): + if header.startswith(signature): + detected_type = file_type + break + + if not detected_type: + return False, 'Plik nie jest prawidłowym dokumentem (PDF, DOCX lub DOC)' + + # Check if extension matches detected type + if detected_type != ext: + # Allow docx detected as zip (PK signature) + if not (detected_type == 'docx' and ext == 'docx'): + return False, f'Rozszerzenie pliku ({ext}) nie odpowiada zawartości ({detected_type})' + + 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' + return f"{uuid.uuid4()}.{ext}" + + @staticmethod + def get_upload_path() -> str: + """ + Get upload directory path with date-based organization. + + Returns: + Full path to upload directory + """ + now = datetime.now() + path = os.path.join(UPLOAD_BASE_PATH, str(now.year), f"{now.month:02d}") + os.makedirs(path, exist_ok=True) + return path + + @staticmethod + def save_file(file: FileStorage) -> Tuple[str, str, int, str]: + """ + Save document file securely. + + Args: + file: Werkzeug FileStorage object + + Returns: + Tuple of (stored_filename, file_path, file_size, mime_type) + """ + stored_filename = DocumentUploadService.generate_stored_filename(file.filename) + upload_dir = DocumentUploadService.get_upload_path() + file_path = os.path.join(upload_dir, stored_filename) + + # Determine mime type + ext = stored_filename.rsplit('.', 1)[-1].lower() + mime_type = MIME_TYPES.get(ext, 'application/octet-stream') + + # Save file + file.seek(0) + file.save(file_path) + file_size = os.path.getsize(file_path) + + logger.info(f"Saved board document: {stored_filename} ({file_size} bytes)") + return stored_filename, file_path, file_size, mime_type + + @staticmethod + def delete_file(stored_filename: str, uploaded_at: Optional[datetime] = None) -> bool: + """ + Delete document file from storage. + + Args: + stored_filename: UUID-based filename + uploaded_at: Upload timestamp to determine path + + Returns: + True if deleted, False otherwise + """ + if uploaded_at: + # Try exact path first + path = os.path.join( + UPLOAD_BASE_PATH, + str(uploaded_at.year), f"{uploaded_at.month:02d}", + stored_filename + ) + if os.path.exists(path): + try: + os.remove(path) + logger.info(f"Deleted board document: {stored_filename}") + return True + except OSError as e: + logger.error(f"Failed to delete {stored_filename}: {e}") + return False + + # Search in all date directories + for root, dirs, files in os.walk(UPLOAD_BASE_PATH): + if stored_filename in files: + try: + os.remove(os.path.join(root, stored_filename)) + logger.info(f"Deleted board document: {stored_filename}") + return True + except OSError as e: + logger.error(f"Failed to delete {stored_filename}: {e}") + return False + + logger.warning(f"Document not found for deletion: {stored_filename}") + return False + + @staticmethod + def get_file_path(stored_filename: str, uploaded_at: datetime) -> str: + """ + Get full path to the stored file. + + Args: + stored_filename: UUID-based filename + uploaded_at: Upload timestamp + + Returns: + Full path to the file + """ + return os.path.join( + UPLOAD_BASE_PATH, + str(uploaded_at.year), f"{uploaded_at.month:02d}", + stored_filename + ) + + @staticmethod + def file_exists(stored_filename: str, uploaded_at: datetime) -> bool: + """ + Check if file exists in storage. + + Args: + stored_filename: UUID-based filename + uploaded_at: Upload timestamp + + Returns: + True if file exists, False otherwise + """ + path = DocumentUploadService.get_file_path(stored_filename, uploaded_at) + return os.path.exists(path) diff --git a/templates/admin/users.html b/templates/admin/users.html index 7b4fb6d..d3f72cd 100644 --- a/templates/admin/users.html +++ b/templates/admin/users.html @@ -181,6 +181,11 @@ color: #1D4ED8; } + .badge-rada { + background: #FEF3C7; + color: #D97706; + } + .role-select { padding: 4px 8px; font-size: var(--font-size-sm); @@ -1156,6 +1161,9 @@ {% if user.is_admin %} Admin {% endif %} + {% if user.is_rada_member %} + Rada + {% endif %} {% if user.is_verified %} Zweryfikowany {% else %} @@ -1192,6 +1200,18 @@ + + + + + + +
+ + Anuluj +
+ + +{% endblock %} + +{% block extra_js %} +const fileInput = document.getElementById('document'); +const fileUploadArea = document.getElementById('fileUploadArea'); +const selectedFile = document.getElementById('selectedFile'); +const fileName = document.getElementById('fileName'); +const fileSize = document.getElementById('fileSize'); + +fileInput.addEventListener('change', function(e) { + if (this.files.length > 0) { + showSelectedFile(this.files[0]); + } +}); + +fileUploadArea.addEventListener('dragover', function(e) { + e.preventDefault(); + this.classList.add('dragover'); +}); + +fileUploadArea.addEventListener('dragleave', function(e) { + e.preventDefault(); + this.classList.remove('dragover'); +}); + +fileUploadArea.addEventListener('drop', function(e) { + e.preventDefault(); + this.classList.remove('dragover'); + const files = e.dataTransfer.files; + if (files.length > 0) { + fileInput.files = files; + showSelectedFile(files[0]); + } +}); + +function showSelectedFile(file) { + fileName.textContent = file.name; + fileSize.textContent = formatFileSize(file.size); + selectedFile.style.display = 'flex'; + fileUploadArea.style.display = 'none'; +} + +function removeFile() { + fileInput.value = ''; + selectedFile.style.display = 'none'; + fileUploadArea.style.display = 'block'; +} + +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 %} diff --git a/utils/decorators.py b/utils/decorators.py index be4cd9e..0abf497 100644 --- a/utils/decorators.py +++ b/utils/decorators.py @@ -236,6 +236,33 @@ def moderator_required(f): return decorated_function +def rada_member_required(f): + """ + Decorator that requires user to be a member of Rada Izby (Board Council). + OFFICE_MANAGER and ADMIN roles also have access for management purposes. + + Usage: + @bp.route('/rada/') + @login_required + @rada_member_required + def board_index(): + ... + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated: + return redirect(url_for('auth.login')) + + SystemRole = _get_system_role() + # Allow access if: is_rada_member OR has OFFICE_MANAGER/ADMIN role + if not current_user.is_rada_member and not current_user.has_role(SystemRole.OFFICE_MANAGER): + flash('Strefa RADA jest dostępna tylko dla członków Rady Izby.', 'warning') + return redirect(url_for('public.index')) + + return f(*args, **kwargs) + return decorated_function + + # ============================================================ # LEGACY DECORATORS (backward compatibility) # ============================================================