feat: Add Strefa RADA - closed section for Board Council members
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

- 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 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-03 18:41:12 +01:00
parent 4eadcd9a8d
commit 650c0d5760
13 changed files with 1541 additions and 0 deletions

View File

@ -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

View File

@ -286,6 +286,31 @@ def admin_user_toggle_verified(user_id):
db.close()
@bp.route('/users/<int:user_id>/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/<int:user_id>/update', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)

View File

@ -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

258
blueprints/board/routes.py Normal file
View File

@ -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/<id>/download - Download document
- POST /rada/<id>/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('/<int:doc_id>/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('/<int:doc_id>/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'))

View File

@ -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'

View File

@ -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 $$;

1
services/__init__.py Normal file
View File

@ -0,0 +1 @@
# Services package

View File

@ -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)

View File

@ -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 %}
<span class="badge badge-admin">Admin</span>
{% endif %}
{% if user.is_rada_member %}
<span class="badge badge-rada">Rada</span>
{% endif %}
{% if user.is_verified %}
<span class="badge badge-verified">Zweryfikowany</span>
{% else %}
@ -1192,6 +1200,18 @@
</svg>
</button>
<!-- Toggle Rada Member -->
<button class="btn-icon rada-toggle {{ 'active' if user.is_rada_member else '' }}"
onclick="toggleRadaMember({{ user.id }})"
title="{{ 'Usuń z Rady Izby' if user.is_rada_member else 'Dodaj do Rady Izby' }}">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</button>
<!-- Assign Company -->
<button class="btn-icon"
onclick="openCompanyModal({{ user.id }}, '{{ user.name|e }}', {{ user.company_id or 'null' }})"
@ -1834,6 +1854,27 @@ Lub format CSV, Excel, lista emaili..."></textarea>
}
}
async function toggleRadaMember(userId) {
try {
const response = await fetch(`/admin/users/${userId}/toggle-rada-member`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
// Add User functions
function openAddUserModal() {
// Reset form

View File

@ -1105,6 +1105,11 @@
<li><a href="{{ url_for('education.education_index') }}" class="nav-link {% if request.endpoint and 'education' in request.endpoint %}active{% endif %}">Edukacja</a></li>
<!-- Rada Izby - tylko dla członków Rady -->
{% if current_user.is_authenticated and (current_user.is_rada_member or current_user.can_access_admin_panel()) %}
<li><a href="{{ url_for('board.index') }}" class="nav-link {% if request.endpoint and 'board' in request.endpoint %}active{% endif %}">Rada</a></li>
{% endif %}
<!-- Korzyści członkowskie (tylko dla adminów w trybie testowym) -->
{% if current_user.can_access_admin_panel() %}
<li><a href="{{ url_for('benefits.benefits_list') }}" class="nav-link {% if request.endpoint and 'benefits' in request.endpoint %}active{% endif %}">Korzyści</a></li>

398
templates/board/index.html Normal file
View File

@ -0,0 +1,398 @@
{% extends "base.html" %}
{% block title %}Strefa RADA - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.board-header {
margin-bottom: var(--spacing-xl);
}
.board-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.board-header p {
color: var(--text-secondary);
font-size: var(--font-size-lg);
}
.board-info-banner {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border: 1px solid #f59e0b;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
}
.board-info-banner svg {
width: 24px;
height: 24px;
color: #d97706;
flex-shrink: 0;
margin-top: 2px;
}
.board-info-banner .info-content {
flex: 1;
}
.board-info-banner .info-title {
font-weight: 600;
color: #92400e;
margin-bottom: var(--spacing-xs);
}
.board-info-banner .info-text {
color: #a16207;
font-size: var(--font-size-sm);
}
.board-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.board-filters {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.board-filters select {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: white;
min-width: 120px;
}
.btn-upload {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--primary);
color: white;
border-radius: var(--radius-md);
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
}
.btn-upload:hover {
background: var(--primary-dark);
transform: translateY(-1px);
}
.btn-upload svg {
width: 18px;
height: 18px;
}
.documents-table {
width: 100%;
background: white;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
overflow: hidden;
box-shadow: var(--shadow);
}
.documents-table thead {
background: var(--bg-secondary);
}
.documents-table th {
padding: var(--spacing-md);
text-align: left;
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
border-bottom: 1px solid var(--border-color);
}
.documents-table td {
padding: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
vertical-align: middle;
}
.documents-table tbody tr:last-child td {
border-bottom: none;
}
.documents-table tbody tr:hover {
background: var(--bg-secondary);
}
.doc-title {
font-weight: 500;
color: var(--text-primary);
}
.doc-type-badge {
display: inline-block;
padding: 4px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.doc-type-badge.protocol {
background: #dbeafe;
color: #1d4ed8;
}
.doc-type-badge.minutes {
background: #d1fae5;
color: #059669;
}
.doc-type-badge.resolution {
background: #fef3c7;
color: #d97706;
}
.doc-type-badge.report {
background: #e0e7ff;
color: #4f46e5;
}
.doc-type-badge.other {
background: #f3f4f6;
color: #6b7280;
}
.doc-meta {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.doc-actions {
display: flex;
gap: var(--spacing-sm);
}
.btn-download {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--success);
color: white;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
text-decoration: none;
transition: all 0.2s;
}
.btn-download:hover {
background: #059669;
}
.btn-download svg {
width: 16px;
height: 16px;
}
.btn-delete {
display: inline-flex;
align-items: center;
padding: 6px 10px;
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.2s;
}
.btn-delete:hover {
background: var(--danger);
color: white;
}
.btn-delete svg {
width: 16px;
height: 16px;
}
.empty-state {
text-align: center;
padding: var(--spacing-3xl);
color: var(--text-secondary);
}
.empty-state svg {
width: 64px;
height: 64px;
margin-bottom: var(--spacing-md);
color: var(--text-muted);
}
.empty-state h3 {
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
}
@media (max-width: 768px) {
.documents-table {
display: block;
overflow-x: auto;
}
.board-actions {
flex-direction: column;
align-items: flex-start;
}
}
</style>
{% endblock %}
{% block content %}
<div class="board-header">
<h1>
<svg width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="color: #f59e0b;">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Strefa RADA
</h1>
<p>Dokumenty i protokoly z posiedzen Rady Izby NORDA</p>
</div>
<div class="board-info-banner">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<div class="info-content">
<div class="info-title">Strefa z ograniczonym dostepem</div>
<div class="info-text">
Ta sekcja jest dostepna wylacznie dla czlonkow Rady Izby NORDA.
Wszystkie dokumenty sa poufne i przeznaczone tylko do uzytku wewnetrznego.
</div>
</div>
</div>
<div class="board-actions">
<form class="board-filters" method="GET" action="{{ url_for('board.index') }}">
<select name="year" onchange="this.form.submit()">
<option value="">Wszystkie lata</option>
{% for year in available_years %}
<option value="{{ year }}" {% if current_year == year %}selected{% endif %}>{{ year }}</option>
{% endfor %}
</select>
<select name="month" onchange="this.form.submit()">
<option value="">Wszystkie miesiace</option>
{% for m in range(1, 13) %}
<option value="{{ m }}" {% if current_month == m %}selected{% endif %}>
{{ ['Styczen', 'Luty', 'Marzec', 'Kwiecien', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpien', 'Wrzesien', 'Pazdziernik', 'Listopad', 'Grudzien'][m-1] }}
</option>
{% endfor %}
</select>
<select name="type" onchange="this.form.submit()">
<option value="">Wszystkie typy</option>
{% for doc_type in document_types %}
<option value="{{ doc_type }}" {% if current_type == doc_type %}selected{% endif %}>{{ type_labels[doc_type] }}</option>
{% endfor %}
</select>
</form>
{% if can_upload %}
<a href="{{ url_for('board.upload') }}" class="btn-upload">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4"/>
</svg>
Dodaj dokument
</a>
{% endif %}
</div>
{% if documents %}
<table class="documents-table">
<thead>
<tr>
<th>Tytul</th>
<th>Typ</th>
<th>Data posiedzenia</th>
<th>Rozmiar</th>
<th>Dodano</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>
<div class="doc-title">{{ doc.title }}</div>
{% if doc.description %}
<div class="doc-meta">{{ doc.description[:80] }}{% if doc.description|length > 80 %}...{% endif %}</div>
{% endif %}
</td>
<td>
<span class="doc-type-badge {{ doc.document_type }}">{{ doc.type_label }}</span>
</td>
<td class="doc-meta">{{ doc.meeting_date.strftime('%d.%m.%Y') }}</td>
<td class="doc-meta">{{ doc.size_display }}</td>
<td class="doc-meta">{{ doc.uploaded_at.strftime('%d.%m.%Y') }}</td>
<td>
<div class="doc-actions">
<a href="{{ url_for('board.download', doc_id=doc.id) }}" class="btn-download">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Pobierz
</a>
{% if can_upload %}
<form action="{{ url_for('board.delete', doc_id=doc.id) }}" method="POST" style="display: inline;"
onsubmit="return confirm('Czy na pewno chcesz usunac ten dokument?');">
<button type="submit" class="btn-delete">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>
</svg>
<h3>Brak dokumentow</h3>
<p>Nie znaleziono dokumentow spelniajacych kryteria wyszukiwania.</p>
{% if can_upload %}
<a href="{{ url_for('board.upload') }}" class="btn-upload" style="margin-top: var(--spacing-md);">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4"/>
</svg>
Dodaj pierwszy dokument
</a>
{% endif %}
</div>
{% endif %}
{% endblock %}

382
templates/board/upload.html Normal file
View File

@ -0,0 +1,382 @@
{% extends "base.html" %}
{% block title %}Dodaj dokument - Strefa RADA{% endblock %}
{% block extra_css %}
<style>
.upload-container {
max-width: 600px;
margin: 0 auto;
}
.upload-header {
margin-bottom: var(--spacing-xl);
}
.upload-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.upload-header p {
color: var(--text-secondary);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-lg);
}
.back-link:hover {
color: var(--primary);
}
.back-link svg {
width: 16px;
height: 16px;
}
.upload-form {
background: white;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
padding: var(--spacing-xl);
box-shadow: var(--shadow);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-weight: 500;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.form-group label .required {
color: var(--danger);
}
.form-group input[type="text"],
.form-group input[type="date"],
.form-group input[type="number"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.form-hint {
font-size: var(--font-size-sm);
color: var(--text-muted);
margin-top: var(--spacing-xs);
}
.file-upload-area {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
text-align: center;
background: var(--bg-secondary);
transition: all 0.2s;
cursor: pointer;
}
.file-upload-area:hover,
.file-upload-area.dragover {
border-color: var(--primary);
background: rgba(37, 99, 235, 0.05);
}
.file-upload-area svg {
width: 48px;
height: 48px;
color: var(--text-muted);
margin-bottom: var(--spacing-md);
}
.file-upload-area p {
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.file-upload-area .file-types {
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.file-upload-area input[type="file"] {
display: none;
}
.selected-file {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--radius-md);
margin-top: var(--spacing-md);
}
.selected-file svg {
width: 24px;
height: 24px;
color: var(--success);
}
.selected-file .file-name {
flex: 1;
font-weight: 500;
}
.selected-file .file-size {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.btn-remove-file {
background: none;
border: none;
color: var(--danger);
cursor: pointer;
padding: 4px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
}
.form-actions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border-color);
}
.btn-submit {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius-md);
font-weight: 500;
font-size: var(--font-size-base);
cursor: pointer;
transition: all 0.2s;
}
.btn-submit:hover {
background: var(--primary-dark);
}
.btn-submit svg {
width: 18px;
height: 18px;
}
.btn-cancel {
display: inline-flex;
align-items: center;
padding: 12px 24px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
}
.btn-cancel:hover {
background: var(--bg-secondary);
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
}
</style>
{% endblock %}
{% block content %}
<div class="upload-container">
<a href="{{ url_for('board.index') }}" class="back-link">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M15 19l-7-7 7-7"/>
</svg>
Powrot do listy dokumentow
</a>
<div class="upload-header">
<h1>Dodaj nowy dokument</h1>
<p>Dodaj protokol lub inny dokument z posiedzenia Rady Izby</p>
</div>
<form class="upload-form" method="POST" enctype="multipart/form-data">
<div class="form-group">
<label for="title">Tytul dokumentu <span class="required">*</span></label>
<input type="text" id="title" name="title" required
value="{{ form_data.get('title', '') }}"
placeholder="np. Protokol z posiedzenia Rady Izby - Luty 2026">
</div>
<div class="form-row">
<div class="form-group">
<label for="document_type">Typ dokumentu</label>
<select id="document_type" name="document_type">
{% for doc_type in document_types %}
<option value="{{ doc_type }}"
{% if form_data.get('document_type') == doc_type %}selected{% endif %}>
{{ type_labels[doc_type] }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="meeting_date">Data posiedzenia <span class="required">*</span></label>
<input type="date" id="meeting_date" name="meeting_date" required
value="{{ form_data.get('meeting_date', '') }}">
</div>
</div>
<div class="form-group">
<label for="meeting_number">Numer posiedzenia (opcjonalnie)</label>
<input type="number" id="meeting_number" name="meeting_number" min="1"
value="{{ form_data.get('meeting_number', '') }}"
placeholder="np. 12">
<div class="form-hint">Numer kolejny posiedzenia w danym roku</div>
</div>
<div class="form-group">
<label for="description">Opis (opcjonalnie)</label>
<textarea id="description" name="description"
placeholder="Krotki opis zawartosci dokumentu...">{{ form_data.get('description', '') }}</textarea>
</div>
<div class="form-group">
<label>Plik dokumentu <span class="required">*</span></label>
<div class="file-upload-area" id="fileUploadArea" onclick="document.getElementById('document').click()">
<svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
<p>Kliknij lub przeciagnij plik tutaj</p>
<span class="file-types">PDF, DOCX lub DOC (max 50MB)</span>
<input type="file" id="document" name="document" accept=".pdf,.docx,.doc" required>
</div>
<div id="selectedFile" class="selected-file" style="display: none;">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="file-name" id="fileName"></span>
<span class="file-size" id="fileSize"></span>
<button type="button" class="btn-remove-file" onclick="removeFile()">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-submit">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
Dodaj dokument
</button>
<a href="{{ url_for('board.index') }}" class="btn-cancel">Anuluj</a>
</div>
</form>
</div>
{% 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 %}

View File

@ -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)
# ============================================================