From a8f2178b7ee5993fa09522cbe5fcd8fa28ca1cf6 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Fri, 20 Feb 2026 12:04:44 +0100 Subject: [PATCH] feat: activate board document upload/download with meeting 2/2026 import 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 --- blueprints/board/routes.py | 172 +++++++++- database.py | 2 + .../074_board_document_meeting_fk.sql | 11 + scripts/import_board_documents_2_2026.py | 157 +++++++++ scripts/import_board_meeting_2_2026.py | 300 ++++++++++++++++++ templates/board/meeting_view.html | 104 ++++++ 6 files changed, 743 insertions(+), 3 deletions(-) create mode 100644 database/migrations/074_board_document_meeting_fk.sql create mode 100644 scripts/import_board_documents_2_2026.py create mode 100644 scripts/import_board_meeting_2_2026.py diff --git a/blueprints/board/routes.py b/blueprints/board/routes.py index eb81830..31046c1 100644 --- a/blueprints/board/routes.py +++ b/blueprints/board/routes.py @@ -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//publikuj-protokol - Publish protocol (office_manager+) - GET /rada/posiedzenia//pdf-program - Download agenda PDF - GET /rada/posiedzenia//pdf-protokol - Download protocol PDF + +Endpoints - Documents: +- POST /rada/posiedzenia//dokumenty/dodaj - Upload document (office_manager+) +- GET /rada/dokumenty//pobierz - Download document (rada_member+) +- POST /rada/dokumenty//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//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//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//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 # ============================================================================= diff --git a/database.py b/database.py index f9cf54d..c643e1f 100644 --- a/database.py +++ b/database.py @@ -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]) diff --git a/database/migrations/074_board_document_meeting_fk.sql b/database/migrations/074_board_document_meeting_fk.sql new file mode 100644 index 0000000..b6ab2a2 --- /dev/null +++ b/database/migrations/074_board_document_meeting_fk.sql @@ -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; diff --git a/scripts/import_board_documents_2_2026.py b/scripts/import_board_documents_2_2026.py new file mode 100644 index 0000000..6f76738 --- /dev/null +++ b/scripts/import_board_documents_2_2026.py @@ -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 maciejpi@:{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) diff --git a/scripts/import_board_meeting_2_2026.py b/scripts/import_board_meeting_2_2026.py new file mode 100644 index 0000000..5815a08 --- /dev/null +++ b/scripts/import_board_meeting_2_2026.py @@ -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) diff --git a/templates/board/meeting_view.html b/templates/board/meeting_view.html index be83a37..2f281ce 100644 --- a/templates/board/meeting_view.html +++ b/templates/board/meeting_view.html @@ -716,6 +716,110 @@ {% endfor %} {% endif %} + + +
+

+ + + + Dokumenty +

+ + {% if documents %} + + + + + + + + + + + + + {% for doc in documents %} + + + + + + + + + {% endfor %} + +
TytułTypPlikRozmiarDodano
+ {{ doc.title }} + {% if doc.description %} +
{{ doc.description }} + {% endif %} +
{{ doc.type_label }}{{ doc.original_filename }}{{ doc.size_display }} + {{ doc.uploaded_at.strftime('%d.%m.%Y') if doc.uploaded_at else '—' }} + {% if doc.uploader %}
{{ doc.uploader.name }}{% endif %} +
+ + + + + Pobierz + + {% if can_manage %} +
+ + +
+ {% endif %} +
+ {% else %} +

Brak dokumentów przypisanych do tego posiedzenia.

+ {% endif %} + + {% if can_manage %} +
+

Dodaj dokument

+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

Dozwolone formaty: PDF, DOCX, DOC (max 50 MB)

+
+
+ {% endif %} +
{% endblock %}