feat: activate board document upload/download with meeting 2/2026 import
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 document management routes (upload, download, soft-delete) to board blueprint,
link BoardDocument to BoardMeeting via meeting_id FK, add documents section to
meeting view template, and include import scripts for meeting 2/2026 data and PDFs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-20 12:04:44 +01:00
parent 0b82419753
commit a8f2178b7e
6 changed files with 743 additions and 3 deletions

View File

@ -2,7 +2,7 @@
Board Routes (Rada Izby) Board Routes (Rada Izby)
======================== ========================
Routes for board meeting management and PDF generation. Routes for board meeting management, document handling, and PDF generation.
Endpoints - Meetings: Endpoints - Meetings:
- GET /rada/ - List all meetings + board members - GET /rada/ - List all meetings + board members
@ -14,20 +14,27 @@ Endpoints - Meetings:
- POST /rada/posiedzenia/<id>/publikuj-protokol - Publish protocol (office_manager+) - POST /rada/posiedzenia/<id>/publikuj-protokol - Publish protocol (office_manager+)
- GET /rada/posiedzenia/<id>/pdf-program - Download agenda PDF - GET /rada/posiedzenia/<id>/pdf-program - Download agenda PDF
- GET /rada/posiedzenia/<id>/pdf-protokol - Download protocol PDF - GET /rada/posiedzenia/<id>/pdf-protokol - Download protocol PDF
Endpoints - Documents:
- POST /rada/posiedzenia/<id>/dokumenty/dodaj - Upload document (office_manager+)
- GET /rada/dokumenty/<id>/pobierz - Download document (rada_member+)
- POST /rada/dokumenty/<id>/usun - Soft delete document (office_manager+)
""" """
import os
from datetime import datetime from datetime import datetime
from flask import ( from flask import (
render_template, request, redirect, url_for, flash, render_template, request, redirect, url_for, flash,
current_app, Response current_app, Response, send_file
) )
from flask_login import login_required, current_user from flask_login import login_required, current_user
from sqlalchemy import desc from sqlalchemy import desc
from . import bp from . import bp
from database import SessionLocal, BoardMeeting, SystemRole, User from database import SessionLocal, BoardMeeting, BoardDocument, SystemRole, User
from utils.decorators import rada_member_required, office_manager_required from utils.decorators import rada_member_required, office_manager_required
from utils.helpers import sanitize_html from utils.helpers import sanitize_html
from services.document_upload_service import DocumentUploadService
from datetime import date, time from datetime import date, time
try: try:
@ -215,12 +222,19 @@ def meeting_view(meeting_id):
User.is_active == True User.is_active == True
).order_by(User.name).all() ).order_by(User.name).all()
# Get documents for this meeting
documents = db.query(BoardDocument).filter(
BoardDocument.meeting_id == meeting_id,
BoardDocument.is_active == True
).order_by(BoardDocument.document_type, BoardDocument.title).all()
can_manage = current_user.has_role(SystemRole.OFFICE_MANAGER) can_manage = current_user.has_role(SystemRole.OFFICE_MANAGER)
return render_template( return render_template(
'board/meeting_view.html', 'board/meeting_view.html',
meeting=meeting, meeting=meeting,
board_members=board_members, board_members=board_members,
documents=documents,
can_manage=can_manage can_manage=can_manage
) )
finally: finally:
@ -407,6 +421,158 @@ def meeting_pdf_protocol(meeting_id):
return _generate_meeting_pdf(meeting_id, 'protocol') return _generate_meeting_pdf(meeting_id, 'protocol')
# =============================================================================
# DOCUMENT ROUTES
# =============================================================================
@bp.route('/posiedzenia/<int:meeting_id>/dokumenty/dodaj', methods=['POST'])
@login_required
@office_manager_required
def document_upload(meeting_id):
"""Upload document to a board meeting"""
db = SessionLocal()
try:
meeting = db.query(BoardMeeting).filter(
BoardMeeting.id == meeting_id
).first()
if not meeting:
flash('Posiedzenie nie zostało znalezione.', 'error')
return redirect(url_for('board.index'))
file = request.files.get('document')
if not file:
flash('Nie wybrano pliku.', 'error')
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
# Validate file
is_valid, error_msg = DocumentUploadService.validate_file(file)
if not is_valid:
flash(error_msg, 'error')
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
# Save file
stored_filename, file_path, file_size, mime_type = DocumentUploadService.save_file(file)
# Get form data
title = request.form.get('title', '').strip()
if not title:
title = file.filename
document_type = request.form.get('document_type', 'other')
description = request.form.get('description', '').strip() or None
# Get file extension
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
# Create database record
doc = BoardDocument(
title=title,
description=description,
document_type=document_type,
meeting_id=meeting_id,
meeting_date=meeting.meeting_date,
meeting_number=meeting.meeting_number,
original_filename=file.filename,
stored_filename=stored_filename,
file_extension=ext,
file_size=file_size,
mime_type=mime_type,
uploaded_by=current_user.id
)
db.add(doc)
db.commit()
current_app.logger.info(
f"Board document uploaded: '{title}' for meeting {meeting.meeting_identifier} "
f"by user {current_user.id}"
)
flash(f'Dokument „{title}" został dodany.', 'success')
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
except Exception as e:
db.rollback()
current_app.logger.error(f"Failed to upload board document: {e}")
flash('Błąd podczas dodawania dokumentu.', 'error')
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
finally:
db.close()
@bp.route('/dokumenty/<int:doc_id>/pobierz')
@login_required
@rada_member_required
def document_download(doc_id):
"""Download a board document"""
db = SessionLocal()
try:
doc = db.query(BoardDocument).filter(
BoardDocument.id == doc_id,
BoardDocument.is_active == True
).first()
if not doc:
flash('Dokument nie został znaleziony.', 'error')
return redirect(url_for('board.index'))
file_path = DocumentUploadService.get_file_path(
doc.stored_filename, doc.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 na serwerze.', 'error')
return redirect(url_for('board.meeting_view', meeting_id=doc.meeting_id))
return send_file(
file_path,
mimetype=doc.mime_type,
as_attachment=True,
download_name=doc.original_filename
)
finally:
db.close()
@bp.route('/dokumenty/<int:doc_id>/usun', methods=['POST'])
@login_required
@office_manager_required
def document_delete(doc_id):
"""Soft delete a board document"""
db = SessionLocal()
try:
doc = db.query(BoardDocument).filter(
BoardDocument.id == doc_id,
BoardDocument.is_active == True
).first()
if not doc:
flash('Dokument nie został znaleziony.', 'error')
return redirect(url_for('board.index'))
meeting_id = doc.meeting_id
doc_title = doc.title
# Soft delete — file stays on disk
doc.is_active = False
doc.updated_by = current_user.id
doc.updated_at = datetime.now()
db.commit()
current_app.logger.info(
f"Board document soft-deleted: '{doc_title}' (id={doc_id}) by user {current_user.id}"
)
flash(f'Dokument „{doc_title}" został usunięty.', 'success')
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
except Exception as e:
db.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'))
finally:
db.close()
# ============================================================================= # =============================================================================
# MEETING FORM HANDLER # MEETING FORM HANDLER
# ============================================================================= # =============================================================================

View File

@ -1753,6 +1753,7 @@ class BoardDocument(Base):
document_type = Column(String(50), default='protocol') # protocol, minutes, resolution, report, other document_type = Column(String(50), default='protocol') # protocol, minutes, resolution, report, other
# Meeting reference # Meeting reference
meeting_id = Column(Integer, ForeignKey('board_meetings.id'))
meeting_date = Column(Date, nullable=False) meeting_date = Column(Date, nullable=False)
meeting_number = Column(Integer) # Sequential meeting number (optional) meeting_number = Column(Integer) # Sequential meeting number (optional)
@ -1773,6 +1774,7 @@ class BoardDocument(Base):
is_active = Column(Boolean, default=True) # Soft delete is_active = Column(Boolean, default=True) # Soft delete
# Relationships # Relationships
meeting = relationship('BoardMeeting', backref='documents')
uploader = relationship('User', foreign_keys=[uploaded_by]) uploader = relationship('User', foreign_keys=[uploaded_by])
editor = relationship('User', foreign_keys=[updated_by]) editor = relationship('User', foreign_keys=[updated_by])

View File

@ -0,0 +1,11 @@
-- Migration 074: Add meeting_id FK to board_documents
-- Links documents directly to board_meetings for reliable associations
-- Date: 2026-02-20
ALTER TABLE board_documents
ADD COLUMN IF NOT EXISTS meeting_id INTEGER REFERENCES board_meetings(id);
CREATE INDEX IF NOT EXISTS idx_board_documents_meeting_id ON board_documents(meeting_id);
-- Grant permissions
GRANT ALL ON TABLE board_documents TO nordabiz_app;

View File

@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""
Import Board Documents for Meeting 2/2026.
Copies PDF files to /data/board-docs/ with UUID filenames and creates
BoardDocument records linked to the meeting.
Prerequisites:
- Meeting 2/2026 must already exist (run import_board_meeting_2_2026.py first)
- PDF files must be placed in /tmp/ on the target server:
/tmp/INPI_NORDABIZNES_PROTOKOL_RADA_2026-02-04.pdf
/tmp/INPI_NORDABIZNES_LISTA_OBECNOSCI_RADA_2026-02-04.pdf
Usage:
# Local dev:
python3 scripts/import_board_documents_2_2026.py
# Production:
DATABASE_URL=$(grep DATABASE_URL .env | cut -d'=' -f2) \
/var/www/nordabiznes/venv/bin/python3 scripts/import_board_documents_2_2026.py
"""
import os
import sys
import shutil
import uuid
from datetime import date, datetime
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import SessionLocal, BoardMeeting, BoardDocument, User
UPLOAD_BASE = '/data/board-docs'
# Source files (expected in /tmp/ after SCP)
FILES = [
{
'source': '/tmp/INPI_NORDABIZNES_PROTOKOL_RADA_2026-02-04.pdf',
'title': 'Protokół z posiedzenia Rady nr 2/2026',
'document_type': 'protocol',
'description': 'Pełny protokół z posiedzenia Rady Izby nr 2/2026 z dnia 04.02.2026',
'original_filename': 'INPI_NORDABIZNES_PROTOKOL_RADA_2026-02-04.pdf',
},
{
'source': '/tmp/INPI_NORDABIZNES_LISTA_OBECNOSCI_RADA_2026-02-04.pdf',
'title': 'Lista obecności z podpisami - posiedzenie Rady nr 2/2026',
'document_type': 'minutes',
'description': 'Skan listy obecności z fizycznymi podpisami członków Rady',
'original_filename': 'INPI_NORDABIZNES_LISTA_OBECNOSCI_RADA_2026-02-04.pdf',
},
]
def main():
db = SessionLocal()
try:
# Find meeting 2/2026
meeting = db.query(BoardMeeting).filter(
BoardMeeting.meeting_number == 2,
BoardMeeting.year == 2026
).first()
if not meeting:
print("ERROR: Meeting 2/2026 not found. Run import_board_meeting_2_2026.py first.")
return False
print(f"Found meeting 2/2026 (id={meeting.id})")
# Find admin/office_manager as uploader
uploader = db.query(User).filter(
User.role.in_(['ADMIN', 'OFFICE_MANAGER']),
User.is_active == True
).first()
if not uploader:
print("ERROR: No ADMIN or OFFICE_MANAGER user found.")
return False
print(f"Uploader: {uploader.name} (id={uploader.id})")
# Create target directory
target_dir = os.path.join(UPLOAD_BASE, '2026', '02')
os.makedirs(target_dir, exist_ok=True)
print(f"Target directory: {target_dir}")
imported = 0
for file_info in FILES:
source = file_info['source']
# Check if already imported (by title + meeting_id)
existing = db.query(BoardDocument).filter(
BoardDocument.meeting_id == meeting.id,
BoardDocument.title == file_info['title'],
BoardDocument.is_active == True
).first()
if existing:
print(f" SKIP: '{file_info['title']}' already exists (id={existing.id})")
continue
# Check source file
if not os.path.exists(source):
print(f" WARNING: Source file not found: {source}")
print(f" SCP the file to the server first:")
print(f" scp <local-path> maciejpi@<server>:{source}")
continue
# Generate UUID filename
stored_filename = f"{uuid.uuid4()}.pdf"
target_path = os.path.join(target_dir, stored_filename)
# Copy file
shutil.copy2(source, target_path)
file_size = os.path.getsize(target_path)
print(f" Copied: {source} -> {target_path} ({file_size} bytes)")
# Create database record
doc = BoardDocument(
title=file_info['title'],
description=file_info['description'],
document_type=file_info['document_type'],
meeting_id=meeting.id,
meeting_date=date(2026, 2, 4),
meeting_number=2,
original_filename=file_info['original_filename'],
stored_filename=stored_filename,
file_extension='pdf',
file_size=file_size,
mime_type='application/pdf',
uploaded_by=uploader.id,
uploaded_at=datetime(2026, 2, 20, 12, 0),
)
db.add(doc)
db.flush()
print(f" Created BoardDocument id={doc.id}: '{file_info['title']}'")
imported += 1
db.commit()
print(f"\nImported {imported} document(s) for meeting 2/2026.")
return True
except Exception as e:
db.rollback()
print(f"ERROR: {e}")
import traceback
traceback.print_exc()
return False
finally:
db.close()
if __name__ == '__main__':
success = main()
if not success:
sys.exit(1)

View File

@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""
Import Board Meeting 2/2026 (04.02.2026) data.
One-time script to create BoardMeeting record with full agenda,
attendance, proceedings and decisions from the February 4 session.
Usage:
# Local dev:
python3 scripts/import_board_meeting_2_2026.py
# Production:
DATABASE_URL=$(grep DATABASE_URL .env | cut -d'=' -f2) \
/var/www/nordabiznes/venv/bin/python3 scripts/import_board_meeting_2_2026.py
"""
import os
import sys
from datetime import date, time, datetime
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import SessionLocal, BoardMeeting, User
def get_user_by_email(db, email):
"""Find user by email, return None if not found."""
return db.query(User).filter(User.email == email).first()
def main():
db = SessionLocal()
try:
# Check if meeting already exists
existing = db.query(BoardMeeting).filter(
BoardMeeting.meeting_number == 2,
BoardMeeting.year == 2026
).first()
if existing:
print(f"Meeting 2/2026 already exists (id={existing.id}). Skipping.")
return existing.id
# Resolve key users
chairperson = get_user_by_email(db, 'leszek@rotor.pl')
secretary = get_user_by_email(db, 'magdalena.kloska@norda-biznes.info')
# Fallback: try partial match for secretary
if not secretary:
secretary = db.query(User).filter(User.email.like('%kloska%')).first()
# Find admin/office_manager as creator
creator = db.query(User).filter(
User.role.in_(['ADMIN', 'OFFICE_MANAGER']),
User.is_active == True
).first()
if not creator:
print("ERROR: No ADMIN or OFFICE_MANAGER user found. Cannot set created_by.")
return None
print(f"Chairperson: {chairperson.name if chairperson else 'NOT FOUND'} (id={chairperson.id if chairperson else '?'})")
print(f"Secretary: {secretary.name if secretary else 'NOT FOUND'} (id={secretary.id if secretary else '?'})")
print(f"Creator: {creator.name} (id={creator.id})")
# Build attendance map: email -> status
PRESENT_EMAILS = [
'leszek@rotor.pl', # Glaza
'andrzej.gorczycki@zukwejherowo.pl', # Gorczycki
'pawel.kwidzinski@norda-biznes.info', # Kwidzinski
'dariusz.schmidtke@tkchopin.pl', # Schmidtke
'artur.wiertel@norda-biznes.info', # Wiertel
'a.jedrzejewski@scrol.pl', # Jedrzejewski
'info@greenhousesystems.pl', # Piechocka
'jm@hebel-masiak.pl', # Masiak
'kuba@bormax.com.pl', # Bornowski
'pawel.piechota@norda-biznes.info', # Piechota
'radoslaw@skwarlo.pl', # Skwarlo
'roman@sigmabudownictwo.pl', # Wiercinski
'mjwesierski@gmail.com', # Wesierski
]
ABSENT_EMAILS = [
'iwonamusial@cristap.pl', # Musial
'krzysztof.kubis@sibuk.pl', # Kubis
'jacek.pomieczynski@eura-tech.eu', # Pomieczynski
]
attendance = {}
for email in PRESENT_EMAILS:
user = get_user_by_email(db, email)
if user:
# Generate initials from name
initials = ''.join(p[0].upper() for p in (user.name or '').split() if p) or ''
attendance[str(user.id)] = {
'status': 'present',
'present': True,
'initials': initials
}
else:
print(f" WARNING: User not found: {email}")
for email in ABSENT_EMAILS:
user = get_user_by_email(db, email)
if user:
initials = ''.join(p[0].upper() for p in (user.name or '').split() if p) or ''
attendance[str(user.id)] = {
'status': 'absent',
'present': False,
'initials': initials
}
else:
print(f" WARNING: User not found: {email}")
present_count = sum(1 for a in attendance.values() if a['present'])
print(f"Attendance: {present_count}/{len(attendance)} present")
# Agenda items (15 points)
agenda_items = [
{"time_start": "16:00", "time_end": "16:05", "title": "Otwarcie posiedzenia i przyjęcie programu"},
{"time_start": "16:05", "time_end": "16:10", "title": "Przyjęcie protokołu z posiedzenia nr 1/2026"},
{"time_start": "16:10", "time_end": "16:30", "title": "Prezentacja i głosowanie nad kandydatami na nowych członków Izby"},
{"time_start": "16:30", "time_end": "16:45", "title": "Informacja o stanie finansów Izby"},
{"time_start": "16:45", "time_end": "17:00", "title": "Omówienie składek członkowskich na 2026 rok"},
{"time_start": "17:00", "time_end": "17:15", "title": "Spotkanie dot. marketingu i komunikacji"},
{"time_start": "17:15", "time_end": "17:30", "title": "Planowanie grilla integracyjnego"},
{"time_start": "17:30", "time_end": "17:45", "title": "Obchody 30-lecia Izby"},
{"time_start": "17:45", "time_end": "18:00", "title": "Konkurs Tytani Przedsiębiorczości"},
{"time_start": "18:00", "time_end": "18:10", "title": "Aplikacja NordaBiznes — aktualizacja"},
{"time_start": "18:10", "time_end": "18:20", "title": "Współpraca z samorządem"},
{"time_start": "18:20", "time_end": "18:30", "title": "Walne Zgromadzenie Członków — termin"},
{"time_start": "18:30", "time_end": "18:40", "title": "Ustalenie terminu kolejnego posiedzenia Rady"},
{"time_start": "18:40", "time_end": "18:55", "title": "Wolne wnioski i dyskusja"},
{"time_start": "18:55", "time_end": "19:00", "title": "Zamknięcie posiedzenia"},
]
# Proceedings (key discussions and decisions)
proceedings = [
{
"agenda_item": 0,
"title": "Otwarcie posiedzenia i przyjęcie programu",
"discussion": "Prezes Leszek Glaza otworzył posiedzenie, powitał obecnych członków Rady. Stwierdzono kworum (13 z 16 członków). Program posiedzenia przyjęto jednogłośnie.",
"decisions": ["Program posiedzenia nr 2/2026 przyjęty jednogłośnie"],
"tasks": []
},
{
"agenda_item": 1,
"title": "Przyjęcie protokołu z posiedzenia nr 1/2026",
"discussion": "Protokół z posiedzenia Rady nr 1/2026 z dnia 07.01.2026 został przedstawiony członkom Rady.",
"decisions": ["Protokół z posiedzenia nr 1/2026 przyjęty jednogłośnie"],
"tasks": []
},
{
"agenda_item": 2,
"title": "Prezentacja i głosowanie nad kandydatami na nowych członków Izby",
"discussion": "Przedstawiono 5 kandydatów na nowych członków Izby Przedsiębiorców NORDA:\n\n1. Konkol Sp. z o.o. — branża budowlana, rekomendacja od członka Rady\n2. Ibet Sp. z o.o. — producent kostki brukowej i elementów betonowych\n3. Audioline — usługi audiologiczne\n4. PC Invest — inwestycje i nieruchomości\n5. Pacific Sun / Fiume — branża turystyczna i gastronomiczna\n\nKażdy kandydat został krótko przedstawiony wraz z uzasadnieniem rekomendacji.",
"decisions": [
"Przyjęto jednogłośnie firmę Konkol Sp. z o.o. jako nowego członka Izby",
"Przyjęto jednogłośnie firmę Ibet Sp. z o.o. jako nowego członka Izby",
"Przyjęto jednogłośnie firmę Audioline jako nowego członka Izby",
"Przyjęto jednogłośnie firmę PC Invest jako nowego członka Izby",
"Przyjęto jednogłośnie firmę Pacific Sun / Fiume jako nowego członka Izby"
],
"tasks": ["Przygotować dokumenty przyjęcia dla 5 nowych członków"]
},
{
"agenda_item": 3,
"title": "Informacja o stanie finansów Izby",
"discussion": "Przedstawiono bieżący stan finansów Izby. Omówiono wpływy ze składek oraz wydatki operacyjne.",
"decisions": [],
"tasks": []
},
{
"agenda_item": 4,
"title": "Omówienie składek członkowskich na 2026 rok",
"discussion": "Dyskutowano nad wysokością składek członkowskich od stycznia 2026. Zaproponowano podział na małe i duże firmy.",
"decisions": [
"Składki od 01.2026: małe firmy 200 zł/miesiąc, duże firmy 300 zł/miesiąc (głosowanie: 12 za, 0 przeciw, 1 wstrzymujący się)"
],
"tasks": ["Przygotować informację o nowych stawkach składek dla członków"]
},
{
"agenda_item": 5,
"title": "Spotkanie dot. marketingu i komunikacji",
"discussion": "Omówiono potrzebę spotkania roboczego dot. strategii social media i komunikacji marketingowej Izby.",
"decisions": [
"Spotkanie ws. social media wyznaczone na 18.02.2026, godz. 09:00, Ekofabryka"
],
"tasks": ["Przygotować spotkanie ws. social media na 18.02.2026"]
},
{
"agenda_item": 6,
"title": "Planowanie grilla integracyjnego",
"discussion": "Omówiono organizację grilla integracyjnego dla członków Izby. Zaproponowano termin i lokalizację.",
"decisions": [
"Grill integracyjny: 16.05.2026, strzelnica / Bractwo Kurkowe Wejherowo"
],
"tasks": ["Zarezerwować lokalizację na grill integracyjny 16.05.2026"]
},
{
"agenda_item": 7,
"title": "Obchody 30-lecia Izby",
"discussion": "Omówiono plany obchodów 30-lecia istnienia Izby Przedsiębiorców NORDA. Powołano komitet organizacyjny.",
"decisions": [
"Komitet 30-lecia Izby: DS, AG, RW + Zarząd"
],
"tasks": ["Komitet 30-lecia — rozpocząć planowanie obchodów"]
},
{
"agenda_item": 8,
"title": "Konkurs Tytani Przedsiębiorczości",
"discussion": "Omówiono stan przygotowań do kolejnej edycji konkursu Tytani Przedsiębiorczości Powiatu Wejherowskiego.",
"decisions": [],
"tasks": []
},
{
"agenda_item": 9,
"title": "Aplikacja NordaBiznes — aktualizacja",
"discussion": "Przedstawiono postępy w rozwoju aplikacji NordaBiznes Partner — katalogu firmowego i platformy networkingowej Izby.",
"decisions": [],
"tasks": []
},
{
"agenda_item": 10,
"title": "Współpraca z samorządem",
"discussion": "Omówiono bieżącą współpracę z samorządem lokalnym.",
"decisions": [],
"tasks": []
},
{
"agenda_item": 11,
"title": "Walne Zgromadzenie Członków — termin",
"discussion": "Ustalono termin i miejsce Walnego Zgromadzenia Członków Izby (zgromadzenie wyborcze).",
"decisions": [
"Walne Zgromadzenie Członków (wyborcze): 08.06.2026, godz. 14:00, Urząd Miasta Wejherowo"
],
"tasks": ["Przygotować zawiadomienia o Walnym Zgromadzeniu"]
},
{
"agenda_item": 12,
"title": "Ustalenie terminu kolejnego posiedzenia Rady",
"discussion": "Zaproponowano termin kolejnego posiedzenia Rady Izby.",
"decisions": [
"Propozycja kolejnego posiedzenia Rady: 04.03.2026, godz. 16:00"
],
"tasks": []
},
]
# Create meeting
meeting = BoardMeeting(
meeting_number=2,
year=2026,
meeting_date=date(2026, 2, 4),
start_time=time(16, 0),
end_time=time(19, 0),
location='Siedziba Izby',
chairperson_id=chairperson.id if chairperson else None,
secretary_id=secretary.id if secretary else None,
guests=None,
agenda_items=agenda_items,
attendance=attendance,
quorum_count=present_count,
quorum_confirmed=present_count >= 9,
proceedings=proceedings,
status=BoardMeeting.STATUS_PROTOCOL_PUBLISHED,
created_by=creator.id,
created_at=datetime(2026, 2, 4, 19, 0),
agenda_published_at=datetime(2026, 2, 4, 16, 0),
protocol_published_at=datetime(2026, 2, 20, 12, 0),
)
db.add(meeting)
db.commit()
print(f"\nMeeting 2/2026 created successfully (id={meeting.id})")
print(f" Date: 04.02.2026, 16:00-19:00")
print(f" Attendance: {present_count}/16 (quorum: {'YES' if meeting.quorum_confirmed else 'NO'})")
print(f" Agenda items: {len(agenda_items)}")
print(f" Proceedings: {len(proceedings)}")
print(f" Status: {meeting.status}")
return meeting.id
except Exception as e:
db.rollback()
print(f"ERROR: {e}")
import traceback
traceback.print_exc()
return None
finally:
db.close()
if __name__ == '__main__':
meeting_id = main()
if meeting_id:
print(f"\nDone. Meeting ID: {meeting_id}")
else:
print("\nFailed to import meeting.")
sys.exit(1)

View File

@ -716,6 +716,110 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<!-- Documents -->
<div class="meeting-section" id="documents">
<h2>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
Dokumenty
</h2>
{% if documents %}
<table class="attendance-table">
<thead>
<tr>
<th>Tytuł</th>
<th>Typ</th>
<th>Plik</th>
<th>Rozmiar</th>
<th>Dodano</th>
<th></th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>
<strong>{{ doc.title }}</strong>
{% if doc.description %}
<br><small style="color: var(--text-muted)">{{ doc.description }}</small>
{% endif %}
</td>
<td><span class="attendance-status present" style="background: #dbeafe; color: #1e40af;">{{ doc.type_label }}</span></td>
<td style="font-size: var(--font-size-sm); color: var(--text-muted)">{{ doc.original_filename }}</td>
<td style="font-size: var(--font-size-sm)">{{ doc.size_display }}</td>
<td style="font-size: var(--font-size-sm); color: var(--text-muted)">
{{ doc.uploaded_at.strftime('%d.%m.%Y') if doc.uploaded_at else '—' }}
{% if doc.uploader %}<br>{{ doc.uploader.name }}{% endif %}
</td>
<td>
<a href="{{ url_for('board.document_download', doc_id=doc.id) }}" class="btn-action btn-edit" style="font-size: var(--font-size-sm); padding: 6px 12px;">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="width:14px;height:14px">
<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_manage %}
<form action="{{ url_for('board.document_delete', doc_id=doc.id) }}" method="POST" class="inline-form" style="margin-top: 4px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-action" style="font-size: var(--font-size-sm); padding: 6px 12px; background: #fee2e2; color: #991b1b;" onclick="return confirm('Czy na pewno chcesz usunąć ten dokument?')">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="width:14px;height:14px">
<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>
Usuń
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-note">Brak dokumentów przypisanych do tego posiedzenia.</p>
{% endif %}
{% if can_manage %}
<div style="margin-top: var(--spacing-lg); padding-top: var(--spacing-lg); border-top: 1px solid var(--border-color);">
<h3 style="font-size: var(--font-size-base); margin-bottom: var(--spacing-md); color: var(--text-secondary);">Dodaj dokument</h3>
<form action="{{ url_for('board.document_upload', meeting_id=meeting.id) }}" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); margin-bottom: var(--spacing-md);">
<div>
<label style="display: block; font-size: var(--font-size-sm); color: var(--text-muted); margin-bottom: 4px;">Tytuł dokumentu</label>
<input type="text" name="title" placeholder="np. Protokół z posiedzenia..." style="width: 100%; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: var(--radius-md); font-size: var(--font-size-sm);">
</div>
<div>
<label style="display: block; font-size: var(--font-size-sm); color: var(--text-muted); margin-bottom: 4px;">Typ dokumentu</label>
<select name="document_type" style="width: 100%; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: var(--radius-md); font-size: var(--font-size-sm);">
<option value="protocol">Protokół</option>
<option value="minutes">Notatki</option>
<option value="resolution">Uchwała</option>
<option value="report">Raport</option>
<option value="other">Inny</option>
</select>
</div>
</div>
<div style="margin-bottom: var(--spacing-md);">
<label style="display: block; font-size: var(--font-size-sm); color: var(--text-muted); margin-bottom: 4px;">Opis (opcjonalnie)</label>
<input type="text" name="description" placeholder="Krótki opis dokumentu..." style="width: 100%; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: var(--radius-md); font-size: var(--font-size-sm);">
</div>
<div style="display: flex; align-items: center; gap: var(--spacing-md);">
<input type="file" name="document" accept=".pdf,.docx,.doc" required style="font-size: var(--font-size-sm);">
<button type="submit" class="btn-action btn-publish" style="white-space: nowrap;">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="width:16px;height:16px">
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
Dodaj dokument
</button>
</div>
<p style="margin-top: var(--spacing-sm); font-size: var(--font-size-sm); color: var(--text-muted);">Dozwolone formaty: PDF, DOCX, DOC (max 50 MB)</p>
</form>
</div>
{% endif %}
</div>
</div> </div>
{% endblock %} {% endblock %}