nordabiz/blueprints/membership/routes.py
Maciej Pienczyn 3931b1466c
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
feat(membership): record workflow history for all status changes
Added workflow_history entries to all membership application status transitions:
- submitted (user submits form) — blueprints/membership/routes.py
- start_review, approved, rejected, changes_requested — blueprints/admin/routes_membership.py

Each entry includes event name, action_label (Polish display text), timestamp,
user_id/user_name, and relevant details (comment, member_number, category, IP).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 13:39:55 +02:00

661 lines
26 KiB
Python

"""
Membership Routes
==================
Routes for membership application wizard and status tracking.
"""
import logging
from datetime import datetime
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from sqlalchemy.orm.attributes import flag_modified
from . import bp
from database import SessionLocal, MembershipApplication, CompanyDataRequest, Company, UserNotification, User, SystemRole
logger = logging.getLogger(__name__)
# ============================================================
# APPLICATION WIZARD
# ============================================================
@bp.route('/apply')
@login_required
def apply():
"""
Membership application wizard - multi-step form.
"""
db = SessionLocal()
try:
# Check if user already has a company
if current_user.company_id:
flash('Masz już przypisaną firmę. Nie możesz składać nowej deklaracji.', 'warning')
return redirect(url_for('index'))
# Check for existing draft or pending application
existing = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['draft', 'submitted', 'under_review', 'pending_user_approval', 'changes_requested'])
).first()
if existing:
# If pending user approval, redirect to review page
if existing.status == 'pending_user_approval':
flash('Administrator zaproponował zmiany do Twojej deklaracji. Przejrzyj je i zaakceptuj lub odrzuć.', 'info')
return redirect(url_for('membership.review_changes', app_id=existing.id))
# If already submitted, redirect to status page
if existing.status in ['submitted', 'under_review']:
flash('Masz już wysłaną deklarację oczekującą na rozpatrzenie.', 'info')
return redirect(url_for('membership.status'))
# Otherwise continue editing
return redirect(url_for('membership.apply_step', step=1))
# Create new draft
application = MembershipApplication(
user_id=current_user.id,
company_name='',
nip='',
email=current_user.email or '',
status='draft'
)
db.add(application)
db.commit()
return redirect(url_for('membership.apply_step', step=1))
finally:
db.close()
@bp.route('/apply/step/<int:step>', methods=['GET', 'POST'])
@login_required
def apply_step(step):
"""
Handle specific step of the application wizard.
Step 1: Company data (from registry)
Step 2: Additional info
Step 3: Sections and declaration
"""
if step not in [1, 2, 3]:
return redirect(url_for('membership.apply_step', step=1))
db = SessionLocal()
try:
# Get current application
application = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['draft', 'changes_requested'])
).first()
if not application:
# Check if user has submitted application
submitted = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['submitted', 'under_review'])
).first()
if submitted:
flash('Twoja deklaracja oczekuje na rozpatrzenie.', 'info')
return redirect(url_for('membership.status'))
# No application at all - redirect to start
flash('Nie znaleziono aktywnej deklaracji.', 'error')
return redirect(url_for('membership.status'))
if request.method == 'POST':
action = request.form.get('action', 'save')
if step == 1:
# Save step 1 data
application.company_name = request.form.get('company_name', '').strip()
application.nip = request.form.get('nip', '').strip().replace('-', '').replace(' ', '')
application.address_postal_code = request.form.get('address_postal_code', '').strip()
application.address_city = request.form.get('address_city', '').strip()
application.address_street = request.form.get('address_street', '').strip()
application.address_number = request.form.get('address_number', '').strip()
application.delegate_1 = request.form.get('delegate_1', '').strip()
application.delegate_2 = request.form.get('delegate_2', '').strip()
application.delegate_3 = request.form.get('delegate_3', '').strip()
application.krs_number = request.form.get('krs_number', '').strip()
application.regon = request.form.get('regon', '').strip()
application.registry_source = request.form.get('registry_source', 'manual')
elif step == 2:
# Save step 2 data
application.website = request.form.get('website', '').strip()
application.email = request.form.get('email', '').strip()
application.phone = request.form.get('phone', '').strip()
application.short_name = request.form.get('short_name', '').strip()
application.description = request.form.get('description', '').strip()
founded_date = request.form.get('founded_date', '').strip()
if founded_date:
try:
application.founded_date = datetime.strptime(founded_date, '%Y-%m-%d').date()
except ValueError:
pass
employee_count = request.form.get('employee_count', '').strip()
if employee_count:
try:
application.employee_count = int(employee_count)
except ValueError:
pass
application.show_employee_count = request.form.get('show_employee_count') == 'on'
application.annual_revenue = request.form.get('annual_revenue', '').strip()
# Related companies (JSON array)
related = []
for i in range(1, 6):
company = request.form.get(f'related_company_{i}', '').strip()
if company:
related.append(company)
application.related_companies = related if related else None
elif step == 3:
# Save step 3 data
sections = request.form.getlist('sections')
application.sections = sections if sections else []
application.sections_other = request.form.get('sections_other', '').strip()
application.business_type = request.form.get('business_type', 'sp_z_oo')
application.business_type_other = request.form.get('business_type_other', '').strip()
# GDPR consents
application.consent_email = request.form.get('consent_email') == 'on'
application.consent_email_address = request.form.get('consent_email_address', '').strip()
application.consent_sms = request.form.get('consent_sms') == 'on'
application.consent_sms_phone = request.form.get('consent_sms_phone', '').strip()
# Declaration
application.declaration_accepted = request.form.get('declaration_accepted') == 'on'
if application.declaration_accepted:
application.declaration_accepted_at = datetime.now()
application.declaration_ip_address = request.remote_addr
application.updated_at = datetime.now()
db.commit()
if action == 'next':
if step < 3:
return redirect(url_for('membership.apply_step', step=step + 1))
else:
# Final validation before submit
errors = validate_application(application)
if errors:
for error in errors:
flash(error, 'error')
return redirect(url_for('membership.apply_step', step=3))
# Submit application
application.status = 'submitted'
application.submitted_at = datetime.now()
# Add to workflow history
history = application.workflow_history or []
history.append({
'event': 'submitted',
'action_label': 'Deklaracja złożona',
'timestamp': datetime.now().isoformat(),
'user_id': current_user.id,
'user_name': current_user.name or current_user.email,
'details': {
'company_name': application.company_name,
'nip': application.nip,
'ip_address': request.remote_addr
}
})
application.workflow_history = list(history)
flag_modified(application, 'workflow_history')
db.commit()
# Wyślij notyfikacje do administratorów i kierowników biura
try:
admin_users = db.query(User).filter(
User.system_role.in_([SystemRole.ADMIN.value, SystemRole.OFFICE_MANAGER.value])
).all()
for admin in admin_users:
notification = UserNotification(
user_id=admin.id,
title='Nowa deklaracja członkowska',
message=f'{current_user.name or current_user.email} złożył/a deklarację członkowską dla firmy "{application.company_name}".',
notification_type='alert',
related_type='membership_application',
related_id=application.id,
action_url=url_for('admin.membership_detail', app_id=application.id)
)
db.add(notification)
db.commit()
logger.info(f"Notifications sent to {len(admin_users)} admins for membership application {application.id}")
except Exception as e:
logger.error(f"Failed to send notifications for membership application: {e}")
# Nie przerywaj flow - deklaracja już została wysłana
flash('Deklaracja została wysłana do rozpatrzenia!', 'success')
logger.info(f"Membership application submitted: user={current_user.id}, app={application.id}")
return redirect(url_for('membership.status'))
elif action == 'prev' and step > 1:
return redirect(url_for('membership.apply_step', step=step - 1))
else:
flash('Zapisano zmiany.', 'success')
return redirect(url_for('membership.apply_step', step=step))
# GET - render step
return render_template(
'membership/apply.html',
application=application,
step=step,
section_choices=MembershipApplication.SECTION_CHOICES,
business_type_choices=MembershipApplication.BUSINESS_TYPE_CHOICES
)
finally:
db.close()
def validate_application(app):
"""Validate application before submission."""
errors = []
if not app.company_name:
errors.append('Nazwa firmy jest wymagana.')
if not app.nip or len(app.nip) != 10:
errors.append('NIP jest wymagany (10 cyfr).')
if not app.email:
errors.append('Email jest wymagany.')
if not app.delegate_1:
errors.append('Przynajmniej jeden delegat jest wymagany.')
if not app.sections:
errors.append('Wybierz przynajmniej jedną sekcję tematyczną.')
if not app.consent_email:
errors.append('Zgoda na kontakt email jest wymagana.')
if not app.declaration_accepted:
errors.append('Musisz zaakceptować oświadczenie.')
return errors
# ============================================================
# APPLICATION STATUS
# ============================================================
@bp.route('/status')
@login_required
def status():
"""
Show current application status.
"""
db = SessionLocal()
try:
applications = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id
).order_by(MembershipApplication.created_at.desc()).all()
return render_template('membership/status.html', applications=applications)
finally:
db.close()
# ============================================================
# PROPOSED CHANGES REVIEW (User approval workflow)
# ============================================================
@bp.route('/review-changes/<int:app_id>')
@login_required
def review_changes(app_id):
"""
Page for user to review proposed changes from admin.
"""
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
flash('Nie znaleziono deklaracji.', 'error')
return redirect(url_for('membership.status'))
# Verify ownership
if application.user_id != current_user.id:
flash('Brak dostępu do tej deklaracji.', 'error')
return redirect(url_for('membership.status'))
# Check status
if application.status != 'pending_user_approval':
flash('Ta deklaracja nie wymaga przeglądu zmian.', 'info')
return redirect(url_for('membership.status'))
# Get reviewer info (person who proposed changes)
reviewer = None
if application.proposed_changes_by_id:
reviewer = db.query(User).get(application.proposed_changes_by_id)
return render_template(
'membership/review_changes.html',
application=application,
reviewer=reviewer
)
finally:
db.close()
@bp.route('/review-changes/<int:app_id>/accept', methods=['POST'])
@login_required
def accept_changes(app_id):
"""
User accepts proposed changes from admin.
Changes are applied and application returns to under_review.
"""
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
return jsonify({'success': False, 'error': 'Nie znaleziono deklaracji'}), 404
if application.user_id != current_user.id:
return jsonify({'success': False, 'error': 'Brak dostępu'}), 403
if application.status != 'pending_user_approval':
return jsonify({
'success': False,
'error': 'Ta deklaracja nie oczekuje na akceptację zmian'
}), 400
proposed = application.proposed_changes or {}
# Get proposer info for history
proposer = db.query(User).get(application.proposed_changes_by_id) if application.proposed_changes_by_id else None
proposer_name = proposer.name if proposer else 'Biuro Izby NORDA'
# Apply all proposed changes
applied_fields = []
changes_summary = []
for field_name, change in proposed.items():
new_value = change.get('new')
if new_value is not None:
if field_name == 'founded_date':
try:
setattr(application, field_name, datetime.strptime(new_value, '%Y-%m-%d').date())
except (ValueError, TypeError):
setattr(application, field_name, None)
else:
setattr(application, field_name, new_value)
applied_fields.append(field_name)
changes_summary.append(f"{change.get('label', field_name)}: {change.get('old', '-')}{new_value}")
# Record user acceptance
application.user_changes_accepted_at = datetime.now()
application.user_changes_action = 'accepted'
# Add to workflow history
history = application.workflow_history or []
history.append({
'event': 'user_accepted_changes',
'timestamp': datetime.now().isoformat(),
'user_id': current_user.id,
'user_name': current_user.name or current_user.email,
'details': {
'proposed_by': proposer_name,
'proposed_at': application.proposed_changes_at.isoformat() if application.proposed_changes_at else None,
'changes_applied': changes_summary,
'comment': application.proposed_changes_comment
}
})
application.workflow_history = list(history) # Create new list for SQLAlchemy change detection
flag_modified(application, 'workflow_history')
# Update status - back to under_review for final approval
application.status = 'under_review'
application.updated_at = datetime.now()
# Keep proposed_changes_by_id for reference but clear the rest
application.proposed_changes = None
application.proposed_changes_at = None
application.proposed_changes_comment = None
# Create notification for admins
admins = db.query(User).filter(User.role == 'ADMIN').all()
for admin in admins:
notification = UserNotification(
user_id=admin.id,
title='Użytkownik zaakceptował zmiany',
message=f'Użytkownik {current_user.name or current_user.email} zaakceptował proponowane zmiany dla firmy "{application.company_name}". Deklaracja oczekuje na ostateczne zatwierdzenie.',
notification_type='alert',
related_type='membership_application',
related_id=app_id,
action_url=f'/admin/membership/{app_id}'
)
db.add(notification)
db.commit()
logger.info(
f"User {current_user.id} accepted proposed changes for application {app_id}. "
f"Applied fields: {applied_fields}"
)
return jsonify({
'success': True,
'applied_fields': applied_fields,
'message': 'Zmiany zostały zaakceptowane. Twoja deklaracja wróciła do rozpatrzenia.'
})
except Exception as e:
db.rollback()
logger.error(f"Error accepting changes for application {app_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/review-changes/<int:app_id>/reject', methods=['POST'])
@login_required
def reject_changes(app_id):
"""
User rejects proposed changes from admin.
Application returns to under_review with original data.
"""
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
return jsonify({'success': False, 'error': 'Nie znaleziono deklaracji'}), 404
if application.user_id != current_user.id:
return jsonify({'success': False, 'error': 'Brak dostępu'}), 403
if application.status != 'pending_user_approval':
return jsonify({
'success': False,
'error': 'Ta deklaracja nie oczekuje na akceptację zmian'
}), 400
data = request.get_json() or {}
user_comment = data.get('comment', '').strip()
# Get proposer info for history
proposer = db.query(User).get(application.proposed_changes_by_id) if application.proposed_changes_by_id else None
proposer_name = proposer.name if proposer else 'Biuro Izby NORDA'
# Record user rejection
application.user_changes_accepted_at = datetime.now()
application.user_changes_action = 'rejected'
# Add to workflow history
history = application.workflow_history or []
history.append({
'event': 'user_rejected_changes',
'timestamp': datetime.now().isoformat(),
'user_id': current_user.id,
'user_name': current_user.name or current_user.email,
'details': {
'proposed_by': proposer_name,
'proposed_at': application.proposed_changes_at.isoformat() if application.proposed_changes_at else None,
'user_comment': user_comment,
'original_comment': application.proposed_changes_comment
}
})
application.workflow_history = list(history) # Create new list for SQLAlchemy change detection
flag_modified(application, 'workflow_history')
# Clear proposed changes - keep original data
application.proposed_changes = None
application.proposed_changes_at = None
application.proposed_changes_by_id = None
application.proposed_changes_comment = None
# Add user's rejection reason to review_comment
if user_comment:
existing_comment = application.review_comment or ''
application.review_comment = f"{existing_comment}\n\n[Użytkownik odrzucił propozycje zmian: {user_comment}]".strip()
# Back to under_review with original data
application.status = 'under_review'
application.updated_at = datetime.now()
# Create notification for admins
admins = db.query(User).filter(User.role == 'ADMIN').all()
for admin in admins:
notification = UserNotification(
user_id=admin.id,
title='Użytkownik odrzucił zmiany',
message=f'Użytkownik {current_user.name or current_user.email} odrzucił proponowane zmiany dla firmy "{application.company_name}".{" Powód: " + user_comment if user_comment else ""} Deklaracja wraca do rozpatrzenia z oryginalnymi danymi.',
notification_type='alert',
related_type='membership_application',
related_id=app_id,
action_url=f'/admin/membership/{app_id}'
)
db.add(notification)
db.commit()
logger.info(
f"User {current_user.id} rejected proposed changes for application {app_id}. "
f"Reason: {user_comment or 'brak'}"
)
return jsonify({
'success': True,
'message': 'Odrzuciłeś proponowane zmiany. Twoja deklaracja zachowuje oryginalne dane.'
})
except Exception as e:
db.rollback()
logger.error(f"Error rejecting changes for application {app_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
# ============================================================
# COMPANY DATA REQUEST (Modal-based)
# ============================================================
@bp.route('/data-request', methods=['GET', 'POST'])
@login_required
def data_request():
"""
Handle company data update request.
For users with a company but missing NIP/data.
"""
if not current_user.company_id:
flash('Nie masz przypisanej firmy.', 'warning')
return redirect(url_for('membership.apply'))
db = SessionLocal()
try:
company = db.query(Company).get(current_user.company_id)
if not company:
flash('Firma nie została znaleziona.', 'error')
return redirect(url_for('index'))
# Check for pending request
pending = db.query(CompanyDataRequest).filter(
CompanyDataRequest.user_id == current_user.id,
CompanyDataRequest.company_id == company.id,
CompanyDataRequest.status == 'pending'
).first()
if request.method == 'POST':
if pending:
flash('Masz już oczekujące zgłoszenie.', 'warning')
return redirect(url_for('membership.data_request'))
nip = request.form.get('nip', '').strip().replace('-', '').replace(' ', '')
if not nip or len(nip) != 10:
flash('NIP musi mieć 10 cyfr.', 'error')
return redirect(url_for('membership.data_request'))
# Create request
data_request = CompanyDataRequest(
request_type='update_data',
user_id=current_user.id,
company_id=company.id,
nip=nip,
user_note=request.form.get('user_note', '').strip()
)
# Try to fetch registry data
fetched = fetch_registry_data(nip)
if fetched:
data_request.registry_source = fetched.get('source')
data_request.fetched_data = fetched
db.add(data_request)
db.commit()
flash('Zgłoszenie zostało wysłane do rozpatrzenia.', 'success')
logger.info(f"Company data request created: user={current_user.id}, company={company.id}")
return redirect(url_for('index'))
return render_template(
'membership/data_request.html',
company=company,
pending_request=pending
)
finally:
db.close()
def fetch_registry_data(nip):
"""
Fetch company data from KRS/CEIDG registries.
Returns dict with source and data, or None if not found.
"""
try:
# Try KRS first
from krs_api_service import krs_api_service
krs_data = krs_api_service.search_by_nip(nip)
if krs_data:
return {
'source': 'KRS',
'name': krs_data.get('nazwa'),
'krs': krs_data.get('krs'),
'regon': krs_data.get('regon'),
'address': krs_data.get('adres'),
'founding_date': krs_data.get('data_rejestracji'),
'raw': krs_data
}
except Exception as e:
logger.warning(f"KRS lookup failed for NIP {nip}: {e}")
try:
# Try CEIDG
from ceidg_api_service import fetch_ceidg_by_nip
ceidg_data = fetch_ceidg_by_nip(nip)
if ceidg_data:
return {
'source': 'CEIDG',
'name': ceidg_data.get('firma'),
'regon': ceidg_data.get('regon'),
'address': ceidg_data.get('adres'),
'raw': ceidg_data
}
except Exception as e:
logger.warning(f"CEIDG lookup failed for NIP {nip}: {e}")
return None