diff --git a/blueprints/board/routes.py b/blueprints/board/routes.py index 3bf456b..f732075 100644 --- a/blueprints/board/routes.py +++ b/blueprints/board/routes.py @@ -327,6 +327,18 @@ def meeting_publish_protocol(meeting_id): meeting.updated_at = datetime.now() db.commit() + # Trigger admission workflow in background + user_id = current_user.id + import threading + def _run_admission_workflow(): + try: + from services.admission_workflow import run_admission_workflow + run_admission_workflow(meeting_id, user_id) + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Admission workflow failed for meeting {meeting_id}: {e}") + threading.Thread(target=_run_admission_workflow, daemon=True).start() + flash(f'Protokół z posiedzenia {meeting.meeting_identifier} został opublikowany.', 'success') current_app.logger.info( f"Meeting protocol published: {meeting.meeting_identifier} by user {current_user.id}" @@ -342,6 +354,40 @@ def meeting_publish_protocol(meeting_id): db.close() +# ============================================================================= +# ADMISSION DASHBOARD +# ============================================================================= + +@bp.route('/posiedzenia//przyjecia') +@login_required +@office_manager_required +def meeting_admissions(meeting_id): + """Dashboard: companies admitted at a board meeting.""" + db = SessionLocal() + try: + meeting = db.query(BoardMeeting).filter_by(id=meeting_id).first() + if not meeting: + flash('Posiedzenie nie zostało znalezione.', 'error') + return redirect(url_for('board.index')) + + from database import Company, AdmissionWorkflowLog + admitted = db.query(Company).filter( + Company.admitted_at_meeting_id == meeting_id + ).order_by(Company.name).all() + + workflow_log = db.query(AdmissionWorkflowLog).filter_by( + meeting_id=meeting_id + ).order_by(AdmissionWorkflowLog.executed_at.desc()).first() + + return render_template('board/meeting_admissions.html', + meeting=meeting, + admitted=admitted, + workflow_log=workflow_log, + ) + finally: + db.close() + + # ============================================================================= # MEETING PDF GENERATION # ============================================================================= diff --git a/blueprints/public/routes.py b/blueprints/public/routes.py index 2ca631c..0bcfb2f 100644 --- a/blueprints/public/routes.py +++ b/blueprints/public/routes.py @@ -994,22 +994,33 @@ def events(): @bp.route('/nowi-czlonkowie') @login_required def new_members(): - """Lista nowych firm członkowskich""" - days = request.args.get('days', 90, type=int) - + """Lista nowych członków Izby — pogrupowana wg posiedzeń Rady.""" db = SessionLocal() try: - cutoff_date = datetime.now() - timedelta(days=days) + from database import BoardMeeting + from sqlalchemy import exists - new_companies = db.query(Company).filter( - Company.status == 'active', - Company.created_at >= cutoff_date - ).order_by(Company.created_at.desc()).all() + # Get meetings that have admitted companies, newest first + meetings = db.query(BoardMeeting).filter( + exists().where(Company.admitted_at_meeting_id == BoardMeeting.id) + ).order_by(BoardMeeting.meeting_date.desc()).limit(12).all() + + # For each meeting, get admitted companies + meetings_data = [] + total = 0 + for meeting in meetings: + companies = db.query(Company).filter( + Company.admitted_at_meeting_id == meeting.id + ).order_by(Company.name).all() + meetings_data.append({ + 'meeting': meeting, + 'companies': companies + }) + total += len(companies) return render_template('new_members.html', - companies=new_companies, - days=days, - total=len(new_companies) + meetings_data=meetings_data, + total=total ) finally: db.close() diff --git a/database.py b/database.py index 99ec6f1..ed8bc75 100644 --- a/database.py +++ b/database.py @@ -1989,6 +1989,31 @@ class BoardMeeting(Base): return sorted(result, key=lambda x: x['user'].name or '') +class AdmissionWorkflowLog(Base): + """Audit log for post-protocol admission workflow runs.""" + __tablename__ = 'admission_workflow_logs' + + id = Column(Integer, primary_key=True) + meeting_id = Column(Integer, ForeignKey('board_meetings.id'), nullable=False, index=True) + + executed_at = Column(DateTime, default=datetime.now) + executed_by = Column(Integer, ForeignKey('users.id')) + + extracted_companies = Column(PG_JSONB) # [{"title", "extracted_name", "decision_text"}] + matched_companies = Column(PG_JSONB) # [{"extracted_name", "matched_id", "matched_name", "confidence"}] + created_companies = Column(PG_JSONB) # [{"name", "id", "slug"}] + skipped = Column(PG_JSONB) # [{"name", "reason"}] + + status = Column(String(20), default='completed') # completed, partial_error, failed + error_message = Column(Text) + + notifications_sent = Column(Integer, default=0) + emails_sent = Column(Integer, default=0) + + meeting = relationship('BoardMeeting') + executor = relationship('User', foreign_keys=[executed_by]) + + class ForumTopicSubscription(Base): """Forum topic subscriptions for notifications""" __tablename__ = 'forum_topic_subscriptions' diff --git a/database/migrations/099_add_admission_workflow_log.sql b/database/migrations/099_add_admission_workflow_log.sql new file mode 100644 index 0000000..7757a26 --- /dev/null +++ b/database/migrations/099_add_admission_workflow_log.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS admission_workflow_logs ( + id SERIAL PRIMARY KEY, + meeting_id INTEGER NOT NULL REFERENCES board_meetings(id), + executed_at TIMESTAMP DEFAULT NOW(), + executed_by INTEGER REFERENCES users(id), + extracted_companies JSONB, + matched_companies JSONB, + created_companies JSONB, + skipped JSONB, + status VARCHAR(20) DEFAULT 'completed', + error_message TEXT, + notifications_sent INTEGER DEFAULT 0, + emails_sent INTEGER DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_admission_workflow_meeting ON admission_workflow_logs(meeting_id); +GRANT ALL ON TABLE admission_workflow_logs TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE admission_workflow_logs_id_seq TO nordabiz_app; diff --git a/services/admission_workflow.py b/services/admission_workflow.py new file mode 100644 index 0000000..447ed6f --- /dev/null +++ b/services/admission_workflow.py @@ -0,0 +1,395 @@ +""" +Post-Rada Admission Workflow Engine +==================================== +Automatically processes board meeting protocols to: +1. Extract admitted companies from proceedings +2. Match them to existing companies in DB +3. Create placeholder profiles for new companies +4. Notify Office Managers about companies needing attention +""" + +import re +import logging +from datetime import datetime +from database import SessionLocal, BoardMeeting, Company, AdmissionWorkflowLog, User +from sqlalchemy import func, text + +logger = logging.getLogger(__name__) + + +def run_admission_workflow(meeting_id: int, executed_by_user_id: int) -> dict: + """Main entry point. Called in background thread after protocol publish.""" + db = SessionLocal() + try: + meeting = db.query(BoardMeeting).filter_by(id=meeting_id).first() + if not meeting or not meeting.proceedings: + logger.warning(f"Admission workflow: meeting {meeting_id} not found or no proceedings") + return {'status': 'skipped', 'reason': 'no proceedings'} + + # Idempotency: check if already processed + existing_log = db.query(AdmissionWorkflowLog).filter_by(meeting_id=meeting_id).first() + + # Extract companies from proceedings + extracted = extract_admitted_companies(meeting.proceedings) + if not extracted: + logger.info(f"Admission workflow: no admissions found in meeting {meeting_id}") + if not existing_log: + log = AdmissionWorkflowLog( + meeting_id=meeting_id, + executed_by=executed_by_user_id, + extracted_companies=[], + status='completed' + ) + db.add(log) + db.commit() + return {'status': 'completed', 'extracted': 0} + + matched = [] + created = [] + skipped = [] + + for item in extracted: + name = item['extracted_name'] + + # Skip if already linked to this meeting + existing_company = db.query(Company).filter( + Company.admitted_at_meeting_id == meeting_id, + func.lower(Company.name) == func.lower(name) + ).first() + if existing_company: + skipped.append({'name': name, 'reason': 'already_linked', 'company_id': existing_company.id}) + continue + + # Try to match existing company + company, confidence = match_company_by_name(db, name) + + if company and confidence >= 0.5: + # Link existing company + link_existing_company(db, company, meeting_id, meeting.meeting_date) + matched.append({ + 'extracted_name': name, + 'matched_id': company.id, + 'matched_name': company.name, + 'confidence': confidence + }) + else: + # Create placeholder + new_company = create_placeholder_company(db, name, meeting_id, meeting.meeting_date) + created.append({ + 'name': new_company.name, + 'id': new_company.id, + 'slug': new_company.slug + }) + + db.flush() + + # Send notifications + notif_count, email_count = notify_office_managers(db, meeting, { + 'extracted': extracted, + 'matched': matched, + 'created': created, + 'skipped': skipped + }) + + # Log the workflow run + if existing_log: + # Update existing log + existing_log.extracted_companies = [e for e in extracted] + existing_log.matched_companies = matched + existing_log.created_companies = created + existing_log.skipped = skipped + existing_log.notifications_sent = notif_count + existing_log.emails_sent = email_count + existing_log.executed_at = datetime.now() + existing_log.executed_by = executed_by_user_id + else: + log = AdmissionWorkflowLog( + meeting_id=meeting_id, + executed_by=executed_by_user_id, + extracted_companies=[e for e in extracted], + matched_companies=matched, + created_companies=created, + skipped=skipped, + notifications_sent=notif_count, + emails_sent=email_count, + status='completed' + ) + db.add(log) + + db.commit() + + logger.info( + f"Admission workflow completed for meeting {meeting_id}: " + f"{len(extracted)} extracted, {len(matched)} matched, " + f"{len(created)} created, {len(skipped)} skipped" + ) + + return { + 'status': 'completed', + 'extracted': len(extracted), + 'matched': len(matched), + 'created': len(created), + 'skipped': len(skipped) + } + + except Exception as e: + logger.error(f"Admission workflow failed for meeting {meeting_id}: {e}", exc_info=True) + try: + db.rollback() + error_log = AdmissionWorkflowLog( + meeting_id=meeting_id, + executed_by=executed_by_user_id, + status='failed', + error_message=str(e) + ) + db.add(error_log) + db.commit() + except Exception: + pass + return {'status': 'failed', 'error': str(e)} + finally: + db.close() + + +def extract_admitted_companies(proceedings: list) -> list: + """ + Parse proceedings JSONB to find admission decisions. + + Looks for proceedings where: + - title matches "Prezentacja firmy X -- kandydat" pattern + - decisions contain "Przyjeta/Przyjety jednoglosnie" (not "przeniesiona") + """ + results = [] + + for i, proc in enumerate(proceedings): + title = proc.get('title', '') + decisions = proc.get('decisions', []) + if isinstance(decisions, str): + decisions = [decisions] + + # Check if this is an admission proceeding + is_admission = False + decision_text = '' + for d in decisions: + d_lower = d.lower() + if ('przyjęt' in d_lower and 'jednogłośnie' in d_lower + and 'przeniesion' not in d_lower + and 'program' not in d_lower + and 'protokół' not in d_lower + and 'protokol' not in d_lower): + is_admission = True + decision_text = d + break + + if not is_admission: + continue + + # Extract company name from title + # Pattern 1: "Prezentacja firmy X -- kandydat na czlonka Izby" + # Pattern 2: "Prezentacja firmy X - kandydat na czlonka Izby" + # Pattern 3: "Prezentacja: X -- coach/mentoring (kandydatka na czlonka Izby)" + # Pattern 4: "Prezentacja i glosowanie nad kandydatami..." (bulk - extract from decisions) + + company_name = None + + # Try title patterns + for pattern in [ + r'[Pp]rezentacja\s+firmy\s+(.+?)\s*[—–\-]\s*kandydat', + r'[Pp]rezentacja:\s+(.+?)\s*[—–\-]\s*', + r'[Pp]rezentacja\s+firmy\s+(.+?)$', + ]: + match = re.search(pattern, title) + if match: + company_name = match.group(1).strip() + break + + # If no match from title, try to extract from decision text + # Pattern: "Przyjeto jednoglosnie firme X jako nowego czlonka Izby" + if not company_name: + match = re.search(r'[Pp]rzyjęt[oa]\s+jednogłośnie\s+firmę\s+(.+?)\s+jako', decision_text) + if match: + company_name = match.group(1).strip() + + if company_name: + # Clean up: remove trailing dots, Sp. z o.o. standardization + company_name = company_name.rstrip('.') + results.append({ + 'title': title, + 'extracted_name': company_name, + 'decision_text': decision_text, + 'proceeding_index': i + }) + + return results + + +def match_company_by_name(db, name: str) -> tuple: + """ + Try to find existing company by name. + Returns (Company or None, confidence float). + """ + # 1. Exact case-insensitive match + exact = db.query(Company).filter( + func.lower(Company.name) == func.lower(name) + ).first() + if exact: + return (exact, 1.0) + + # 2. ILIKE contains match + ilike = db.query(Company).filter( + Company.name.ilike(f'%{name}%') + ).first() + if ilike: + return (ilike, 0.8) + + # 3. Reverse ILIKE (DB name contained in extracted name) + # e.g. extracted "Prospoland" matches DB "Pros Poland" + all_companies = db.query(Company.id, Company.name).all() + for c_id, c_name in all_companies: + if c_name and (c_name.lower() in name.lower() or name.lower() in c_name.lower()): + company = db.query(Company).filter_by(id=c_id).first() + return (company, 0.7) + + # 4. pg_trgm similarity (if extension available) + try: + result = db.execute( + text("SELECT id, name, similarity(name, :name) as sim FROM companies WHERE similarity(name, :name) > 0.3 ORDER BY sim DESC LIMIT 1"), + {'name': name} + ).first() + if result: + company = db.query(Company).filter_by(id=result[0]).first() + return (company, float(result[2])) + except Exception: + pass + + return (None, 0.0) + + +def create_placeholder_company(db, name: str, meeting_id: int, meeting_date) -> Company: + """Create a minimal placeholder company.""" + import unicodedata + + # Generate slug + slug = name.lower().strip() + slug = unicodedata.normalize('NFKD', slug).encode('ascii', 'ignore').decode('ascii') + slug = re.sub(r'[^a-z0-9]+', '-', slug).strip('-') + + # Ensure unique + base_slug = slug + counter = 1 + while db.query(Company).filter_by(slug=slug).first(): + slug = f"{base_slug}-{counter}" + counter += 1 + + company = Company( + name=name, + slug=slug, + status='pending', + data_quality='basic', + admitted_at_meeting_id=meeting_id, + member_since=meeting_date, + ) + db.add(company) + db.flush() # Get ID + + logger.info(f"Created placeholder company: {name} (ID {company.id}, slug {slug})") + return company + + +def link_existing_company(db, company, meeting_id: int, meeting_date): + """Link existing company to a board meeting admission.""" + if not company.admitted_at_meeting_id: + company.admitted_at_meeting_id = meeting_id + if not company.member_since: + company.member_since = meeting_date + logger.info(f"Linked company {company.name} (ID {company.id}) to meeting {meeting_id}") + + +def notify_office_managers(db, meeting, results: dict) -> tuple: + """Send in-app notifications and emails to Office Managers.""" + notif_count = 0 + email_count = 0 + + total = len(results.get('matched', [])) + len(results.get('created', [])) + needs_attention = len(results.get('created', [])) + + if total == 0: + return (0, 0) + + # Find Office Managers and Admins + managers = db.query(User).filter( + User.role.in_(['ADMIN', 'OFFICE_MANAGER']), + User.is_active == True # noqa: E712 + ).all() + + meeting_id_str = f"{meeting.meeting_number}/{meeting.year}" + action_url = f"/rada/posiedzenia/{meeting.id}/przyjecia" + + title = f"Rada {meeting_id_str} -- nowi czlonkowie" + if needs_attention > 0: + message = f"Przyjeto {total} firm. {needs_attention} wymaga uzupelnienia profilu." + else: + message = f"Przyjeto {total} firm. Wszystkie profile sa juz uzupelnione." + + for manager in managers: + try: + from utils.notifications import create_notification + create_notification( + user_id=manager.id, + title=title, + message=message, + notification_type='system', + related_type='board_meeting', + related_id=meeting.id, + action_url=action_url + ) + notif_count += 1 + except Exception as e: + logger.error(f"Failed to notify user {manager.id}: {e}") + + # Send email to managers + try: + from email_service import send_email + + # Build HTML table + rows = [] + for m in results.get('matched', []): + rows.append(f"{m['matched_name']}Profil istnieje") + for c in results.get('created', []): + rows.append(f"{c['name']}Wymaga uzupelnienia") + + table_html = f""" + + + {''.join(rows)} +
FirmaStatus
+ """ + + body_html = f""" +

