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
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:
parent
4eadcd9a8d
commit
650c0d5760
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
16
blueprints/board/__init__.py
Normal file
16
blueprints/board/__init__.py
Normal 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
258
blueprints/board/routes.py
Normal 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'))
|
||||
69
database.py
69
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'
|
||||
|
||||
74
database/migrations/048_board_documents.sql
Normal file
74
database/migrations/048_board_documents.sql
Normal 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
1
services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Services package
|
||||
237
services/document_upload_service.py
Normal file
237
services/document_upload_service.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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
398
templates/board/index.html
Normal 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
382
templates/board/upload.html
Normal 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 %}
|
||||
@ -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)
|
||||
# ============================================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user