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
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:
parent
86d10c8d70
commit
4c20e17855
@ -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
|
||||
|
||||
@ -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 %}
|
||||
|
||||
247
templates/board/view_document.html
Normal file
247
templates/board/view_document.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user