Na posiedzeniu Rady {meeting_id_str} ({meeting.meeting_date.strftime('%d.%m.%Y')}) + przyjeto {total} nowych czlonkow.

+ {table_html} + {'

Uwaga: ' + str(needs_attention) + ' firm wymaga uzupelnienia profilu na portalu.

' if needs_attention else ''} +

Przejdz do dashboardu przyjec

+ """ + + body_text = f"Na posiedzeniu Rady {meeting_id_str} przyjeto {total} nowych czlonkow. {needs_attention} wymaga uzupelnienia profilu." + + for manager in managers: + if manager.email: + try: + send_email( + to=manager.email, + subject=f"[NordaBiz] Rada {meeting_id_str} -- przyjeto {total} nowych czlonkow", + body_text=body_text, + body_html=body_html, + email_type='system', + recipient_name=manager.name + ) + email_count += 1 + except Exception as e: + logger.error(f"Failed to email {manager.email}: {e}") + except Exception as e: + logger.error(f"Failed to send admission emails: {e}") + + return (notif_count, email_count) diff --git a/templates/board/meeting_admissions.html b/templates/board/meeting_admissions.html new file mode 100644 index 0000000..91f29da --- /dev/null +++ b/templates/board/meeting_admissions.html @@ -0,0 +1,215 @@ +{% extends "base.html" %} + +{% block title %}Przyjęcia nowych członków — Posiedzenie {{ meeting.meeting_identifier }} — Norda Biznes Partner{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ ← Powrót do posiedzenia +

Przyjęcia nowych członków

+

Posiedzenie Rady {{ meeting.meeting_identifier }} — {{ meeting.meeting_date.strftime('%d.%m.%Y') }}

+
+ +{% set active_count = admitted|selectattr('status', 'equalto', 'active')|list|length %} +{% set pending_count = admitted|length - active_count %} + +
+
+
{{ admitted|length }}
+
Przyjętych firm
+
+
+
{{ active_count }}
+
Profil uzupełniony
+
+
+
{{ pending_count }}
+
Wymaga uzupełnienia
+
+
+ +{% if admitted %} + + + + + + + + + + + + {% for company in admitted %} + + + + + + + + {% endfor %} + +
FirmaStatus profiluNIPKategoriaAkcje
+ {{ company.name }} + {% if company.address_city %} +
{{ company.address_city }} + {% endif %} +
+ {% if company.status == 'active' %} + Aktywny + {% else %} + Do uzupełnienia + {% endif %} + {{ company.nip or '—' }}{{ company.category.name if company.category else '—' }} + {% if company.status == 'active' %} + Profil + {% else %} + Uzupełnij + {% endif %} + Edytuj +
+{% else %} +
+

Brak firm przypisanych do tego posiedzenia.

+

Firmy zostaną automatycznie przypisane po opublikowaniu protokołu.

+
+{% endif %} + +{% if workflow_log %} +
+

Log workflow przyjęć

+
Wykonano: {{ workflow_log.executed_at|local_time }}
+
Status: {{ workflow_log.status }}
+ {% if workflow_log.extracted_companies %} +
Wyodrębniono z protokołu: {{ workflow_log.extracted_companies|length }} firm
+ {% endif %} + {% if workflow_log.matched_companies %} +
Dopasowano do istniejących: {{ workflow_log.matched_companies|length }}
+ {% endif %} + {% if workflow_log.created_companies %} +
Utworzono nowe profile: {{ workflow_log.created_companies|length }}
+ {% endif %} + {% if workflow_log.skipped %} +
Pominięto (już przypisane): {{ workflow_log.skipped|length }}
+ {% endif %} +
Powiadomienia: {{ workflow_log.notifications_sent }} in-app, {{ workflow_log.emails_sent }} email
+ {% if workflow_log.error_message %} +
Błąd: {{ workflow_log.error_message }}
+ {% endif %} +
+{% endif %} + +{% endblock %} diff --git a/templates/new_members.html b/templates/new_members.html index d151462..84b3cf9 100755 --- a/templates/new_members.html +++ b/templates/new_members.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Nowi czlonkowie - Norda Biznes Partner{% endblock %} +{% block title %}Nowi członkowie — Norda Biznes Partner{% endblock %} {% block extra_css %} {% endblock %} {% block content %} +{% if meetings_data %}
{{ total }}
-
nowych firm w ciagu ostatnich {{ days }} dni
+
nowych firm przyjętych na {{ meetings_data|length }} posiedzeniach Rady
-
- Ostatnie 30 dni - Ostatnie 60 dni - Ostatnie 90 dni - Ostatnie 6 miesiecy -
- -
- {% if companies %} - {% for company in companies %} +{% for item in meetings_data %} +
+

Posiedzenie Rady {{ item.meeting.meeting_number }}/{{ item.meeting.year }} — {{ item.meeting.meeting_date.strftime('%d.%m.%Y') }}

+
+ {% for company in item.companies %}
+ {% if company.status == 'active' %} Nowy + {% else %} + Profil w trakcie uzupełniania + {% endif %} {% if company.category %}
{{ company.category.name }}
{% endif %}
- {{ company.name }} + {% if company.status == 'active' %} + {{ company.name }} + {% else %} + {{ company.name }} + {% endif %}
{% if company.description_short %}
@@ -189,23 +189,25 @@
{% endif %}
- Dolaczyl: {{ company.created_at|local_time('%d.%m.%Y') }} - {% if company.address_city %} + {% if company.address_city %} {{ company.address_city }} + {% endif %} - {% endif %}
{% endfor %} - {% else %} -
-

Brak nowych firm w wybranym okresie

-
- {% endif %} +
+{% endfor %} + +{% else %} +
+

Brak informacji o nowych członkach

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