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)
|
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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -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])
|
||||||
|
|
||||||
|
|||||||
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 %}
|
{% 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 %}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user