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
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:
parent
0b82419753
commit
a8f2178b7e
@ -2,7 +2,7 @@
|
||||
Board Routes (Rada Izby)
|
||||
========================
|
||||
|
||||
Routes for board meeting management and PDF generation.
|
||||
Routes for board meeting management, document handling, and PDF generation.
|
||||
|
||||
Endpoints - Meetings:
|
||||
- GET /rada/ - List all meetings + board members
|
||||
@ -14,20 +14,27 @@ Endpoints - Meetings:
|
||||
- POST /rada/posiedzenia/<id>/publikuj-protokol - Publish protocol (office_manager+)
|
||||
- GET /rada/posiedzenia/<id>/pdf-program - Download agenda 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 flask import (
|
||||
render_template, request, redirect, url_for, flash,
|
||||
current_app, Response
|
||||
current_app, Response, send_file
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy import desc
|
||||
|
||||
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.helpers import sanitize_html
|
||||
from services.document_upload_service import DocumentUploadService
|
||||
from datetime import date, time
|
||||
|
||||
try:
|
||||
@ -215,12 +222,19 @@ def meeting_view(meeting_id):
|
||||
User.is_active == True
|
||||
).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)
|
||||
|
||||
return render_template(
|
||||
'board/meeting_view.html',
|
||||
meeting=meeting,
|
||||
board_members=board_members,
|
||||
documents=documents,
|
||||
can_manage=can_manage
|
||||
)
|
||||
finally:
|
||||
@ -407,6 +421,158 @@ def meeting_pdf_protocol(meeting_id):
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
@ -1753,6 +1753,7 @@ class BoardDocument(Base):
|
||||
document_type = Column(String(50), default='protocol') # protocol, minutes, resolution, report, other
|
||||
|
||||
# Meeting reference
|
||||
meeting_id = Column(Integer, ForeignKey('board_meetings.id'))
|
||||
meeting_date = Column(Date, nullable=False)
|
||||
meeting_number = Column(Integer) # Sequential meeting number (optional)
|
||||
|
||||
@ -1773,6 +1774,7 @@ class BoardDocument(Base):
|
||||
is_active = Column(Boolean, default=True) # Soft delete
|
||||
|
||||
# Relationships
|
||||
meeting = relationship('BoardMeeting', backref='documents')
|
||||
uploader = relationship('User', foreign_keys=[uploaded_by])
|
||||
editor = relationship('User', foreign_keys=[updated_by])
|
||||
|
||||
|
||||
11
database/migrations/074_board_document_meeting_fk.sql
Normal file
11
database/migrations/074_board_document_meeting_fk.sql
Normal 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;
|
||||
157
scripts/import_board_documents_2_2026.py
Normal file
157
scripts/import_board_documents_2_2026.py
Normal 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)
|
||||
300
scripts/import_board_meeting_2_2026.py
Normal file
300
scripts/import_board_meeting_2_2026.py
Normal 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)
|
||||
@ -716,6 +716,110 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user