feat: Add document preview and board members list to Strefa RADA
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/<id>/view endpoint for document preview
- PDF files displayed inline in browser
- DOCX files converted to HTML using mammoth library
- Add board members section showing all is_rada_member users
- Add "Podgląd" button next to "Pobierz" in document list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-03 18:53:54 +01:00
parent 86d10c8d70
commit 4c20e17855
3 changed files with 482 additions and 6 deletions

View File

@ -5,8 +5,9 @@ Board Routes (Rada Izby)
Routes for board document management.
Endpoints:
- GET /rada/ - Document list
- GET /rada/ - Document list + board members
- GET/POST /rada/upload - Upload new document (office_manager+)
- GET /rada/<id>/view - View document in browser
- GET /rada/<id>/download - Download document
- POST /rada/<id>/delete - Soft delete document (office_manager+)
"""
@ -15,13 +16,13 @@ import os
from datetime import datetime
from flask import (
render_template, request, redirect, url_for, flash,
send_file, current_app
send_file, current_app, Response
)
from flask_login import login_required, current_user
from sqlalchemy import desc, extract
from . import bp
from database import SessionLocal, BoardDocument, SystemRole
from database import SessionLocal, BoardDocument, SystemRole, User
from utils.decorators import rada_member_required, office_manager_required
from services.document_upload_service import DocumentUploadService
@ -30,7 +31,7 @@ from services.document_upload_service import DocumentUploadService
@login_required
@rada_member_required
def index():
"""Display list of board documents with filtering"""
"""Display list of board documents and board members"""
# Get filter parameters
year = request.args.get('year', type=int)
month = request.args.get('month', type=int)
@ -38,7 +39,7 @@ def index():
db = SessionLocal()
try:
# Build query
# Build query for documents
query = db.query(BoardDocument).filter(BoardDocument.is_active == True)
if year:
@ -57,6 +58,12 @@ def index():
doc.meeting_date.year for doc in all_docs if doc.meeting_date
), reverse=True)
# Get board members (is_rada_member = True)
board_members = db.query(User).filter(
User.is_rada_member == True,
User.is_active == True
).order_by(User.name).all()
# Check if user can upload (office_manager or admin)
can_upload = current_user.has_role(SystemRole.OFFICE_MANAGER)
@ -69,12 +76,90 @@ def index():
current_year=year,
current_month=month,
current_type=doc_type,
can_upload=can_upload
can_upload=can_upload,
board_members=board_members
)
finally:
db.close()
@bp.route('/<int:doc_id>/view')
@login_required
@rada_member_required
def view(doc_id):
"""View document in browser (PDF inline, DOCX converted to HTML)"""
db = SessionLocal()
try:
document = db.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 view
current_app.logger.info(
f"Board document viewed: {document.title} (ID: {doc_id}) by user {current_user.id}"
)
# Handle based on file type
if document.file_extension == 'pdf':
# Display PDF inline in browser
return send_file(
file_path,
as_attachment=False,
download_name=document.original_filename,
mimetype='application/pdf'
)
elif document.file_extension in ('docx', 'doc'):
# Convert DOCX to HTML using mammoth
try:
import mammoth
with open(file_path, 'rb') as docx_file:
result = mammoth.convert_to_html(docx_file)
html_content = result.value
# Render in template with styling
return render_template(
'board/view_document.html',
document=document,
html_content=html_content,
conversion_messages=result.messages
)
except ImportError:
current_app.logger.error("mammoth library not installed")
flash('Podgląd dokumentów DOCX nie jest dostępny.', 'error')
return redirect(url_for('board.index'))
except Exception as e:
current_app.logger.error(f"Failed to convert DOCX: {e}")
flash('Błąd podczas konwersji dokumentu.', 'error')
return redirect(url_for('board.index'))
else:
# Unknown format - redirect to download
flash('Podgląd tego typu pliku nie jest obsługiwany.', 'warning')
return redirect(url_for('board.download', doc_id=doc_id))
finally:
db.close()
@bp.route('/upload', methods=['GET', 'POST'])
@login_required
@office_manager_required

View File

@ -209,6 +209,28 @@
height: 16px;
}
.btn-view {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--primary);
color: white;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
text-decoration: none;
transition: all 0.2s;
}
.btn-view:hover {
background: var(--primary-dark);
}
.btn-view svg {
width: 16px;
height: 16px;
}
.btn-delete {
display: inline-flex;
align-items: center;
@ -250,6 +272,85 @@
color: var(--text-primary);
}
/* Board Members Section */
.board-members-section {
margin-top: var(--spacing-3xl);
padding-top: var(--spacing-xl);
border-top: 1px solid var(--border-color);
}
.board-members-section h2 {
font-size: var(--font-size-xl);
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.board-members-section h2 svg {
width: 24px;
height: 24px;
color: #f59e0b;
}
.members-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-md);
}
.member-card {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--spacing-md);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.member-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: #92400e;
font-size: var(--font-size-lg);
flex-shrink: 0;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-company {
font-size: var(--font-size-sm);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-count {
font-size: var(--font-size-sm);
color: var(--text-muted);
margin-left: var(--spacing-sm);
}
@media (max-width: 768px) {
.documents-table {
display: block;
@ -260,6 +361,10 @@
flex-direction: column;
align-items: flex-start;
}
.members-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
@ -356,6 +461,13 @@
<td class="doc-meta">{{ doc.uploaded_at.strftime('%d.%m.%Y') }}</td>
<td>
<div class="doc-actions">
<a href="{{ url_for('board.view', doc_id=doc.id) }}" class="btn-view" target="_blank">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
Podglad
</a>
<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"/>
@ -395,4 +507,36 @@
{% endif %}
</div>
{% endif %}
<!-- Board Members Section -->
<div class="board-members-section">
<h2>
<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>
Czlonkowie Rady Izby
<span class="member-count">({{ board_members|length }} osob)</span>
</h2>
<div class="members-grid">
{% for member in board_members %}
<div class="member-card">
<div class="member-avatar">
{{ member.name[:1].upper() if member.name else member.email[:1].upper() }}
</div>
<div class="member-info">
<div class="member-name">{{ member.name or member.email.split('@')[0] }}</div>
{% if member.company %}
<div class="member-company">{{ member.company.name }}</div>
{% endif %}
</div>
</div>
{% else %}
<p style="color: var(--text-muted);">Brak przypisanych czlonkow Rady.</p>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,247 @@
{% extends "base.html" %}
{% block title %}{{ document.title }} - Strefa RADA{% endblock %}
{% block extra_css %}
<style>
.document-viewer {
max-width: 900px;
margin: 0 auto;
}
.document-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
}
.document-info h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.document-meta {
display: flex;
gap: var(--spacing-lg);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.document-meta span {
display: flex;
align-items: center;
gap: 6px;
}
.document-meta svg {
width: 16px;
height: 16px;
}
.document-actions {
display: flex;
gap: var(--spacing-sm);
}
.btn-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
text-decoration: none;
transition: all 0.2s;
}
.btn-action svg {
width: 16px;
height: 16px;
}
.btn-download {
background: var(--success);
color: white;
}
.btn-download:hover {
background: #059669;
}
.btn-back {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.btn-back:hover {
background: var(--bg-tertiary);
}
.document-content {
background: white;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
padding: var(--spacing-xl);
box-shadow: var(--shadow);
min-height: 500px;
}
/* Styling for converted DOCX content */
.document-content h1 {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-lg);
color: var(--text-primary);
}
.document-content h2 {
font-size: var(--font-size-xl);
margin-top: var(--spacing-xl);
margin-bottom: var(--spacing-md);
color: var(--text-primary);
}
.document-content h3 {
font-size: var(--font-size-lg);
margin-top: var(--spacing-lg);
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
}
.document-content p {
margin-bottom: var(--spacing-md);
line-height: 1.7;
color: var(--text-primary);
}
.document-content ul, .document-content ol {
margin-bottom: var(--spacing-md);
padding-left: var(--spacing-xl);
}
.document-content li {
margin-bottom: var(--spacing-xs);
line-height: 1.6;
}
.document-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--spacing-lg);
}
.document-content th, .document-content td {
border: 1px solid var(--border-color);
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
}
.document-content th {
background: var(--bg-secondary);
font-weight: 600;
}
.document-content img {
max-width: 100%;
height: auto;
margin: var(--spacing-md) 0;
}
.document-content strong, .document-content b {
font-weight: 600;
}
.document-content em, .document-content i {
font-style: italic;
}
.conversion-warning {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: var(--radius-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-lg);
font-size: var(--font-size-sm);
color: #92400e;
}
.conversion-warning svg {
width: 18px;
height: 18px;
vertical-align: middle;
margin-right: 6px;
}
@media print {
.document-header {
display: none;
}
.document-content {
border: none;
box-shadow: none;
padding: 0;
}
}
</style>
{% endblock %}
{% block content %}
<div class="document-viewer">
<div class="document-header">
<div class="document-info">
<h1>{{ document.title }}</h1>
<div class="document-meta">
<span>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{{ document.meeting_date.strftime('%d.%m.%Y') }}
</span>
<span>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ document.type_label }}
</span>
<span>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
</svg>
{{ document.size_display }}
</span>
</div>
</div>
<div class="document-actions">
<a href="{{ url_for('board.download', doc_id=document.id) }}" class="btn-action 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>
<a href="{{ url_for('board.index') }}" class="btn-action btn-back">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M15 19l-7-7 7-7"/>
</svg>
Powrot
</a>
</div>
</div>
{% if conversion_messages %}
<div class="conversion-warning">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
Niektore elementy dokumentu mogly nie zostac poprawnie skonwertowane.
</div>
{% endif %}
<div class="document-content">
{{ html_content | safe }}
</div>
</div>
{% endblock %}