feat: Post-Rada Workflow Engine + redesign /nowi-czlonkowie
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
- AdmissionWorkflowLog model + migration - services/admission_workflow.py — auto-extract admitted companies from board meeting proceedings, match to existing companies, create placeholders, notify Office Managers (in-app + email) - Hook in meeting_publish_protocol() — triggers workflow in background - Dashboard /rada/posiedzenia/ID/przyjecia for Office Manager - /nowi-czlonkowie redesigned: grouped by board meetings, fixed Polish characters, removed days-based filter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3ccd7f837b
commit
90203676e5
@ -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/<int:meeting_id>/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
|
||||
# =============================================================================
|
||||
|
||||
@ -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()
|
||||
|
||||
25
database.py
25
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'
|
||||
|
||||
18
database/migrations/099_add_admission_workflow_log.sql
Normal file
18
database/migrations/099_add_admission_workflow_log.sql
Normal file
@ -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;
|
||||
395
services/admission_workflow.py
Normal file
395
services/admission_workflow.py
Normal file
@ -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"<tr><td>{m['matched_name']}</td><td style='color:green;'>Profil istnieje</td></tr>")
|
||||
for c in results.get('created', []):
|
||||
rows.append(f"<tr><td>{c['name']}</td><td style='color:orange;'>Wymaga uzupelnienia</td></tr>")
|
||||
|
||||
table_html = f"""
|
||||
<table style="width:100%; border-collapse:collapse; margin:16px 0;">
|
||||
<thead><tr style="background:#f3f4f6;"><th style="text-align:left;padding:8px;">Firma</th><th style="text-align:left;padding:8px;">Status</th></tr></thead>
|
||||
<tbody>{''.join(rows)}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
body_html = f"""
|
||||
<p>Na posiedzeniu Rady <strong>{meeting_id_str}</strong> ({meeting.meeting_date.strftime('%d.%m.%Y')})
|
||||
przyjeto <strong>{total}</strong> nowych czlonkow.</p>
|
||||
{table_html}
|
||||
{'<p><strong>Uwaga:</strong> ' + str(needs_attention) + ' firm wymaga uzupelnienia profilu na portalu.</p>' if needs_attention else ''}
|
||||
<p><a href="https://nordabiznes.pl{action_url}" style="display:inline-block;padding:10px 20px;background:#2563eb;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Przejdz do dashboardu przyjec</a></p>
|
||||
"""
|
||||
|
||||
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)
|
||||
215
templates/board/meeting_admissions.html
Normal file
215
templates/board/meeting_admissions.html
Normal file
@ -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 %}
|
||||
<style>
|
||||
.admissions-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
.admissions-header h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.admissions-header p {
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
.admissions-stats {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border: 1px solid var(--border);
|
||||
text-align: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
.stat-card .stat-value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
.stat-card .stat-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.stat-card.pending .stat-value { color: #D97706; }
|
||||
.stat-card.active .stat-value { color: var(--success); }
|
||||
|
||||
.companies-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.companies-table th {
|
||||
text-align: left;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--background);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.companies-table td {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-badge.active {
|
||||
background: #D1FAE5;
|
||||
color: #065F46;
|
||||
}
|
||||
.status-badge.pending {
|
||||
background: #FEF3C7;
|
||||
color: #92400E;
|
||||
}
|
||||
.action-btn {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
transition: var(--transition);
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.workflow-log {
|
||||
margin-top: var(--spacing-xl);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
.workflow-log h3 {
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.workflow-log .log-item {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admissions-header">
|
||||
<a href="{{ url_for('board.meeting_view', meeting_id=meeting.id) }}" style="color: var(--text-secondary); text-decoration: none; font-size: var(--font-size-sm);">← Powrót do posiedzenia</a>
|
||||
<h1>Przyjęcia nowych członków</h1>
|
||||
<p>Posiedzenie Rady {{ meeting.meeting_identifier }} — {{ meeting.meeting_date.strftime('%d.%m.%Y') }}</p>
|
||||
</div>
|
||||
|
||||
{% set active_count = admitted|selectattr('status', 'equalto', 'active')|list|length %}
|
||||
{% set pending_count = admitted|length - active_count %}
|
||||
|
||||
<div class="admissions-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ admitted|length }}</div>
|
||||
<div class="stat-label">Przyjętych firm</div>
|
||||
</div>
|
||||
<div class="stat-card active">
|
||||
<div class="stat-value">{{ active_count }}</div>
|
||||
<div class="stat-label">Profil uzupełniony</div>
|
||||
</div>
|
||||
<div class="stat-card pending">
|
||||
<div class="stat-value">{{ pending_count }}</div>
|
||||
<div class="stat-label">Wymaga uzupełnienia</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if admitted %}
|
||||
<table class="companies-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Firma</th>
|
||||
<th>Status profilu</th>
|
||||
<th>NIP</th>
|
||||
<th>Kategoria</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for company in admitted %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ company.name }}</strong>
|
||||
{% if company.address_city %}
|
||||
<br><span style="color: var(--text-muted); font-size: var(--font-size-xs);">{{ company.address_city }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if company.status == 'active' %}
|
||||
<span class="status-badge active">Aktywny</span>
|
||||
{% else %}
|
||||
<span class="status-badge pending">Do uzupełnienia</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ company.nip or '—' }}</td>
|
||||
<td>{{ company.category.name if company.category else '—' }}</td>
|
||||
<td>
|
||||
{% if company.status == 'active' %}
|
||||
<a href="{{ url_for('public.company_detail_by_slug', slug=company.slug) }}" class="action-btn" target="_blank">Profil</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('admin.admin_company_detail', company_id=company.id) }}" class="action-btn" style="border-color: #D97706; color: #92400E;">Uzupełnij</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.admin_company_detail', company_id=company.id) }}" class="action-btn">Edytuj</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: var(--spacing-2xl); color: var(--text-secondary); background: var(--surface); border-radius: var(--radius);">
|
||||
<p>Brak firm przypisanych do tego posiedzenia.</p>
|
||||
<p style="font-size: var(--font-size-sm); margin-top: var(--spacing-sm);">Firmy zostaną automatycznie przypisane po opublikowaniu protokołu.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if workflow_log %}
|
||||
<div class="workflow-log">
|
||||
<h3>Log workflow przyjęć</h3>
|
||||
<div class="log-item">Wykonano: {{ workflow_log.executed_at|local_time }}</div>
|
||||
<div class="log-item">Status: {{ workflow_log.status }}</div>
|
||||
{% if workflow_log.extracted_companies %}
|
||||
<div class="log-item">Wyodrębniono z protokołu: {{ workflow_log.extracted_companies|length }} firm</div>
|
||||
{% endif %}
|
||||
{% if workflow_log.matched_companies %}
|
||||
<div class="log-item">Dopasowano do istniejących: {{ workflow_log.matched_companies|length }}</div>
|
||||
{% endif %}
|
||||
{% if workflow_log.created_companies %}
|
||||
<div class="log-item">Utworzono nowe profile: {{ workflow_log.created_companies|length }}</div>
|
||||
{% endif %}
|
||||
{% if workflow_log.skipped %}
|
||||
<div class="log-item">Pominięto (już przypisane): {{ workflow_log.skipped|length }}</div>
|
||||
{% endif %}
|
||||
<div class="log-item">Powiadomienia: {{ workflow_log.notifications_sent }} in-app, {{ workflow_log.emails_sent }} email</div>
|
||||
{% if workflow_log.error_message %}
|
||||
<div class="log-item" style="color: var(--error);">Błąd: {{ workflow_log.error_message }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@ -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 %}
|
||||
<style>
|
||||
@ -13,32 +13,41 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
text-decoration: none;
|
||||
.page-header .subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
transition: var(--transition);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
.stats-summary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stats-number {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stats-text {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.meeting-section {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.meeting-section h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.members-grid {
|
||||
@ -73,6 +82,18 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pending-badge {
|
||||
position: absolute;
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.member-category {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
@ -112,10 +133,6 @@
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.member-date {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.member-location {
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
@ -129,59 +146,42 @@
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.stats-summary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stats-number {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stats-text {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Nowi czlonkowie</h1>
|
||||
<p class="text-muted">Firmy ktore dolaczylky do Norda Biznes</p>
|
||||
<h1>Nowi członkowie Izby</h1>
|
||||
<p class="subtitle">Firmy przyjęte na posiedzeniach Rady Izby Norda Biznes</p>
|
||||
</div>
|
||||
|
||||
{% if meetings_data %}
|
||||
<div class="stats-summary">
|
||||
<div class="stats-number">{{ total }}</div>
|
||||
<div class="stats-text">nowych firm w ciagu ostatnich {{ days }} dni</div>
|
||||
<div class="stats-text">nowych firm przyjętych na {{ meetings_data|length }} posiedzeniach Rady</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<a href="{{ url_for('new_members', days=30) }}" class="filter-btn {% if days == 30 %}active{% endif %}">Ostatnie 30 dni</a>
|
||||
<a href="{{ url_for('new_members', days=60) }}" class="filter-btn {% if days == 60 %}active{% endif %}">Ostatnie 60 dni</a>
|
||||
<a href="{{ url_for('new_members', days=90) }}" class="filter-btn {% if days == 90 %}active{% endif %}">Ostatnie 90 dni</a>
|
||||
<a href="{{ url_for('new_members', days=180) }}" class="filter-btn {% if days == 180 %}active{% endif %}">Ostatnie 6 miesiecy</a>
|
||||
</div>
|
||||
|
||||
<div class="members-grid">
|
||||
{% if companies %}
|
||||
{% for company in companies %}
|
||||
{% for item in meetings_data %}
|
||||
<div class="meeting-section">
|
||||
<h2>Posiedzenie Rady {{ item.meeting.meeting_number }}/{{ item.meeting.year }} — {{ item.meeting.meeting_date.strftime('%d.%m.%Y') }}</h2>
|
||||
<div class="members-grid">
|
||||
{% for company in item.companies %}
|
||||
<div class="member-card">
|
||||
{% if company.status == 'active' %}
|
||||
<span class="new-badge">Nowy</span>
|
||||
{% else %}
|
||||
<span class="pending-badge">Profil w trakcie uzupełniania</span>
|
||||
{% endif %}
|
||||
{% if company.category %}
|
||||
<div class="member-category">{{ company.category.name }}</div>
|
||||
{% endif %}
|
||||
<div class="member-name">
|
||||
<a href="{{ url_for('company_detail', company_id=company.id) }}">{{ company.name }}</a>
|
||||
{% if company.status == 'active' %}
|
||||
<a href="{{ url_for('public.company_detail_by_slug', slug=company.slug) }}">{{ company.name }}</a>
|
||||
{% else %}
|
||||
{{ company.name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if company.description_short %}
|
||||
<div class="member-description">
|
||||
@ -189,23 +189,25 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="member-meta">
|
||||
<span class="member-date">Dolaczyl: {{ company.created_at|local_time('%d.%m.%Y') }}</span>
|
||||
{% if company.address_city %}
|
||||
<span class="member-location">
|
||||
{% if company.address_city %}
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
{{ company.address_city }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Brak nowych firm w wybranym okresie</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Brak informacji o nowych członkach</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user