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
Six types: website (blue), store (green), booking (purple), blog (orange), portfolio (pink), other (gray). Each type has unique icon, color in contact bar and banner section, and tooltip with site description. Form edit adds type selector dropdown per website row. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5353 lines
196 KiB
Python
5353 lines
196 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: Norda Biznes Development Team
|
|
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, 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
|
|
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 - pełne prawa
|
|
"""
|
|
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
|
|
|
|
@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',
|
|
}
|
|
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,
|
|
}
|
|
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)
|
|
|
|
# Timestamps
|
|
created_at = Column(DateTime, default=datetime.now)
|
|
last_login = Column(DateTime)
|
|
verified_at = Column(DateTime)
|
|
|
|
# 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)
|
|
|
|
# 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')
|
|
|
|
# === ROLE SYSTEM HELPER METHODS ===
|
|
|
|
@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 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."""
|
|
perms = session.query(cls).filter_by(
|
|
user_id=user_id,
|
|
company_id=company_id
|
|
).first()
|
|
|
|
if not perms:
|
|
perms = cls(user_id=user_id, company_id=company_id)
|
|
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))
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# 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()')
|
|
|
|
|
|
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)
|
|
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 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))
|
|
|
|
# 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', 'test']
|
|
STATUSES = ['new', 'in_progress', 'resolved', 'rejected']
|
|
|
|
CATEGORY_LABELS = {
|
|
'feature_request': 'Propozycja funkcji',
|
|
'bug': 'Błąd',
|
|
'question': 'Pytanie',
|
|
'announcement': 'Ogłoszenie',
|
|
'test': 'Testowy'
|
|
}
|
|
|
|
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_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
|
|
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 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)
|
|
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)
|
|
|
|
# Ź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
|
|
|
|
# Relationships
|
|
speaker_company = relationship('Company')
|
|
creator = relationship('User', foreign_keys=[created_by])
|
|
attendees = relationship('EventAttendee', back_populates='event', cascade='all, delete-orphan')
|
|
|
|
@property
|
|
def attendee_count(self):
|
|
return len(self.attendees)
|
|
|
|
@property
|
|
def is_past(self):
|
|
from datetime import date
|
|
return self.event_date < date.today()
|
|
|
|
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':
|
|
return user.is_norda_member or user.has_role(SystemRole.MEMBER)
|
|
elif access == 'rada_only':
|
|
# Wszyscy członkowie WIDZĄ wydarzenia Rady Izby (tytuł, data, miejsce)
|
|
# ale nie mogą dołączyć ani zobaczyć uczestników
|
|
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.
|
|
"""
|
|
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':
|
|
return True
|
|
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)
|
|
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)
|
|
|
|
event = relationship('NordaEvent', back_populates='attendees')
|
|
user = relationship('User')
|
|
|
|
|
|
# ============================================================
|
|
# 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])
|
|
|
|
|
|
# ============================================================
|
|
# 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, onupdate=datetime.now)
|
|
|
|
author = relationship('User', backref='classifieds')
|
|
company = relationship('Company')
|
|
|
|
@property
|
|
def is_expired(self):
|
|
if self.expires_at:
|
|
return datetime.now() > self.expires_at
|
|
return False
|
|
|
|
|
|
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')
|
|
|
|
|
|
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 and filing dates.
|
|
"""
|
|
__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')
|
|
|
|
# Timestamps
|
|
created_at = Column(DateTime, default=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 __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)
|
|
|
|
# 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']
|
|
CATEGORY_LABELS = {
|
|
'internal': 'Wewnętrzne',
|
|
'external': 'Zewnętrzne',
|
|
'event': 'Wydarzenie',
|
|
'opportunity': 'Okazja biznesowa',
|
|
'partnership': 'Partnerstwo'
|
|
}
|
|
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}>'
|
|
|
|
|
|
# ============================================================
|
|
# 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}")
|