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
- norda_events: kolumna event_date_end (NULLABLE, check constraint >= event_date) - NordaEvent: property is_multi_day, date_range_display; is_past uwzględnia koniec - Admin (new/edit): pole "Data zakończenia" w formularzu - Calendar grid: wydarzenie wielodniowe wyświetla się na każdym dniu zakresu - Upcoming/past filter: używa COALESCE(end, date) — 2-dniowe zostaje w Upcoming do swojego ostatniego dnia - event.html: "Termin" + zakres dla wielodniowych; ICS/Google end date z dateEnd - Lekki markdown dla opisów: tylko **bold** → <strong> (audyt: tylko event #60) Zero wpływu na 42 istniejące wydarzenia (NULL == stare zachowanie). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6221 lines
233 KiB
Python
6221 lines
233 KiB
Python
"""
|
||
Norda Biznes - Database Models
|
||
===============================
|
||
|
||
SQLAlchemy models for PostgreSQL database.
|
||
|
||
Models:
|
||
- User: User accounts with authentication
|
||
- Company: Company information
|
||
- Category, Service, Competency: Company classifications
|
||
- AIChatConversation, AIChatMessage: Chat history
|
||
- AIAPICostLog: API cost tracking
|
||
- CompanyDigitalMaturity: Digital maturity scores and benchmarking
|
||
- CompanyWebsiteAnalysis: Website analysis and SEO metrics
|
||
- MaturityAssessment: Historical tracking of maturity scores
|
||
- GBPAudit: Google Business Profile audit results
|
||
- ITAudit: IT infrastructure audit results
|
||
- ITCollaborationMatch: IT collaboration matches between companies
|
||
- AIUsageLog, AIUsageDaily, AIRateLimit: AI API usage monitoring
|
||
- Announcement: Ogłoszenia i aktualności dla członków
|
||
|
||
Author: Maciej Pienczyn, InPi sp. z o.o.
|
||
Created: 2025-11-23
|
||
Updated: 2026-01-11 (AI Usage Tracking)
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
from datetime import datetime
|
||
from enum import IntEnum
|
||
from sqlalchemy import create_engine, Column, Integer, SmallInteger, String, Text, Boolean, DateTime, Float, ForeignKey, Table, Numeric, Date, Time, TypeDecorator, UniqueConstraint, Enum
|
||
from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY, JSONB as PG_JSONB
|
||
from sqlalchemy.ext.declarative import declarative_base
|
||
from sqlalchemy.orm import sessionmaker, relationship, backref
|
||
from flask_login import UserMixin
|
||
|
||
|
||
# ============================================================
|
||
# ROLE SYSTEM ENUMS
|
||
# ============================================================
|
||
|
||
class SystemRole(IntEnum):
|
||
"""
|
||
System-wide user roles with hierarchical access levels.
|
||
Higher value = more permissions.
|
||
|
||
Role hierarchy:
|
||
- UNAFFILIATED (10): Firma spoza Izby - tylko publiczne profile (bez kontaktów)
|
||
- MEMBER (20): Członek Norda bez firmy - pełny dostęp do treści
|
||
- EMPLOYEE (30): Pracownik firmy członkowskiej - może edytować dane firmy
|
||
- MANAGER (40): Kadra zarządzająca - pełna kontrola firmy + zarządzanie użytkownikami
|
||
- OFFICE_MANAGER (50): Kierownik biura Norda - panel admina (bez użytkowników)
|
||
- ADMIN (100): Administrator portalu - zarządzanie treścią i użytkownikami
|
||
- SUPERADMIN (200): Superadministrator - pełne prawa + audyty techniczne
|
||
"""
|
||
UNAFFILIATED = 10 # Niezrzeszony (firma spoza Izby)
|
||
MEMBER = 20 # Członek Norda bez firmy
|
||
EMPLOYEE = 30 # Pracownik firmy członkowskiej
|
||
MANAGER = 40 # Kadra zarządzająca firmy
|
||
OFFICE_MANAGER = 50 # Kierownik biura Norda
|
||
ADMIN = 100 # Administrator portalu
|
||
SUPERADMIN = 200 # Superadministrator - pełne prawa + audyty
|
||
|
||
@classmethod
|
||
def choices(cls):
|
||
"""Return list of (value, label) tuples for forms."""
|
||
labels = {
|
||
cls.UNAFFILIATED: 'Niezrzeszony',
|
||
cls.MEMBER: 'Członek',
|
||
cls.EMPLOYEE: 'Pracownik',
|
||
cls.MANAGER: 'Kadra Zarządzająca',
|
||
cls.OFFICE_MANAGER: 'Kierownik Biura',
|
||
cls.ADMIN: 'Administrator',
|
||
cls.SUPERADMIN: 'Superadministrator',
|
||
}
|
||
return [(role.value, labels[role]) for role in cls]
|
||
|
||
@classmethod
|
||
def from_string(cls, value: str) -> 'SystemRole':
|
||
"""Convert string to SystemRole enum."""
|
||
mapping = {
|
||
'UNAFFILIATED': cls.UNAFFILIATED,
|
||
'MEMBER': cls.MEMBER,
|
||
'EMPLOYEE': cls.EMPLOYEE,
|
||
'MANAGER': cls.MANAGER,
|
||
'OFFICE_MANAGER': cls.OFFICE_MANAGER,
|
||
'ADMIN': cls.ADMIN,
|
||
'SUPERADMIN': cls.SUPERADMIN,
|
||
}
|
||
return mapping.get(value.upper(), cls.UNAFFILIATED)
|
||
|
||
|
||
class CompanyRole(IntEnum):
|
||
"""
|
||
User's role within their assigned company.
|
||
Determines what actions they can perform on company data.
|
||
|
||
- NONE (0): Brak powiązania z firmą
|
||
- VIEWER (10): Może przeglądać dashboard firmy
|
||
- EMPLOYEE (20): Może edytować dane firmy (opis, usługi, kompetencje)
|
||
- MANAGER (30): Pełna kontrola + zarządzanie użytkownikami firmy
|
||
"""
|
||
NONE = 0 # Brak powiązania z firmą
|
||
VIEWER = 10 # Może przeglądać dashboard firmy
|
||
EMPLOYEE = 20 # Może edytować dane firmy
|
||
MANAGER = 30 # Pełna kontrola + zarządzanie użytkownikami firmy
|
||
|
||
@classmethod
|
||
def choices(cls):
|
||
"""Return list of (value, label) tuples for forms."""
|
||
labels = {
|
||
cls.NONE: 'Brak',
|
||
cls.VIEWER: 'Podgląd',
|
||
cls.EMPLOYEE: 'Pracownik',
|
||
cls.MANAGER: 'Zarządzający',
|
||
}
|
||
return [(role.value, labels[role]) for role in cls]
|
||
|
||
@classmethod
|
||
def from_string(cls, value: str) -> 'CompanyRole':
|
||
"""Convert string to CompanyRole enum."""
|
||
mapping = {
|
||
'NONE': cls.NONE,
|
||
'VIEWER': cls.VIEWER,
|
||
'EMPLOYEE': cls.EMPLOYEE,
|
||
'MANAGER': cls.MANAGER,
|
||
}
|
||
return mapping.get(value.upper(), cls.NONE)
|
||
|
||
# Database configuration
|
||
# WARNING: The fallback DATABASE_URL uses a placeholder password.
|
||
# Production credentials MUST be set via the DATABASE_URL environment variable.
|
||
# NEVER commit real credentials to version control (CWE-798).
|
||
DATABASE_URL = os.getenv(
|
||
'DATABASE_URL',
|
||
'postgresql://nordabiz_app:CHANGE_ME@localhost:5432/nordabiz'
|
||
)
|
||
|
||
# Determine if we're using SQLite
|
||
IS_SQLITE = DATABASE_URL.startswith('sqlite')
|
||
|
||
|
||
def normalize_social_url(url: str, platform: str = None) -> str:
|
||
"""
|
||
Normalize social media URLs to prevent duplicates.
|
||
|
||
Handles:
|
||
- www vs non-www (removes www.)
|
||
- http vs https (forces https)
|
||
- Trailing slashes (removes)
|
||
- Platform-specific canonicalization
|
||
|
||
Examples:
|
||
normalize_social_url('http://www.facebook.com/inpipl/')
|
||
-> 'https://facebook.com/inpipl'
|
||
|
||
normalize_social_url('https://www.instagram.com/user/')
|
||
-> 'https://instagram.com/user'
|
||
"""
|
||
if not url:
|
||
return url
|
||
|
||
url = url.strip()
|
||
|
||
# Force https
|
||
if url.startswith('http://'):
|
||
url = 'https://' + url[7:]
|
||
elif not url.startswith('https://'):
|
||
url = 'https://' + url
|
||
|
||
# Remove www. prefix
|
||
url = url.replace('https://www.', 'https://')
|
||
|
||
# Remove trailing slash
|
||
url = url.rstrip('/')
|
||
|
||
# Platform-specific normalization
|
||
if platform == 'facebook' or 'facebook.com' in url:
|
||
# fb.com -> facebook.com
|
||
url = url.replace('https://fb.com/', 'https://facebook.com/')
|
||
url = url.replace('https://m.facebook.com/', 'https://facebook.com/')
|
||
|
||
if platform == 'twitter' or 'twitter.com' in url or 'x.com' in url:
|
||
# x.com -> twitter.com (or vice versa, pick one canonical)
|
||
url = url.replace('https://x.com/', 'https://twitter.com/')
|
||
|
||
if platform == 'linkedin' or 'linkedin.com' in url:
|
||
# Remove locale prefix
|
||
url = url.replace('/pl/', '/').replace('/en/', '/')
|
||
|
||
return url
|
||
|
||
|
||
class StringArray(TypeDecorator):
|
||
"""
|
||
Platform-agnostic array type.
|
||
Uses PostgreSQL ARRAY for PostgreSQL, stores as JSON string for SQLite.
|
||
"""
|
||
impl = Text
|
||
cache_ok = True
|
||
|
||
def load_dialect_impl(self, dialect):
|
||
if dialect.name == 'postgresql':
|
||
return dialect.type_descriptor(PG_ARRAY(String))
|
||
return dialect.type_descriptor(Text())
|
||
|
||
def process_bind_param(self, value, dialect):
|
||
if value is None:
|
||
return None
|
||
if dialect.name == 'postgresql':
|
||
return value
|
||
return json.dumps(value)
|
||
|
||
def process_result_value(self, value, dialect):
|
||
if value is None:
|
||
return None
|
||
if dialect.name == 'postgresql':
|
||
return value
|
||
if isinstance(value, list):
|
||
return value
|
||
return json.loads(value)
|
||
|
||
|
||
class JSONBType(TypeDecorator):
|
||
"""
|
||
Platform-agnostic JSONB type.
|
||
Uses PostgreSQL JSONB for PostgreSQL, stores as JSON string for SQLite.
|
||
"""
|
||
impl = Text
|
||
cache_ok = True
|
||
|
||
def load_dialect_impl(self, dialect):
|
||
if dialect.name == 'postgresql':
|
||
return dialect.type_descriptor(PG_JSONB())
|
||
return dialect.type_descriptor(Text())
|
||
|
||
def process_bind_param(self, value, dialect):
|
||
if value is None:
|
||
return None
|
||
if dialect.name == 'postgresql':
|
||
return value
|
||
return json.dumps(value)
|
||
|
||
def process_result_value(self, value, dialect):
|
||
if value is None:
|
||
return None
|
||
if dialect.name == 'postgresql':
|
||
return value
|
||
if isinstance(value, dict):
|
||
return value
|
||
return json.loads(value)
|
||
|
||
|
||
# Aliases for backwards compatibility
|
||
ARRAY = StringArray
|
||
JSONB = JSONBType
|
||
|
||
|
||
# Create engine
|
||
engine = create_engine(DATABASE_URL, echo=False, pool_size=10, pool_pre_ping=True)
|
||
SessionLocal = sessionmaker(bind=engine)
|
||
Base = declarative_base()
|
||
|
||
|
||
# ============================================================
|
||
# USER MANAGEMENT
|
||
# ============================================================
|
||
|
||
class User(Base, UserMixin):
|
||
"""User accounts with role-based access control."""
|
||
__tablename__ = 'users'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||
password_hash = Column(String(255), nullable=False)
|
||
name = Column(String(255))
|
||
company_nip = Column(String(10))
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
|
||
company = relationship('Company', backref='users', lazy='joined') # eager load to avoid DetachedInstanceError
|
||
person_id = Column(Integer, ForeignKey('people.id'), nullable=True)
|
||
person = relationship('Person', backref='users', lazy='joined') # Link to Person (KRS data)
|
||
phone = Column(String(50))
|
||
|
||
# === ROLE SYSTEM (added 2026-02-01) ===
|
||
# System-wide role determining overall access level
|
||
role = Column(String(20), default='UNAFFILIATED', nullable=False)
|
||
# Role within assigned company (if any)
|
||
company_role = Column(String(20), default='NONE', nullable=False)
|
||
|
||
# Status
|
||
is_active = Column(Boolean, default=True)
|
||
is_verified = Column(Boolean, default=False)
|
||
is_admin = Column(Boolean, default=False) # DEPRECATED: synced by set_role() for backward compat. Use has_role(SystemRole.ADMIN) instead. Will be removed in future migration.
|
||
is_norda_member = Column(Boolean, default=False)
|
||
is_rada_member = Column(Boolean, default=False) # Member of Rada Izby (Board Council)
|
||
chamber_role = Column(String(50)) # prezes, wiceprezes, czlonek_rady, komisja_rewizyjna, sad_kolezenski
|
||
avatar_path = Column(String(500)) # Path to profile photo (relative to static/uploads/)
|
||
|
||
# Account origin tracking
|
||
created_by_id = Column(Integer, ForeignKey('users.id'), nullable=True) # NULL = self-registration
|
||
created_by = relationship('User', remote_side='User.id', foreign_keys='User.created_by_id', lazy='joined')
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
last_login = Column(DateTime)
|
||
last_active_at = Column(DateTime)
|
||
verified_at = Column(DateTime)
|
||
|
||
# Engagement metrics
|
||
login_count = Column(Integer, default=0)
|
||
page_views_count = Column(Integer, default=0)
|
||
|
||
# Verification token
|
||
verification_token = Column(String(255))
|
||
verification_token_expires = Column(DateTime)
|
||
|
||
# Password reset token
|
||
reset_token = Column(String(255))
|
||
reset_token_expires = Column(DateTime)
|
||
|
||
# Account lockout (brute force protection)
|
||
failed_login_attempts = Column(Integer, default=0)
|
||
locked_until = Column(DateTime, nullable=True)
|
||
|
||
# Two-Factor Authentication (TOTP)
|
||
totp_secret = Column(String(32), nullable=True) # Base32 encoded secret
|
||
totp_enabled = Column(Boolean, default=False)
|
||
totp_backup_codes = Column(StringArray, nullable=True) # Emergency backup codes
|
||
|
||
# Privacy settings
|
||
privacy_show_phone = Column(Boolean, default=True) # If FALSE, phone hidden from other users
|
||
privacy_show_email = Column(Boolean, default=True) # If FALSE, email hidden from other users
|
||
|
||
# Contact preferences
|
||
contact_prefer_email = Column(Boolean, default=True) # User prefers email contact
|
||
contact_prefer_phone = Column(Boolean, default=True) # User prefers phone contact
|
||
contact_prefer_portal = Column(Boolean, default=True) # User prefers portal messages
|
||
contact_note = Column(Text, nullable=True) # Additional note (e.g. best hours)
|
||
|
||
# Email notification preferences (per event type)
|
||
notify_email_messages = Column(Boolean, default=True) # Prywatna wiadomość
|
||
notify_email_classified_question = Column(Boolean, default=True) # Pytanie pod moim ogłoszeniem B2B
|
||
notify_email_classified_answer = Column(Boolean, default=True) # Odpowiedź pod moim pytaniem B2B
|
||
notify_email_classified_expiry = Column(Boolean, default=True) # Moje ogłoszenie wygasa za 3 dni
|
||
notify_email_forum_reply = Column(Boolean, default=False) # Odpowiedź w moim wątku forum (default OFF — forum subs duplikuje)
|
||
notify_email_forum_quote = Column(Boolean, default=True) # Cytat mojego wpisu forum
|
||
notify_email_announcements = Column(Boolean, default=False) # Nowa aktualność Izby (broadcast)
|
||
notify_email_board_meetings = Column(Boolean, default=False) # Posiedzenia Rady (broadcast)
|
||
notify_email_event_invites = Column(Boolean, default=True) # Nowe wydarzenie w kalendarzu
|
||
notify_email_event_reminders = Column(Boolean, default=True) # Przypomnienie 24h przed wydarzeniem
|
||
|
||
# Web Push notification preferences (per event type)
|
||
notify_push_messages = Column(Boolean, default=True) # Prywatna wiadomość
|
||
notify_push_classified_interest = Column(Boolean, default=True) # Zainteresowanie ogłoszeniem B2B
|
||
notify_push_classified_question = Column(Boolean, default=True) # Pytanie pod ogłoszeniem B2B
|
||
notify_push_forum_reply = Column(Boolean, default=True) # Odpowiedź w moim wątku forum
|
||
notify_push_forum_quote = Column(Boolean, default=True) # Cytat mojego wpisu forum
|
||
notify_push_announcements = Column(Boolean, default=True) # Nowa aktualność Izby
|
||
notify_push_board_meetings = Column(Boolean, default=True) # Posiedzenia Rady (utw./program/protokół)
|
||
notify_push_event_invites = Column(Boolean, default=True) # Zaproszenie na wydarzenie
|
||
notify_push_event_reminders = Column(Boolean, default=True) # Przypomnienie 24h przed wydarzeniem
|
||
|
||
# Relationships
|
||
conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan')
|
||
forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumTopic.author_id')
|
||
forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumReply.author_id')
|
||
forum_subscriptions = relationship('ForumTopicSubscription', back_populates='user', cascade='all, delete-orphan')
|
||
push_subscriptions = relationship('PushSubscription', back_populates='user', cascade='all, delete-orphan')
|
||
|
||
# === ROLE SYSTEM HELPER METHODS ===
|
||
|
||
@property
|
||
def chamber_role_label(self):
|
||
labels = {
|
||
'prezes': 'Prezes Izby',
|
||
'wiceprezes': 'Wiceprezes Izby',
|
||
'czlonek_rady': 'Członek Rady Izby',
|
||
'komisja_rewizyjna': 'Komisja Rewizyjna',
|
||
'sad_kolezenski': 'Sąd Koleżeński',
|
||
}
|
||
return labels.get(self.chamber_role)
|
||
|
||
@property
|
||
def system_role(self) -> SystemRole:
|
||
"""Get the user's SystemRole enum value."""
|
||
return SystemRole.from_string(self.role or 'UNAFFILIATED')
|
||
|
||
@property
|
||
def company_role_enum(self) -> CompanyRole:
|
||
"""Get the user's CompanyRole enum value."""
|
||
return CompanyRole.from_string(self.company_role or 'NONE')
|
||
|
||
def has_role(self, required_role: SystemRole) -> bool:
|
||
"""
|
||
Check if user has at least the required role level.
|
||
|
||
Args:
|
||
required_role: Minimum required SystemRole
|
||
|
||
Returns:
|
||
True if user's role >= required_role
|
||
|
||
Example:
|
||
if user.has_role(SystemRole.OFFICE_MANAGER):
|
||
# User is Office Manager or Admin
|
||
"""
|
||
return self.system_role >= required_role
|
||
|
||
def can_view_contacts(self) -> bool:
|
||
"""
|
||
Check if user can view contact information (email, phone) of other members.
|
||
Requires at least MEMBER role.
|
||
"""
|
||
return self.has_role(SystemRole.MEMBER)
|
||
|
||
def can_access_forum(self) -> bool:
|
||
"""
|
||
Check if user can read and write on the forum.
|
||
Requires at least MEMBER role.
|
||
"""
|
||
return self.has_role(SystemRole.MEMBER)
|
||
|
||
def can_access_chat(self) -> bool:
|
||
"""
|
||
Check if user can use NordaGPT chat with full features.
|
||
UNAFFILIATED users get limited access.
|
||
"""
|
||
return self.has_role(SystemRole.MEMBER)
|
||
|
||
def get_companies(self, session=None):
|
||
"""
|
||
Get all companies associated with this user.
|
||
|
||
Returns:
|
||
List of UserCompany objects (uses relationship if loaded, else queries).
|
||
"""
|
||
if self.company_associations:
|
||
return self.company_associations
|
||
if session:
|
||
return session.query(UserCompany).filter_by(user_id=self.id).all()
|
||
return []
|
||
|
||
def get_company_role(self, company_id: int, session=None) -> 'CompanyRole':
|
||
"""
|
||
Get user's role for a specific company from user_companies table.
|
||
|
||
Args:
|
||
company_id: The company to check.
|
||
session: SQLAlchemy session (optional, uses relationship if loaded).
|
||
|
||
Returns:
|
||
CompanyRole enum value, or CompanyRole.NONE if not associated.
|
||
"""
|
||
# Check loaded relationships first
|
||
for assoc in (self.company_associations or []):
|
||
if assoc.company_id == company_id:
|
||
return assoc.role_enum
|
||
# Fallback to query
|
||
if session:
|
||
assoc = session.query(UserCompany).filter_by(
|
||
user_id=self.id, company_id=company_id
|
||
).first()
|
||
if assoc:
|
||
return assoc.role_enum
|
||
# Legacy fallback: if company_id matches primary, use company_role
|
||
if self.company_id == company_id:
|
||
return self.company_role_enum
|
||
return CompanyRole.NONE
|
||
|
||
def can_edit_company(self, company_id: int = None) -> bool:
|
||
"""
|
||
Check if user can edit a company's profile.
|
||
|
||
Args:
|
||
company_id: Company to check. If None, checks user's primary company.
|
||
|
||
Returns:
|
||
True if user can edit the company.
|
||
"""
|
||
# Admins and Office Managers can edit any company
|
||
if self.has_role(SystemRole.OFFICE_MANAGER):
|
||
return True
|
||
|
||
target_company = company_id or self.company_id
|
||
if not target_company:
|
||
return False
|
||
|
||
# Check role via user_companies (supports multi-company)
|
||
role = self.get_company_role(target_company)
|
||
return role >= CompanyRole.EMPLOYEE
|
||
|
||
def can_manage_company(self, company_id: int = None) -> bool:
|
||
"""
|
||
Check if user can manage a company (including user management).
|
||
|
||
Args:
|
||
company_id: Company to check. If None, checks user's primary company.
|
||
|
||
Returns:
|
||
True if user has full management rights.
|
||
"""
|
||
# Admins can manage any company
|
||
if self.has_role(SystemRole.ADMIN):
|
||
return True
|
||
|
||
target_company = company_id or self.company_id
|
||
if not target_company:
|
||
return False
|
||
|
||
# Check role via user_companies (supports multi-company)
|
||
role = self.get_company_role(target_company)
|
||
return role >= CompanyRole.MANAGER
|
||
|
||
def can_manage_users(self) -> bool:
|
||
"""
|
||
Check if user can manage all portal users.
|
||
Only ADMIN role has this permission.
|
||
"""
|
||
return self.has_role(SystemRole.ADMIN)
|
||
|
||
def can_access_admin_panel(self) -> bool:
|
||
"""
|
||
Check if user can access the admin panel.
|
||
OFFICE_MANAGER and ADMIN roles have access.
|
||
"""
|
||
return self.has_role(SystemRole.OFFICE_MANAGER)
|
||
|
||
def can_moderate_forum(self) -> bool:
|
||
"""
|
||
Check if user can moderate forum content.
|
||
OFFICE_MANAGER and ADMIN roles have this permission.
|
||
"""
|
||
return self.has_role(SystemRole.OFFICE_MANAGER)
|
||
|
||
def has_delegated_permission(self, permission: str, company_id: int = None, session=None) -> bool:
|
||
"""
|
||
Check if user has a specific delegated permission for a company.
|
||
|
||
This checks UserCompanyPermissions table for fine-grained permissions
|
||
granted by a MANAGER.
|
||
|
||
Args:
|
||
permission: One of: 'edit_description', 'edit_services', 'edit_contacts',
|
||
'edit_social', 'manage_classifieds', 'post_forum', 'view_analytics'
|
||
company_id: Company to check. If None, checks user's primary company.
|
||
session: SQLAlchemy session (optional, uses relationship if available)
|
||
|
||
Returns:
|
||
True if user has this specific permission
|
||
"""
|
||
target_company = company_id or self.company_id
|
||
|
||
# Managers have all permissions by default
|
||
role = self.get_company_role(target_company) if target_company else self.company_role_enum
|
||
if role >= CompanyRole.MANAGER:
|
||
return True
|
||
|
||
# Check delegated permissions
|
||
if self.company_permissions:
|
||
for perm in self.company_permissions:
|
||
if perm.company_id == target_company:
|
||
attr_name = f'can_{permission}'
|
||
return getattr(perm, attr_name, False)
|
||
|
||
return False
|
||
|
||
def can_edit_company_field(self, field_category: str, company_id: int = None) -> bool:
|
||
"""
|
||
Check if user can edit a specific category of company fields.
|
||
|
||
Args:
|
||
field_category: 'description', 'services', 'contacts', or 'social'
|
||
company_id: Company to check. If None, checks user's primary company.
|
||
|
||
Returns:
|
||
True if user can edit fields in this category
|
||
"""
|
||
# Admins and Office Managers can edit everything
|
||
if self.has_role(SystemRole.OFFICE_MANAGER):
|
||
return True
|
||
|
||
target_company = company_id or self.company_id
|
||
if not target_company:
|
||
return False
|
||
|
||
# Check role via user_companies (supports multi-company)
|
||
role = self.get_company_role(target_company)
|
||
|
||
# Managers can edit everything in their company
|
||
if role >= CompanyRole.MANAGER:
|
||
return True
|
||
|
||
# Employees need delegated permission
|
||
if role >= CompanyRole.EMPLOYEE:
|
||
return self.has_delegated_permission(f'edit_{field_category}', company_id=target_company)
|
||
|
||
return False
|
||
|
||
def set_role(self, new_role: SystemRole, sync_is_admin: bool = True):
|
||
"""
|
||
Set the user's system role.
|
||
|
||
Args:
|
||
new_role: The new SystemRole to assign
|
||
sync_is_admin: If True, also update is_admin field for backward compatibility
|
||
"""
|
||
self.role = new_role.name
|
||
if sync_is_admin:
|
||
self.is_admin = (new_role >= SystemRole.ADMIN)
|
||
|
||
def set_company_role(self, new_role: CompanyRole):
|
||
"""Set the user's company role."""
|
||
self.company_role = new_role.name
|
||
|
||
def __repr__(self):
|
||
return f'<User {self.email} role={self.role}>'
|
||
|
||
|
||
class PushSubscription(Base):
|
||
"""Web Push subscription per user device (desktop, mobile browser, PWA iOS).
|
||
|
||
Jeden user może mieć wiele subskrypcji — po jednej na każde urządzenie/przeglądarkę.
|
||
Endpoint unique — powtórny subscribe z tej samej przeglądarki aktualizuje istniejący rekord.
|
||
"""
|
||
__tablename__ = 'push_subscriptions'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
endpoint = Column(Text, nullable=False, unique=True)
|
||
p256dh = Column(String(255), nullable=False)
|
||
auth = Column(String(255), nullable=False)
|
||
user_agent = Column(String(500))
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
last_used_at = Column(DateTime)
|
||
|
||
user = relationship('User', back_populates='push_subscriptions')
|
||
|
||
def __repr__(self):
|
||
return f'<PushSubscription user={self.user_id} id={self.id}>'
|
||
|
||
|
||
class UserCompanyPermissions(Base):
|
||
"""
|
||
Delegated permissions for company employees.
|
||
|
||
Allows MANAGER to grant specific permissions to EMPLOYEE users,
|
||
enabling fine-grained control over what each employee can do.
|
||
|
||
Example:
|
||
- Jan Kowalski (EMPLOYEE) gets permission to edit social media
|
||
- Anna Nowak (EMPLOYEE) gets permission to manage B2B classifieds
|
||
"""
|
||
__tablename__ = 'user_company_permissions'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
|
||
|
||
# Content editing permissions
|
||
can_edit_description = Column(Boolean, default=True) # Opis firmy, historia, wartości
|
||
can_edit_services = Column(Boolean, default=True) # Usługi, kompetencje, technologie
|
||
can_edit_contacts = Column(Boolean, default=False) # Email, telefon, adres
|
||
can_edit_social = Column(Boolean, default=False) # Social media, strona www
|
||
|
||
# Feature permissions
|
||
can_manage_classifieds = Column(Boolean, default=True) # B2B ogłoszenia w imieniu firmy
|
||
can_post_forum = Column(Boolean, default=True) # Posty na forum w imieniu firmy
|
||
can_view_analytics = Column(Boolean, default=False) # Statystyki firmy, wyświetlenia
|
||
|
||
# Granted by (for audit trail)
|
||
granted_by_id = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||
granted_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
user = relationship('User', foreign_keys=[user_id], backref='company_permissions')
|
||
company = relationship('Company', backref='user_permissions')
|
||
granted_by = relationship('User', foreign_keys=[granted_by_id])
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('user_id', 'company_id', name='uq_user_company_permissions'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f'<UserCompanyPermissions user={self.user_id} company={self.company_id}>'
|
||
|
||
@classmethod
|
||
def get_or_create(cls, session, user_id: int, company_id: int) -> 'UserCompanyPermissions':
|
||
"""Get existing permissions or create default ones.
|
||
|
||
MANAGER+ roles get full permissions automatically.
|
||
EMPLOYEE gets restricted defaults (no contacts/social/analytics).
|
||
"""
|
||
perms = session.query(cls).filter_by(
|
||
user_id=user_id,
|
||
company_id=company_id
|
||
).first()
|
||
|
||
if not perms:
|
||
# Check if user has MANAGER+ role → grant full permissions
|
||
user = session.query(User).filter_by(id=user_id).first()
|
||
is_manager = False
|
||
if user:
|
||
role = user.get_company_role(company_id) if company_id else user.company_role_enum
|
||
is_manager = role >= CompanyRole.MANAGER
|
||
|
||
perms = cls(
|
||
user_id=user_id,
|
||
company_id=company_id,
|
||
can_edit_contacts=is_manager,
|
||
can_edit_social=is_manager,
|
||
can_view_analytics=is_manager,
|
||
)
|
||
session.add(perms)
|
||
session.flush()
|
||
|
||
return perms
|
||
|
||
|
||
class UserCompany(Base):
|
||
"""
|
||
Association between users and companies (multi-company support).
|
||
|
||
Allows a user to be linked to multiple companies with different roles.
|
||
One association per user can be marked as is_primary, which syncs
|
||
to users.company_id via database trigger.
|
||
"""
|
||
__tablename__ = 'user_companies'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
|
||
role = Column(String(20), nullable=False, default='MANAGER')
|
||
is_primary = Column(Boolean, default=False)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
user = relationship('User', backref='company_associations')
|
||
company = relationship('Company', backref='user_associations')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('user_id', 'company_id', name='uq_user_company'),
|
||
)
|
||
|
||
@property
|
||
def role_enum(self) -> CompanyRole:
|
||
"""Get the CompanyRole enum value."""
|
||
return CompanyRole.from_string(self.role or 'NONE')
|
||
|
||
def __repr__(self):
|
||
return f'<UserCompany user={self.user_id} company={self.company_id} role={self.role} primary={self.is_primary}>'
|
||
|
||
|
||
# ============================================================
|
||
# COMPANY DIRECTORY (existing schema from SQL)
|
||
# ============================================================
|
||
|
||
class Category(Base):
|
||
"""Company categories with hierarchical structure"""
|
||
__tablename__ = 'categories'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
name = Column(String(100), nullable=False, unique=True)
|
||
slug = Column(String(100), nullable=False, unique=True)
|
||
description = Column(Text)
|
||
icon = Column(String(50))
|
||
sort_order = Column(Integer, default=0)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Hierarchical structure
|
||
parent_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
|
||
display_order = Column(Integer, default=0)
|
||
|
||
# Relationships
|
||
companies = relationship('Company', back_populates='category')
|
||
parent = relationship('Category', remote_side=[id], backref='subcategories')
|
||
|
||
@property
|
||
def is_main_category(self):
|
||
"""Check if this is a main (parent) category"""
|
||
return self.parent_id is None
|
||
|
||
|
||
class Company(Base):
|
||
"""Companies"""
|
||
__tablename__ = 'companies'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
name = Column(String(255), nullable=False)
|
||
legal_name = Column(String(255))
|
||
slug = Column(String(255), nullable=False, unique=True, index=True)
|
||
category_id = Column(Integer, ForeignKey('categories.id'))
|
||
|
||
# Descriptions
|
||
description_short = Column(Text)
|
||
description_full = Column(Text)
|
||
|
||
# Legal
|
||
nip = Column(String(10), unique=True)
|
||
regon = Column(String(14))
|
||
krs = Column(String(10))
|
||
|
||
# Parent company relationship (for brands/divisions of the same legal entity)
|
||
parent_company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
|
||
|
||
# External registry slugs
|
||
aleo_slug = Column(String(255)) # ALEO.com company slug for direct links
|
||
|
||
# Contact
|
||
website = Column(String(500))
|
||
email = Column(String(255))
|
||
phone = Column(String(50))
|
||
|
||
# Address
|
||
address_street = Column(String(255))
|
||
address_city = Column(String(100))
|
||
address_postal = Column(String(10))
|
||
address_full = Column(Text)
|
||
|
||
# Business data
|
||
year_established = Column(Integer)
|
||
employees_count = Column(Integer)
|
||
capital_amount = Column(Numeric(15, 2))
|
||
|
||
# Status (PostgreSQL uses ENUM types, no default here)
|
||
status = Column(String(20))
|
||
data_quality = Column(String(20))
|
||
|
||
# Membership status: active, resigned, suspended, exempt
|
||
membership_status = Column(String(20), default='active')
|
||
resignation_date = Column(Date)
|
||
fee_included_in_parent = Column(Boolean, default=False)
|
||
|
||
# Extended company info
|
||
legal_form = Column(String(100))
|
||
parent_organization = Column(String(255))
|
||
industry_sector = Column(String(255))
|
||
services_offered = Column(Text)
|
||
operational_area = Column(String(500))
|
||
languages_offered = Column(String(200))
|
||
technologies_used = Column(Text)
|
||
founding_history = Column(Text) # Historia firmy + właściciele
|
||
core_values = Column(Text) # Wartości firmy
|
||
branch_count = Column(Integer)
|
||
employee_count_range = Column(String(50))
|
||
|
||
# === CEIDG DATA (API dane.biznes.gov.pl) ===
|
||
# Podstawowe dane CEIDG
|
||
ceidg_id = Column(String(50)) # GUID firmy w CEIDG
|
||
ceidg_status = Column(String(50)) # AKTYWNY, ZAWIESZONY, WYKREŚLONY
|
||
|
||
# PKD (kod działalności gospodarczej) - z CEIDG
|
||
pkd_code = Column(String(10)) # np. "6201Z" (główny PKD)
|
||
pkd_description = Column(Text) # np. "Działalność związana z oprogramowaniem"
|
||
ceidg_pkd_list = Column(PG_JSONB, default=[]) # Wszystkie PKD z CEIDG jako [{kod, nazwa}]
|
||
|
||
# Data rozpoczęcia działalności - z CEIDG
|
||
business_start_date = Column(Date) # np. 2021-02-10
|
||
|
||
# Właściciel JDG - z CEIDG (tylko dla jednoosobowych działalności)
|
||
owner_first_name = Column(String(100))
|
||
owner_last_name = Column(String(100))
|
||
owner_citizenships = Column(PG_JSONB, default=[]) # [{symbol, kraj}]
|
||
|
||
# Adres korespondencyjny (z CEIDG)
|
||
correspondence_street = Column(String(255))
|
||
correspondence_city = Column(String(100))
|
||
correspondence_postal = Column(String(10))
|
||
|
||
# Surowe dane z CEIDG API
|
||
ceidg_raw_data = Column(PG_JSONB)
|
||
ceidg_fetched_at = Column(DateTime)
|
||
|
||
# === KRS DATA (API prs.ms.gov.pl) ===
|
||
krs_raw_data = Column(PG_JSONB) # Pełna odpowiedź z KRS API
|
||
krs_fetched_at = Column(DateTime)
|
||
krs_registration_date = Column(Date) # Data rejestracji w KRS
|
||
krs_representation = Column(Text) # Sposób reprezentacji spółki
|
||
krs_activities = Column(PG_JSONB, default=[]) # Przedmiot działalności
|
||
|
||
# Admin notes (internal, not visible to users)
|
||
admin_notes = Column(Text)
|
||
|
||
# Logo display preference
|
||
logo_dark_bg = Column(Boolean, default=False) # True = show logo on dark background
|
||
|
||
# Data source tracking
|
||
data_source = Column(String(100))
|
||
data_quality_score = Column(Integer)
|
||
last_verified_at = Column(DateTime)
|
||
norda_biznes_url = Column(String(500))
|
||
norda_biznes_member_id = Column(String(50))
|
||
member_since = Column(Date) # Data przystąpienia do Izby NORDA
|
||
admitted_at_meeting_id = Column(Integer, ForeignKey('board_meetings.id'), nullable=True)
|
||
previous_years_debt = Column(Numeric(10, 2), default=0) # Zaległości z lat poprzednich (ręcznie wpisane)
|
||
|
||
# Metadata
|
||
last_updated = Column(DateTime, default=datetime.now)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# === DIGITAL MATURITY (added 2025-11-26) ===
|
||
digital_maturity_last_assessed = Column(DateTime)
|
||
digital_maturity_score = Column(Integer) # 0-100 composite score
|
||
digital_maturity_rank_category = Column(Integer)
|
||
digital_maturity_rank_overall = Column(Integer)
|
||
|
||
# AI Readiness
|
||
ai_enabled = Column(Boolean, default=False)
|
||
ai_tools_used = Column(ARRAY(String)) # PostgreSQL array (will be Text for SQLite)
|
||
data_structured = Column(Boolean, default=False)
|
||
|
||
# IT Management
|
||
it_manager_exists = Column(Boolean, default=False)
|
||
it_outsourced = Column(Boolean, default=False)
|
||
it_provider_company_id = Column(Integer, ForeignKey('companies.id'))
|
||
|
||
# Website tracking
|
||
website_last_analyzed = Column(DateTime)
|
||
website_status = Column(String(20)) # 'active', 'broken', 'no_website'
|
||
website_quality_score = Column(Integer) # 0-100
|
||
|
||
# === KRS DATA (added 2026-01-13) ===
|
||
krs_registration_date = Column(Date) # Data wpisu do KRS
|
||
krs_company_agreement_date = Column(Date) # Data umowy spółki
|
||
krs_duration = Column(String(100)) # Czas trwania (NIEOZNACZONY lub data)
|
||
krs_representation_rules = Column(Text) # Sposób reprezentacji
|
||
capital_currency = Column(String(3), default='PLN')
|
||
capital_shares_count = Column(Integer) # Liczba udziałów
|
||
capital_share_value = Column(Numeric(15, 2)) # Wartość nominalna udziału
|
||
is_opp = Column(Boolean, default=False) # Czy OPP
|
||
krs_last_audit_at = Column(DateTime) # Data ostatniego audytu KRS
|
||
krs_pdf_path = Column(Text) # Ścieżka do pliku PDF
|
||
|
||
# Board meeting where company was admitted
|
||
admitted_at_meeting = relationship('BoardMeeting', foreign_keys=[admitted_at_meeting_id])
|
||
|
||
# Relationships
|
||
category = relationship('Category', back_populates='companies')
|
||
services = relationship('CompanyService', back_populates='company', cascade='all, delete-orphan')
|
||
competencies = relationship('CompanyCompetency', back_populates='company', cascade='all, delete-orphan')
|
||
certifications = relationship('Certification', back_populates='company', cascade='all, delete-orphan')
|
||
awards = relationship('Award', back_populates='company', cascade='all, delete-orphan')
|
||
events = relationship('CompanyEvent', back_populates='company', cascade='all, delete-orphan')
|
||
|
||
# Digital Maturity relationships
|
||
digital_maturity = relationship('CompanyDigitalMaturity', back_populates='company', uselist=False)
|
||
website_analyses = relationship('CompanyWebsiteAnalysis', back_populates='company', cascade='all, delete-orphan')
|
||
maturity_history = relationship('MaturityAssessment', back_populates='company', cascade='all, delete-orphan')
|
||
|
||
# Quality tracking
|
||
quality_tracking = relationship('CompanyQualityTracking', back_populates='company', uselist=False)
|
||
|
||
# Website scraping and AI analysis
|
||
website_content = relationship('CompanyWebsiteContent', back_populates='company', cascade='all, delete-orphan')
|
||
ai_insights = relationship('CompanyAIInsights', back_populates='company', uselist=False)
|
||
ai_enrichment_proposals = relationship('AiEnrichmentProposal', back_populates='company', cascade='all, delete-orphan')
|
||
|
||
# Multiple websites
|
||
websites = relationship('CompanyWebsite', back_populates='company',
|
||
cascade='all, delete-orphan',
|
||
order_by='CompanyWebsite.is_primary.desc()')
|
||
|
||
# Profile section visibility
|
||
hidden_sections = Column(PG_JSONB, nullable=False, default=list, server_default='[]')
|
||
|
||
# Company creation wizard
|
||
wizard_step = Column(Integer, nullable=True)
|
||
wizard_started_by = Column(Integer, nullable=True)
|
||
wizard_audit_status = Column(PG_JSONB, nullable=True)
|
||
|
||
def is_section_hidden(self, section_key):
|
||
"""Check if a profile section is hidden from visitors."""
|
||
return section_key in (self.hidden_sections or [])
|
||
|
||
|
||
class Service(Base):
|
||
"""Services offered by companies"""
|
||
__tablename__ = 'services'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
name = Column(String(255), nullable=False, unique=True)
|
||
slug = Column(String(255), nullable=False, unique=True)
|
||
description = Column(Text)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
companies = relationship('CompanyService', back_populates='service')
|
||
|
||
|
||
class CompanyService(Base):
|
||
"""Many-to-many: Companies <-> Services"""
|
||
__tablename__ = 'company_services'
|
||
|
||
company_id = Column(Integer, ForeignKey('companies.id'), primary_key=True)
|
||
service_id = Column(Integer, ForeignKey('services.id'), primary_key=True)
|
||
is_primary = Column(Boolean, default=False)
|
||
added_at = Column(DateTime, default=datetime.now)
|
||
|
||
company = relationship('Company', back_populates='services')
|
||
service = relationship('Service', back_populates='companies')
|
||
|
||
|
||
class Competency(Base):
|
||
"""Competencies/skills of companies"""
|
||
__tablename__ = 'competencies'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
name = Column(String(255), nullable=False, unique=True)
|
||
slug = Column(String(255), nullable=False, unique=True)
|
||
category = Column(String(100))
|
||
description = Column(Text)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
companies = relationship('CompanyCompetency', back_populates='competency')
|
||
|
||
|
||
class CompanyCompetency(Base):
|
||
"""Many-to-many: Companies <-> Competencies"""
|
||
__tablename__ = 'company_competencies'
|
||
|
||
company_id = Column(Integer, ForeignKey('companies.id'), primary_key=True)
|
||
competency_id = Column(Integer, ForeignKey('competencies.id'), primary_key=True)
|
||
level = Column(String(50))
|
||
added_at = Column(DateTime, default=datetime.now)
|
||
|
||
company = relationship('Company', back_populates='competencies')
|
||
competency = relationship('Competency', back_populates='companies')
|
||
|
||
|
||
class Certification(Base):
|
||
"""Company certifications"""
|
||
__tablename__ = 'certifications'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'))
|
||
name = Column(String(255), nullable=False)
|
||
issuer = Column(String(255))
|
||
certificate_number = Column(String(100))
|
||
issue_date = Column(Date)
|
||
expiry_date = Column(Date)
|
||
is_active = Column(Boolean, default=True)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
company = relationship('Company', back_populates='certifications')
|
||
|
||
|
||
class Award(Base):
|
||
"""Company awards and achievements"""
|
||
__tablename__ = 'awards'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'))
|
||
name = Column(String(255), nullable=False)
|
||
issuer = Column(String(255))
|
||
year = Column(Integer)
|
||
description = Column(Text)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
company = relationship('Company', back_populates='awards')
|
||
|
||
|
||
class CompanyEvent(Base):
|
||
"""Company events and news"""
|
||
__tablename__ = 'company_events'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'))
|
||
event_type = Column(String(50), nullable=False)
|
||
title = Column(String(500), nullable=False)
|
||
description = Column(Text)
|
||
event_date = Column(Date)
|
||
source_url = Column(String(1000))
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
company = relationship('Company', back_populates='events')
|
||
|
||
|
||
# ============================================================
|
||
# DIGITAL MATURITY ASSESSMENT PLATFORM
|
||
# ============================================================
|
||
|
||
class CompanyDigitalMaturity(Base):
|
||
"""Central dashboard for company digital maturity - composite scores and benchmarking"""
|
||
__tablename__ = 'company_digital_maturity'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, unique=True, index=True)
|
||
last_updated = Column(DateTime, default=datetime.now)
|
||
|
||
# === COMPOSITE SCORES (0-100 each) ===
|
||
overall_score = Column(Integer)
|
||
online_presence_score = Column(Integer)
|
||
social_media_score = Column(Integer)
|
||
it_infrastructure_score = Column(Integer)
|
||
business_applications_score = Column(Integer)
|
||
backup_disaster_recovery_score = Column(Integer)
|
||
cybersecurity_score = Column(Integer)
|
||
ai_readiness_score = Column(Integer)
|
||
digital_marketing_score = Column(Integer)
|
||
|
||
# === GAPS & OPPORTUNITIES ===
|
||
critical_gaps = Column(ARRAY(String)) # ['no_backup', 'no_firewall', etc.]
|
||
improvement_priority = Column(String(20)) # 'critical', 'high', 'medium', 'low'
|
||
estimated_investment_needed = Column(Numeric(10, 2)) # PLN
|
||
|
||
# === BENCHMARKING ===
|
||
rank_in_category = Column(Integer) # position in category
|
||
rank_overall = Column(Integer) # overall position
|
||
percentile = Column(Integer) # top X% of companies
|
||
|
||
# === SALES INTELLIGENCE ===
|
||
total_opportunity_value = Column(Numeric(10, 2)) # potential sales value (PLN)
|
||
sales_readiness = Column(String(20)) # 'hot', 'warm', 'cold', 'not_ready'
|
||
|
||
# Relationship
|
||
company = relationship('Company', back_populates='digital_maturity')
|
||
|
||
|
||
class CompanyWebsiteAnalysis(Base):
|
||
"""Detailed website and online presence analysis"""
|
||
__tablename__ = 'company_website_analysis'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, index=True)
|
||
company_website_id = Column(Integer, ForeignKey('company_websites.id', ondelete='SET NULL'), nullable=True, index=True)
|
||
analyzed_at = Column(DateTime, default=datetime.now, index=True)
|
||
|
||
# === BASIC INFO ===
|
||
website_url = Column(String(500))
|
||
final_url = Column(String(500)) # After redirects
|
||
http_status_code = Column(Integer)
|
||
load_time_ms = Column(Integer)
|
||
|
||
# === TECHNICAL ===
|
||
has_ssl = Column(Boolean, default=False)
|
||
ssl_expires_at = Column(Date)
|
||
ssl_issuer = Column(String(100)) # Certificate Authority (Let's Encrypt, DigiCert, etc.)
|
||
is_responsive = Column(Boolean, default=False) # mobile-friendly
|
||
cms_detected = Column(String(100))
|
||
frameworks_detected = Column(ARRAY(String)) # ['WordPress', 'Bootstrap', etc.]
|
||
|
||
# === HOSTING & SERVER (from audit) ===
|
||
last_modified_at = Column(DateTime)
|
||
hosting_provider = Column(String(100))
|
||
hosting_ip = Column(String(45))
|
||
server_software = Column(String(100))
|
||
site_author = Column(String(255)) # Website creator/agency
|
||
site_generator = Column(String(100))
|
||
domain_registrar = Column(String(100))
|
||
is_mobile_friendly = Column(Boolean, default=False)
|
||
has_viewport_meta = Column(Boolean, default=False)
|
||
|
||
# === GOOGLE BUSINESS (from audit) ===
|
||
google_rating = Column(Numeric(2, 1))
|
||
google_reviews_count = Column(Integer)
|
||
google_place_id = Column(String(100))
|
||
google_business_status = Column(String(50))
|
||
google_opening_hours = Column(JSONB) # Opening hours from GBP
|
||
google_photos_count = Column(Integer) # Number of photos on GBP
|
||
google_name = Column(String(255)) # Business name from Google
|
||
google_address = Column(String(500)) # Formatted address from Google
|
||
google_phone = Column(String(50)) # Phone from Google
|
||
google_website = Column(String(500)) # Website from Google
|
||
google_types = Column(ARRAY(Text)) # Business types/categories
|
||
google_maps_url = Column(String(500)) # Google Maps URL
|
||
|
||
# === AUDIT METADATA ===
|
||
audit_source = Column(String(50), default='automated')
|
||
audit_version = Column(String(20), default='1.0')
|
||
audit_errors = Column(Text)
|
||
|
||
# === CONTENT RICHNESS ===
|
||
content_richness_score = Column(Integer) # 1-10
|
||
page_count_estimate = Column(Integer)
|
||
word_count_homepage = Column(Integer)
|
||
has_blog = Column(Boolean, default=False)
|
||
has_portfolio = Column(Boolean, default=False)
|
||
has_contact_form = Column(Boolean, default=False)
|
||
has_live_chat = Column(Boolean, default=False)
|
||
|
||
# === EXTRACTED CONTENT ===
|
||
content_summary = Column(Text) # AI-generated summary from website
|
||
services_extracted = Column(ARRAY(String)) # Services mentioned on website
|
||
main_keywords = Column(ARRAY(String)) # Top keywords
|
||
|
||
# === SEO ===
|
||
seo_title = Column(String(500))
|
||
seo_description = Column(Text)
|
||
has_sitemap = Column(Boolean, default=False)
|
||
has_robots_txt = Column(Boolean, default=False)
|
||
google_indexed_pages = Column(Integer)
|
||
|
||
# === PAGESPEED INSIGHTS SCORES (0-100) ===
|
||
pagespeed_seo_score = Column(Integer) # Google PageSpeed SEO score 0-100
|
||
pagespeed_performance_score = Column(Integer) # Google PageSpeed Performance score 0-100
|
||
pagespeed_accessibility_score = Column(Integer) # Google PageSpeed Accessibility score 0-100
|
||
pagespeed_best_practices_score = Column(Integer) # Google PageSpeed Best Practices score 0-100
|
||
pagespeed_audits = Column(JSONB) # Full PageSpeed audit results as JSON
|
||
|
||
# === ON-PAGE SEO DETAILS ===
|
||
meta_title = Column(String(500)) # Full meta title from <title> tag
|
||
meta_description = Column(Text) # Full meta description from <meta name="description">
|
||
meta_keywords = Column(Text) # Meta keywords (legacy, rarely used)
|
||
|
||
# Heading structure
|
||
h1_count = Column(Integer) # Number of H1 tags on homepage (should be 1)
|
||
h2_count = Column(Integer) # Number of H2 tags on homepage
|
||
h3_count = Column(Integer) # Number of H3 tags on homepage
|
||
h1_text = Column(String(500)) # Text content of first H1 tag
|
||
|
||
# Image analysis
|
||
total_images = Column(Integer) # Total number of images
|
||
images_without_alt = Column(Integer) # Images missing alt attribute - accessibility issue
|
||
images_with_alt = Column(Integer) # Images with proper alt text
|
||
|
||
# Link analysis
|
||
internal_links_count = Column(Integer) # Links to same domain
|
||
external_links_count = Column(Integer) # Links to external domains
|
||
broken_links_count = Column(Integer) # Links returning 4xx/5xx
|
||
|
||
# Structured data (Schema.org, JSON-LD, Microdata)
|
||
has_structured_data = Column(Boolean, default=False) # Whether page contains JSON-LD, Microdata, or RDFa
|
||
structured_data_types = Column(ARRAY(String)) # Schema.org types found: Organization, LocalBusiness, etc.
|
||
structured_data_json = Column(JSONB) # Full structured data as JSON
|
||
|
||
# === TECHNICAL SEO ===
|
||
# Canonical URL handling
|
||
has_canonical = Column(Boolean, default=False) # Whether page has canonical URL defined
|
||
canonical_url = Column(String(500)) # The canonical URL value
|
||
|
||
# Indexability
|
||
is_indexable = Column(Boolean, default=True) # Whether page can be indexed (no noindex directive)
|
||
noindex_reason = Column(String(200)) # Reason if page is not indexable: meta tag, robots.txt, etc.
|
||
|
||
# Core Web Vitals
|
||
viewport_configured = Column(Boolean) # Whether viewport meta tag is properly configured
|
||
largest_contentful_paint_ms = Column(Integer) # Core Web Vital: LCP in milliseconds
|
||
interaction_to_next_paint_ms = Column(Integer) # Core Web Vital: INP in milliseconds (replaced FID March 2024)
|
||
cumulative_layout_shift = Column(Numeric(5, 3)) # Core Web Vital: CLS score
|
||
|
||
# Open Graph & Social Meta
|
||
has_og_tags = Column(Boolean, default=False) # Whether page has Open Graph tags
|
||
og_title = Column(String(500)) # Open Graph title
|
||
og_description = Column(Text) # Open Graph description
|
||
og_image = Column(String(500)) # Open Graph image URL
|
||
has_twitter_cards = Column(Boolean, default=False) # Whether page has Twitter Card meta tags
|
||
|
||
# Language & International
|
||
html_lang = Column(String(10)) # Language attribute from <html lang="...">
|
||
has_hreflang = Column(Boolean, default=False) # Whether page has hreflang tags
|
||
|
||
# === CrUX FIELD DATA (Chrome User Experience Report) ===
|
||
crux_lcp_ms = Column(Integer) # Real user LCP p75 in milliseconds
|
||
crux_inp_ms = Column(Integer) # Real user INP p75 in milliseconds
|
||
crux_cls = Column(Numeric(6, 4)) # Real user CLS p75
|
||
crux_fcp_ms = Column(Integer) # Real user FCP p75 in milliseconds
|
||
crux_ttfb_ms = Column(Integer) # Real user TTFB p75 in milliseconds
|
||
crux_lcp_good_pct = Column(Numeric(5, 2)) # % of users with good LCP
|
||
crux_inp_good_pct = Column(Numeric(5, 2)) # % of users with good INP
|
||
crux_period_end = Column(Date) # End date of CrUX data collection period
|
||
|
||
# === SECURITY HEADERS ===
|
||
has_hsts = Column(Boolean) # Strict-Transport-Security header present
|
||
has_csp = Column(Boolean) # Content-Security-Policy header present
|
||
has_x_frame_options = Column(Boolean) # X-Frame-Options header present
|
||
has_x_content_type_options = Column(Boolean) # X-Content-Type-Options header present
|
||
security_headers_count = Column(SmallInteger) # Count of security headers (0-4)
|
||
|
||
# === IMAGE FORMAT ANALYSIS ===
|
||
modern_image_count = Column(Integer) # WebP + AVIF + SVG images count
|
||
legacy_image_count = Column(Integer) # JPG + PNG + GIF images count
|
||
modern_image_ratio = Column(Numeric(5, 2)) # % of images in modern formats
|
||
|
||
# === GOOGLE PLACES API ENRICHMENT ===
|
||
google_primary_type = Column(String(100)) # Places API: primary business type
|
||
google_editorial_summary = Column(Text) # Places API: Google's curated description
|
||
google_price_level = Column(String(50)) # Places API: PRICE_LEVEL_* enum
|
||
google_attributes = Column(JSONB) # Places API: business attributes
|
||
google_reviews_data = Column(JSONB) # Places API: reviews with text/rating
|
||
google_photos_metadata = Column(JSONB) # Places API: photo references
|
||
google_maps_links = Column(JSONB) # Places API: directionsUri, writeAReviewUri, etc.
|
||
google_open_now = Column(Boolean) # Whether business is currently open (at audit time)
|
||
|
||
# === GOOGLE SEARCH CONSOLE (OAuth) ===
|
||
gsc_clicks = Column(Integer) # Total clicks from Google Search in period
|
||
gsc_impressions = Column(Integer) # Total impressions in Google Search in period
|
||
gsc_ctr = Column(Numeric(5, 2)) # Click-through rate as percentage
|
||
gsc_avg_position = Column(Numeric(5, 1)) # Average position in search results
|
||
gsc_top_queries = Column(JSONB) # Top search queries with clicks/impressions
|
||
gsc_period_days = Column(Integer, default=28) # Data collection period in days
|
||
|
||
# === GBP PERFORMANCE API ===
|
||
gbp_impressions_maps = Column(Integer) # Maps views (desktop + mobile) in period
|
||
gbp_impressions_search = Column(Integer) # Search views (desktop + mobile) in period
|
||
gbp_call_clicks = Column(Integer) # Phone call clicks in period
|
||
gbp_website_clicks = Column(Integer) # Website clicks in period
|
||
gbp_direction_requests = Column(Integer) # Direction requests in period
|
||
gbp_conversations = Column(Integer) # Conversations started in period
|
||
gbp_search_keywords = Column(JSONB) # Top search keywords with impression counts
|
||
gbp_performance_period_days = Column(Integer, default=30) # Performance data period
|
||
|
||
# === GOOGLE SEARCH CONSOLE EXTENSIONS ===
|
||
gsc_top_pages = Column(JSONB) # Top pages with clicks/impressions
|
||
gsc_device_breakdown = Column(JSONB) # {desktop: {clicks, impressions}, mobile: {...}, tablet: {...}}
|
||
gsc_index_status = Column(String(50)) # URL Inspection: PASS, NEUTRAL, FAIL, etc.
|
||
gsc_last_crawl = Column(DateTime) # URL Inspection: last crawl timestamp
|
||
gsc_crawled_as = Column(String(50)) # URL Inspection: Googlebot type
|
||
gsc_sitemaps = Column(JSONB) # Sitemaps list with status/errors
|
||
gsc_country_breakdown = Column(JSONB) # Top countries with clicks/impressions
|
||
gsc_search_type_breakdown = Column(JSONB) # Web/image/video breakdown
|
||
gsc_trend_data = Column(JSONB) # Period-over-period comparison
|
||
|
||
# === GBP OWNER DATA (Management API) ===
|
||
google_owner_responses_count = Column(Integer) # Number of owner responses to reviews
|
||
google_review_response_rate = Column(Numeric(5, 1)) # % of reviews with owner response
|
||
google_posts_data = Column(JSONB) # Recent Google Posts data
|
||
google_posts_count = Column(Integer) # Total number of Google Posts
|
||
|
||
# === SEO AUDIT METADATA ===
|
||
seo_audit_version = Column(String(20)) # Version of SEO audit script used
|
||
seo_audited_at = Column(DateTime) # Timestamp of last SEO audit
|
||
seo_audit_errors = Column(ARRAY(String)) # Errors encountered during SEO audit
|
||
seo_overall_score = Column(Integer) # Calculated overall SEO score 0-100
|
||
seo_health_score = Column(Integer) # On-page SEO health score 0-100
|
||
seo_issues = Column(JSONB) # List of SEO issues found with severity levels
|
||
|
||
# === LOCAL SEO ===
|
||
local_seo_score = Column(Integer) # 0-100
|
||
has_local_business_schema = Column(Boolean)
|
||
local_business_schema_fields = Column(JSONB)
|
||
nap_on_website = Column(JSONB) # {"name": "...", "address": "...", "phone": "..."}
|
||
has_google_maps_embed = Column(Boolean)
|
||
has_local_keywords = Column(Boolean)
|
||
local_keywords_found = Column(JSONB)
|
||
|
||
# === CITATIONS ===
|
||
citations_found = Column(JSONB)
|
||
citations_count = Column(Integer, default=0)
|
||
|
||
# === CONTENT FRESHNESS ===
|
||
content_freshness_score = Column(Integer) # 0-100
|
||
last_content_update = Column(DateTime)
|
||
|
||
# === SCORE HISTORY ===
|
||
score_history = Column(JSONB) # [{"date": "2026-02-01", "score": 72}]
|
||
|
||
# === DOMAIN ===
|
||
domain_registered_at = Column(Date)
|
||
domain_expires_at = Column(Date)
|
||
domain_age_years = Column(Integer)
|
||
|
||
# === ANALYTICS ===
|
||
has_google_analytics = Column(Boolean, default=False)
|
||
has_google_tag_manager = Column(Boolean, default=False)
|
||
has_facebook_pixel = Column(Boolean, default=False)
|
||
|
||
# === OPPORTUNITY SCORING ===
|
||
needs_redesign = Column(Boolean, default=False)
|
||
missing_features = Column(ARRAY(String)) # ['blog', 'portfolio', 'ssl', etc.]
|
||
opportunity_score = Column(Integer) # 0-100
|
||
estimated_project_value = Column(Numeric(10, 2)) # PLN
|
||
opportunity_notes = Column(Text)
|
||
|
||
# Relationship
|
||
company = relationship('Company', back_populates='website_analyses')
|
||
|
||
|
||
class CompanyCitation(Base):
|
||
"""Tracks company presence in local business directories"""
|
||
__tablename__ = 'company_citations'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
directory_name = Column(String(100), nullable=False)
|
||
directory_url = Column(String(500))
|
||
listing_url = Column(String(500))
|
||
status = Column(String(20), default='unknown') # found, not_found, incorrect
|
||
nap_accurate = Column(Boolean)
|
||
details = Column(JSONB)
|
||
checked_at = Column(DateTime, default=datetime.now)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationship
|
||
company = relationship('Company', backref='citations')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'directory_name', name='uq_company_directory'),
|
||
)
|
||
|
||
|
||
class CompanyQualityTracking(Base):
|
||
"""Quality tracking for company data - verification counter and quality score"""
|
||
__tablename__ = 'company_quality_tracking'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, unique=True, index=True)
|
||
verification_count = Column(Integer, default=0)
|
||
last_verified_at = Column(DateTime)
|
||
verified_by = Column(String(100))
|
||
verification_notes = Column(Text)
|
||
quality_score = Column(Integer) # 0-100%
|
||
issues_found = Column(Integer, default=0)
|
||
issues_fixed = Column(Integer, default=0)
|
||
created_at = Column(DateTime, default=datetime.utcnow)
|
||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||
|
||
# Relationship
|
||
company = relationship('Company', back_populates='quality_tracking')
|
||
|
||
|
||
class BulkEnrichmentJob(Base):
|
||
"""Tracks bulk enrichment jobs started from admin dashboard."""
|
||
__tablename__ = 'bulk_enrichment_jobs'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
started_at = Column(DateTime, default=datetime.now)
|
||
started_by = Column(Integer, ForeignKey('users.id'))
|
||
status = Column(String(20), default='running') # running, completed, failed
|
||
total_companies = Column(Integer, default=0)
|
||
processed_companies = Column(Integer, default=0)
|
||
steps = Column(PG_JSONB) # ['registry', 'seo', 'social', 'gbp', 'logo']
|
||
results = Column(PG_JSONB, default={}) # {company_id: {step: result, ...}, ...}
|
||
completed_at = Column(DateTime)
|
||
|
||
# Relationship
|
||
user = relationship('User')
|
||
|
||
|
||
class CompanyWebsiteContent(Base):
|
||
"""Scraped website content for companies"""
|
||
__tablename__ = 'company_website_content'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, index=True)
|
||
scraped_at = Column(DateTime, default=datetime.utcnow)
|
||
url = Column(String(500))
|
||
http_status = Column(Integer)
|
||
raw_html = Column(Text)
|
||
raw_text = Column(Text)
|
||
page_title = Column(String(500))
|
||
meta_description = Column(Text)
|
||
main_content = Column(Text)
|
||
email_addresses = Column(ARRAY(String))
|
||
phone_numbers = Column(ARRAY(String))
|
||
social_media = Column(JSONB)
|
||
word_count = Column(Integer)
|
||
|
||
# Relationship
|
||
company = relationship('Company', back_populates='website_content')
|
||
|
||
|
||
class CompanyAIInsights(Base):
|
||
"""AI-generated insights from website analysis"""
|
||
__tablename__ = 'company_ai_insights'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, unique=True, index=True)
|
||
content_id = Column(Integer, ForeignKey('company_website_content.id'))
|
||
business_summary = Column(Text)
|
||
services_list = Column(ARRAY(String))
|
||
target_market = Column(Text)
|
||
unique_selling_points = Column(ARRAY(String))
|
||
company_values = Column(ARRAY(String))
|
||
certifications = Column(ARRAY(String))
|
||
suggested_category = Column(String(100))
|
||
category_confidence = Column(Numeric(3, 2))
|
||
industry_tags = Column(ARRAY(String))
|
||
ai_confidence_score = Column(Numeric(3, 2))
|
||
processing_time_ms = Column(Integer)
|
||
analyzed_at = Column(DateTime, default=datetime.utcnow)
|
||
|
||
# Relationship
|
||
company = relationship('Company', back_populates='ai_insights')
|
||
|
||
|
||
class AiEnrichmentProposal(Base):
|
||
"""
|
||
Propozycje wzbogacenia danych przez AI - wymagają akceptacji właściciela/admina.
|
||
|
||
Workflow:
|
||
1. AI analizuje dane ze strony WWW
|
||
2. Tworzy propozycję (status: pending)
|
||
3. Właściciel/admin przegląda i akceptuje lub odrzuca
|
||
4. Po akceptacji dane są dodawane do profilu firmy
|
||
"""
|
||
__tablename__ = 'ai_enrichment_proposals'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, index=True)
|
||
|
||
# Status: pending, approved, rejected, expired
|
||
status = Column(String(20), default='pending', nullable=False, index=True)
|
||
|
||
# Typ propozycji (website_extraction, krs_update, manual_suggestion)
|
||
proposal_type = Column(String(50), default='website_extraction', nullable=False)
|
||
|
||
# Źródło danych (URL strony, API, itp.)
|
||
data_source = Column(String(500))
|
||
|
||
# Proponowane dane jako JSON
|
||
proposed_data = Column(JSONB, nullable=False)
|
||
# Struktura proposed_data:
|
||
# {
|
||
# "services": ["usługa 1", "usługa 2"],
|
||
# "products": ["produkt 1"],
|
||
# "keywords": ["keyword 1"],
|
||
# "specializations": ["spec 1"],
|
||
# "brands": ["marka 1"],
|
||
# "target_customers": ["klient 1"],
|
||
# "regions": ["region 1"],
|
||
# "summary": "Opis firmy..."
|
||
# }
|
||
|
||
# Komentarz AI wyjaśniający propozycję
|
||
ai_explanation = Column(Text)
|
||
|
||
# Wskaźnik pewności AI (0.0 - 1.0)
|
||
confidence_score = Column(Numeric(3, 2))
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||
reviewed_at = Column(DateTime)
|
||
applied_at = Column(DateTime)
|
||
expires_at = Column(DateTime) # Propozycje wygasają po X dniach
|
||
|
||
# Kto przeglądał
|
||
reviewed_by_id = Column(Integer, ForeignKey('users.id'))
|
||
|
||
# Komentarz przy akceptacji/odrzuceniu
|
||
review_comment = Column(Text)
|
||
|
||
# Które pola zostały zaakceptowane (jeśli częściowa akceptacja)
|
||
approved_fields = Column(JSONB) # ["services", "keywords"]
|
||
|
||
# Relationships
|
||
company = relationship('Company', back_populates='ai_enrichment_proposals')
|
||
reviewed_by = relationship('User', foreign_keys=[reviewed_by_id])
|
||
|
||
def __repr__(self):
|
||
return f"<AiEnrichmentProposal {self.id} for Company {self.company_id} ({self.status})>"
|
||
|
||
|
||
class MaturityAssessment(Base):
|
||
"""Historical tracking of digital maturity scores over time"""
|
||
__tablename__ = 'maturity_assessments'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, index=True)
|
||
assessed_at = Column(DateTime, default=datetime.now, index=True)
|
||
assessed_by_user_id = Column(Integer, ForeignKey('users.id'))
|
||
assessment_type = Column(String(50)) # 'full', 'quick', 'self_reported', 'audit'
|
||
|
||
# === SNAPSHOT OF SCORES ===
|
||
overall_score = Column(Integer)
|
||
online_presence_score = Column(Integer)
|
||
social_media_score = Column(Integer)
|
||
it_infrastructure_score = Column(Integer)
|
||
business_applications_score = Column(Integer)
|
||
backup_dr_score = Column(Integer)
|
||
cybersecurity_score = Column(Integer)
|
||
ai_readiness_score = Column(Integer)
|
||
|
||
# === CHANGES SINCE LAST ASSESSMENT ===
|
||
score_change = Column(Integer) # +5, -3, etc.
|
||
areas_improved = Column(ARRAY(String)) # ['cybersecurity', 'backup']
|
||
areas_declined = Column(ARRAY(String)) # ['social_media']
|
||
|
||
notes = Column(Text)
|
||
|
||
# Relationship
|
||
company = relationship('Company', back_populates='maturity_history')
|
||
|
||
|
||
# ============================================================
|
||
# AI CHAT
|
||
# ============================================================
|
||
|
||
class AIChatConversation(Base):
|
||
"""Chat conversations"""
|
||
__tablename__ = 'ai_chat_conversations'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||
title = Column(String(255))
|
||
conversation_type = Column(String(50), default='general')
|
||
|
||
# Timestamps
|
||
started_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
is_active = Column(Boolean, default=True)
|
||
|
||
# Metrics
|
||
message_count = Column(Integer, default=0)
|
||
model_name = Column(String(100))
|
||
|
||
# Pin & custom name
|
||
is_pinned = Column(Boolean, default=False)
|
||
pinned_at = Column(DateTime)
|
||
custom_name = Column(String(255))
|
||
|
||
# Relationships
|
||
user = relationship('User', back_populates='conversations')
|
||
messages = relationship('AIChatMessage', back_populates='conversation', cascade='all, delete-orphan', order_by='AIChatMessage.created_at')
|
||
|
||
|
||
class AIChatMessage(Base):
|
||
"""Chat messages"""
|
||
__tablename__ = 'ai_chat_messages'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
conversation_id = Column(Integer, ForeignKey('ai_chat_conversations.id'), nullable=False, index=True)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Message
|
||
role = Column(String(20), nullable=False) # 'user' or 'assistant'
|
||
content = Column(Text, nullable=False)
|
||
|
||
# Metrics
|
||
tokens_input = Column(Integer)
|
||
tokens_output = Column(Integer)
|
||
cost_usd = Column(Numeric(10, 6))
|
||
latency_ms = Column(Integer)
|
||
|
||
# Flags
|
||
edited = Column(Boolean, default=False)
|
||
regenerated = Column(Boolean, default=False)
|
||
|
||
# Feedback (for assistant messages)
|
||
feedback_rating = Column(Integer) # 1 = thumbs down, 2 = thumbs up
|
||
feedback_comment = Column(Text) # Optional user comment
|
||
feedback_at = Column(DateTime)
|
||
|
||
# Quality metrics (for analytics)
|
||
companies_mentioned = Column(Integer) # Number of companies in response
|
||
query_intent = Column(String(100)) # Detected intent: 'find_company', 'get_info', 'compare', etc.
|
||
|
||
# Relationship
|
||
conversation = relationship('AIChatConversation', back_populates='messages')
|
||
feedback = relationship('AIChatFeedback', back_populates='message', uselist=False)
|
||
|
||
|
||
class AIChatFeedback(Base):
|
||
"""Detailed feedback for AI responses - for learning and improvement"""
|
||
__tablename__ = 'ai_chat_feedback'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
message_id = Column(Integer, ForeignKey('ai_chat_messages.id'), nullable=False, unique=True)
|
||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Rating
|
||
rating = Column(Integer, nullable=False) # 1-5 stars or 1=bad, 2=good
|
||
is_helpful = Column(Boolean) # Was the answer helpful?
|
||
is_accurate = Column(Boolean) # Was the information accurate?
|
||
found_company = Column(Boolean) # Did user find what they were looking for?
|
||
|
||
# Feedback text
|
||
comment = Column(Text)
|
||
suggested_answer = Column(Text) # What should have been the answer?
|
||
|
||
# Context for learning
|
||
original_query = Column(Text) # The user's question
|
||
expected_companies = Column(Text) # JSON list of company names user expected
|
||
|
||
# Relationship
|
||
message = relationship('AIChatMessage', back_populates='feedback')
|
||
|
||
|
||
# ============================================================
|
||
# FORUM
|
||
# ============================================================
|
||
|
||
class ForumTopic(Base):
|
||
"""Forum topics/threads"""
|
||
__tablename__ = 'forum_topics'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
title = Column(String(255), nullable=False)
|
||
content = Column(Text, nullable=False)
|
||
author_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
|
||
# Category and Status (for feedback tracking)
|
||
category = Column(String(50), default='question') # feature_request, bug, question, announcement
|
||
status = Column(String(50), default='new') # new, in_progress, resolved, rejected
|
||
status_changed_by = Column(Integer, ForeignKey('users.id'))
|
||
status_changed_at = Column(DateTime)
|
||
status_note = Column(Text)
|
||
|
||
# Moderation flags
|
||
is_pinned = Column(Boolean, default=False)
|
||
is_locked = Column(Boolean, default=False)
|
||
is_ai_generated = Column(Boolean, default=False)
|
||
views_count = Column(Integer, default=0)
|
||
|
||
# Edit tracking
|
||
edited_at = Column(DateTime)
|
||
edited_by = Column(Integer, ForeignKey('users.id'))
|
||
edit_count = Column(Integer, default=0)
|
||
|
||
# Soft delete
|
||
is_deleted = Column(Boolean, default=False)
|
||
deleted_at = Column(DateTime)
|
||
deleted_by = Column(Integer, ForeignKey('users.id'))
|
||
|
||
# Reactions (JSONB: {"👍": [user_ids], "❤️": [user_ids], "🎉": [user_ids]})
|
||
reactions = Column(PG_JSONB, default={})
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Constants for validation
|
||
CATEGORIES = ['feature_request', 'bug', 'question', 'announcement']
|
||
STATUSES = ['new', 'in_progress', 'resolved', 'rejected']
|
||
|
||
CATEGORY_LABELS = {
|
||
'feature_request': 'Propozycja funkcji',
|
||
'bug': 'Błąd',
|
||
'question': 'Pytanie',
|
||
'announcement': 'Ogłoszenie'
|
||
}
|
||
|
||
STATUS_LABELS = {
|
||
'new': 'Nowy',
|
||
'in_progress': 'W realizacji',
|
||
'resolved': 'Rozwiązany',
|
||
'rejected': 'Odrzucony'
|
||
}
|
||
|
||
# Relationships
|
||
author = relationship('User', foreign_keys=[author_id], back_populates='forum_topics')
|
||
status_changer = relationship('User', foreign_keys=[status_changed_by])
|
||
editor = relationship('User', foreign_keys=[edited_by])
|
||
deleter = relationship('User', foreign_keys=[deleted_by])
|
||
replies = relationship('ForumReply', back_populates='topic', cascade='all, delete-orphan', order_by='ForumReply.created_at')
|
||
attachments = relationship('ForumAttachment', back_populates='topic', cascade='all, delete-orphan',
|
||
primaryjoin="and_(ForumAttachment.topic_id==ForumTopic.id, ForumAttachment.attachment_type=='topic')")
|
||
subscriptions = relationship('ForumTopicSubscription', back_populates='topic', cascade='all, delete-orphan')
|
||
|
||
@property
|
||
def reply_count(self):
|
||
return len(self.replies)
|
||
|
||
@property
|
||
def last_activity(self):
|
||
if self.replies:
|
||
return max(r.created_at for r in self.replies)
|
||
return self.created_at
|
||
|
||
@property
|
||
def category_label(self):
|
||
return self.CATEGORY_LABELS.get(self.category, self.category)
|
||
|
||
@property
|
||
def status_label(self):
|
||
return self.STATUS_LABELS.get(self.status, self.status)
|
||
|
||
|
||
class ForumReply(Base):
|
||
"""Forum replies to topics"""
|
||
__tablename__ = 'forum_replies'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
topic_id = Column(Integer, ForeignKey('forum_topics.id'), nullable=False)
|
||
author_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
content = Column(Text, nullable=False)
|
||
is_ai_generated = Column(Boolean, default=False)
|
||
|
||
# Edit tracking
|
||
edited_at = Column(DateTime)
|
||
edited_by = Column(Integer, ForeignKey('users.id'))
|
||
edit_count = Column(Integer, default=0)
|
||
|
||
# Soft delete
|
||
is_deleted = Column(Boolean, default=False)
|
||
deleted_at = Column(DateTime)
|
||
deleted_by = Column(Integer, ForeignKey('users.id'))
|
||
|
||
# Reactions (JSONB: {"👍": [user_ids], "❤️": [user_ids], "🎉": [user_ids]})
|
||
reactions = Column(PG_JSONB, default={})
|
||
|
||
# Solution marking
|
||
is_solution = Column(Boolean, default=False)
|
||
marked_as_solution_by = Column(Integer, ForeignKey('users.id'))
|
||
marked_as_solution_at = Column(DateTime)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
topic = relationship('ForumTopic', back_populates='replies')
|
||
author = relationship('User', foreign_keys=[author_id], back_populates='forum_replies')
|
||
editor = relationship('User', foreign_keys=[edited_by])
|
||
deleter = relationship('User', foreign_keys=[deleted_by])
|
||
solution_marker = relationship('User', foreign_keys=[marked_as_solution_by])
|
||
attachments = relationship('ForumAttachment', back_populates='reply', cascade='all, delete-orphan',
|
||
primaryjoin="and_(ForumAttachment.reply_id==ForumReply.id, ForumAttachment.attachment_type=='reply')")
|
||
|
||
|
||
class ForumAttachment(Base):
|
||
"""Forum file attachments for topics and replies"""
|
||
__tablename__ = 'forum_attachments'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Polymorphic relationship (topic or reply)
|
||
attachment_type = Column(String(20), nullable=False) # 'topic' or 'reply'
|
||
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'))
|
||
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'))
|
||
|
||
# File metadata
|
||
original_filename = Column(String(255), nullable=False)
|
||
stored_filename = Column(String(255), nullable=False, unique=True)
|
||
file_extension = Column(String(10), nullable=False)
|
||
file_size = Column(Integer, nullable=False) # in bytes
|
||
mime_type = Column(String(100), nullable=False)
|
||
|
||
# Uploader
|
||
uploaded_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
topic = relationship('ForumTopic', back_populates='attachments', foreign_keys=[topic_id])
|
||
reply = relationship('ForumReply', back_populates='attachments', foreign_keys=[reply_id])
|
||
uploader = relationship('User')
|
||
|
||
# Allowed file types
|
||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
|
||
|
||
@property
|
||
def url(self):
|
||
"""Get the URL to serve this file"""
|
||
date = self.created_at or datetime.now()
|
||
subdir = 'topics' if self.attachment_type == 'topic' else 'replies'
|
||
return f"/static/uploads/forum/{subdir}/{date.year}/{date.month:02d}/{self.stored_filename}"
|
||
|
||
@property
|
||
def is_image(self):
|
||
"""Check if this is an image file"""
|
||
return self.mime_type.startswith('image/')
|
||
|
||
@property
|
||
def size_display(self):
|
||
"""Human-readable file size"""
|
||
if self.file_size < 1024:
|
||
return f"{self.file_size} B"
|
||
elif self.file_size < 1024 * 1024:
|
||
return f"{self.file_size / 1024:.1f} KB"
|
||
else:
|
||
return f"{self.file_size / (1024 * 1024):.1f} MB"
|
||
|
||
|
||
class BoardDocument(Base):
|
||
"""Documents for Rada Izby (Board Council) - protocols, minutes, resolutions"""
|
||
__tablename__ = 'board_documents'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Document metadata
|
||
title = Column(String(255), nullable=False)
|
||
description = Column(Text)
|
||
document_type = Column(String(50), default='protocol') # protocol, minutes, resolution, report, other
|
||
|
||
# Meeting reference
|
||
meeting_id = Column(Integer, ForeignKey('board_meetings.id'))
|
||
meeting_date = Column(Date, nullable=False)
|
||
meeting_number = Column(Integer) # Sequential meeting number (optional)
|
||
|
||
# File metadata
|
||
original_filename = Column(String(255), nullable=False)
|
||
stored_filename = Column(String(255), nullable=False, unique=True)
|
||
file_extension = Column(String(10), nullable=False)
|
||
file_size = Column(Integer, nullable=False) # in bytes
|
||
mime_type = Column(String(100), nullable=False)
|
||
|
||
# Upload tracking
|
||
uploaded_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
uploaded_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Audit fields
|
||
updated_at = Column(DateTime, onupdate=datetime.now)
|
||
updated_by = Column(Integer, ForeignKey('users.id'))
|
||
is_active = Column(Boolean, default=True) # Soft delete
|
||
|
||
# Relationships
|
||
meeting = relationship('BoardMeeting', backref='documents')
|
||
uploader = relationship('User', foreign_keys=[uploaded_by])
|
||
editor = relationship('User', foreign_keys=[updated_by])
|
||
|
||
# Constants
|
||
DOCUMENT_TYPES = ['protocol', 'minutes', 'resolution', 'report', 'other']
|
||
DOCUMENT_TYPE_LABELS = {
|
||
'protocol': 'Protokół',
|
||
'minutes': 'Notatki',
|
||
'resolution': 'Uchwała',
|
||
'report': 'Raport',
|
||
'other': 'Inny'
|
||
}
|
||
ALLOWED_EXTENSIONS = {'pdf', 'docx', 'doc'}
|
||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
||
|
||
@property
|
||
def type_label(self):
|
||
"""Get Polish label for document type"""
|
||
return self.DOCUMENT_TYPE_LABELS.get(self.document_type, 'Dokument')
|
||
|
||
@property
|
||
def size_display(self):
|
||
"""Human-readable file size"""
|
||
if self.file_size < 1024:
|
||
return f"{self.file_size} B"
|
||
elif self.file_size < 1024 * 1024:
|
||
return f"{self.file_size / 1024:.1f} KB"
|
||
else:
|
||
return f"{self.file_size / (1024 * 1024):.1f} MB"
|
||
|
||
@property
|
||
def file_path(self):
|
||
"""Get the full path to the stored file"""
|
||
date = self.uploaded_at or datetime.now()
|
||
return f"/data/board-docs/{date.year}/{date.month:02d}/{self.stored_filename}"
|
||
|
||
|
||
class BoardMeeting(Base):
|
||
"""
|
||
Board Council meetings with agenda and protocol data.
|
||
Stores meeting metadata, agenda items, attendance, and proceedings.
|
||
"""
|
||
__tablename__ = 'board_meetings'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Meeting identification
|
||
meeting_number = Column(Integer, nullable=False) # Sequential number (e.g., 1 for first meeting of year)
|
||
year = Column(Integer, nullable=False) # Year for numbering (e.g., 2026)
|
||
|
||
# Meeting schedule
|
||
meeting_date = Column(Date, nullable=False)
|
||
start_time = Column(Time) # e.g., 16:00
|
||
end_time = Column(Time) # e.g., 18:30
|
||
location = Column(String(255), default='Siedziba Izby')
|
||
|
||
# Meeting roles
|
||
chairperson_id = Column(Integer, ForeignKey('users.id')) # Prowadzący
|
||
secretary_id = Column(Integer, ForeignKey('users.id')) # Protokolant
|
||
|
||
# Guests (non-members attending)
|
||
guests = Column(Text) # Free text or JSON array
|
||
|
||
# Agenda items - JSON array of objects:
|
||
# [{"time_start": "16:00", "time_end": "16:10", "title": "...", "description": "..."}]
|
||
agenda_items = Column(PG_JSONB)
|
||
|
||
# Attendance records - JSON object:
|
||
# {"user_id": {"present": true/false, "initials": "LG", "notes": ""}}
|
||
attendance = Column(PG_JSONB)
|
||
|
||
# Quorum info
|
||
quorum_count = Column(Integer) # Number of present members
|
||
quorum_confirmed = Column(Boolean) # Was quorum achieved?
|
||
|
||
# Proceedings - JSON array of objects:
|
||
# [{"agenda_item": 1, "discussed": "...", "decisions": "...", "votes": {...}}]
|
||
proceedings = Column(PG_JSONB)
|
||
|
||
# Status workflow
|
||
status = Column(String(20), default='draft')
|
||
# draft -> agenda_published -> protocol_draft -> protocol_published
|
||
|
||
# Audit fields
|
||
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_by = Column(Integer, ForeignKey('users.id'))
|
||
updated_at = Column(DateTime, onupdate=datetime.now)
|
||
agenda_published_at = Column(DateTime)
|
||
protocol_published_at = Column(DateTime)
|
||
|
||
# Relationships
|
||
chairperson = relationship('User', foreign_keys=[chairperson_id])
|
||
secretary = relationship('User', foreign_keys=[secretary_id])
|
||
creator = relationship('User', foreign_keys=[created_by])
|
||
editor = relationship('User', foreign_keys=[updated_by])
|
||
|
||
# Status constants
|
||
STATUS_DRAFT = 'draft'
|
||
STATUS_AGENDA_PUBLISHED = 'agenda_published'
|
||
STATUS_PROTOCOL_DRAFT = 'protocol_draft'
|
||
STATUS_PROTOCOL_PUBLISHED = 'protocol_published'
|
||
|
||
STATUS_LABELS = {
|
||
'draft': 'Szkic',
|
||
'agenda_published': 'Program opublikowany',
|
||
'protocol_draft': 'Protokół w przygotowaniu',
|
||
'protocol_published': 'Protokół opublikowany'
|
||
}
|
||
|
||
@property
|
||
def status_label(self):
|
||
"""Get Polish label for status"""
|
||
return self.STATUS_LABELS.get(self.status, 'Nieznany')
|
||
|
||
@property
|
||
def meeting_identifier(self):
|
||
"""Get meeting identifier like '1/2026'"""
|
||
return f"{self.meeting_number}/{self.year}"
|
||
|
||
def get_attendance_list(self, db_session):
|
||
"""Get attendance list with user details"""
|
||
from sqlalchemy.orm import Session
|
||
if not self.attendance:
|
||
return []
|
||
|
||
user_ids = [int(uid) for uid in self.attendance.keys()]
|
||
users = db_session.query(User).filter(User.id.in_(user_ids)).all()
|
||
user_map = {u.id: u for u in users}
|
||
|
||
result = []
|
||
for user_id_str, data in self.attendance.items():
|
||
user_id = int(user_id_str)
|
||
user = user_map.get(user_id)
|
||
if user:
|
||
result.append({
|
||
'user': user,
|
||
'present': data.get('present', False),
|
||
'initials': data.get('initials', ''),
|
||
'notes': data.get('notes', '')
|
||
})
|
||
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'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'), nullable=False)
|
||
notify_email = Column(Boolean, default=True)
|
||
notify_app = Column(Boolean, default=True)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
__table_args__ = (UniqueConstraint('user_id', 'topic_id', name='uq_forum_subscription_user_topic'),)
|
||
|
||
# Relationships
|
||
user = relationship('User', back_populates='forum_subscriptions')
|
||
topic = relationship('ForumTopic', back_populates='subscriptions')
|
||
|
||
|
||
class ForumReport(Base):
|
||
"""Forum content reports for moderation"""
|
||
__tablename__ = 'forum_reports'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
reporter_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
|
||
# Polymorphic relationship (topic or reply)
|
||
content_type = Column(String(20), nullable=False) # 'topic' or 'reply'
|
||
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'))
|
||
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'))
|
||
|
||
reason = Column(String(50), nullable=False) # spam, offensive, off-topic, other
|
||
description = Column(Text)
|
||
|
||
status = Column(String(20), default='pending') # pending, reviewed, dismissed
|
||
reviewed_by = Column(Integer, ForeignKey('users.id'))
|
||
reviewed_at = Column(DateTime)
|
||
review_note = Column(Text)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Constants
|
||
REASONS = ['spam', 'offensive', 'off-topic', 'other']
|
||
REASON_LABELS = {
|
||
'spam': 'Spam',
|
||
'offensive': 'Obraźliwe treści',
|
||
'off-topic': 'Nie na temat',
|
||
'other': 'Inne'
|
||
}
|
||
STATUSES = ['pending', 'reviewed', 'dismissed']
|
||
|
||
# Relationships
|
||
reporter = relationship('User', foreign_keys=[reporter_id])
|
||
reviewer = relationship('User', foreign_keys=[reviewed_by])
|
||
topic = relationship('ForumTopic')
|
||
reply = relationship('ForumReply')
|
||
|
||
@property
|
||
def reason_label(self):
|
||
return self.REASON_LABELS.get(self.reason, self.reason)
|
||
|
||
|
||
class ForumEditHistory(Base):
|
||
"""Forum edit history for audit trail"""
|
||
__tablename__ = 'forum_edit_history'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Polymorphic relationship (topic or reply)
|
||
content_type = Column(String(20), nullable=False) # 'topic' or 'reply'
|
||
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'))
|
||
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'))
|
||
|
||
editor_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
old_content = Column(Text, nullable=False)
|
||
new_content = Column(Text, nullable=False)
|
||
edit_reason = Column(String(255))
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
editor = relationship('User')
|
||
topic = relationship('ForumTopic')
|
||
reply = relationship('ForumReply')
|
||
|
||
|
||
class ForumTopicRead(Base):
|
||
"""
|
||
Śledzenie odczytów wątków forum (seen by).
|
||
Zapisuje kto i kiedy przeczytał dany wątek.
|
||
"""
|
||
__tablename__ = 'forum_topic_reads'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'), nullable=False)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
read_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
topic = relationship('ForumTopic', backref='readers')
|
||
user = relationship('User')
|
||
|
||
# Unique constraint
|
||
__table_args__ = (
|
||
UniqueConstraint('topic_id', 'user_id', name='uq_forum_topic_user_read'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<ForumTopicRead topic={self.topic_id} user={self.user_id}>"
|
||
|
||
|
||
class ForumReplyRead(Base):
|
||
"""
|
||
Śledzenie odczytów odpowiedzi na forum (seen by).
|
||
Zapisuje kto i kiedy przeczytał daną odpowiedź.
|
||
"""
|
||
__tablename__ = 'forum_reply_reads'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'), nullable=False)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
read_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
reply = relationship('ForumReply', backref='readers')
|
||
user = relationship('User')
|
||
|
||
# Unique constraint
|
||
__table_args__ = (
|
||
UniqueConstraint('reply_id', 'user_id', name='uq_forum_reply_user_read'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<ForumReplyRead reply={self.reply_id} user={self.user_id}>"
|
||
|
||
|
||
class AIAPICostLog(Base):
|
||
"""API cost tracking"""
|
||
__tablename__ = 'ai_api_costs'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
timestamp = Column(DateTime, default=datetime.now, index=True)
|
||
|
||
# API details
|
||
api_provider = Column(String(50)) # 'gemini'
|
||
model_name = Column(String(100))
|
||
feature = Column(String(100)) # 'ai_chat', 'general', etc.
|
||
|
||
# User context
|
||
user_id = Column(Integer, ForeignKey('users.id'), index=True)
|
||
|
||
# Token usage
|
||
input_tokens = Column(Integer)
|
||
output_tokens = Column(Integer)
|
||
total_tokens = Column(Integer)
|
||
|
||
# Costs
|
||
input_cost = Column(Numeric(10, 6))
|
||
output_cost = Column(Numeric(10, 6))
|
||
total_cost = Column(Numeric(10, 6))
|
||
|
||
# Status
|
||
success = Column(Boolean, default=True)
|
||
error_message = Column(Text)
|
||
latency_ms = Column(Integer)
|
||
|
||
# Privacy
|
||
prompt_hash = Column(String(64)) # SHA256 hash, not storing actual prompts
|
||
|
||
|
||
# ============================================================
|
||
# CALENDAR / EVENTS
|
||
# ============================================================
|
||
|
||
class NordaEvent(Base):
|
||
"""Spotkania i wydarzenia Norda Biznes"""
|
||
__tablename__ = 'norda_events'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
title = Column(String(255), nullable=False)
|
||
description = Column(Text)
|
||
event_type = Column(String(50), default='meeting') # meeting, webinar, networking, other
|
||
|
||
# Data i czas
|
||
event_date = Column(Date, nullable=False)
|
||
event_date_end = Column(Date) # NULL dla wydarzeń jednodniowych
|
||
time_start = Column(Time)
|
||
time_end = Column(Time)
|
||
|
||
# Lokalizacja
|
||
location = Column(String(500)) # Adres lub "Online"
|
||
location_url = Column(String(1000)) # Link do Google Maps lub Zoom
|
||
|
||
# Prelegent (opcjonalnie)
|
||
speaker_name = Column(String(255))
|
||
speaker_company_id = Column(Integer, ForeignKey('companies.id'))
|
||
|
||
# Metadane
|
||
is_featured = Column(Boolean, default=False)
|
||
is_ai_generated = Column(Boolean, default=False)
|
||
max_attendees = Column(Integer)
|
||
created_by = Column(Integer, ForeignKey('users.id'))
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
reminder_24h_sent_at = Column(DateTime) # Cron znacznik — nie wysyłaj przypomnienia 24h dwa razy
|
||
|
||
# Źródło danych (tracking)
|
||
source = Column(String(255)) # np. 'kalendarz_norda_2026', 'manual', 'api'
|
||
source_note = Column(Text) # Pełna informacja o źródle
|
||
|
||
# Kontrola dostępu
|
||
access_level = Column(String(50), default='members_only') # public, members_only, rada_only
|
||
|
||
# Media
|
||
image_url = Column(String(1000)) # Banner/header image URL
|
||
|
||
# Organizer (for calendar exports)
|
||
organizer_name = Column(String(255), default='Norda Biznes')
|
||
organizer_email = Column(String(255), default='biuro@norda-biznes.info')
|
||
|
||
# External event (ARP, KIG, etc.)
|
||
is_external = Column(Boolean, default=False)
|
||
external_url = Column(String(1000)) # Registration link at external organizer
|
||
external_source = Column(String(255)) # Source name (e.g. "Agencja Rozwoju Pomorza")
|
||
|
||
# Paid event
|
||
is_paid = Column(Boolean, default=False)
|
||
price_member = Column(Numeric(10, 2)) # Price for Izba members
|
||
price_guest = Column(Numeric(10, 2)) # Price for non-members and guest companions
|
||
|
||
# Attachment
|
||
attachment_filename = Column(String(255)) # Original filename
|
||
attachment_path = Column(String(1000)) # Server path (static/uploads/events/...)
|
||
|
||
# Relationships
|
||
speaker_company = relationship('Company')
|
||
creator = relationship('User', foreign_keys=[created_by])
|
||
attendees = relationship('EventAttendee', back_populates='event', cascade='all, delete-orphan')
|
||
guests = relationship('EventGuest', back_populates='event', cascade='all, delete-orphan')
|
||
|
||
@property
|
||
def attendee_count(self):
|
||
return len(self.attendees)
|
||
|
||
@property
|
||
def total_attendee_count(self):
|
||
"""Łączna liczba uczestników + gości (do sprawdzania limitu max_attendees)."""
|
||
return len(self.attendees) + len(self.guests)
|
||
|
||
@property
|
||
def is_past(self):
|
||
from datetime import date
|
||
end = self.event_date_end or self.event_date
|
||
return end < date.today()
|
||
|
||
@property
|
||
def is_multi_day(self):
|
||
return bool(self.event_date_end and self.event_date_end > self.event_date)
|
||
|
||
@property
|
||
def date_range_display(self):
|
||
"""Zwraca 'DD.MM.YYYY' lub 'DD.MM.YYYY – DD.MM.YYYY' dla wielodniowych."""
|
||
start = self.event_date.strftime('%d.%m.%Y')
|
||
if self.is_multi_day:
|
||
return f"{start} – {self.event_date_end.strftime('%d.%m.%Y')}"
|
||
return start
|
||
|
||
def can_user_view(self, user) -> bool:
|
||
"""Check if a user can view this event (title, date, location).
|
||
|
||
All members can SEE all events including Rada Izby events.
|
||
They just can't register or see attendee list for restricted events.
|
||
"""
|
||
if not user or not user.is_authenticated:
|
||
return False
|
||
|
||
# Admins and office managers can see everything
|
||
if user.has_role(SystemRole.OFFICE_MANAGER):
|
||
return True
|
||
|
||
access = self.access_level or 'members_only'
|
||
|
||
if access == 'public':
|
||
return True
|
||
elif access == 'members_only':
|
||
# Guests (UNAFFILIATED) can also VIEW events — but cannot register (see can_user_attend)
|
||
return True
|
||
elif access == 'rada_only':
|
||
# Wszyscy członkowie WIDZĄ wydarzenia Rady Izby (tytuł, data, miejsce)
|
||
# ale nie mogą dołączyć ani zobaczyć uczestników. Goście nie widzą.
|
||
return user.is_norda_member or user.has_role(SystemRole.MEMBER) or user.is_rada_member
|
||
else:
|
||
return False
|
||
|
||
def can_user_attend(self, user) -> bool:
|
||
"""Check if a user can register for this event.
|
||
|
||
For Rada Izby events, only designated board members can register.
|
||
UNAFFILIATED guests can view events but cannot register for any.
|
||
"""
|
||
if not user or not user.is_authenticated:
|
||
return False
|
||
|
||
# Admins and office managers can attend everything
|
||
if user.has_role(SystemRole.OFFICE_MANAGER):
|
||
return True
|
||
|
||
access = self.access_level or 'members_only'
|
||
|
||
if access == 'public':
|
||
# Guests can view but not register — only members can RSVP
|
||
return user.is_norda_member or user.has_role(SystemRole.MEMBER)
|
||
elif access == 'members_only':
|
||
return user.is_norda_member or user.has_role(SystemRole.MEMBER)
|
||
elif access == 'rada_only':
|
||
# Tylko członkowie Rady Izby mogą się zapisać
|
||
return user.is_rada_member
|
||
else:
|
||
return False
|
||
|
||
def can_user_see_attendees(self, user) -> bool:
|
||
"""Check if a user can see the attendee list.
|
||
|
||
For Rada Izby events, only board members can see who's attending.
|
||
"""
|
||
if not user or not user.is_authenticated:
|
||
return False
|
||
|
||
# Admins and office managers can see attendees
|
||
if user.has_role(SystemRole.OFFICE_MANAGER):
|
||
return True
|
||
|
||
access = self.access_level or 'members_only'
|
||
|
||
if access == 'rada_only':
|
||
# Tylko członkowie Rady Izby widzą listę uczestników
|
||
return user.is_rada_member
|
||
else:
|
||
# Dla innych wydarzeń - wszyscy uprawnieni widzą uczestników
|
||
return self.can_user_view(user)
|
||
|
||
|
||
class EventAttendee(Base):
|
||
"""RSVP na wydarzenia"""
|
||
__tablename__ = 'event_attendees'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
event_id = Column(Integer, ForeignKey('norda_events.id'), nullable=False)
|
||
# (właściwość poniżej)
|
||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
status = Column(String(20), default='confirmed') # confirmed, maybe, declined
|
||
registered_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Payment tracking (for paid events)
|
||
payment_status = Column(String(20), default='unpaid') # unpaid, paid, exempt
|
||
payment_amount = Column(Numeric(10, 2))
|
||
payment_confirmed_by = Column(Integer, ForeignKey('users.id'))
|
||
payment_confirmed_at = Column(DateTime)
|
||
|
||
event = relationship('NordaEvent', back_populates='attendees')
|
||
user = relationship('User', foreign_keys=[user_id])
|
||
payment_confirmer = relationship('User', foreign_keys=[payment_confirmed_by])
|
||
|
||
|
||
class EventGuest(Base):
|
||
"""Osoby towarzyszące na wydarzeniach (bez konta na portalu)"""
|
||
__tablename__ = 'event_guests'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
event_id = Column(Integer, ForeignKey('norda_events.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
host_user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
first_name = Column(String(100))
|
||
last_name = Column(String(100))
|
||
organization = Column(String(255))
|
||
guest_type = Column(String(20), default='external') # 'external' or 'member'
|
||
created_at = Column(DateTime, default=datetime.now, nullable=False)
|
||
|
||
# Payment tracking (for paid events)
|
||
payment_status = Column(String(20), default='unpaid') # unpaid, paid, exempt
|
||
payment_amount = Column(Numeric(10, 2))
|
||
payment_confirmed_by = Column(Integer, ForeignKey('users.id'))
|
||
payment_confirmed_at = Column(DateTime)
|
||
|
||
event = relationship('NordaEvent', back_populates='guests')
|
||
host = relationship('User', foreign_keys=[host_user_id])
|
||
payment_confirmer = relationship('User', foreign_keys=[payment_confirmed_by])
|
||
|
||
@property
|
||
def display_name(self):
|
||
"""Nazwa wyświetlana gościa — łączy dostępne pola."""
|
||
parts = []
|
||
if self.first_name:
|
||
parts.append(self.first_name)
|
||
if self.last_name:
|
||
parts.append(self.last_name)
|
||
return ' '.join(parts) if parts else '(brak danych)'
|
||
|
||
|
||
# ============================================================
|
||
# PRIVATE MESSAGES
|
||
# ============================================================
|
||
|
||
class PrivateMessage(Base):
|
||
"""Wiadomości prywatne między członkami"""
|
||
__tablename__ = 'private_messages'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
sender_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
recipient_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
|
||
subject = Column(String(255))
|
||
content = Column(Text, nullable=False)
|
||
|
||
is_read = Column(Boolean, default=False)
|
||
read_at = Column(DateTime)
|
||
|
||
# Dla wątków konwersacji
|
||
parent_id = Column(Integer, ForeignKey('private_messages.id'))
|
||
|
||
# Kontekst powiązania (np. ogłoszenie B2B, temat forum)
|
||
context_type = Column(String(50)) # 'classified', 'forum_topic', etc.
|
||
context_id = Column(Integer) # ID powiązanego obiektu
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
sender = relationship('User', foreign_keys=[sender_id], backref='sent_messages')
|
||
recipient = relationship('User', foreign_keys=[recipient_id], backref='received_messages')
|
||
parent = relationship('PrivateMessage', remote_side=[id])
|
||
|
||
|
||
class MessageAttachment(Base):
|
||
"""Załączniki do wiadomości prywatnych i grupowych"""
|
||
__tablename__ = 'message_attachments'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
message_id = Column(Integer, ForeignKey('private_messages.id', ondelete='CASCADE')) # nullable now
|
||
group_message_id = Column(Integer, ForeignKey('group_message.id', ondelete='CASCADE')) # NEW
|
||
filename = Column(String(255), nullable=False) # original filename
|
||
stored_filename = Column(String(255), nullable=False) # UUID-based on disk
|
||
file_size = Column(Integer, nullable=False) # bytes
|
||
mime_type = Column(String(100), nullable=False)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
conv_message_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='CASCADE'), nullable=True)
|
||
|
||
message = relationship('PrivateMessage', backref=backref('attachments', cascade='all, delete-orphan'))
|
||
conv_message = relationship('ConvMessage', back_populates='attachments', foreign_keys=[conv_message_id])
|
||
|
||
|
||
# ============================================================
|
||
# GROUP MESSAGES
|
||
# ============================================================
|
||
|
||
class MessageGroup(Base):
|
||
"""Grupa czatowa — nazwana lub ad-hoc"""
|
||
__tablename__ = 'message_group'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
name = Column(String(255))
|
||
description = Column(Text)
|
||
owner_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
is_named = Column(Boolean, default=False, nullable=False)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
owner = relationship('User', foreign_keys=[owner_id])
|
||
members = relationship('MessageGroupMember', backref='group', cascade='all, delete-orphan')
|
||
messages = relationship('GroupMessage', backref='group', cascade='all, delete-orphan',
|
||
order_by='GroupMessage.created_at')
|
||
|
||
@property
|
||
def display_name(self):
|
||
"""Nazwa wyświetlana — nazwa grupy lub lista imion uczestników"""
|
||
if self.name:
|
||
return self.name
|
||
names = [m.user.name or m.user.email.split('@')[0] for m in self.members if m.user]
|
||
return ', '.join(sorted(names)[:4]) + (f' +{len(names)-4}' if len(names) > 4 else '')
|
||
|
||
@property
|
||
def member_count(self):
|
||
return len(self.members)
|
||
|
||
@property
|
||
def last_message(self):
|
||
"""Ostatnia wiadomość w grupie"""
|
||
if self.messages:
|
||
return self.messages[-1]
|
||
return None
|
||
|
||
|
||
class MessageGroupMember(Base):
|
||
"""Członkostwo w grupie czatowej"""
|
||
__tablename__ = 'message_group_member'
|
||
|
||
group_id = Column(Integer, ForeignKey('message_group.id', ondelete='CASCADE'), primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True)
|
||
role = Column(String(20), nullable=False, default='member') # owner, moderator, member
|
||
last_read_at = Column(DateTime)
|
||
joined_at = Column(DateTime, default=datetime.now)
|
||
added_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||
|
||
user = relationship('User', foreign_keys=[user_id])
|
||
added_by = relationship('User', foreign_keys=[added_by_id])
|
||
|
||
@property
|
||
def is_owner(self):
|
||
return self.role == 'owner'
|
||
|
||
@property
|
||
def is_moderator(self):
|
||
return self.role == 'moderator'
|
||
|
||
@property
|
||
def can_manage_members(self):
|
||
return self.role in ('owner', 'moderator')
|
||
|
||
|
||
class GroupMessage(Base):
|
||
"""Wiadomość w grupie czatowej"""
|
||
__tablename__ = 'group_message'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
group_id = Column(Integer, ForeignKey('message_group.id', ondelete='CASCADE'), nullable=False)
|
||
sender_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||
content = Column(Text, nullable=False)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
sender = relationship('User', foreign_keys=[sender_id])
|
||
attachments = relationship('MessageAttachment',
|
||
foreign_keys='MessageAttachment.group_message_id',
|
||
backref='group_message',
|
||
cascade='all, delete-orphan')
|
||
|
||
|
||
# ============================================================
|
||
# B2B CLASSIFIEDS
|
||
# ============================================================
|
||
|
||
class Classified(Base):
|
||
"""Ogłoszenia B2B - Szukam/Oferuję"""
|
||
__tablename__ = 'classifieds'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
author_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
company_id = Column(Integer, ForeignKey('companies.id'))
|
||
|
||
# Typ ogłoszenia
|
||
listing_type = Column(String(20), nullable=False) # 'szukam', 'oferuje'
|
||
category = Column(String(50), nullable=False) # uslugi, produkty, wspolpraca, praca, inne
|
||
|
||
title = Column(String(255), nullable=False)
|
||
description = Column(Text, nullable=False)
|
||
|
||
# Opcjonalne szczegóły
|
||
budget_info = Column(String(255)) # "do negocjacji", "5000-10000 PLN"
|
||
location_info = Column(String(255)) # Wejherowo, Cała Polska, Online
|
||
|
||
# Status
|
||
is_active = Column(Boolean, default=True)
|
||
is_ai_generated = Column(Boolean, default=False)
|
||
is_test = Column(Boolean, default=False) # Oznaczenie dla testowych ogłoszeń
|
||
expires_at = Column(DateTime) # Auto-wygaśnięcie po 30 dniach
|
||
views_count = Column(Integer, default=0)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now) # set manually in edit route only
|
||
|
||
author = relationship('User', backref='classifieds')
|
||
company = relationship('Company')
|
||
attachments = relationship('ClassifiedAttachment', back_populates='classified', cascade='all, delete-orphan', order_by='ClassifiedAttachment.created_at')
|
||
|
||
@property
|
||
def is_expired(self):
|
||
if self.expires_at:
|
||
return datetime.now() > self.expires_at
|
||
return False
|
||
|
||
|
||
class ClassifiedAttachment(Base):
|
||
"""File attachments for B2B classifieds"""
|
||
__tablename__ = 'classified_attachments'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
classified_id = Column(Integer, ForeignKey('classifieds.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
|
||
# File metadata
|
||
original_filename = Column(String(255), nullable=False)
|
||
stored_filename = Column(String(255), nullable=False, unique=True)
|
||
file_extension = Column(String(10), nullable=False)
|
||
file_size = Column(Integer, nullable=False) # in bytes
|
||
mime_type = Column(String(100), nullable=False)
|
||
|
||
# Uploader
|
||
uploaded_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
classified = relationship('Classified', back_populates='attachments')
|
||
uploader = relationship('User')
|
||
|
||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
|
||
|
||
@property
|
||
def url(self):
|
||
date = self.created_at or datetime.now()
|
||
return f"/static/uploads/classifieds/{date.year}/{date.month:02d}/{self.stored_filename}"
|
||
|
||
@property
|
||
def is_image(self):
|
||
return self.file_extension.lower() in ('jpg', 'jpeg', 'png', 'gif')
|
||
|
||
|
||
class ClassifiedRead(Base):
|
||
"""
|
||
Śledzenie odczytów ogłoszeń B2B (seen by).
|
||
Zapisuje kto i kiedy przeczytał dane ogłoszenie.
|
||
"""
|
||
__tablename__ = 'classified_reads'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
classified_id = Column(Integer, ForeignKey('classifieds.id', ondelete='CASCADE'), nullable=False)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
read_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
classified = relationship('Classified', backref='readers')
|
||
user = relationship('User')
|
||
|
||
# Unique constraint
|
||
__table_args__ = (
|
||
UniqueConstraint('classified_id', 'user_id', name='uq_classified_user_read'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<ClassifiedRead classified={self.classified_id} user={self.user_id}>"
|
||
|
||
|
||
class ClassifiedInterest(Base):
|
||
"""Zainteresowania użytkowników ogłoszeniami B2B"""
|
||
__tablename__ = 'classified_interests'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
classified_id = Column(Integer, ForeignKey('classifieds.id', ondelete='CASCADE'), nullable=False)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
message = Column(String(255)) # opcjonalna krótka notatka
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
classified = relationship('Classified', backref='interests')
|
||
user = relationship('User')
|
||
|
||
# Unique constraint - użytkownik może być zainteresowany tylko raz
|
||
__table_args__ = (
|
||
UniqueConstraint('classified_id', 'user_id', name='uq_classified_interest'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<ClassifiedInterest classified={self.classified_id} user={self.user_id}>"
|
||
|
||
|
||
class ClassifiedQuestion(Base):
|
||
"""Publiczne pytania i odpowiedzi do ogłoszeń B2B"""
|
||
__tablename__ = 'classified_questions'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
classified_id = Column(Integer, ForeignKey('classifieds.id', ondelete='CASCADE'), nullable=False)
|
||
author_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
content = Column(Text, nullable=False)
|
||
|
||
# Odpowiedź właściciela ogłoszenia
|
||
answer = Column(Text)
|
||
answered_by = Column(Integer, ForeignKey('users.id'))
|
||
answered_at = Column(DateTime)
|
||
|
||
# Widoczność
|
||
is_public = Column(Boolean, default=True)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
classified = relationship('Classified', backref='questions')
|
||
author = relationship('User', foreign_keys=[author_id])
|
||
answerer = relationship('User', foreign_keys=[answered_by])
|
||
|
||
def __repr__(self):
|
||
return f"<ClassifiedQuestion id={self.id} classified={self.classified_id}>"
|
||
|
||
|
||
class CompanyContact(Base):
|
||
"""Multiple contacts (phones, emails) per company with source tracking"""
|
||
__tablename__ = 'company_contacts'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
|
||
# Contact type: 'phone', 'email', 'fax', 'mobile'
|
||
contact_type = Column(String(20), nullable=False, index=True)
|
||
|
||
# Contact value (phone number or email address)
|
||
value = Column(String(255), nullable=False)
|
||
|
||
# Purpose/description: 'Biuro', 'Sprzedaż', 'Właściciel', 'Transport', 'Serwis', etc.
|
||
purpose = Column(String(100))
|
||
|
||
# Is this the primary contact of this type?
|
||
is_primary = Column(Boolean, default=False)
|
||
|
||
# Source of this contact data
|
||
source = Column(String(100)) # 'website', 'krs', 'google_business', 'facebook', 'manual', 'brave_search'
|
||
source_url = Column(String(500)) # URL where the contact was found
|
||
source_date = Column(Date) # When the contact was found/verified
|
||
|
||
# Validation
|
||
is_verified = Column(Boolean, default=False)
|
||
verified_at = Column(DateTime)
|
||
verified_by = Column(String(100))
|
||
|
||
# Metadata
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationship
|
||
company = relationship('Company', backref='contacts')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'contact_type', 'value', name='uq_company_contact_type_value'),
|
||
)
|
||
|
||
|
||
class CompanySocialMedia(Base):
|
||
"""Social media profiles for companies with verification tracking"""
|
||
__tablename__ = 'company_social_media'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
platform = Column(String(50), nullable=False, index=True) # facebook, linkedin, instagram, youtube, twitter
|
||
url = Column(String(500), nullable=False)
|
||
|
||
# Tracking freshness
|
||
verified_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
|
||
source = Column(String(100)) # website_scrape, brave_search, manual, facebook_api
|
||
|
||
# Validation
|
||
is_valid = Column(Boolean, default=True)
|
||
last_checked_at = Column(DateTime)
|
||
check_status = Column(String(50)) # ok, 404, redirect, blocked
|
||
|
||
# Metadata from platform
|
||
page_name = Column(String(255))
|
||
followers_count = Column(Integer)
|
||
|
||
# Profile completeness indicators
|
||
has_profile_photo = Column(Boolean)
|
||
has_cover_photo = Column(Boolean)
|
||
has_bio = Column(Boolean)
|
||
profile_description = Column(Text)
|
||
|
||
# Activity metrics
|
||
posts_count_30d = Column(Integer)
|
||
posts_count_365d = Column(Integer)
|
||
last_post_date = Column(DateTime)
|
||
|
||
# Scoring & analytics
|
||
posting_frequency_score = Column(Integer) # 0-10
|
||
engagement_rate = Column(Numeric(5, 2)) # percent
|
||
content_types = Column(JSONB) # {"photos": 12, "videos": 3, "text": 5}
|
||
profile_completeness_score = Column(Integer) # 0-100
|
||
|
||
# Historical tracking
|
||
followers_history = Column(JSONB) # [{"date": "2026-02-01", "count": 150}, ...]
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationship
|
||
company = relationship('Company', backref='social_media_profiles')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'platform', 'url', name='uq_company_platform_url'),
|
||
)
|
||
|
||
|
||
class CompanyWebsite(Base):
|
||
"""Multiple website URLs for companies"""
|
||
__tablename__ = 'company_websites'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
url = Column(String(500), nullable=False)
|
||
label = Column(String(100)) # optional: "Sklep", "Strona główna"
|
||
website_type = Column(String(20), default='website') # website, store, booking, blog, portfolio, other
|
||
is_primary = Column(Boolean, default=False)
|
||
source = Column(String(100)) # manual_edit, migration, website_scrape
|
||
is_valid = Column(Boolean, default=True)
|
||
last_checked_at = Column(DateTime)
|
||
check_status = Column(String(50)) # ok, 404, redirect, blocked
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
company = relationship('Company', back_populates='websites')
|
||
analyses = relationship('CompanyWebsiteAnalysis', backref='company_website', foreign_keys='CompanyWebsiteAnalysis.company_website_id')
|
||
|
||
|
||
class CompanyRecommendation(Base):
|
||
"""Peer recommendations between NORDA BIZNES members"""
|
||
__tablename__ = 'company_recommendations'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
|
||
# Recommendation content
|
||
recommendation_text = Column(Text, nullable=False)
|
||
service_category = Column(String(200)) # Optional: specific service recommended for
|
||
|
||
# Privacy settings
|
||
show_contact = Column(Boolean, default=True) # Show recommender's contact info
|
||
|
||
# Moderation
|
||
status = Column(String(20), default='pending', index=True) # pending, approved, rejected
|
||
moderated_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||
moderated_at = Column(DateTime)
|
||
rejection_reason = Column(Text)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
company = relationship('Company', backref='recommendations')
|
||
user = relationship('User', foreign_keys=[user_id], backref='recommendations_given')
|
||
moderator = relationship('User', foreign_keys=[moderated_by], backref='recommendations_moderated')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('user_id', 'company_id', name='uq_user_company_recommendation'),
|
||
)
|
||
|
||
|
||
class UserNotification(Base):
|
||
"""
|
||
In-app notifications for users.
|
||
Supports badges and notification center.
|
||
"""
|
||
__tablename__ = 'user_notifications'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
|
||
# Notification content
|
||
title = Column(String(255), nullable=False)
|
||
message = Column(Text)
|
||
notification_type = Column(String(50), default='info', index=True)
|
||
# Types: news, system, message, event, alert
|
||
|
||
# Related entity (optional)
|
||
related_type = Column(String(50)) # company_news, event, message
|
||
related_id = Column(Integer)
|
||
|
||
# Status
|
||
is_read = Column(Boolean, default=False, index=True)
|
||
read_at = Column(DateTime)
|
||
|
||
# Link
|
||
action_url = Column(String(500))
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now, index=True)
|
||
|
||
# Relationship
|
||
user = relationship('User', backref='notifications')
|
||
|
||
def mark_as_read(self):
|
||
self.is_read = True
|
||
self.read_at = datetime.now()
|
||
|
||
|
||
# ============================================================
|
||
# GOOGLE BUSINESS PROFILE AUDIT
|
||
# ============================================================
|
||
|
||
class GBPAudit(Base):
|
||
"""
|
||
Google Business Profile audit results for companies.
|
||
Tracks completeness scores and provides improvement recommendations.
|
||
"""
|
||
__tablename__ = 'gbp_audits'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
|
||
# Audit timestamp
|
||
audit_date = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
||
|
||
# Completeness scoring (0-100)
|
||
completeness_score = Column(Integer)
|
||
|
||
# Field-by-field status tracking
|
||
# Example: {"name": {"status": "complete", "value": "Company Name"}, "phone": {"status": "missing"}, ...}
|
||
fields_status = Column(JSONB)
|
||
|
||
# AI-generated recommendations
|
||
# Example: [{"priority": "high", "field": "description", "recommendation": "Add a detailed business description..."}, ...]
|
||
recommendations = Column(JSONB)
|
||
|
||
# Individual field scores (for detailed breakdown)
|
||
has_name = Column(Boolean, default=False)
|
||
has_address = Column(Boolean, default=False)
|
||
has_phone = Column(Boolean, default=False)
|
||
has_website = Column(Boolean, default=False)
|
||
has_hours = Column(Boolean, default=False)
|
||
has_categories = Column(Boolean, default=False)
|
||
has_photos = Column(Boolean, default=False)
|
||
has_description = Column(Boolean, default=False)
|
||
has_services = Column(Boolean, default=False)
|
||
has_reviews = Column(Boolean, default=False)
|
||
|
||
# Photo counts
|
||
photo_count = Column(Integer, default=0)
|
||
logo_present = Column(Boolean, default=False)
|
||
cover_photo_present = Column(Boolean, default=False)
|
||
|
||
# Review metrics
|
||
review_count = Column(Integer, default=0)
|
||
average_rating = Column(Numeric(2, 1))
|
||
|
||
# Google Place data
|
||
google_place_id = Column(String(100))
|
||
google_maps_url = Column(String(500))
|
||
|
||
# Review management
|
||
reviews_with_response = Column(Integer, default=0)
|
||
reviews_without_response = Column(Integer, default=0)
|
||
review_response_rate = Column(Numeric(5, 2))
|
||
avg_review_response_days = Column(Numeric(5, 1))
|
||
review_sentiment = Column(JSONB)
|
||
reviews_30d = Column(Integer, default=0)
|
||
review_keywords = Column(JSONB)
|
||
|
||
# Content & activity
|
||
has_posts = Column(Boolean)
|
||
posts_count_30d = Column(Integer)
|
||
has_products = Column(Boolean)
|
||
has_qa = Column(Boolean)
|
||
qa_count = Column(Integer)
|
||
|
||
# Enhanced attributes
|
||
attributes = Column(JSONB)
|
||
special_hours = Column(JSONB)
|
||
has_special_hours = Column(Boolean)
|
||
|
||
# NAP consistency
|
||
nap_consistent = Column(Boolean)
|
||
nap_issues = Column(JSONB)
|
||
|
||
# Keywords
|
||
description_keywords = Column(JSONB)
|
||
keyword_density_score = Column(Integer)
|
||
|
||
# Photo analysis
|
||
photo_categories = Column(JSONB)
|
||
|
||
# Audit metadata
|
||
audit_source = Column(String(50), default='manual') # manual, automated, api
|
||
audit_version = Column(String(20), default='1.0')
|
||
audit_errors = Column(Text)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationship
|
||
company = relationship('Company', backref='gbp_audits')
|
||
|
||
def __repr__(self):
|
||
return f'<GBPAudit company_id={self.company_id} score={self.completeness_score}>'
|
||
|
||
@property
|
||
def score_category(self):
|
||
"""Return score category: excellent, good, needs_work, poor"""
|
||
if self.completeness_score is None:
|
||
return 'unknown'
|
||
if self.completeness_score >= 90:
|
||
return 'excellent'
|
||
elif self.completeness_score >= 70:
|
||
return 'good'
|
||
elif self.completeness_score >= 50:
|
||
return 'needs_work'
|
||
else:
|
||
return 'poor'
|
||
|
||
|
||
class GBPReview(Base):
|
||
"""Individual Google Business Profile reviews for tracking and analysis"""
|
||
__tablename__ = 'gbp_reviews'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
google_review_id = Column(String(255))
|
||
author_name = Column(String(255))
|
||
rating = Column(Integer, nullable=False)
|
||
text = Column(Text)
|
||
publish_time = Column(DateTime)
|
||
has_owner_response = Column(Boolean, default=False)
|
||
owner_response_text = Column(Text)
|
||
owner_response_time = Column(DateTime)
|
||
sentiment = Column(String(20)) # positive, neutral, negative
|
||
keywords = Column(JSONB)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationship
|
||
company = relationship('Company', backref='gbp_reviews')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'google_review_id', name='uq_company_google_review'),
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# COMPETITOR MONITORING
|
||
# ============================================================
|
||
|
||
class CompanyCompetitor(Base):
|
||
"""Tracked competitors for a company via Google Places"""
|
||
__tablename__ = 'company_competitors'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
competitor_place_id = Column(String(255), nullable=False)
|
||
competitor_name = Column(String(255))
|
||
competitor_address = Column(String(500))
|
||
competitor_rating = Column(Numeric(2, 1))
|
||
competitor_review_count = Column(Integer)
|
||
competitor_category = Column(String(255))
|
||
competitor_website = Column(String(500))
|
||
added_by = Column(String(20), default='auto') # auto, manual
|
||
is_active = Column(Boolean, default=True)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
company = relationship('Company', backref='competitors')
|
||
snapshots = relationship('CompetitorSnapshot', backref='competitor', cascade='all, delete-orphan')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'competitor_place_id', name='uq_company_competitor'),
|
||
)
|
||
|
||
|
||
class CompetitorSnapshot(Base):
|
||
"""Periodic snapshot of competitor's Google Business Profile"""
|
||
__tablename__ = 'competitor_snapshots'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
competitor_id = Column(Integer, ForeignKey('company_competitors.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
snapshot_date = Column(Date, nullable=False)
|
||
rating = Column(Numeric(2, 1))
|
||
review_count = Column(Integer)
|
||
photo_count = Column(Integer)
|
||
posts_count = Column(Integer)
|
||
has_website = Column(Boolean)
|
||
has_description = Column(Boolean)
|
||
data = Column(JSONB) # full snapshot
|
||
changes = Column(JSONB) # delta vs previous
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('competitor_id', 'snapshot_date', name='uq_competitor_snapshot_date'),
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# UNIFIED AUDIT REPORTS
|
||
# ============================================================
|
||
|
||
class AuditReport(Base):
|
||
"""Unified audit report combining Social, GBP, and SEO data"""
|
||
__tablename__ = 'audit_reports'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
report_type = Column(String(20), default='full') # full, social, gbp, seo
|
||
period_start = Column(Date)
|
||
period_end = Column(Date)
|
||
|
||
# Overall scores
|
||
overall_score = Column(Integer)
|
||
social_score = Column(Integer)
|
||
gbp_score = Column(Integer)
|
||
seo_score = Column(Integer)
|
||
|
||
# Report sections
|
||
sections = Column(JSONB)
|
||
|
||
# Pre-rendered report data
|
||
data = Column(JSONB)
|
||
|
||
# Metadata
|
||
custom_message = Column(Text)
|
||
generated_by = Column(String(50), default='system')
|
||
generated_at = Column(DateTime, default=datetime.now)
|
||
status = Column(String(20), default='draft')
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationship
|
||
company = relationship('Company', backref='audit_reports')
|
||
|
||
|
||
# ============================================================
|
||
# IT INFRASTRUCTURE AUDIT
|
||
# ============================================================
|
||
|
||
class ITAudit(Base):
|
||
"""
|
||
IT infrastructure audit for companies.
|
||
Tracks IT infrastructure, security posture, and collaboration readiness.
|
||
Used for cross-company collaboration matching.
|
||
"""
|
||
__tablename__ = 'it_audits'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'),
|
||
nullable=False, index=True)
|
||
|
||
# Audit timestamp and metadata
|
||
audit_date = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
||
audit_source = Column(String(50), default='form') # form, api_sync
|
||
audited_by = Column(Integer, ForeignKey('users.id'))
|
||
|
||
# === SCORES (0-100) ===
|
||
overall_score = Column(Integer)
|
||
completeness_score = Column(Integer)
|
||
security_score = Column(Integer)
|
||
collaboration_score = Column(Integer)
|
||
maturity_level = Column(String(20)) # basic, developing, established, advanced
|
||
|
||
# === SECTION 1: IT CONTACT ===
|
||
has_it_manager = Column(Boolean, default=False)
|
||
it_outsourced = Column(Boolean, default=False)
|
||
it_provider_name = Column(String(255))
|
||
it_contact_name = Column(String(255))
|
||
it_contact_email = Column(String(255))
|
||
|
||
# === SECTION 2: CLOUD & IDENTITY ===
|
||
has_azure_ad = Column(Boolean, default=False)
|
||
azure_tenant_name = Column(String(255))
|
||
azure_user_count = Column(String(20)) # Range: 1-10, 11-50, 51-100, 100+
|
||
has_m365 = Column(Boolean, default=False)
|
||
m365_plans = Column(ARRAY(String)) # Business Basic, Business Standard, E3, E5, etc.
|
||
teams_usage = Column(ARRAY(String)) # chat, meetings, files, phone
|
||
has_google_workspace = Column(Boolean, default=False)
|
||
|
||
# === SECTION 3: SERVER INFRASTRUCTURE ===
|
||
server_count = Column(String(20)) # Range: 0, 1-3, 4-10, 10+
|
||
server_types = Column(ARRAY(String)) # physical, vm_onprem, cloud_iaas
|
||
virtualization_platform = Column(String(50)) # none, vmware, hyperv, proxmox, kvm
|
||
server_os = Column(ARRAY(String)) # windows_server, linux_ubuntu, linux_debian, linux_rhel
|
||
network_firewall_brand = Column(String(100))
|
||
|
||
# === SECTION 4: ENDPOINTS ===
|
||
employee_count = Column(String(20)) # Range: 1-10, 11-50, 51-100, 100+
|
||
computer_count = Column(String(20)) # Range: 1-10, 11-50, 51-100, 100+
|
||
desktop_os = Column(ARRAY(String)) # windows_10, windows_11, macos, linux
|
||
has_mdm = Column(Boolean, default=False)
|
||
mdm_solution = Column(String(50)) # intune, jamf, other
|
||
|
||
# === SECTION 5: SECURITY ===
|
||
antivirus_solution = Column(String(50)) # none, windows_defender, eset, kaspersky, other
|
||
has_edr = Column(Boolean, default=False)
|
||
edr_solution = Column(String(100)) # microsoft_defender_atp, crowdstrike, sentinelone, other
|
||
has_vpn = Column(Boolean, default=False)
|
||
vpn_solution = Column(String(50)) # ipsec, wireguard, openvpn, fortinet, other
|
||
has_mfa = Column(Boolean, default=False)
|
||
mfa_scope = Column(ARRAY(String)) # email, vpn, erp, all_apps
|
||
|
||
# === SECTION 6: BACKUP & DISASTER RECOVERY ===
|
||
backup_solution = Column(String(50)) # none, veeam, acronis, pbs, azure_backup, other
|
||
backup_targets = Column(ARRAY(String)) # local_nas, offsite, cloud, tape
|
||
backup_frequency = Column(String(20)) # daily, weekly, monthly, continuous
|
||
has_proxmox_pbs = Column(Boolean, default=False)
|
||
has_dr_plan = Column(Boolean, default=False)
|
||
|
||
# === SECTION 7: MONITORING ===
|
||
monitoring_solution = Column(String(50)) # none, zabbix, prtg, nagios, datadog, other
|
||
zabbix_integration = Column(JSONB) # {hostname: '', agent_installed: bool, templates: []}
|
||
|
||
# === SECTION 8: BUSINESS APPS ===
|
||
ticketing_system = Column(String(50)) # none, freshdesk, zendesk, jira_service, other
|
||
erp_system = Column(String(50)) # none, sap, microsoft_dynamics, enova, optima, other
|
||
crm_system = Column(String(50)) # none, salesforce, hubspot, pipedrive, other
|
||
document_management = Column(String(50)) # none, sharepoint, google_drive, dropbox, other
|
||
|
||
# === SECTION 9: ACTIVE DIRECTORY ===
|
||
has_local_ad = Column(Boolean, default=False)
|
||
ad_domain_name = Column(String(255))
|
||
has_ad_azure_sync = Column(Boolean, default=False) # Azure AD Connect / Cloud Sync
|
||
|
||
# === COLLABORATION FLAGS (for matching algorithm) ===
|
||
open_to_shared_licensing = Column(Boolean, default=False)
|
||
open_to_backup_replication = Column(Boolean, default=False)
|
||
open_to_teams_federation = Column(Boolean, default=False)
|
||
open_to_shared_monitoring = Column(Boolean, default=False)
|
||
open_to_collective_purchasing = Column(Boolean, default=False)
|
||
open_to_knowledge_sharing = Column(Boolean, default=False)
|
||
|
||
# === RAW DATA & METADATA ===
|
||
form_data = Column(JSONB) # Full form submission for reference
|
||
recommendations = Column(JSONB) # AI-generated recommendations
|
||
audit_errors = Column(Text) # Any errors during audit processing
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
company = relationship('Company', backref='it_audits')
|
||
auditor = relationship('User', foreign_keys=[audited_by])
|
||
|
||
def __repr__(self):
|
||
return f'<ITAudit company_id={self.company_id} score={self.overall_score}>'
|
||
|
||
@property
|
||
def maturity_label(self):
|
||
"""Return Polish label for maturity level"""
|
||
labels = {
|
||
'basic': 'Podstawowy',
|
||
'developing': 'Rozwijający się',
|
||
'established': 'Ugruntowany',
|
||
'advanced': 'Zaawansowany'
|
||
}
|
||
return labels.get(self.maturity_level, 'Nieznany')
|
||
|
||
@property
|
||
def score_category(self):
|
||
"""Return score category: excellent, good, needs_work, poor"""
|
||
if self.overall_score is None:
|
||
return 'unknown'
|
||
if self.overall_score >= 80:
|
||
return 'excellent'
|
||
elif self.overall_score >= 60:
|
||
return 'good'
|
||
elif self.overall_score >= 40:
|
||
return 'needs_work'
|
||
else:
|
||
return 'poor'
|
||
|
||
@property
|
||
def collaboration_flags_count(self):
|
||
"""Count how many collaboration flags are enabled"""
|
||
flags = [
|
||
self.open_to_shared_licensing,
|
||
self.open_to_backup_replication,
|
||
self.open_to_teams_federation,
|
||
self.open_to_shared_monitoring,
|
||
self.open_to_collective_purchasing,
|
||
self.open_to_knowledge_sharing
|
||
]
|
||
return sum(1 for f in flags if f)
|
||
|
||
|
||
class ITCollaborationMatch(Base):
|
||
"""
|
||
IT collaboration matches between companies.
|
||
Stores potential collaboration opportunities discovered by the matching algorithm.
|
||
Match types: shared_licensing, backup_replication, teams_federation,
|
||
shared_monitoring, collective_purchasing, knowledge_sharing
|
||
"""
|
||
__tablename__ = 'it_collaboration_matches'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_a_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'),
|
||
nullable=False, index=True)
|
||
company_b_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'),
|
||
nullable=False, index=True)
|
||
|
||
# Match details
|
||
match_type = Column(String(50), nullable=False, index=True)
|
||
# Types: shared_licensing, backup_replication, teams_federation,
|
||
# shared_monitoring, collective_purchasing, knowledge_sharing
|
||
match_reason = Column(Text) # Human-readable explanation of why this is a match
|
||
match_score = Column(Integer) # 0-100 strength of the match
|
||
|
||
# Status: suggested, contacted, in_progress, completed, declined
|
||
status = Column(String(20), default='suggested', index=True)
|
||
|
||
# Shared attributes that led to this match (JSONB for flexibility)
|
||
# Example: {"m365_plans": ["E3", "E5"], "has_proxmox_pbs": true}
|
||
shared_attributes = Column(JSONB)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now, index=True)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
company_a = relationship('Company', foreign_keys=[company_a_id],
|
||
backref='collaboration_matches_as_a')
|
||
company_b = relationship('Company', foreign_keys=[company_b_id],
|
||
backref='collaboration_matches_as_b')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_a_id', 'company_b_id', 'match_type',
|
||
name='uq_it_collab_match_pair_type'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f'<ITCollaborationMatch {self.company_a_id}<->{self.company_b_id} type={self.match_type}>'
|
||
|
||
@property
|
||
def match_type_label(self):
|
||
"""Return Polish label for match type"""
|
||
labels = {
|
||
'shared_licensing': 'Współdzielone licencje',
|
||
'backup_replication': 'Replikacja backupów',
|
||
'teams_federation': 'Federacja Teams',
|
||
'shared_monitoring': 'Wspólny monitoring',
|
||
'collective_purchasing': 'Zakupy grupowe',
|
||
'knowledge_sharing': 'Wymiana wiedzy'
|
||
}
|
||
return labels.get(self.match_type, self.match_type)
|
||
|
||
@property
|
||
def status_label(self):
|
||
"""Return Polish label for status"""
|
||
labels = {
|
||
'suggested': 'Sugerowane',
|
||
'contacted': 'Skontaktowano',
|
||
'in_progress': 'W trakcie',
|
||
'completed': 'Zakończone',
|
||
'declined': 'Odrzucone'
|
||
}
|
||
return labels.get(self.status, self.status)
|
||
|
||
|
||
# ============================================================
|
||
# MEMBERSHIP FEES
|
||
# ============================================================
|
||
|
||
class MembershipFee(Base):
|
||
"""
|
||
Membership fee records for companies.
|
||
Tracks monthly payments from Norda Biznes members.
|
||
"""
|
||
__tablename__ = 'membership_fees'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
|
||
# Period identification
|
||
fee_year = Column(Integer, nullable=False) # e.g., 2026
|
||
fee_month = Column(Integer, nullable=False) # 1-12
|
||
|
||
# Fee details
|
||
amount = Column(Numeric(10, 2), nullable=False) # Amount due in PLN
|
||
amount_paid = Column(Numeric(10, 2), default=0) # Amount actually paid
|
||
|
||
# Payment status: pending, paid, partial, overdue, waived
|
||
status = Column(String(20), default='pending', index=True)
|
||
|
||
# Payment tracking
|
||
payment_date = Column(Date)
|
||
payment_method = Column(String(50)) # transfer, cash, card, other
|
||
payment_reference = Column(String(100)) # Bank transfer reference
|
||
|
||
# Admin tracking
|
||
recorded_by = Column(Integer, ForeignKey('users.id'))
|
||
recorded_at = Column(DateTime)
|
||
|
||
notes = Column(Text)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
company = relationship('Company', backref='membership_fees')
|
||
recorded_by_user = relationship('User', foreign_keys=[recorded_by])
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'fee_year', 'fee_month', name='uq_company_fee_period'),
|
||
)
|
||
|
||
@property
|
||
def is_fully_paid(self):
|
||
return (self.amount_paid or 0) >= self.amount
|
||
|
||
@property
|
||
def outstanding_amount(self):
|
||
return max(0, float(self.amount) - float(self.amount_paid or 0))
|
||
|
||
|
||
class MembershipFeeConfig(Base):
|
||
"""
|
||
Configuration for membership fees.
|
||
Allows variable amounts per company or category.
|
||
"""
|
||
__tablename__ = 'membership_fee_config'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Scope: global, category, or company
|
||
scope = Column(String(20), nullable=False) # 'global', 'category', 'company'
|
||
category_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
|
||
|
||
monthly_amount = Column(Numeric(10, 2), nullable=False)
|
||
|
||
valid_from = Column(Date, nullable=False)
|
||
valid_until = Column(Date) # NULL = currently active
|
||
|
||
created_by = Column(Integer, ForeignKey('users.id'))
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
notes = Column(Text)
|
||
|
||
# Relationships
|
||
category = relationship('Category')
|
||
company = relationship('Company')
|
||
|
||
|
||
# ============================================================
|
||
# ZIELONY OKRĘG PRZEMYSŁOWY KASZUBIA (ZOPK)
|
||
# ============================================================
|
||
|
||
class ZOPKProject(Base):
|
||
"""
|
||
Sub-projects within ZOPK initiative.
|
||
Examples: offshore wind, nuclear plant, data centers, hydrogen labs
|
||
"""
|
||
__tablename__ = 'zopk_projects'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
slug = Column(String(100), unique=True, nullable=False, index=True)
|
||
name = Column(String(255), nullable=False)
|
||
description = Column(Text)
|
||
|
||
# Project details
|
||
project_type = Column(String(50)) # energy, infrastructure, technology, defense
|
||
status = Column(String(50), default='planned') # planned, in_progress, completed
|
||
start_date = Column(Date)
|
||
end_date = Column(Date)
|
||
|
||
# Location info
|
||
location = Column(String(255))
|
||
region = Column(String(100)) # Wejherowo, Rumia, Gdynia, etc.
|
||
|
||
# Key metrics
|
||
estimated_investment = Column(Numeric(15, 2)) # Investment amount in PLN
|
||
estimated_jobs = Column(Integer) # Number of jobs created
|
||
|
||
# Visual
|
||
icon = Column(String(50)) # CSS icon class or emoji
|
||
color = Column(String(20)) # HEX color for badges
|
||
|
||
# Display order
|
||
sort_order = Column(Integer, default=0)
|
||
is_featured = Column(Boolean, default=False)
|
||
is_active = Column(Boolean, default=True)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
|
||
class ZOPKStakeholder(Base):
|
||
"""
|
||
Key people and organizations involved in ZOPK.
|
||
Politicians, coordinators, companies, institutions.
|
||
"""
|
||
__tablename__ = 'zopk_stakeholders'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Person or organization
|
||
stakeholder_type = Column(String(20), nullable=False) # person, organization
|
||
name = Column(String(255), nullable=False)
|
||
|
||
# Role and affiliation
|
||
role = Column(String(255)) # Koordynator, Minister, Starosta, etc.
|
||
organization = Column(String(255)) # MON, Starostwo Wejherowskie, etc.
|
||
|
||
# Contact (optional, public info only)
|
||
email = Column(String(255))
|
||
phone = Column(String(50))
|
||
website = Column(String(500))
|
||
|
||
# Social media
|
||
linkedin_url = Column(String(500))
|
||
twitter_url = Column(String(500))
|
||
|
||
# Photo/logo
|
||
photo_url = Column(String(500))
|
||
|
||
# Description
|
||
bio = Column(Text)
|
||
|
||
# Categorization
|
||
category = Column(String(50)) # government, local_authority, business, academic
|
||
importance = Column(Integer, default=0) # For sorting (higher = more important)
|
||
|
||
is_active = Column(Boolean, default=True)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
project_links = relationship('ZOPKStakeholderProject', back_populates='stakeholder')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('name', name='uq_zopk_stakeholder_name'),
|
||
)
|
||
|
||
|
||
class ZOPKStakeholderProject(Base):
|
||
"""Link table between stakeholders and projects"""
|
||
__tablename__ = 'zopk_stakeholder_projects'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
stakeholder_id = Column(Integer, ForeignKey('zopk_stakeholders.id', ondelete='CASCADE'), nullable=False)
|
||
project_id = Column(Integer, ForeignKey('zopk_projects.id', ondelete='CASCADE'), nullable=False)
|
||
role_in_project = Column(String(255)) # e.g., "Koordynator", "Inwestor"
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
stakeholder = relationship('ZOPKStakeholder', back_populates='project_links')
|
||
project = relationship('ZOPKProject', backref='stakeholder_links')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('stakeholder_id', 'project_id', name='uq_stakeholder_project'),
|
||
)
|
||
|
||
|
||
class ZOPKNews(Base):
|
||
"""
|
||
News articles about ZOPK with approval workflow.
|
||
Can be fetched automatically or added manually.
|
||
"""
|
||
__tablename__ = 'zopk_news'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Source information
|
||
title = Column(String(500), nullable=False)
|
||
description = Column(Text)
|
||
url = Column(String(1000), nullable=False)
|
||
source_name = Column(String(200)) # Portal name: trojmiasto.pl, etc.
|
||
source_domain = Column(String(200)) # Domain: trojmiasto.pl
|
||
|
||
# Article details
|
||
published_at = Column(DateTime)
|
||
author = Column(String(255))
|
||
image_url = Column(String(1000))
|
||
|
||
# Categorization
|
||
news_type = Column(String(50)) # news, announcement, interview, press_release
|
||
project_id = Column(Integer, ForeignKey('zopk_projects.id')) # Link to sub-project
|
||
|
||
# AI Analysis
|
||
relevance_score = Column(Numeric(3, 2)) # 0.00-1.00
|
||
sentiment = Column(String(20)) # positive, neutral, negative
|
||
ai_summary = Column(Text) # AI-generated summary
|
||
keywords = Column(StringArray) # Extracted keywords
|
||
|
||
# Cross-verification (multi-source confidence)
|
||
confidence_score = Column(Integer, default=1) # 1-5, increases with source confirmations
|
||
source_count = Column(Integer, default=1) # Number of sources that found this story
|
||
sources_list = Column(StringArray) # List of sources: ['brave', 'google_news', 'rss_trojmiasto']
|
||
title_hash = Column(String(64), index=True) # For fuzzy title matching (normalized)
|
||
is_auto_verified = Column(Boolean, default=False) # True if 3+ sources confirmed
|
||
|
||
# AI Relevance Evaluation (Gemini)
|
||
ai_relevant = Column(Boolean) # True = relevant to ZOPK, False = not relevant, NULL = not evaluated
|
||
ai_relevance_score = Column(Integer) # 1-5 stars: 1=weak match, 5=perfect match
|
||
ai_evaluation_reason = Column(Text) # AI's explanation of relevance decision
|
||
ai_evaluated_at = Column(DateTime) # When AI evaluation was performed
|
||
ai_model = Column(String(100)) # Which AI model was used (e.g., gemini-3-flash-preview)
|
||
|
||
# Moderation workflow
|
||
status = Column(String(20), default='pending', index=True) # pending, approved, rejected, auto_approved
|
||
moderated_by = Column(Integer, ForeignKey('users.id'))
|
||
moderated_at = Column(DateTime)
|
||
rejection_reason = Column(Text)
|
||
|
||
# Source tracking
|
||
source_type = Column(String(50), default='manual') # manual, brave_search, rss, scraper
|
||
fetch_job_id = Column(String(100)) # ID of the fetch job that found this
|
||
|
||
# Deduplication
|
||
url_hash = Column(String(64), unique=True, index=True) # SHA256 of URL
|
||
|
||
is_featured = Column(Boolean, default=False)
|
||
views_count = Column(Integer, default=0)
|
||
|
||
# Full content (scraped from source URL) - for knowledge extraction
|
||
full_content = Column(Text) # Full article text (without HTML, ads, navigation)
|
||
content_scraped_at = Column(DateTime) # When content was scraped
|
||
scrape_status = Column(String(20), default='pending', index=True) # pending, scraped, failed, skipped
|
||
scrape_error = Column(Text) # Error message if scraping failed
|
||
scrape_attempts = Column(Integer, default=0) # Number of scraping attempts
|
||
content_word_count = Column(Integer) # Word count of scraped content
|
||
content_language = Column(String(10), default='pl') # pl, en
|
||
|
||
# Knowledge extraction status
|
||
knowledge_extracted = Column(Boolean, default=False, index=True) # True if chunks/facts/entities extracted
|
||
knowledge_extracted_at = Column(DateTime) # When knowledge was extracted
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
project = relationship('ZOPKProject', backref='news_items')
|
||
moderator = relationship('User', foreign_keys=[moderated_by])
|
||
|
||
|
||
class ZOPKResource(Base):
|
||
"""
|
||
Resources: documents, links, images, videos related to ZOPK.
|
||
Knowledge base materials.
|
||
"""
|
||
__tablename__ = 'zopk_resources'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Resource identification
|
||
title = Column(String(255), nullable=False)
|
||
description = Column(Text)
|
||
|
||
# Resource type
|
||
resource_type = Column(String(50), nullable=False) # link, document, image, video, map
|
||
|
||
# URL or file path
|
||
url = Column(String(1000))
|
||
file_path = Column(String(500)) # For uploaded files
|
||
file_size = Column(Integer)
|
||
mime_type = Column(String(100))
|
||
|
||
# Thumbnail
|
||
thumbnail_url = Column(String(1000))
|
||
|
||
# Categorization
|
||
category = Column(String(50)) # official, media, research, presentation
|
||
project_id = Column(Integer, ForeignKey('zopk_projects.id'))
|
||
|
||
# Tags for search
|
||
tags = Column(StringArray)
|
||
|
||
# Source
|
||
source_name = Column(String(255))
|
||
source_date = Column(Date)
|
||
|
||
# Moderation
|
||
status = Column(String(20), default='approved', index=True) # pending, approved, rejected
|
||
uploaded_by = Column(Integer, ForeignKey('users.id'))
|
||
|
||
is_featured = Column(Boolean, default=False)
|
||
sort_order = Column(Integer, default=0)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
project = relationship('ZOPKProject', backref='resources')
|
||
uploader = relationship('User', foreign_keys=[uploaded_by])
|
||
|
||
|
||
class ZOPKCompanyLink(Base):
|
||
"""
|
||
Links between ZOPK projects and Norda Biznes member companies.
|
||
Shows which local companies can benefit or collaborate.
|
||
"""
|
||
__tablename__ = 'zopk_company_links'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
|
||
project_id = Column(Integer, ForeignKey('zopk_projects.id', ondelete='CASCADE'), nullable=False)
|
||
|
||
# Type of involvement
|
||
link_type = Column(String(50), nullable=False) # potential_supplier, partner, investor, beneficiary
|
||
|
||
# Description of potential collaboration
|
||
collaboration_description = Column(Text)
|
||
|
||
# Relevance scoring
|
||
relevance_score = Column(Integer) # 1-100
|
||
|
||
# Status
|
||
status = Column(String(20), default='suggested') # suggested, confirmed, active, completed
|
||
|
||
# Admin notes
|
||
admin_notes = Column(Text)
|
||
created_by = Column(Integer, ForeignKey('users.id'))
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
company = relationship('Company', backref='zopk_project_links')
|
||
project = relationship('ZOPKProject', backref='company_links')
|
||
creator = relationship('User', foreign_keys=[created_by])
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'project_id', 'link_type', name='uq_company_project_link'),
|
||
)
|
||
|
||
|
||
class ZOPKNewsFetchJob(Base):
|
||
"""
|
||
Tracking for automated news fetch jobs.
|
||
Records when and what was searched.
|
||
"""
|
||
__tablename__ = 'zopk_news_fetch_jobs'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
job_id = Column(String(100), unique=True, nullable=False, index=True)
|
||
|
||
# Job configuration
|
||
search_query = Column(String(500))
|
||
search_api = Column(String(50)) # brave, google, bing
|
||
date_range_start = Column(Date)
|
||
date_range_end = Column(Date)
|
||
|
||
# Results
|
||
results_found = Column(Integer, default=0)
|
||
results_new = Column(Integer, default=0) # New (not duplicates)
|
||
results_approved = Column(Integer, default=0)
|
||
|
||
# Status
|
||
status = Column(String(20), default='pending') # pending, running, completed, failed
|
||
error_message = Column(Text)
|
||
|
||
# Timing
|
||
started_at = Column(DateTime)
|
||
completed_at = Column(DateTime)
|
||
|
||
# Trigger
|
||
triggered_by = Column(String(50)) # cron, manual, admin
|
||
triggered_by_user = Column(Integer, ForeignKey('users.id'))
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
user = relationship('User', foreign_keys=[triggered_by_user])
|
||
|
||
|
||
# ============================================================
|
||
# ZOPK KNOWLEDGE BASE (AI-powered, with pgvector)
|
||
# ============================================================
|
||
|
||
class ZOPKKnowledgeChunk(Base):
|
||
"""
|
||
Knowledge chunks extracted from approved ZOPK news articles.
|
||
Each chunk is a semantically coherent piece of text with embedding vector
|
||
for similarity search (RAG - Retrieval Augmented Generation).
|
||
|
||
Best practices:
|
||
- Chunk size: 500-1000 tokens with ~100 token overlap
|
||
- Embedding model: gemini-embedding-001 (768 dimensions)
|
||
"""
|
||
__tablename__ = 'zopk_knowledge_chunks'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Source tracking
|
||
source_news_id = Column(Integer, ForeignKey('zopk_news.id'), nullable=False, index=True)
|
||
|
||
# Chunk content
|
||
content = Column(Text, nullable=False) # The actual text chunk
|
||
content_clean = Column(Text) # Cleaned/normalized version for processing
|
||
chunk_index = Column(Integer) # Position in the original article (0, 1, 2...)
|
||
token_count = Column(Integer) # Approximate token count
|
||
|
||
# Semantic embedding (pgvector)
|
||
# Using 768 dimensions for Google gemini-embedding-001
|
||
# Will be stored as: embedding vector(768)
|
||
embedding = Column(Text) # Stored as JSON string, converted to vector for queries
|
||
|
||
# AI-extracted metadata
|
||
chunk_type = Column(String(50)) # narrative, fact, quote, statistic, event, definition
|
||
summary = Column(Text) # 1-2 sentence summary
|
||
keywords = Column(PG_ARRAY(String(100)) if not IS_SQLITE else Text) # Extracted keywords
|
||
language = Column(String(10), default='pl') # pl, en
|
||
|
||
# Context information
|
||
context_date = Column(Date) # Date the information refers to (not article date)
|
||
context_location = Column(String(255)) # Geographic location if mentioned
|
||
|
||
# Quality & relevance
|
||
importance_score = Column(Integer) # 1-5, how important this information is
|
||
confidence_score = Column(Numeric(3, 2)) # 0.00-1.00, AI confidence in extraction
|
||
|
||
# Moderation
|
||
is_verified = Column(Boolean, default=False) # Human verified
|
||
verified_by = Column(Integer, ForeignKey('users.id'))
|
||
verified_at = Column(DateTime)
|
||
|
||
# Processing metadata
|
||
extraction_model = Column(String(100)) # gemini-3-flash-preview, gpt-4, etc.
|
||
extracted_at = Column(DateTime, default=datetime.now)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
source_news = relationship('ZOPKNews', backref='knowledge_chunks')
|
||
verifier = relationship('User', foreign_keys=[verified_by])
|
||
|
||
|
||
class ZOPKKnowledgeEntity(Base):
|
||
"""
|
||
Named entities extracted from ZOPK knowledge base.
|
||
Entities are deduplicated and enriched across all sources.
|
||
|
||
Types: company, person, place, organization, project, technology
|
||
"""
|
||
__tablename__ = 'zopk_knowledge_entities'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Entity identification
|
||
entity_type = Column(String(50), nullable=False, index=True)
|
||
name = Column(String(255), nullable=False)
|
||
normalized_name = Column(String(255), index=True) # Lowercase, no special chars (for dedup)
|
||
aliases = Column(PG_ARRAY(String(255)) if not IS_SQLITE else Text) # Alternative names
|
||
|
||
# Description
|
||
description = Column(Text) # AI-generated description
|
||
short_description = Column(String(500)) # One-liner
|
||
|
||
# Linking to existing data
|
||
company_id = Column(Integer, ForeignKey('companies.id')) # Link to Norda company if exists
|
||
zopk_project_id = Column(Integer, ForeignKey('zopk_projects.id')) # Link to ZOPK project
|
||
external_url = Column(String(1000)) # Wikipedia, company website, etc.
|
||
|
||
# Entity metadata (JSONB for flexibility)
|
||
# Note: 'metadata' is reserved in SQLAlchemy, using 'entity_metadata'
|
||
entity_metadata = Column(PG_JSONB if not IS_SQLITE else Text) # {role: "CEO", founded: 2020, ...}
|
||
|
||
# Statistics
|
||
mentions_count = Column(Integer, default=0)
|
||
first_mentioned_at = Column(DateTime)
|
||
last_mentioned_at = Column(DateTime)
|
||
|
||
# Embedding for entity similarity
|
||
embedding = Column(Text) # Entity description embedding
|
||
|
||
# Quality
|
||
is_verified = Column(Boolean, default=False)
|
||
merged_into_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id')) # For deduplication
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
company = relationship('Company', foreign_keys=[company_id])
|
||
zopk_project = relationship('ZOPKProject', foreign_keys=[zopk_project_id])
|
||
merged_into = relationship('ZOPKKnowledgeEntity', remote_side=[id], foreign_keys=[merged_into_id])
|
||
|
||
|
||
class ZOPKKnowledgeFact(Base):
|
||
"""
|
||
Structured facts extracted from knowledge chunks.
|
||
Facts are atomic, verifiable pieces of information.
|
||
|
||
Examples:
|
||
- "ZOPK otrzymał 500 mln PLN dofinansowania w 2024"
|
||
- "Port Gdynia jest głównym partnerem projektu"
|
||
- "Projekt zakłada utworzenie 5000 miejsc pracy"
|
||
"""
|
||
__tablename__ = 'zopk_knowledge_facts'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Source
|
||
source_chunk_id = Column(Integer, ForeignKey('zopk_knowledge_chunks.id'), nullable=False, index=True)
|
||
source_news_id = Column(Integer, ForeignKey('zopk_news.id'), index=True)
|
||
|
||
# Fact content
|
||
fact_type = Column(String(50), nullable=False) # statistic, event, statement, decision, milestone
|
||
subject = Column(String(255)) # Who/what the fact is about
|
||
predicate = Column(String(100)) # Action/relation type
|
||
object = Column(Text) # The actual information
|
||
full_text = Column(Text, nullable=False) # Complete fact as sentence
|
||
|
||
# Structured data (for queryable facts)
|
||
numeric_value = Column(Numeric(20, 2)) # If fact contains number
|
||
numeric_unit = Column(String(50)) # PLN, EUR, jobs, MW, etc.
|
||
date_value = Column(Date) # If fact refers to specific date
|
||
|
||
# Context
|
||
context = Column(Text) # Surrounding context for disambiguation
|
||
citation = Column(Text) # Original quote if applicable
|
||
|
||
# Entities involved (denormalized for quick access)
|
||
entities_involved = Column(PG_JSONB if not IS_SQLITE else Text) # [{id: 1, name: "...", type: "company"}, ...]
|
||
|
||
# Quality & verification
|
||
confidence_score = Column(Numeric(3, 2)) # AI confidence
|
||
is_verified = Column(Boolean, default=False)
|
||
contradicts_fact_id = Column(Integer, ForeignKey('zopk_knowledge_facts.id')) # If contradicted
|
||
|
||
# Embedding for fact similarity
|
||
embedding = Column(Text)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
source_chunk = relationship('ZOPKKnowledgeChunk', backref='facts')
|
||
source_news = relationship('ZOPKNews', backref='facts')
|
||
contradicted_by = relationship('ZOPKKnowledgeFact', remote_side=[id], foreign_keys=[contradicts_fact_id])
|
||
|
||
|
||
class ZOPKKnowledgeEntityMention(Base):
|
||
"""
|
||
Links between knowledge chunks and entities.
|
||
Tracks where each entity is mentioned and in what context.
|
||
"""
|
||
__tablename__ = 'zopk_knowledge_entity_mentions'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
chunk_id = Column(Integer, ForeignKey('zopk_knowledge_chunks.id'), nullable=False, index=True)
|
||
entity_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id'), nullable=False, index=True)
|
||
|
||
# Mention details
|
||
mention_text = Column(String(500)) # Exact text that matched the entity
|
||
mention_type = Column(String(50)) # direct, reference, pronoun
|
||
start_position = Column(Integer) # Character position in chunk
|
||
end_position = Column(Integer)
|
||
|
||
# Context
|
||
sentiment = Column(String(20)) # positive, neutral, negative
|
||
role_in_context = Column(String(100)) # subject, object, beneficiary, partner
|
||
|
||
confidence = Column(Numeric(3, 2)) # Entity linking confidence
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
chunk = relationship('ZOPKKnowledgeChunk', backref='entity_mentions')
|
||
entity = relationship('ZOPKKnowledgeEntity', backref='mentions')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('chunk_id', 'entity_id', 'start_position', name='uq_chunk_entity_position'),
|
||
)
|
||
|
||
|
||
class ZOPKKnowledgeRelation(Base):
|
||
"""
|
||
Relationships between entities discovered in the knowledge base.
|
||
Forms a knowledge graph of ZOPK ecosystem.
|
||
|
||
Examples:
|
||
- Company A → "partner" → Company B
|
||
- Person X → "CEO of" → Company Y
|
||
- Project Z → "funded by" → Organization W
|
||
"""
|
||
__tablename__ = 'zopk_knowledge_relations'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Entities involved
|
||
entity_a_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id'), nullable=False, index=True)
|
||
entity_b_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id'), nullable=False, index=True)
|
||
|
||
# Relation definition
|
||
relation_type = Column(String(100), nullable=False) # partner, investor, supplier, competitor, subsidiary, employs
|
||
relation_subtype = Column(String(100)) # More specific: strategic_partner, minority_investor
|
||
is_bidirectional = Column(Boolean, default=False) # True for "partners", False for "invests in"
|
||
|
||
# Evidence
|
||
source_chunk_id = Column(Integer, ForeignKey('zopk_knowledge_chunks.id'))
|
||
source_fact_id = Column(Integer, ForeignKey('zopk_knowledge_facts.id'))
|
||
evidence_text = Column(Text) # Quote proving the relation
|
||
|
||
# Temporal aspects
|
||
valid_from = Column(Date) # When relation started
|
||
valid_until = Column(Date) # When relation ended (NULL = still valid)
|
||
is_current = Column(Boolean, default=True)
|
||
|
||
# Strength & confidence
|
||
strength = Column(Integer) # 1-5, how strong the relation is
|
||
confidence = Column(Numeric(3, 2)) # AI confidence in the relation
|
||
mention_count = Column(Integer, default=1) # How many times this relation was found
|
||
|
||
# Quality
|
||
is_verified = Column(Boolean, default=False)
|
||
verified_by = Column(Integer, ForeignKey('users.id'))
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
entity_a = relationship('ZOPKKnowledgeEntity', foreign_keys=[entity_a_id], backref='relations_as_subject')
|
||
entity_b = relationship('ZOPKKnowledgeEntity', foreign_keys=[entity_b_id], backref='relations_as_object')
|
||
source_chunk = relationship('ZOPKKnowledgeChunk', backref='discovered_relations')
|
||
source_fact = relationship('ZOPKKnowledgeFact', backref='relation_evidence')
|
||
verifier = relationship('User', foreign_keys=[verified_by])
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('entity_a_id', 'entity_b_id', 'relation_type', name='uq_entity_relation'),
|
||
)
|
||
|
||
|
||
class ZOPKKnowledgeExtractionJob(Base):
|
||
"""
|
||
Tracks knowledge extraction jobs from approved articles.
|
||
One job per article, tracks progress and results.
|
||
"""
|
||
__tablename__ = 'zopk_knowledge_extraction_jobs'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
job_id = Column(String(100), unique=True, nullable=False, index=True)
|
||
|
||
# Source
|
||
news_id = Column(Integer, ForeignKey('zopk_news.id'), nullable=False, index=True)
|
||
|
||
# Configuration
|
||
extraction_model = Column(String(100)) # gemini-3-flash-preview
|
||
chunk_size = Column(Integer, default=800) # Target tokens per chunk
|
||
chunk_overlap = Column(Integer, default=100) # Overlap tokens
|
||
|
||
# Results
|
||
chunks_created = Column(Integer, default=0)
|
||
entities_extracted = Column(Integer, default=0)
|
||
facts_extracted = Column(Integer, default=0)
|
||
relations_discovered = Column(Integer, default=0)
|
||
|
||
# Costs
|
||
tokens_used = Column(Integer, default=0)
|
||
cost_cents = Column(Numeric(10, 4), default=0)
|
||
|
||
# Status
|
||
status = Column(String(20), default='pending') # pending, running, completed, failed
|
||
error_message = Column(Text)
|
||
progress_percent = Column(Integer, default=0)
|
||
|
||
# Timing
|
||
started_at = Column(DateTime)
|
||
completed_at = Column(DateTime)
|
||
|
||
# Trigger
|
||
triggered_by = Column(String(50)) # auto (on approval), manual, batch
|
||
triggered_by_user = Column(Integer, ForeignKey('users.id'))
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
news = relationship('ZOPKNews', backref='extraction_jobs')
|
||
user = relationship('User', foreign_keys=[triggered_by_user])
|
||
|
||
|
||
# ============================================================
|
||
# AI USAGE TRACKING MODELS
|
||
# ============================================================
|
||
|
||
class AIUsageLog(Base):
|
||
"""
|
||
Individual AI API call logs.
|
||
Tracks tokens, costs, and performance for each Gemini API request.
|
||
"""
|
||
__tablename__ = 'ai_usage_logs'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Request info
|
||
request_type = Column(String(50), nullable=False) # chat, news_evaluation, user_creation, image_analysis
|
||
model = Column(String(100), nullable=False) # gemini-3-flash-preview, gemini-3-pro-preview, etc.
|
||
|
||
# Token counts
|
||
tokens_input = Column(Integer, default=0)
|
||
tokens_output = Column(Integer, default=0)
|
||
# Note: tokens_total is a generated column in PostgreSQL
|
||
|
||
# Cost (in USD cents for precision)
|
||
cost_cents = Column(Numeric(10, 4), default=0)
|
||
|
||
# Context
|
||
user_id = Column(Integer, ForeignKey('users.id'))
|
||
company_id = Column(Integer, ForeignKey('companies.id'))
|
||
related_entity_type = Column(String(50)) # zopk_news, chat_message, company, etc.
|
||
related_entity_id = Column(Integer)
|
||
|
||
# Request details
|
||
prompt_length = Column(Integer)
|
||
response_length = Column(Integer)
|
||
response_time_ms = Column(Integer) # How long the API call took
|
||
|
||
# Status
|
||
success = Column(Boolean, default=True)
|
||
error_message = Column(Text)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
user = relationship('User', foreign_keys=[user_id])
|
||
company = relationship('Company', foreign_keys=[company_id])
|
||
|
||
|
||
class AIUsageDaily(Base):
|
||
"""
|
||
Pre-aggregated daily AI usage statistics.
|
||
Auto-updated by PostgreSQL trigger on ai_usage_logs insert.
|
||
"""
|
||
__tablename__ = 'ai_usage_daily'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
date = Column(Date, unique=True, nullable=False)
|
||
|
||
# Request counts by type
|
||
chat_requests = Column(Integer, default=0)
|
||
news_evaluation_requests = Column(Integer, default=0)
|
||
user_creation_requests = Column(Integer, default=0)
|
||
image_analysis_requests = Column(Integer, default=0)
|
||
other_requests = Column(Integer, default=0)
|
||
total_requests = Column(Integer, default=0)
|
||
|
||
# Token totals
|
||
total_tokens_input = Column(Integer, default=0)
|
||
total_tokens_output = Column(Integer, default=0)
|
||
total_tokens = Column(Integer, default=0)
|
||
|
||
# Cost (in USD cents)
|
||
total_cost_cents = Column(Numeric(10, 4), default=0)
|
||
|
||
# Performance
|
||
avg_response_time_ms = Column(Integer)
|
||
error_count = Column(Integer, default=0)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
|
||
class AIRateLimit(Base):
|
||
"""
|
||
Rate limit tracking for AI API quota management.
|
||
"""
|
||
__tablename__ = 'ai_rate_limits'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Limit type
|
||
limit_type = Column(String(50), nullable=False) # daily, hourly, per_minute
|
||
limit_scope = Column(String(50), nullable=False) # global, user, ip
|
||
scope_identifier = Column(String(255)) # user_id, ip address, or NULL for global
|
||
|
||
# Limits
|
||
max_requests = Column(Integer, nullable=False)
|
||
current_requests = Column(Integer, default=0)
|
||
|
||
# Reset
|
||
reset_at = Column(DateTime, nullable=False)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('limit_type', 'limit_scope', 'scope_identifier', name='uq_rate_limit'),
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# KRS DATA - OSOBY POWIĄZANE Z FIRMAMI
|
||
# ============================================================
|
||
|
||
class Person(Base):
|
||
"""
|
||
Osoby powiązane z firmami (zarząd, wspólnicy, prokurenci).
|
||
Dane pobierane z odpisów KRS.
|
||
"""
|
||
__tablename__ = 'people'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
pesel = Column(String(11), unique=True, nullable=True) # NULL dla osób prawnych
|
||
imiona = Column(String(255), nullable=False)
|
||
nazwisko = Column(String(255), nullable=False)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
company_roles = relationship('CompanyPerson', back_populates='person')
|
||
|
||
def full_name(self):
|
||
return f"{self.imiona} {self.nazwisko}"
|
||
|
||
def __repr__(self):
|
||
return f"<Person {self.full_name()}>"
|
||
|
||
|
||
class CompanyPerson(Base):
|
||
"""
|
||
Relacja osoba-firma (zarząd, wspólnicy, prokurenci).
|
||
Umożliwia śledzenie powiązań między osobami a firmami.
|
||
"""
|
||
__tablename__ = 'company_people'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
|
||
person_id = Column(Integer, ForeignKey('people.id', ondelete='CASCADE'), nullable=False)
|
||
|
||
# Rola w firmie
|
||
role = Column(String(50), nullable=False) # PREZES ZARZĄDU, CZŁONEK ZARZĄDU, WSPÓLNIK
|
||
role_category = Column(String(20), nullable=False) # zarzad, wspolnik, prokurent
|
||
|
||
# Dane dodatkowe (dla wspólników)
|
||
shares_count = Column(Integer)
|
||
shares_value = Column(Numeric(12, 2))
|
||
shares_percent = Column(Numeric(5, 2))
|
||
|
||
# Źródło danych
|
||
source = Column(String(100), default='ekrs.ms.gov.pl')
|
||
source_document = Column(String(255)) # np. "odpis_pelny_0000725183.pdf"
|
||
fetched_at = Column(DateTime)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
company = relationship('Company', backref='people_roles')
|
||
person = relationship('Person', back_populates='company_roles')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'person_id', 'role_category', 'role', name='uq_company_person_role'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<CompanyPerson {self.person.full_name()} - {self.role} @ {self.company.name}>"
|
||
|
||
|
||
# ============================================================
|
||
# KRS AUDIT
|
||
# ============================================================
|
||
|
||
class KRSAudit(Base):
|
||
"""
|
||
KRS audit history - tracks PDF downloads and data extraction.
|
||
Each audit represents one extraction run from EKRS.
|
||
"""
|
||
__tablename__ = 'krs_audits'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
|
||
# Audit timing
|
||
audit_date = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
||
|
||
# PDF source info
|
||
pdf_filename = Column(String(255)) # np. "odpis_pelny_0000882964.pdf"
|
||
pdf_path = Column(Text) # full path to stored PDF
|
||
pdf_downloaded_at = Column(DateTime)
|
||
|
||
# Extraction status
|
||
status = Column(String(20), default='pending', index=True) # pending, downloading, parsing, completed, error
|
||
progress_percent = Column(Integer, default=0)
|
||
progress_message = Column(Text)
|
||
error_message = Column(Text)
|
||
|
||
# Extracted data summary
|
||
extracted_krs = Column(String(10))
|
||
extracted_nazwa = Column(Text)
|
||
extracted_nip = Column(String(10))
|
||
extracted_regon = Column(String(14))
|
||
extracted_forma_prawna = Column(String(255))
|
||
extracted_data_rejestracji = Column(Date)
|
||
extracted_kapital_zakladowy = Column(Numeric(15, 2))
|
||
extracted_waluta = Column(String(3), default='PLN')
|
||
extracted_liczba_udzialow = Column(Integer)
|
||
extracted_sposob_reprezentacji = Column(Text)
|
||
|
||
# Counts for quick stats
|
||
zarzad_count = Column(Integer, default=0)
|
||
wspolnicy_count = Column(Integer, default=0)
|
||
prokurenci_count = Column(Integer, default=0)
|
||
pkd_count = Column(Integer, default=0)
|
||
|
||
# Full parsed data as JSON
|
||
parsed_data = Column(JSONB)
|
||
|
||
# Audit metadata
|
||
audit_version = Column(String(20), default='1.0')
|
||
audit_source = Column(String(50), default='ekrs.ms.gov.pl')
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationship
|
||
company = relationship('Company', backref='krs_audits')
|
||
|
||
def __repr__(self):
|
||
return f'<KRSAudit company_id={self.company_id} status={self.status}>'
|
||
|
||
@property
|
||
def status_label(self):
|
||
"""Human-readable status label in Polish"""
|
||
labels = {
|
||
'pending': 'Oczekuje',
|
||
'downloading': 'Pobieranie PDF',
|
||
'parsing': 'Przetwarzanie',
|
||
'completed': 'Ukończony',
|
||
'error': 'Błąd'
|
||
}
|
||
return labels.get(self.status, self.status)
|
||
|
||
|
||
class CompanyPKD(Base):
|
||
"""
|
||
PKD codes for companies (Polska Klasyfikacja Działalności).
|
||
Multiple PKD codes per company allowed - one is marked as primary.
|
||
"""
|
||
__tablename__ = 'company_pkd'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
pkd_code = Column(String(10), nullable=False, index=True) # np. "62.03.Z"
|
||
pkd_description = Column(Text)
|
||
is_primary = Column(Boolean, default=False) # przeważający PKD
|
||
source = Column(String(50), default='ekrs') # ekrs, ceidg
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationship
|
||
company = relationship('Company', backref='pkd_codes')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'pkd_code', name='uq_company_pkd'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
primary = ' (główny)' if self.is_primary else ''
|
||
return f'<CompanyPKD {self.pkd_code}{primary}>'
|
||
|
||
|
||
class CompanyFinancialReport(Base):
|
||
"""
|
||
Financial reports (sprawozdania finansowe) filed with KRS.
|
||
Tracks report periods, filing dates, and key financial figures.
|
||
"""
|
||
__tablename__ = 'company_financial_reports'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
period_start = Column(Date)
|
||
period_end = Column(Date)
|
||
filed_at = Column(Date)
|
||
report_type = Column(String(50), default='annual') # annual, quarterly
|
||
source = Column(String(50), default='ekrs')
|
||
|
||
# Financial data (in PLN)
|
||
revenue = Column(Numeric(15, 2)) # Przychody netto ze sprzedaży
|
||
operating_profit = Column(Numeric(15, 2)) # Zysk/strata z działalności operacyjnej
|
||
net_profit = Column(Numeric(15, 2)) # Zysk/strata netto
|
||
total_assets = Column(Numeric(15, 2)) # Suma aktywów
|
||
equity = Column(Numeric(15, 2)) # Kapitał własny
|
||
liabilities = Column(Numeric(15, 2)) # Zobowiązania
|
||
employees_count = Column(Integer) # Średnia liczba zatrudnionych
|
||
|
||
# Company size classification: micro, small, medium, large
|
||
size_class = Column(String(20))
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationship
|
||
company = relationship('Company', backref='financial_reports')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'period_start', 'period_end', 'report_type', name='uq_company_financial_report'),
|
||
)
|
||
|
||
def classify_size(self):
|
||
"""Classify company size based on EU criteria (revenue + employees + assets).
|
||
micro: <2M EUR revenue, <10 employees, <2M EUR assets
|
||
small: <10M EUR revenue, <50 employees, <10M EUR assets
|
||
medium: <50M EUR revenue, <250 employees, <43M EUR assets
|
||
large: above medium
|
||
EUR/PLN ~4.3
|
||
"""
|
||
rev = float(self.revenue or 0)
|
||
emp = self.employees_count or 0
|
||
assets = float(self.total_assets or 0)
|
||
|
||
# PLN thresholds (approx EUR * 4.3)
|
||
if rev < 8_600_000 and emp < 10 and assets < 8_600_000:
|
||
return 'micro'
|
||
elif rev < 43_000_000 and emp < 50 and assets < 43_000_000:
|
||
return 'small'
|
||
elif rev < 215_000_000 and emp < 250 and assets < 185_000_000:
|
||
return 'medium'
|
||
else:
|
||
return 'large'
|
||
|
||
def __repr__(self):
|
||
return f'<CompanyFinancialReport {self.period_start} - {self.period_end}>'
|
||
|
||
|
||
# ============================================================
|
||
# USER BLOCKS - BLOKOWANIE UŻYTKOWNIKÓW
|
||
# ============================================================
|
||
|
||
class UserBlock(Base):
|
||
"""
|
||
Blokowanie użytkowników - zablokowany użytkownik nie może wysyłać wiadomości.
|
||
"""
|
||
__tablename__ = 'user_blocks'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
blocked_user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
reason = Column(Text, nullable=True) # optional reason
|
||
|
||
# Relationships
|
||
user = relationship('User', foreign_keys=[user_id], backref='blocks_created')
|
||
blocked_user = relationship('User', foreign_keys=[blocked_user_id], backref='blocked_by')
|
||
|
||
def __repr__(self):
|
||
return f'<UserBlock {self.user_id} -> {self.blocked_user_id}>'
|
||
|
||
|
||
# ============================================================
|
||
# USER ANALYTICS - SESJE I AKTYWNOŚĆ
|
||
# ============================================================
|
||
|
||
class UserSession(Base):
|
||
"""
|
||
Sesje użytkowników portalu.
|
||
Śledzi czas trwania sesji, urządzenie, lokalizację i aktywność.
|
||
"""
|
||
__tablename__ = 'user_sessions'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||
session_id = Column(String(100), unique=True, nullable=False, index=True)
|
||
|
||
# Czas sesji
|
||
started_at = Column(DateTime, nullable=False, default=datetime.now)
|
||
ended_at = Column(DateTime, nullable=True)
|
||
last_activity_at = Column(DateTime, nullable=False, default=datetime.now)
|
||
duration_seconds = Column(Integer, nullable=True)
|
||
|
||
# Urządzenie
|
||
ip_address = Column(String(45), nullable=True)
|
||
user_agent = Column(Text, nullable=True)
|
||
device_type = Column(String(20), nullable=True) # desktop, mobile, tablet
|
||
browser = Column(String(50), nullable=True)
|
||
browser_version = Column(String(20), nullable=True)
|
||
os = Column(String(50), nullable=True)
|
||
os_version = Column(String(20), nullable=True)
|
||
|
||
# Lokalizacja (z IP)
|
||
country = Column(String(100), nullable=True)
|
||
city = Column(String(100), nullable=True)
|
||
region = Column(String(100), nullable=True)
|
||
|
||
# Metryki sesji
|
||
page_views_count = Column(Integer, default=0)
|
||
clicks_count = Column(Integer, default=0)
|
||
|
||
# Bot detection
|
||
is_bot = Column(Boolean, default=False)
|
||
|
||
# PWA detection
|
||
is_pwa = Column(Boolean, default=False)
|
||
|
||
# UTM Parameters (kampanie marketingowe)
|
||
utm_source = Column(String(255), nullable=True) # google, facebook, newsletter
|
||
utm_medium = Column(String(255), nullable=True) # cpc, email, social, organic
|
||
utm_campaign = Column(String(255), nullable=True) # nazwa kampanii
|
||
utm_term = Column(String(255), nullable=True) # słowo kluczowe (PPC)
|
||
utm_content = Column(String(255), nullable=True) # wariant reklamy
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
user = relationship('User', backref='analytics_sessions')
|
||
page_views = relationship('PageView', back_populates='session', cascade='all, delete-orphan')
|
||
|
||
def __repr__(self):
|
||
return f"<UserSession {self.session_id[:8]}... user={self.user_id}>"
|
||
|
||
|
||
class PageView(Base):
|
||
"""
|
||
Wyświetlenia stron przez użytkowników.
|
||
Śledzi odwiedzone strony i czas spędzony na każdej z nich.
|
||
"""
|
||
__tablename__ = 'page_views'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='CASCADE'), nullable=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
|
||
# Strona
|
||
url = Column(String(2000), nullable=False)
|
||
path = Column(String(500), nullable=False, index=True)
|
||
page_title = Column(String(500), nullable=True)
|
||
referrer = Column(String(2000), nullable=True)
|
||
|
||
# Czas
|
||
viewed_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
|
||
time_on_page_seconds = Column(Integer, nullable=True)
|
||
|
||
# Scroll depth (%)
|
||
scroll_depth_percent = Column(Integer, nullable=True) # 0-100
|
||
|
||
# Performance metrics (Web Vitals)
|
||
dom_content_loaded_ms = Column(Integer, nullable=True)
|
||
load_time_ms = Column(Integer, nullable=True)
|
||
first_paint_ms = Column(Integer, nullable=True)
|
||
first_contentful_paint_ms = Column(Integer, nullable=True)
|
||
|
||
# Kontekst
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
session = relationship('UserSession', back_populates='page_views')
|
||
clicks = relationship('UserClick', back_populates='page_view', cascade='all, delete-orphan')
|
||
|
||
def __repr__(self):
|
||
return f"<PageView {self.path}>"
|
||
|
||
|
||
class UserClick(Base):
|
||
"""
|
||
Kliknięcia elementów na stronach.
|
||
Śledzi interakcje użytkowników z elementami UI.
|
||
"""
|
||
__tablename__ = 'user_clicks'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='CASCADE'), nullable=True)
|
||
page_view_id = Column(Integer, ForeignKey('page_views.id', ondelete='CASCADE'), nullable=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
|
||
# Element kliknięty
|
||
element_type = Column(String(50), nullable=True) # button, link, card, nav, form
|
||
element_id = Column(String(100), nullable=True)
|
||
element_text = Column(String(255), nullable=True)
|
||
element_class = Column(String(500), nullable=True)
|
||
target_url = Column(String(2000), nullable=True)
|
||
|
||
# Pozycja kliknięcia
|
||
x_position = Column(Integer, nullable=True)
|
||
y_position = Column(Integer, nullable=True)
|
||
|
||
clicked_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
|
||
|
||
# Relationships
|
||
page_view = relationship('PageView', back_populates='clicks')
|
||
|
||
def __repr__(self):
|
||
return f"<UserClick {self.element_type} at {self.clicked_at}>"
|
||
|
||
|
||
class AnalyticsDaily(Base):
|
||
"""
|
||
Dzienne statystyki agregowane.
|
||
Automatycznie aktualizowane przez trigger PostgreSQL.
|
||
"""
|
||
__tablename__ = 'analytics_daily'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
date = Column(Date, unique=True, nullable=False, index=True)
|
||
|
||
# Sesje
|
||
total_sessions = Column(Integer, default=0)
|
||
unique_users = Column(Integer, default=0)
|
||
new_users = Column(Integer, default=0)
|
||
returning_users = Column(Integer, default=0)
|
||
anonymous_sessions = Column(Integer, default=0)
|
||
|
||
# Aktywność
|
||
total_page_views = Column(Integer, default=0)
|
||
total_clicks = Column(Integer, default=0)
|
||
avg_session_duration_seconds = Column(Integer, nullable=True)
|
||
avg_pages_per_session = Column(Numeric(5, 2), nullable=True)
|
||
|
||
# Urządzenia
|
||
desktop_sessions = Column(Integer, default=0)
|
||
mobile_sessions = Column(Integer, default=0)
|
||
tablet_sessions = Column(Integer, default=0)
|
||
|
||
# Engagement
|
||
bounce_rate = Column(Numeric(5, 2), nullable=True)
|
||
|
||
# Nowe metryki (Analytics Expansion 2026-01-30)
|
||
conversions_count = Column(Integer, default=0)
|
||
searches_count = Column(Integer, default=0)
|
||
searches_no_results = Column(Integer, default=0)
|
||
avg_scroll_depth = Column(Numeric(5, 2), nullable=True)
|
||
js_errors_count = Column(Integer, default=0)
|
||
|
||
# Rozkłady (JSONB)
|
||
utm_breakdown = Column(JSONBType, nullable=True) # {"google": 10, "facebook": 5}
|
||
conversions_breakdown = Column(JSONBType, nullable=True) # {"register": 2, "contact_click": 15}
|
||
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
def __repr__(self):
|
||
return f"<AnalyticsDaily {self.date}>"
|
||
|
||
|
||
class PopularPagesDaily(Base):
|
||
"""
|
||
Popularne strony - dzienne agregaty.
|
||
"""
|
||
__tablename__ = 'popular_pages_daily'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
date = Column(Date, nullable=False, index=True)
|
||
path = Column(String(500), nullable=False)
|
||
page_title = Column(String(500), nullable=True)
|
||
|
||
views_count = Column(Integer, default=0)
|
||
unique_visitors = Column(Integer, default=0)
|
||
avg_time_seconds = Column(Integer, nullable=True)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('date', 'path', name='uq_popular_pages_date_path'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<PopularPagesDaily {self.date} {self.path}>"
|
||
|
||
|
||
class SearchQuery(Base):
|
||
"""
|
||
Historia wyszukiwań użytkowników w portalu.
|
||
Śledzi zapytania, wyniki i interakcje.
|
||
Created: 2026-01-30
|
||
"""
|
||
__tablename__ = 'search_queries'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='SET NULL'), nullable=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
|
||
# Zapytanie
|
||
query = Column(String(500), nullable=False)
|
||
query_normalized = Column(String(500), nullable=True) # lowercase, bez znaków specjalnych
|
||
|
||
# Wyniki
|
||
results_count = Column(Integer, default=0)
|
||
has_results = Column(Boolean, default=True)
|
||
|
||
# Interakcja z wynikami
|
||
clicked_result_position = Column(Integer, nullable=True) # 1-based
|
||
clicked_company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
|
||
|
||
# Kontekst
|
||
search_type = Column(String(50), default='main') # main, chat, autocomplete
|
||
filters_used = Column(JSONBType, nullable=True) # {"category": "IT", "city": "Wejherowo"}
|
||
|
||
# Timing
|
||
searched_at = Column(DateTime, nullable=False, default=datetime.now)
|
||
time_to_click_ms = Column(Integer, nullable=True)
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
def __repr__(self):
|
||
return f"<SearchQuery '{self.query[:30]}...' results={self.results_count}>"
|
||
|
||
|
||
class ConversionEvent(Base):
|
||
"""
|
||
Kluczowe konwersje: rejestracje, kontakty z firmami, RSVP na wydarzenia.
|
||
Created: 2026-01-30
|
||
"""
|
||
__tablename__ = 'conversion_events'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='SET NULL'), nullable=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
|
||
# Typ konwersji
|
||
event_type = Column(String(50), nullable=False) # register, login, contact_click, rsvp, message, classified
|
||
event_category = Column(String(50), nullable=True) # engagement, acquisition, activation
|
||
|
||
# Kontekst
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
|
||
target_type = Column(String(50), nullable=True) # email, phone, website, rsvp_event
|
||
target_value = Column(String(500), nullable=True)
|
||
|
||
# Źródło konwersji
|
||
source_page = Column(String(500), nullable=True)
|
||
referrer = Column(String(500), nullable=True)
|
||
|
||
# Dodatkowe dane
|
||
event_metadata = Column(JSONBType, nullable=True)
|
||
|
||
# Timing
|
||
converted_at = Column(DateTime, nullable=False, default=datetime.now)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
company = relationship('Company', backref='conversion_events')
|
||
|
||
def __repr__(self):
|
||
return f"<ConversionEvent {self.event_type} at {self.converted_at}>"
|
||
|
||
|
||
class JSError(Base):
|
||
"""
|
||
Błędy JavaScript zgłaszane z przeglądarek użytkowników.
|
||
Created: 2026-01-30
|
||
"""
|
||
__tablename__ = 'js_errors'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='SET NULL'), nullable=True)
|
||
|
||
# Błąd
|
||
message = Column(Text, nullable=False)
|
||
source = Column(String(500), nullable=True) # URL pliku JS
|
||
lineno = Column(Integer, nullable=True)
|
||
colno = Column(Integer, nullable=True)
|
||
stack = Column(Text, nullable=True)
|
||
|
||
# Kontekst
|
||
url = Column(String(2000), nullable=True)
|
||
user_agent = Column(String(500), nullable=True)
|
||
|
||
# Agregacja
|
||
error_hash = Column(String(64), nullable=True) # SHA256 dla grupowania
|
||
|
||
occurred_at = Column(DateTime, nullable=False, default=datetime.now)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
def __repr__(self):
|
||
return f"<JSError '{self.message[:50]}...'>"
|
||
|
||
|
||
class PopularSearchesDaily(Base):
|
||
"""
|
||
Popularne wyszukiwania - dzienne agregaty.
|
||
Created: 2026-01-30
|
||
"""
|
||
__tablename__ = 'popular_searches_daily'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
date = Column(Date, nullable=False, index=True)
|
||
query_normalized = Column(String(500), nullable=False)
|
||
|
||
search_count = Column(Integer, default=0)
|
||
unique_users = Column(Integer, default=0)
|
||
click_count = Column(Integer, default=0)
|
||
avg_results_count = Column(Numeric(10, 2), nullable=True)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('date', 'query_normalized', name='uq_popular_searches_date_query'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<PopularSearchesDaily {self.date} '{self.query_normalized}'>"
|
||
|
||
|
||
class HourlyActivity(Base):
|
||
"""
|
||
Aktywność wg godziny - dla analizy wzorców czasowych.
|
||
Created: 2026-01-30
|
||
"""
|
||
__tablename__ = 'hourly_activity'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
date = Column(Date, nullable=False, index=True)
|
||
hour = Column(Integer, nullable=False) # 0-23
|
||
|
||
sessions_count = Column(Integer, default=0)
|
||
page_views_count = Column(Integer, default=0)
|
||
unique_users = Column(Integer, default=0)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('date', 'hour', name='uq_hourly_activity_date_hour'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<HourlyActivity {self.date} {self.hour}:00>"
|
||
|
||
|
||
# ============================================================
|
||
# EMAIL LOGGING
|
||
# ============================================================
|
||
|
||
class EmailLog(Base):
|
||
"""
|
||
Log wszystkich wysłanych emaili systemowych.
|
||
|
||
Śledzi:
|
||
- Emaile rejestracyjne (weryfikacja)
|
||
- Emaile resetowania hasła
|
||
- Powiadomienia systemowe
|
||
- Status dostarczenia
|
||
|
||
Created: 2026-01-14
|
||
"""
|
||
__tablename__ = 'email_logs'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Dane emaila
|
||
email_type = Column(String(50), nullable=False, index=True) # welcome, password_reset, notification
|
||
recipient_email = Column(String(255), nullable=False, index=True)
|
||
recipient_name = Column(String(255), nullable=True)
|
||
subject = Column(String(500), nullable=False)
|
||
|
||
# Powiązanie z użytkownikiem (opcjonalne)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
|
||
# Status
|
||
status = Column(String(20), default='pending', index=True) # pending, sent, failed
|
||
error_message = Column(Text, nullable=True)
|
||
|
||
# Metadane
|
||
sender_email = Column(String(255), nullable=True)
|
||
ip_address = Column(String(45), nullable=True) # IP requestu (jeśli dostępne)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.utcnow)
|
||
sent_at = Column(DateTime, nullable=True)
|
||
|
||
# Relacje
|
||
user = relationship('User', backref='email_logs')
|
||
|
||
def __repr__(self):
|
||
return f"<EmailLog {self.id} {self.email_type} -> {self.recipient_email} ({self.status})>"
|
||
|
||
|
||
# ============================================================
|
||
# SECURITY & AUDIT
|
||
# ============================================================
|
||
|
||
class AuditLog(Base):
|
||
"""
|
||
Audit log dla śledzenia działań administracyjnych.
|
||
|
||
Śledzi wszystkie wrażliwe operacje wykonywane przez adminów:
|
||
- Moderacja newsów (approve/reject)
|
||
- Zmiany składek członkowskich
|
||
- Edycja profili firm
|
||
- Zmiany uprawnień użytkowników
|
||
- Operacje na wydarzeniach
|
||
|
||
Created: 2026-01-14
|
||
"""
|
||
__tablename__ = 'audit_logs'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Kto wykonał akcję
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
user_email = Column(String(255), nullable=False) # Zachowane nawet po usunięciu usera
|
||
|
||
# Co zostało wykonane
|
||
action = Column(String(100), nullable=False, index=True) # np. 'news.approve', 'company.edit', 'user.ban'
|
||
entity_type = Column(String(50), nullable=False, index=True) # np. 'news', 'company', 'user', 'event'
|
||
entity_id = Column(Integer, nullable=True) # ID encji której dotyczy akcja
|
||
entity_name = Column(String(255), nullable=True) # Nazwa encji (dla czytelności)
|
||
|
||
# Szczegóły
|
||
details = Column(JSONBType, nullable=True) # Dodatkowe dane: old_value, new_value, reason
|
||
|
||
# Kontekst requestu
|
||
ip_address = Column(String(45), nullable=True)
|
||
user_agent = Column(String(500), nullable=True)
|
||
request_path = Column(String(500), nullable=True)
|
||
|
||
# Timestamp
|
||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||
|
||
# Relacje
|
||
user = relationship('User', backref='audit_logs')
|
||
|
||
def __repr__(self):
|
||
return f"<AuditLog {self.id} {self.user_email} {self.action} on {self.entity_type}:{self.entity_id}>"
|
||
|
||
|
||
class SecurityAlert(Base):
|
||
"""
|
||
Alerty bezpieczeństwa wysyłane emailem.
|
||
|
||
Śledzi:
|
||
- Zbyt wiele nieudanych logowań
|
||
- Próby dostępu do honeypotów
|
||
- Podejrzane wzorce aktywności
|
||
- Blokady kont
|
||
|
||
Created: 2026-01-14
|
||
"""
|
||
__tablename__ = 'security_alerts'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Typ alertu
|
||
alert_type = Column(String(50), nullable=False, index=True)
|
||
# Typy: 'brute_force', 'honeypot_hit', 'account_locked', 'suspicious_activity', 'geo_blocked'
|
||
|
||
severity = Column(String(20), nullable=False, default='medium') # low, medium, high, critical
|
||
|
||
# Kontekst
|
||
ip_address = Column(String(45), nullable=True, index=True)
|
||
user_email = Column(String(255), nullable=True)
|
||
details = Column(JSONBType, nullable=True) # Dodatkowe dane
|
||
|
||
# Status alertu
|
||
status = Column(String(20), default='new', index=True) # new, acknowledged, resolved
|
||
acknowledged_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
acknowledged_at = Column(DateTime, nullable=True)
|
||
resolution_note = Column(Text, nullable=True)
|
||
|
||
# Email notification
|
||
email_sent = Column(Boolean, default=False)
|
||
email_sent_at = Column(DateTime, nullable=True)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||
|
||
# Relacje
|
||
acknowledger = relationship('User', foreign_keys=[acknowledged_by])
|
||
|
||
def __repr__(self):
|
||
return f"<SecurityAlert {self.id} {self.alert_type} ({self.severity}) from {self.ip_address}>"
|
||
|
||
|
||
# ============================================================
|
||
# ANNOUNCEMENTS (Ogłoszenia dla członków)
|
||
# ============================================================
|
||
|
||
class Announcement(Base):
|
||
"""
|
||
Ogłoszenia i aktualności dla członków Norda Biznes.
|
||
Obsługuje różne kategorie: ogólne, wydarzenia, okazje biznesowe, od członków.
|
||
"""
|
||
__tablename__ = 'announcements'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
title = Column(String(300), nullable=False)
|
||
slug = Column(String(300), unique=True, index=True)
|
||
excerpt = Column(String(500)) # Krótki opis do listy
|
||
content = Column(Text, nullable=False) # Pełna treść (HTML)
|
||
|
||
# Kategoryzacja (obsługa wielu kategorii)
|
||
categories = Column(ARRAY(String), default=[]) # Tablica kategorii
|
||
# Wartości: internal, external, event, opportunity, partnership
|
||
# Stare pole dla kompatybilności wstecznej (do usunięcia po migracji)
|
||
category = Column(String(50), default='internal')
|
||
|
||
# Media
|
||
image_url = Column(String(1000))
|
||
external_link = Column(String(1000)) # Link do zewnętrznego źródła
|
||
|
||
# Publikacja
|
||
status = Column(String(20), default='draft', index=True)
|
||
# Wartości: draft, published, archived
|
||
published_at = Column(DateTime)
|
||
expires_at = Column(DateTime) # Opcjonalne wygaśnięcie
|
||
|
||
# Wyróżnienie
|
||
is_featured = Column(Boolean, default=False)
|
||
is_pinned = Column(Boolean, default=False)
|
||
|
||
# Statystyki
|
||
views_count = Column(Integer, default=0)
|
||
|
||
# Audyt
|
||
created_by = Column(Integer, ForeignKey('users.id'))
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
author = relationship('User', foreign_keys=[created_by])
|
||
readers = relationship('AnnouncementRead', back_populates='announcement', cascade='all, delete-orphan')
|
||
|
||
# Constants
|
||
CATEGORIES = ['internal', 'external', 'event', 'opportunity', 'partnership', 'pej']
|
||
CATEGORY_LABELS = {
|
||
'internal': 'Wewnętrzne',
|
||
'external': 'Zewnętrzne',
|
||
'event': 'Wydarzenie',
|
||
'opportunity': 'Okazja biznesowa',
|
||
'partnership': 'Partnerstwo',
|
||
'pej': 'PEJ / Energetyka jądrowa'
|
||
}
|
||
STATUSES = ['draft', 'published', 'archived']
|
||
STATUS_LABELS = {
|
||
'draft': 'Szkic',
|
||
'published': 'Opublikowane',
|
||
'archived': 'Zarchiwizowane'
|
||
}
|
||
|
||
@property
|
||
def category_label(self):
|
||
"""Zwraca polską etykietę pierwszej kategorii (kompatybilność wsteczna)"""
|
||
if self.categories:
|
||
return self.CATEGORY_LABELS.get(self.categories[0], self.categories[0])
|
||
return self.CATEGORY_LABELS.get(self.category, self.category)
|
||
|
||
@property
|
||
def categories_labels(self):
|
||
"""Zwraca listę polskich etykiet wszystkich kategorii"""
|
||
if self.categories:
|
||
return [self.CATEGORY_LABELS.get(cat, cat) for cat in self.categories]
|
||
return [self.CATEGORY_LABELS.get(self.category, self.category)]
|
||
|
||
def has_category(self, category):
|
||
"""Sprawdza czy ogłoszenie ma daną kategorię"""
|
||
if self.categories:
|
||
return category in self.categories
|
||
return self.category == category
|
||
|
||
@property
|
||
def status_label(self):
|
||
"""Zwraca polską etykietę statusu"""
|
||
return self.STATUS_LABELS.get(self.status, self.status)
|
||
|
||
@property
|
||
def is_active(self):
|
||
"""Sprawdza czy ogłoszenie jest aktywne (opublikowane i nie wygasło)"""
|
||
if self.status != 'published':
|
||
return False
|
||
if self.expires_at and self.expires_at < datetime.now():
|
||
return False
|
||
return True
|
||
|
||
def __repr__(self):
|
||
return f"<Announcement {self.id} '{self.title[:50]}' ({self.status})>"
|
||
|
||
|
||
class AnnouncementRead(Base):
|
||
"""
|
||
Śledzenie odczytów ogłoszeń (seen by).
|
||
Zapisuje kto i kiedy przeczytał dane ogłoszenie.
|
||
"""
|
||
__tablename__ = 'announcement_reads'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
announcement_id = Column(Integer, ForeignKey('announcements.id', ondelete='CASCADE'), nullable=False)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
read_at = Column(DateTime, default=datetime.now)
|
||
|
||
# Relationships
|
||
announcement = relationship('Announcement', back_populates='readers')
|
||
user = relationship('User')
|
||
|
||
# Unique constraint
|
||
__table_args__ = (
|
||
UniqueConstraint('announcement_id', 'user_id', name='uq_announcement_user_read'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<AnnouncementRead announcement={self.announcement_id} user={self.user_id}>"
|
||
|
||
|
||
# ============================================================
|
||
# EXTERNAL CONTACTS (Kontakty zewnętrzne)
|
||
# ============================================================
|
||
|
||
class ExternalContact(Base):
|
||
"""
|
||
Baza kontaktów zewnętrznych - urzędy, instytucje, partnerzy projektów.
|
||
Dostępna dla wszystkich zalogowanych członków Norda Biznes.
|
||
"""
|
||
__tablename__ = 'external_contacts'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Dane osobowe
|
||
first_name = Column(String(100), nullable=False)
|
||
last_name = Column(String(100), nullable=False)
|
||
position = Column(String(200)) # Stanowisko (opcjonalne)
|
||
photo_url = Column(String(500)) # Zdjęcie osoby (opcjonalne)
|
||
|
||
# Dane kontaktowe
|
||
phone = Column(String(50))
|
||
phone_secondary = Column(String(50)) # Drugi numer telefonu
|
||
email = Column(String(255))
|
||
website = Column(String(500)) # Strona osobista/wizytówka
|
||
|
||
# Social Media
|
||
linkedin_url = Column(String(500))
|
||
facebook_url = Column(String(500))
|
||
twitter_url = Column(String(500))
|
||
|
||
# Organizacja
|
||
organization_name = Column(String(300), nullable=False)
|
||
organization_type = Column(String(50), default='other')
|
||
# Typy: government (urząd), agency (agencja), company (firma), ngo (organizacja), university (uczelnia), other
|
||
|
||
organization_address = Column(String(500))
|
||
organization_website = Column(String(500))
|
||
organization_logo_url = Column(String(500))
|
||
|
||
# Kontekst/Projekt
|
||
project_name = Column(String(300)) # Nazwa projektu (Tytani, EJ Choczewo, itp.)
|
||
project_description = Column(Text) # Krótki opis kontekstu
|
||
|
||
# Źródło kontaktu
|
||
source_type = Column(String(50)) # announcement, forum_post, manual
|
||
source_id = Column(Integer) # ID ogłoszenia lub wpisu (opcjonalne)
|
||
source_url = Column(String(500)) # URL do źródła
|
||
|
||
# Powiązane linki (artykuły, strony, dokumenty) - JSON array
|
||
# Format: [{"title": "Artykuł o...", "url": "https://...", "type": "article"}, ...]
|
||
related_links = Column(PG_JSONB, default=list)
|
||
|
||
# Tagi do wyszukiwania
|
||
tags = Column(String(500)) # Tagi oddzielone przecinkami
|
||
|
||
# Notatki
|
||
notes = Column(Text)
|
||
|
||
# Audyt
|
||
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Status
|
||
is_active = Column(Boolean, default=True)
|
||
is_verified = Column(Boolean, default=False) # Zweryfikowany przez admina/moderatora
|
||
|
||
# Relationships
|
||
creator = relationship('User', foreign_keys=[created_by])
|
||
|
||
# Constants
|
||
ORGANIZATION_TYPES = ['government', 'agency', 'company', 'ngo', 'university', 'other']
|
||
ORGANIZATION_TYPE_LABELS = {
|
||
'government': 'Urząd',
|
||
'agency': 'Agencja',
|
||
'company': 'Firma',
|
||
'ngo': 'Organizacja pozarządowa',
|
||
'university': 'Uczelnia',
|
||
'other': 'Inne'
|
||
}
|
||
SOURCE_TYPES = ['announcement', 'forum_post', 'manual']
|
||
|
||
@property
|
||
def full_name(self):
|
||
return f"{self.first_name} {self.last_name}"
|
||
|
||
@property
|
||
def organization_type_label(self):
|
||
return self.ORGANIZATION_TYPE_LABELS.get(self.organization_type, self.organization_type)
|
||
|
||
@property
|
||
def tags_list(self):
|
||
"""Zwraca tagi jako listę."""
|
||
if not self.tags:
|
||
return []
|
||
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
|
||
|
||
@property
|
||
def has_social_media(self):
|
||
"""Sprawdza czy kontakt ma jakiekolwiek social media."""
|
||
return bool(self.linkedin_url or self.facebook_url or self.twitter_url)
|
||
|
||
@property
|
||
def has_contact_info(self):
|
||
"""Sprawdza czy kontakt ma dane kontaktowe."""
|
||
return bool(self.phone or self.email or self.website)
|
||
|
||
def __repr__(self):
|
||
return f"<ExternalContact {self.full_name} @ {self.organization_name}>"
|
||
|
||
|
||
# ============================================================
|
||
# ZOPK MILESTONES (Timeline)
|
||
# ============================================================
|
||
|
||
class ZOPKMilestone(Base):
|
||
"""
|
||
Kamienie milowe projektu ZOPK dla wizualizacji timeline.
|
||
"""
|
||
__tablename__ = 'zopk_milestones'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
title = Column(String(500), nullable=False)
|
||
description = Column(Text)
|
||
|
||
# Kategoria: nuclear, offshore, infrastructure, defense, other
|
||
category = Column(String(50), default='other')
|
||
|
||
# Daty
|
||
target_date = Column(Date) # Planowana data
|
||
actual_date = Column(Date) # Rzeczywista data (jeśli zakończone)
|
||
|
||
# Status: planned, in_progress, completed, delayed, cancelled
|
||
status = Column(String(20), default='planned')
|
||
|
||
# Źródło informacji
|
||
source_url = Column(String(1000))
|
||
source_news_id = Column(Integer, ForeignKey('zopk_news.id'))
|
||
|
||
# Wyświetlanie
|
||
icon = Column(String(50)) # emoji lub ikona
|
||
color = Column(String(20)) # kolor dla timeline
|
||
is_featured = Column(Boolean, default=False)
|
||
is_verified = Column(Boolean, default=True) # Czy zatwierdzony do wyświetlenia
|
||
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
source_news = relationship('ZOPKNews', backref='milestones')
|
||
|
||
|
||
# ============================================================
|
||
# MEMBERSHIP APPLICATION SYSTEM
|
||
# ============================================================
|
||
|
||
class MembershipApplication(Base):
|
||
"""
|
||
Deklaracja przystąpienia do Izby NORDA.
|
||
Pełny formularz online z workflow zatwierdzania.
|
||
"""
|
||
__tablename__ = 'membership_applications'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Kto złożył
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
|
||
# Status workflow (max 50 znaków)
|
||
# draft, submitted, under_review, pending_user_approval, changes_requested, approved, rejected
|
||
status = Column(String(50), nullable=False, default='draft')
|
||
|
||
# Dane firmy (strona 1 - Deklaracja)
|
||
company_name = Column(String(255), nullable=False)
|
||
nip = Column(String(10), nullable=False)
|
||
address_postal_code = Column(String(6))
|
||
address_city = Column(String(100))
|
||
address_street = Column(String(200))
|
||
address_number = Column(String(50))
|
||
|
||
# Delegaci do Walnego Zgromadzenia
|
||
delegate_1 = Column(String(150))
|
||
delegate_2 = Column(String(150))
|
||
delegate_3 = Column(String(150))
|
||
|
||
# Kontakt (strona 2 - Karta Informacyjna)
|
||
website = Column(String(255))
|
||
email = Column(String(255), nullable=False)
|
||
phone = Column(String(30))
|
||
short_name = Column(String(100))
|
||
|
||
# Informacje dodatkowe
|
||
description = Column(Text)
|
||
founded_date = Column(Date)
|
||
employee_count = Column(Integer)
|
||
show_employee_count = Column(Boolean, default=False)
|
||
annual_revenue = Column(String(50))
|
||
related_companies = Column(JSONBType) # ["Firma A", "Firma B"]
|
||
|
||
# Sekcje tematyczne
|
||
sections = Column(JSONBType, nullable=False, default=[])
|
||
sections_other = Column(String(200))
|
||
|
||
# Typ działalności
|
||
business_type = Column(String(50), nullable=False, default='sp_z_oo')
|
||
business_type_other = Column(String(100))
|
||
|
||
# Zgody RODO
|
||
consent_email = Column(Boolean, nullable=False, default=False)
|
||
consent_email_address = Column(String(255))
|
||
consent_sms = Column(Boolean, default=False)
|
||
consent_sms_phone = Column(String(30))
|
||
|
||
# Oświadczenie końcowe
|
||
declaration_accepted = Column(Boolean, nullable=False, default=False)
|
||
declaration_accepted_at = Column(DateTime)
|
||
declaration_ip_address = Column(String(45))
|
||
|
||
# Dane z rejestru KRS/CEIDG
|
||
registry_source = Column(String(20)) # 'KRS', 'CEIDG', 'manual'
|
||
registry_data = Column(JSONBType)
|
||
krs_number = Column(String(10))
|
||
regon = Column(String(14))
|
||
|
||
# Workflow timestamps
|
||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
submitted_at = Column(DateTime)
|
||
reviewed_at = Column(DateTime)
|
||
reviewed_by_id = Column(Integer, ForeignKey('users.id'))
|
||
review_comment = Column(Text)
|
||
|
||
# Proposed changes workflow (admin → user approval)
|
||
proposed_changes = Column(JSONBType) # {"field": {"old": x, "new": y}, ...}
|
||
proposed_changes_at = Column(DateTime)
|
||
proposed_changes_by_id = Column(Integer, ForeignKey('users.id'))
|
||
proposed_changes_comment = Column(Text)
|
||
|
||
# User response to proposed changes
|
||
user_changes_accepted_at = Column(DateTime) # When user accepted/rejected
|
||
user_changes_action = Column(String(20)) # 'accepted' or 'rejected'
|
||
|
||
# Full workflow history (for timeline display on both user and admin side)
|
||
workflow_history = Column(JSONBType, default=[]) # [{event, timestamp, user_id, user_name, details}]
|
||
|
||
# Po zatwierdzeniu
|
||
company_id = Column(Integer, ForeignKey('companies.id'))
|
||
member_number = Column(String(20))
|
||
|
||
# Relationships
|
||
user = relationship('User', foreign_keys=[user_id], backref='membership_applications')
|
||
reviewed_by = relationship('User', foreign_keys=[reviewed_by_id])
|
||
proposed_by = relationship('User', foreign_keys=[proposed_changes_by_id])
|
||
company = relationship('Company')
|
||
|
||
# Constants
|
||
STATUS_CHOICES = [
|
||
('draft', 'Szkic'),
|
||
('submitted', 'Wysłane'),
|
||
('under_review', 'W trakcie rozpatrywania'),
|
||
('pending_user_approval', 'Oczekuje na akceptację użytkownika'),
|
||
('changes_requested', 'Prośba o poprawki'),
|
||
('approved', 'Zatwierdzone'),
|
||
('rejected', 'Odrzucone'),
|
||
]
|
||
|
||
SECTION_CHOICES = [
|
||
('turystyka', 'Turystyka i hotelarstwo'),
|
||
('szkolnictwo', 'Szkolnictwo, praktyki, zatrudnienie'),
|
||
('budownictwo', 'Budownictwo'),
|
||
('produkcja', 'Produkcja'),
|
||
('handel', 'Handel'),
|
||
('uslugi', 'Usługi'),
|
||
('inna', 'Inna'),
|
||
]
|
||
|
||
BUSINESS_TYPE_CHOICES = [
|
||
('jdg', 'Jednoosobowa działalność gospodarcza'),
|
||
('spolka_cywilna', 'Spółka cywilna'),
|
||
('spolka_jawna', 'Spółka jawna'),
|
||
('spolka_partnerska', 'Spółka partnerska'),
|
||
('spolka_komandytowa', 'Spółka komandytowa'),
|
||
('spolka_komandytowo_akcyjna', 'Spółka komandytowo-akcyjna'),
|
||
('sp_z_oo_komandytowa', 'Sp. z o.o. komandytowa'),
|
||
('spolka_akcyjna', 'Spółka akcyjna'),
|
||
('sp_z_oo', 'Spółka z o.o.'),
|
||
('inna', 'Inna'),
|
||
]
|
||
|
||
@property
|
||
def status_label(self):
|
||
return dict(self.STATUS_CHOICES).get(self.status, self.status)
|
||
|
||
@property
|
||
def business_type_label(self):
|
||
return dict(self.BUSINESS_TYPE_CHOICES).get(self.business_type, self.business_type)
|
||
|
||
@property
|
||
def sections_labels(self):
|
||
section_map = dict(self.SECTION_CHOICES)
|
||
if not self.sections:
|
||
return []
|
||
return [section_map.get(s, s) for s in self.sections]
|
||
|
||
@property
|
||
def full_address(self):
|
||
parts = []
|
||
if self.address_postal_code:
|
||
parts.append(self.address_postal_code)
|
||
if self.address_city:
|
||
parts.append(self.address_city)
|
||
if parts:
|
||
address = ' '.join(parts)
|
||
if self.address_street:
|
||
address += f', {self.address_street}'
|
||
if self.address_number:
|
||
address += f' {self.address_number}'
|
||
return address
|
||
return None
|
||
|
||
def __repr__(self):
|
||
return f"<MembershipApplication {self.id} [{self.status}] {self.company_name}>"
|
||
|
||
|
||
class CompanyDataRequest(Base):
|
||
"""
|
||
Zgłoszenie uzupełnienia danych firmy.
|
||
Prostsza funkcjonalność dla istniejących członków bez pełnych danych.
|
||
"""
|
||
__tablename__ = 'company_data_requests'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Typ zgłoszenia
|
||
request_type = Column(String(30), nullable=False, default='update_data')
|
||
|
||
# Kto zgłasza
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
|
||
# Firma (jeśli istnieje)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'))
|
||
|
||
# NIP wprowadzony przez użytkownika
|
||
nip = Column(String(10), nullable=False)
|
||
|
||
# Dane pobrane z KRS/CEIDG
|
||
registry_source = Column(String(20)) # 'KRS', 'CEIDG'
|
||
fetched_data = Column(JSONBType)
|
||
|
||
# Status
|
||
status = Column(String(20), nullable=False, default='pending')
|
||
|
||
# Workflow
|
||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||
reviewed_at = Column(DateTime)
|
||
reviewed_by_id = Column(Integer, ForeignKey('users.id'))
|
||
review_comment = Column(Text)
|
||
|
||
# Notatka użytkownika
|
||
user_note = Column(Text)
|
||
|
||
# Które pola zostały zaktualizowane
|
||
applied_fields = Column(JSONBType)
|
||
|
||
# Relationships
|
||
user = relationship('User', foreign_keys=[user_id], backref='company_data_requests')
|
||
company = relationship('Company')
|
||
reviewed_by = relationship('User', foreign_keys=[reviewed_by_id])
|
||
|
||
# Constants
|
||
REQUEST_TYPE_CHOICES = [
|
||
('update_data', 'Uzupełnienie danych'),
|
||
('claim_company', 'Przejęcie firmy'),
|
||
]
|
||
|
||
STATUS_CHOICES = [
|
||
('pending', 'Oczekuje'),
|
||
('approved', 'Zatwierdzone'),
|
||
('rejected', 'Odrzucone'),
|
||
]
|
||
|
||
@property
|
||
def request_type_label(self):
|
||
return dict(self.REQUEST_TYPE_CHOICES).get(self.request_type, self.request_type)
|
||
|
||
@property
|
||
def status_label(self):
|
||
return dict(self.STATUS_CHOICES).get(self.status, self.status)
|
||
|
||
def __repr__(self):
|
||
return f"<CompanyDataRequest {self.id} [{self.status}] NIP:{self.nip}>"
|
||
|
||
|
||
# ============================================================
|
||
# MEMBER BENEFITS
|
||
# ============================================================
|
||
|
||
class Benefit(Base):
|
||
"""
|
||
Korzyści członkowskie - oferty afiliacyjne dla członków Izby.
|
||
Zniżki na licencje, subskrypcje, narzędzia SaaS itp.
|
||
"""
|
||
__tablename__ = 'benefits'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Podstawowe info
|
||
name = Column(String(100), nullable=False)
|
||
slug = Column(String(100), unique=True, nullable=False)
|
||
short_description = Column(String(200)) # Na kartę
|
||
description = Column(Text) # Pełny opis
|
||
category = Column(String(50)) # np. 'productivity', 'ai', 'marketing'
|
||
|
||
# Ceny i oferta
|
||
regular_price = Column(String(50)) # np. "$10/mies"
|
||
member_price = Column(String(50)) # np. "$8/mies" lub "10% zniżki"
|
||
discount_description = Column(String(100)) # np. "10% zniżki dla członków"
|
||
|
||
# Linki
|
||
affiliate_url = Column(String(500)) # Link afiliacyjny
|
||
product_url = Column(String(500)) # Link do strony produktu
|
||
logo_url = Column(String(500)) # Logo produktu
|
||
|
||
# Kod promocyjny (opcjonalny)
|
||
promo_code = Column(String(50))
|
||
promo_code_instructions = Column(Text) # Jak użyć kodu
|
||
|
||
# QR Code (generowany przez Dub)
|
||
qr_code_url = Column(String(500)) # URL do obrazka QR
|
||
|
||
# Prowizja (dla admina)
|
||
commission_rate = Column(String(50)) # np. "25%"
|
||
commission_duration = Column(String(50)) # np. "12 miesięcy"
|
||
partner_platform = Column(String(100)) # np. "Dub Partners"
|
||
partner_since = Column(Date)
|
||
|
||
# Status
|
||
is_featured = Column(Boolean, default=False)
|
||
is_active = Column(Boolean, default=True)
|
||
display_order = Column(Integer, default=0)
|
||
|
||
# Statystyki
|
||
click_count = Column(Integer, default=0)
|
||
|
||
# Timestamps
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Constants
|
||
CATEGORY_CHOICES = [
|
||
('productivity', 'Produktywność'),
|
||
('ai', 'Sztuczna Inteligencja'),
|
||
('marketing', 'Marketing'),
|
||
('finance', 'Finanse'),
|
||
('communication', 'Komunikacja'),
|
||
('design', 'Design'),
|
||
('development', 'Development'),
|
||
('other', 'Inne'),
|
||
]
|
||
|
||
@property
|
||
def category_label(self):
|
||
return dict(self.CATEGORY_CHOICES).get(self.category, self.category)
|
||
|
||
def __repr__(self):
|
||
return f"<Benefit {self.id} {self.name}>"
|
||
|
||
|
||
class BenefitClick(Base):
|
||
"""
|
||
Śledzenie kliknięć w linki afiliacyjne.
|
||
"""
|
||
__tablename__ = 'benefit_clicks'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
benefit_id = Column(Integer, ForeignKey('benefits.id', ondelete='CASCADE'), nullable=False)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||
|
||
clicked_at = Column(DateTime, default=datetime.now)
|
||
ip_address = Column(String(45)) # IPv6 support
|
||
user_agent = Column(String(500))
|
||
|
||
# Relationships
|
||
benefit = relationship('Benefit', backref='clicks')
|
||
user = relationship('User')
|
||
|
||
def __repr__(self):
|
||
return f"<BenefitClick {self.id} benefit={self.benefit_id}>"
|
||
|
||
|
||
# ============================================================
|
||
# AUDIT AI ACTIONS & CACHE
|
||
# ============================================================
|
||
|
||
class AuditAction(Base):
|
||
"""
|
||
AI-generated action items from audit analysis.
|
||
Tracks suggestions, their generated content, and implementation status.
|
||
"""
|
||
__tablename__ = 'audit_actions'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
|
||
audit_type = Column(String(20), nullable=False) # 'seo', 'gbp', 'social'
|
||
action_type = Column(String(50), nullable=False) # 'generate_schema_org', etc.
|
||
title = Column(String(255), nullable=False)
|
||
description = Column(Text)
|
||
priority = Column(String(20), default='medium') # 'critical', 'high', 'medium', 'low'
|
||
impact_score = Column(Integer) # 1-10
|
||
effort_score = Column(Integer) # 1-10
|
||
ai_content = Column(Text) # Generated content
|
||
ai_model = Column(String(50))
|
||
status = Column(String(20), default='suggested') # 'suggested', 'approved', 'implemented', 'dismissed'
|
||
platform = Column(String(30)) # 'google', 'facebook', etc.
|
||
created_by = Column(Integer, ForeignKey('users.id'))
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
implemented_at = Column(DateTime)
|
||
|
||
# Relationships
|
||
company = relationship('Company', backref='audit_actions')
|
||
creator = relationship('User', foreign_keys=[created_by])
|
||
|
||
PRIORITY_ORDER = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
|
||
|
||
@property
|
||
def priority_rank(self):
|
||
return self.PRIORITY_ORDER.get(self.priority, 2)
|
||
|
||
def __repr__(self):
|
||
return f"<AuditAction {self.id} {self.audit_type}/{self.action_type} [{self.status}]>"
|
||
|
||
|
||
class AuditAICache(Base):
|
||
"""
|
||
Cache for AI-generated audit analyses.
|
||
Avoids regenerating analysis when audit data hasn't changed.
|
||
"""
|
||
__tablename__ = 'audit_ai_cache'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
|
||
audit_type = Column(String(20), nullable=False)
|
||
analysis_summary = Column(Text)
|
||
actions_json = Column(JSONB)
|
||
audit_data_hash = Column(String(64))
|
||
generated_at = Column(DateTime, default=datetime.now)
|
||
expires_at = Column(DateTime)
|
||
previous_summary = Column(Text)
|
||
previous_actions_json = Column(JSONB)
|
||
previous_generated_at = Column(DateTime)
|
||
|
||
# Relationships
|
||
company = relationship('Company', backref='audit_ai_caches')
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'audit_type', name='uq_audit_ai_cache_company_type'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<AuditAICache {self.id} company={self.company_id} type={self.audit_type}>"
|
||
|
||
|
||
class SocialConnection(Base):
|
||
"""
|
||
OAuth connections for social media publishing (Phase 2-3).
|
||
Stores access/refresh tokens for GBP, Facebook, Instagram, LinkedIn APIs.
|
||
"""
|
||
__tablename__ = 'social_connections'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
|
||
platform = Column(String(30), nullable=False)
|
||
access_token = Column(Text)
|
||
refresh_token = Column(Text)
|
||
token_expires_at = Column(DateTime)
|
||
scope = Column(Text)
|
||
external_account_id = Column(String(255))
|
||
external_account_name = Column(String(255))
|
||
connected_by = Column(Integer, ForeignKey('users.id'))
|
||
connected_at = Column(DateTime, default=datetime.now)
|
||
is_active = Column(Boolean, default=True)
|
||
|
||
# Relationships
|
||
company = relationship('Company', backref='social_connections')
|
||
connector = relationship('User', foreign_keys=[connected_by])
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'platform', name='uq_social_connection_company_platform'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f"<SocialConnection {self.id} company={self.company_id} platform={self.platform}>"
|
||
|
||
|
||
class OAuthToken(Base):
|
||
"""OAuth tokens for external API integrations (Google, Meta)."""
|
||
__tablename__ = 'oauth_tokens'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False)
|
||
company = relationship('Company', backref='oauth_tokens')
|
||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
user = relationship('User', backref='oauth_tokens')
|
||
provider = Column(String(50), nullable=False) # google, meta
|
||
service = Column(String(50), nullable=False) # gbp, search_console, facebook, instagram
|
||
access_token = Column(Text, nullable=False)
|
||
refresh_token = Column(Text)
|
||
token_type = Column(String(50), default='Bearer')
|
||
expires_at = Column(DateTime)
|
||
scopes = Column(Text)
|
||
account_id = Column(String(255))
|
||
account_name = Column(String(255))
|
||
metadata_json = Column('metadata', PG_JSONB)
|
||
is_active = Column(Boolean, default=True)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'provider', 'service', name='uq_oauth_company_provider_service'),
|
||
)
|
||
|
||
@property
|
||
def is_expired(self):
|
||
if not self.expires_at:
|
||
return False
|
||
return datetime.now() > self.expires_at
|
||
|
||
def __repr__(self):
|
||
return f'<OAuthToken {self.provider}/{self.service} for company_id={self.company_id}>'
|
||
|
||
|
||
# ============================================================
|
||
# SOCIAL MEDIA PUBLISHER
|
||
# ============================================================
|
||
|
||
class SocialPost(Base):
|
||
"""Social media posts for NORDA chamber page publishing."""
|
||
__tablename__ = 'social_media_posts'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
|
||
# Typ i platforma
|
||
post_type = Column(String(50), nullable=False) # member_spotlight, regional_news, event_invitation, event_recap, chamber_news
|
||
platform = Column(String(20), nullable=False, default='facebook')
|
||
|
||
# Treść
|
||
content = Column(Text, nullable=False)
|
||
hashtags = Column(Text)
|
||
image_path = Column(String(500))
|
||
|
||
# Kontekst (opcjonalny)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
|
||
event_id = Column(Integer, ForeignKey('norda_events.id'), nullable=True)
|
||
|
||
# Firma publikująca (której stroną FB publikujemy)
|
||
publishing_company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
|
||
|
||
# Workflow status
|
||
status = Column(String(20), nullable=False, default='draft')
|
||
|
||
# Scheduling
|
||
scheduled_at = Column(DateTime, nullable=True)
|
||
published_at = Column(DateTime, nullable=True)
|
||
|
||
# Publish mode
|
||
is_live = Column(Boolean, default=False) # True = public, False = debug/draft on FB
|
||
|
||
# Facebook response
|
||
meta_post_id = Column(String(100))
|
||
meta_response = Column(JSONBType)
|
||
|
||
# Engagement (cache z FB API)
|
||
engagement_likes = Column(Integer, default=0)
|
||
engagement_comments = Column(Integer, default=0)
|
||
engagement_shares = Column(Integer, default=0)
|
||
engagement_reach = Column(Integer)
|
||
engagement_updated_at = Column(DateTime)
|
||
|
||
# AI metadata
|
||
ai_model = Column(String(100))
|
||
ai_prompt_template = Column(String(100))
|
||
|
||
# Audyt
|
||
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||
approved_by = Column(Integer, ForeignKey('users.id'))
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
# Relationships
|
||
company = relationship('Company', foreign_keys=[company_id])
|
||
publishing_company = relationship('Company', foreign_keys=[publishing_company_id])
|
||
event = relationship('NordaEvent', foreign_keys=[event_id])
|
||
creator = relationship('User', foreign_keys=[created_by])
|
||
approver = relationship('User', foreign_keys=[approved_by])
|
||
|
||
def __repr__(self):
|
||
return f'<SocialPost {self.id} type={self.post_type} status={self.status}>'
|
||
|
||
|
||
class SocialMediaConfig(Base):
|
||
"""Configuration for social media platform connections per company."""
|
||
__tablename__ = 'social_media_config'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
platform = Column(String(50), nullable=False)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
|
||
page_id = Column(String(100))
|
||
page_name = Column(String(255))
|
||
access_token = Column(Text)
|
||
token_expires_at = Column(DateTime)
|
||
is_active = Column(Boolean, default=True)
|
||
debug_mode = Column(Boolean, default=True)
|
||
config_data = Column(JSONBType)
|
||
cached_posts = Column(JSONBType)
|
||
posts_cached_at = Column(DateTime)
|
||
updated_by = Column(Integer, ForeignKey('users.id'))
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
company = relationship('Company', foreign_keys=[company_id])
|
||
updater = relationship('User', foreign_keys=[updated_by])
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('platform', 'company_id', name='uq_social_config_platform_company'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f'<SocialMediaConfig {self.platform} company_id={self.company_id} page={self.page_name}>'
|
||
|
||
|
||
class WebsiteDiscoveryCandidate(Base):
|
||
"""Website candidates found via Brave Search for companies missing website field."""
|
||
__tablename__ = 'website_discovery_candidates'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False)
|
||
discovered_at = Column(DateTime, default=datetime.now)
|
||
search_query = Column(Text)
|
||
candidate_url = Column(String(500), nullable=False)
|
||
candidate_domain = Column(String(255))
|
||
brave_title = Column(Text)
|
||
brave_description = Column(Text)
|
||
extracted_nips = Column(PG_ARRAY(Text))
|
||
extracted_regons = Column(PG_ARRAY(Text))
|
||
extracted_krs = Column(PG_ARRAY(Text))
|
||
extracted_phones = Column(PG_ARRAY(Text))
|
||
extracted_emails = Column(PG_ARRAY(Text))
|
||
page_text_snippet = Column(Text)
|
||
match_nip = Column(Boolean, default=False)
|
||
match_regon = Column(Boolean, default=False)
|
||
match_krs = Column(Boolean, default=False)
|
||
match_phone = Column(Boolean, default=False)
|
||
match_email = Column(Boolean, default=False)
|
||
match_city = Column(Boolean, default=False)
|
||
match_owner = Column(Boolean, default=False)
|
||
confidence = Column(String(10), default='low')
|
||
match_score = Column(Integer, default=0)
|
||
status = Column(String(20), default='pending')
|
||
reviewed_at = Column(DateTime)
|
||
error_message = Column(Text)
|
||
|
||
company = relationship('Company', foreign_keys=[company_id])
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint('company_id', 'candidate_url', name='uq_wdc_company_url'),
|
||
)
|
||
|
||
def __repr__(self):
|
||
return f'<WebsiteDiscoveryCandidate {self.id} company={self.company_id} confidence={self.confidence}>'
|
||
|
||
|
||
class PortalSEOAudit(Base):
|
||
"""SEO audit snapshots for nordabiznes.pl portal itself.
|
||
Uses the same SEOAuditor as company audits for consistency."""
|
||
__tablename__ = 'portal_seo_audits'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
audited_at = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
||
url = Column(String(500), nullable=False, default='https://nordabiznes.pl')
|
||
|
||
# PageSpeed scores (0-100)
|
||
pagespeed_performance = Column(Integer)
|
||
pagespeed_seo = Column(Integer)
|
||
pagespeed_accessibility = Column(Integer)
|
||
pagespeed_best_practices = Column(Integer)
|
||
|
||
# Core Web Vitals
|
||
lcp_ms = Column(Numeric(10, 2))
|
||
fcp_ms = Column(Numeric(10, 2))
|
||
cls = Column(Numeric(6, 4))
|
||
tbt_ms = Column(Numeric(10, 2))
|
||
speed_index_ms = Column(Numeric(10, 2))
|
||
|
||
# On-page SEO checks
|
||
has_meta_title = Column(Boolean)
|
||
has_meta_description = Column(Boolean)
|
||
has_canonical = Column(Boolean)
|
||
has_robots_txt = Column(Boolean)
|
||
has_sitemap = Column(Boolean)
|
||
has_structured_data = Column(Boolean)
|
||
has_og_tags = Column(Boolean)
|
||
has_ssl = Column(Boolean)
|
||
is_mobile_friendly = Column(Boolean)
|
||
|
||
# Security headers
|
||
has_hsts = Column(Boolean)
|
||
has_csp = Column(Boolean)
|
||
has_x_frame = Column(Boolean)
|
||
has_x_content_type = Column(Boolean)
|
||
|
||
# Content metrics
|
||
page_size_bytes = Column(Integer)
|
||
image_count = Column(Integer)
|
||
images_without_alt = Column(Integer)
|
||
|
||
# Full audit results from SEOAuditor
|
||
full_results = Column(JSONB)
|
||
|
||
notes = Column(Text)
|
||
created_by = Column(String(100))
|
||
|
||
def __repr__(self):
|
||
return f'<PortalSEOAudit {self.id} {self.audited_at} perf={self.pagespeed_performance}>'
|
||
|
||
|
||
# ============================================================
|
||
# UPTIME MONITORING
|
||
# ============================================================
|
||
|
||
class UptimeMonitor(Base):
|
||
"""Konfiguracja monitorów UptimeRobot"""
|
||
__tablename__ = 'uptime_monitors'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
uptimerobot_id = Column(Integer, unique=True, nullable=False)
|
||
name = Column(String(200), nullable=False)
|
||
url = Column(String(500), nullable=False)
|
||
check_interval_sec = Column(Integer, default=300)
|
||
is_active = Column(Boolean, default=True)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
checks = relationship('UptimeCheck', backref='monitor', lazy='dynamic')
|
||
incidents = relationship('UptimeIncident', backref='monitor', lazy='dynamic')
|
||
|
||
def __repr__(self):
|
||
return f'<UptimeMonitor {self.name} ({self.url})>'
|
||
|
||
|
||
class UptimeCheck(Base):
|
||
"""Wyniki sprawdzeń z UptimeRobot (synchronizowane co godzinę)"""
|
||
__tablename__ = 'uptime_checks'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
monitor_id = Column(Integer, ForeignKey('uptime_monitors.id'), nullable=False, index=True)
|
||
checked_at = Column(DateTime, nullable=False, index=True)
|
||
status = Column(String(20), nullable=False) # 'up', 'down', 'paused'
|
||
response_time_ms = Column(Integer)
|
||
status_code = Column(Integer)
|
||
|
||
def __repr__(self):
|
||
return f'<UptimeCheck {self.checked_at} {self.status}>'
|
||
|
||
|
||
class UptimeIncident(Base):
|
||
"""Okresy niedostępności z automatyczną diagnozą przyczyny"""
|
||
__tablename__ = 'uptime_incidents'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
monitor_id = Column(Integer, ForeignKey('uptime_monitors.id'), nullable=False, index=True)
|
||
started_at = Column(DateTime, nullable=False, index=True)
|
||
ended_at = Column(DateTime)
|
||
duration_seconds = Column(Integer)
|
||
cause = Column(String(20), default='unknown') # 'isp', 'server', 'infra', 'unknown'
|
||
notes = Column(Text)
|
||
auto_resolved = Column(Boolean, default=False)
|
||
|
||
def __repr__(self):
|
||
return f'<UptimeIncident {self.started_at} cause={self.cause} duration={self.duration_seconds}s>'
|
||
|
||
|
||
class InternalHealthLog(Base):
|
||
"""Wewnętrzny stan serwera (cron co 5 min)"""
|
||
__tablename__ = 'internal_health_logs'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
checked_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
|
||
app_ok = Column(Boolean, nullable=False)
|
||
db_ok = Column(Boolean, nullable=False)
|
||
cpu_percent = Column(Numeric(5, 2))
|
||
ram_percent = Column(Numeric(5, 2))
|
||
disk_percent = Column(Numeric(5, 2))
|
||
gunicorn_workers = Column(Integer)
|
||
|
||
def __repr__(self):
|
||
return f'<InternalHealthLog {self.checked_at} app={self.app_ok} db={self.db_ok}>'
|
||
|
||
|
||
# ============================================================
|
||
# UNIFIED CONVERSATIONS (messaging redesign)
|
||
# ============================================================
|
||
|
||
class Conversation(Base):
|
||
"""Zunifikowana konwersacja — 1:1 lub grupowa"""
|
||
__tablename__ = 'conversations'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
name = Column(String(255), nullable=True)
|
||
is_group = Column(Boolean, nullable=False, default=False)
|
||
owner_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
last_message_id = Column(Integer, ForeignKey('conv_messages.id', name='fk_conversations_last_message', use_alter=True, ondelete='SET NULL'), nullable=True)
|
||
|
||
owner = relationship('User', foreign_keys=[owner_id])
|
||
members = relationship('ConversationMember', backref='conversation', cascade='all, delete-orphan')
|
||
messages = relationship('ConvMessage', backref='conversation',
|
||
foreign_keys='ConvMessage.conversation_id',
|
||
cascade='all, delete-orphan',
|
||
order_by='ConvMessage.created_at')
|
||
last_message = relationship('ConvMessage', foreign_keys=[last_message_id],
|
||
post_update=True)
|
||
pins = relationship('MessagePin', backref='conversation', cascade='all, delete-orphan')
|
||
|
||
@property
|
||
def display_name(self):
|
||
"""Nazwa wyświetlana — nazwa grupy lub lista imion uczestników"""
|
||
if self.name:
|
||
return self.name
|
||
names = [m.user.name or m.user.email.split('@')[0] for m in self.members if m.user]
|
||
return ', '.join(sorted(names)[:4]) + (f' +{len(names)-4}' if len(names) > 4 else '')
|
||
|
||
@property
|
||
def member_count(self):
|
||
return len(self.members)
|
||
|
||
def __repr__(self):
|
||
return f'<Conversation {self.id} group={self.is_group} members={self.member_count}>'
|
||
|
||
|
||
class ConversationMember(Base):
|
||
"""Członkostwo w konwersacji"""
|
||
__tablename__ = 'conversation_members'
|
||
|
||
conversation_id = Column(Integer, ForeignKey('conversations.id', ondelete='CASCADE'), primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True)
|
||
role = Column(String(20), nullable=False, default='member')
|
||
last_read_at = Column(DateTime, nullable=True)
|
||
is_muted = Column(Boolean, nullable=False, default=False)
|
||
is_archived = Column(Boolean, nullable=False, default=False)
|
||
joined_at = Column(DateTime, default=datetime.now)
|
||
added_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
|
||
user = relationship('User', foreign_keys=[user_id])
|
||
added_by = relationship('User', foreign_keys=[added_by_id])
|
||
|
||
@property
|
||
def is_owner(self):
|
||
return self.role == 'owner'
|
||
|
||
def __repr__(self):
|
||
return f'<ConversationMember conv={self.conversation_id} user={self.user_id} role={self.role}>'
|
||
|
||
|
||
class ConvMessage(Base):
|
||
"""Wiadomość w konwersacji"""
|
||
__tablename__ = 'conv_messages'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
conversation_id = Column(Integer, ForeignKey('conversations.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
sender_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
content = Column(Text, nullable=False)
|
||
reply_to_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='SET NULL'), nullable=True)
|
||
edited_at = Column(DateTime, nullable=True)
|
||
is_deleted = Column(Boolean, nullable=False, default=False)
|
||
link_preview = Column(PG_JSONB, nullable=True)
|
||
created_at = Column(DateTime, default=datetime.now, index=True)
|
||
|
||
sender = relationship('User', foreign_keys=[sender_id])
|
||
reply_to = relationship('ConvMessage', remote_side=[id], foreign_keys=[reply_to_id])
|
||
reactions = relationship('MessageReaction', backref='message', cascade='all, delete-orphan')
|
||
attachments = relationship('MessageAttachment',
|
||
foreign_keys='MessageAttachment.conv_message_id',
|
||
back_populates='conv_message',
|
||
cascade='all, delete-orphan')
|
||
|
||
def __repr__(self):
|
||
return f'<ConvMessage {self.id} conv={self.conversation_id} sender={self.sender_id}>'
|
||
|
||
|
||
class MessageReaction(Base):
|
||
"""Reakcja emoji na wiadomość"""
|
||
__tablename__ = 'message_reactions'
|
||
__table_args__ = (
|
||
UniqueConstraint('message_id', 'user_id', 'emoji', name='uq_message_reaction'),
|
||
)
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
message_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
emoji = Column(String(10), nullable=False)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
user = relationship('User', foreign_keys=[user_id])
|
||
|
||
def __repr__(self):
|
||
return f'<MessageReaction {self.emoji} msg={self.message_id} user={self.user_id}>'
|
||
|
||
|
||
class MessagePin(Base):
|
||
"""Przypięta wiadomość w konwersacji"""
|
||
__tablename__ = 'message_pins'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
conversation_id = Column(Integer, ForeignKey('conversations.id', ondelete='CASCADE'), nullable=False, index=True)
|
||
message_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='CASCADE'), nullable=False)
|
||
pinned_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||
created_at = Column(DateTime, default=datetime.now)
|
||
|
||
message = relationship('ConvMessage', foreign_keys=[message_id])
|
||
pinned_by = relationship('User', foreign_keys=[pinned_by_id])
|
||
|
||
def __repr__(self):
|
||
return f'<MessagePin msg={self.message_id} conv={self.conversation_id}>'
|
||
|
||
|
||
class AIUserMemory(Base):
|
||
__tablename__ = 'ai_user_memory'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
fact = Column(Text, nullable=False)
|
||
category = Column(String(50), default='general')
|
||
source_conversation_id = Column(Integer, ForeignKey('ai_chat_conversations.id', ondelete='SET NULL'), nullable=True)
|
||
confidence = Column(Float, default=1.0)
|
||
created_at = Column(DateTime, default=datetime.utcnow)
|
||
expires_at = Column(DateTime)
|
||
is_active = Column(Boolean, default=True)
|
||
|
||
user = relationship('User')
|
||
source_conversation = relationship('AIChatConversation')
|
||
|
||
def __repr__(self):
|
||
return f'<AIUserMemory id={self.id} user={self.user_id} fact="{self.fact[:30]}">'
|
||
|
||
|
||
class AIConversationSummary(Base):
|
||
__tablename__ = 'ai_conversation_summary'
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
conversation_id = Column(Integer, ForeignKey('ai_chat_conversations.id', ondelete='CASCADE'), nullable=False, unique=True)
|
||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||
summary = Column(Text, nullable=False)
|
||
key_topics = Column(JSONBType, default=list)
|
||
created_at = Column(DateTime, default=datetime.utcnow)
|
||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||
|
||
user = relationship('User')
|
||
conversation = relationship('AIChatConversation')
|
||
|
||
def __repr__(self):
|
||
return f'<AIConversationSummary conv={self.conversation_id}>'
|
||
|
||
|
||
# ============================================================
|
||
# DATABASE INITIALIZATION
|
||
# ============================================================
|
||
|
||
def init_db():
|
||
"""Initialize database - create all tables"""
|
||
# Import all models to ensure they're registered
|
||
# (already done at module level)
|
||
|
||
# Create tables (only creates if they don't exist)
|
||
Base.metadata.create_all(bind=engine)
|
||
|
||
print("Database tables created successfully!")
|
||
|
||
|
||
def drop_all_tables():
|
||
"""Drop all tables - USE WITH CAUTION!"""
|
||
Base.metadata.drop_all(bind=engine)
|
||
print("All tables dropped!")
|
||
|
||
|
||
if __name__ == '__main__':
|
||
# Test database connection
|
||
try:
|
||
init_db()
|
||
print("✅ Database initialized successfully")
|
||
|
||
# Test query
|
||
db = SessionLocal()
|
||
try:
|
||
count = db.query(Company).count()
|
||
print(f"✅ Database connected. Found {count} companies.")
|
||
finally:
|
||
db.close()
|
||
|
||
except Exception as e:
|
||
print(f"❌ Database error: {e}")
|