Initial commit

This commit is contained in:
2026-02-01 09:31:38 +01:00
commit e02db93960
4396 changed files with 1511612 additions and 0 deletions

24
backend/.env Normal file
View File

@@ -0,0 +1,24 @@
# ========================================
# CONFIGURATION CHARTBASTAN
# ========================================
# Database
DATABASE_URL=sqlite:///chartbastan.db
# FastAPI
API_HOST=127.0.0.1
API_PORT=8000
# RabbitMQ
RABBITMQ_URL=amqp://guest:guest@localhost:5672
# Twitter API (Optionnel - pour scraping réel)
TWITTER_BEARER_TOKEN=AAAAAAAAAAAAAAAAAAAAAKsS1QAAAAAAuP8t7YI55YK4%2FdWnKwT6ZURjZWE%3D4f5Ru9MqMQySfxKbLSH2uzqsTorbWc73pEEK4m4ZPzQKVbSQ20
# Reddit API (Optionnel - pour scraping réel)
REDDIT_CLIENT_ID=HzlGJ8xiv92beg
REDDIT_CLIENT_SECRET=xPvN9IfdQC4JoO_t4Mww-9Pcqro
# Environnement
ENVIRONMENT=development
LOG_LEVEL=INFO

10
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
backend/.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

0
backend/README.md Normal file
View File

View File

@@ -0,0 +1,18 @@
INSTRUCTIONS POUR RÉPARER LA BASE DE DONNÉES ET LES MIGRATIONS
1. SUPPRIMER LA BASE DE DONNÉES EXISTANTE
- Allez dans le dossier backend
- Supprimez le fichier chartbastan.db (s'il existe)
2. CRÉER UN FICHIER ALEMBIC DE TEST
- Créez un nouveau fichier backend/alembic/versions/current.txt
- Contenant: current: 20260117_0000
3. RÉINITIALISER LES MIGRATIONS
- Exécuter: alembic downgrade base
- Puis: alembic upgrade head
4. DÉMARRER LE SERVEUR
- uvicorn app.main:app --reload
L'authentification devrait fonctionner après ces étapes !

110
backend/alembic.ini Normal file
View File

@@ -0,0 +1,110 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library to be installed.
# Any valid tzinfo object can be specified here (i.e. datetime.timezone.utc).
#timezone =
# max length of characters to apply to the
# "slug" field
#truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
#revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
#sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
#recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
#output_encoding = utf-8
sqlalchemy.url = sqlite:///../chartbastan.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Ce répertoire contient les scripts de migration Alembic.

82
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,82 @@
"""Configuration de l'environnement Alembic."""
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import sys
from pathlib import Path
# Ajouter le répertoire parent au path pour importer les modèles
sys.path.insert(0, str(Path(__file__).resolve().parents[0]))
# Importer la configuration et le base
from app.database import Base
from app.models.user import User
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1 @@
# Ce fichier permet de garder le répertoire vide dans git

View File

@@ -0,0 +1,45 @@
"""Initial migration
Revision ID: 0001
Revises:
Create Date: 2026-01-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create initial tables."""
# Create users table
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('password_hash', sa.String(), nullable=True),
sa.Column('is_premium', sa.Boolean(), nullable=False, default=False),
sa.Column('referral_code', sa.String(), nullable=True, unique=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('daily_predictions_count', sa.Integer(), default=0),
sa.Column('last_prediction_date', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_referral_code'), 'users', ['referral_code'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
def downgrade() -> None:
"""Drop initial tables."""
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')

View File

@@ -0,0 +1,47 @@
"""Create tweets table
Revision ID: 0002
Revises: 0001
Create Date: 2026-01-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0002'
down_revision = '0001'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create tweets table."""
op.create_table(
'tweets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tweet_id', sa.String(length=255), nullable=False),
sa.Column('text', sa.String(length=1000), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('retweet_count', sa.Integer(), nullable=True),
sa.Column('like_count', sa.Integer(), nullable=True),
sa.Column('match_id', sa.Integer(), nullable=True),
sa.Column('source', sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tweets_id'), 'tweets', ['id'], unique=False)
op.create_index(op.f('ix_tweets_tweet_id'), 'tweets', ['tweet_id'], unique=True)
op.create_index(op.f('ix_tweets_created_at'), 'tweets', ['created_at'], unique=False)
op.create_index(op.f('ix_tweets_match_id'), 'tweets', ['match_id'], unique=False)
op.create_index('idx_tweets_match_id_source', 'tweets', ['match_id', 'source'], unique=False)
def downgrade() -> None:
"""Drop tweets table."""
op.drop_index('idx_tweets_match_id_source', table_name='tweets')
op.drop_index(op.f('ix_tweets_match_id'), table_name='tweets')
op.drop_index(op.f('ix_tweets_created_at'), table_name='tweets')
op.drop_index(op.f('ix_tweets_tweet_id'), table_name='tweets')
op.drop_index(op.f('ix_tweets_id'), table_name='tweets')
op.drop_table('tweets')

View File

@@ -0,0 +1,74 @@
"""Create Reddit posts and comments tables
Revision ID: 0003
Revises: 0002
Create Date: 2026-01-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0003'
down_revision = '0002'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create Reddit posts and comments tables."""
# Create posts_reddit table
op.create_table(
'posts_reddit',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('post_id', sa.String(length=255), nullable=False),
sa.Column('title', sa.String(length=500), nullable=False),
sa.Column('text', sa.Text(), nullable=True),
sa.Column('upvotes', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('match_id', sa.Integer(), nullable=True),
sa.Column('subreddit', sa.String(length=100), nullable=False),
sa.Column('source', sa.String(length=50), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_posts_reddit_created_at'), 'posts_reddit', ['created_at'], unique=False)
op.create_index(op.f('ix_posts_reddit_id'), 'posts_reddit', ['id'], unique=False)
op.create_index(op.f('ix_posts_reddit_match_id'), 'posts_reddit', ['match_id'], unique=False)
op.create_index(op.f('ix_posts_reddit_post_id'), 'posts_reddit', ['post_id'], unique=True)
op.create_index(op.f('ix_posts_reddit_subreddit'), 'posts_reddit', ['subreddit'], unique=False)
# Create comments_reddit table
op.create_table(
'comments_reddit',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('comment_id', sa.String(length=255), nullable=False),
sa.Column('post_id', sa.String(length=255), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('upvotes', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('source', sa.String(length=50), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_comments_reddit_created_at'), 'comments_reddit', ['created_at'], unique=False)
op.create_index(op.f('ix_comments_reddit_id'), 'comments_reddit', ['id'], unique=False)
op.create_index(op.f('ix_comments_reddit_comment_id'), 'comments_reddit', ['comment_id'], unique=True)
op.create_index(op.f('ix_comments_reddit_post_id'), 'comments_reddit', ['post_id'], unique=False)
def downgrade() -> None:
"""Drop Reddit posts and comments tables."""
# Drop comments_reddit table
op.drop_index(op.f('ix_comments_reddit_post_id'), table_name='comments_reddit')
op.drop_index(op.f('ix_comments_reddit_comment_id'), table_name='comments_reddit')
op.drop_index(op.f('ix_comments_reddit_id'), table_name='comments_reddit')
op.drop_index(op.f('ix_comments_reddit_created_at'), table_name='comments_reddit')
op.drop_table('comments_reddit')
# Drop posts_reddit table
op.drop_index(op.f('ix_posts_reddit_subreddit'), table_name='posts_reddit')
op.drop_index(op.f('ix_posts_reddit_post_id'), table_name='posts_reddit')
op.drop_index(op.f('ix_posts_reddit_match_id'), table_name='posts_reddit')
op.drop_index(op.f('ix_posts_reddit_id'), table_name='posts_reddit')
op.drop_index(op.f('ix_posts_reddit_created_at'), table_name='posts_reddit')
op.drop_table('posts_reddit')

View File

@@ -0,0 +1,55 @@
"""Create sentiment scores table
Revision ID: 0004
Revises: 0003
Create Date: 2026-01-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0004'
down_revision = '0003'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create sentiment_scores table."""
op.create_table(
'sentiment_scores',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('entity_id', sa.String(length=255), nullable=False),
sa.Column('entity_type', sa.String(length=50), nullable=False),
sa.Column('score', sa.Float(), nullable=False),
sa.Column('sentiment_type', sa.String(length=20), nullable=False),
sa.Column('positive', sa.Float(), nullable=False, server_default='0.0'),
sa.Column('negative', sa.Float(), nullable=False, server_default='0.0'),
sa.Column('neutral', sa.Float(), nullable=False, server_default='0.0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sentiment_scores_id'), 'sentiment_scores', ['id'], unique=False)
op.create_index(op.f('ix_sentiment_scores_entity_id'), 'sentiment_scores', ['entity_id'], unique=False)
op.create_index(op.f('ix_sentiment_scores_entity_type'), 'sentiment_scores', ['entity_type'], unique=False)
op.create_index(op.f('ix_sentiment_scores_score'), 'sentiment_scores', ['score'], unique=False)
op.create_index(op.f('ix_sentiment_scores_sentiment_type'), 'sentiment_scores', ['sentiment_type'], unique=False)
op.create_index(op.f('ix_sentiment_scores_created_at'), 'sentiment_scores', ['created_at'], unique=False)
op.create_index('idx_sentiment_scores_entity', 'sentiment_scores', ['entity_id', 'entity_type'], unique=False)
def downgrade() -> None:
"""Drop sentiment_scores table."""
# Drop indexes
op.drop_index('idx_sentiment_scores_entity', table_name='sentiment_scores')
op.drop_index(op.f('ix_sentiment_scores_created_at'), table_name='sentiment_scores')
op.drop_index(op.f('ix_sentiment_scores_sentiment_type'), table_name='sentiment_scores')
op.drop_index(op.f('ix_sentiment_scores_score'), table_name='sentiment_scores')
op.drop_index(op.f('ix_sentiment_scores_entity_type'), table_name='sentiment_scores')
op.drop_index(op.f('ix_sentiment_scores_entity_id'), table_name='sentiment_scores')
op.drop_index(op.f('ix_sentiment_scores_id'), table_name='sentiment_scores')
# Drop table
op.drop_table('sentiment_scores')

View File

@@ -0,0 +1,62 @@
"""create energy scores table
Revision ID: 20260117_0004
Revises: 20260117_0003
Create Date: 2026-01-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260117_0004'
down_revision = '20260117_0003'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create energy_scores table."""
op.create_table(
'energy_scores',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('match_id', sa.Integer(), nullable=False),
sa.Column('team_id', sa.Integer(), nullable=False),
sa.Column('score', sa.Float(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False, server_default='0.0'),
sa.Column('sources_used', sa.JSON(), nullable=False, server_default='[]'),
sa.Column('twitter_score', sa.Float(), nullable=True),
sa.Column('reddit_score', sa.Float(), nullable=True),
sa.Column('rss_score', sa.Float(), nullable=True),
sa.Column('temporal_factor', sa.Float(), nullable=True),
sa.Column('twitter_weight', sa.Float(), nullable=True),
sa.Column('reddit_weight', sa.Float(), nullable=True),
sa.Column('rss_weight', sa.Float(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for performance
op.create_index('idx_energy_scores_match_team', 'energy_scores', ['match_id', 'team_id'])
op.create_index('idx_energy_scores_score', 'energy_scores', ['score'])
op.create_index('idx_energy_scores_confidence', 'energy_scores', ['confidence'])
op.create_index('idx_energy_scores_created_at', 'energy_scores', ['created_at'])
op.create_index('idx_energy_scores_updated_at', 'energy_scores', ['updated_at'])
op.create_index(op.f('ix_energy_scores_id'), 'energy_scores', ['id'])
op.create_index(op.f('ix_energy_scores_match_id'), 'energy_scores', ['match_id'])
op.create_index(op.f('ix_energy_scores_team_id'), 'energy_scores', ['team_id'])
def downgrade() -> None:
"""Drop energy_scores table."""
op.drop_index(op.f('ix_energy_scores_team_id'), table_name='energy_scores')
op.drop_index(op.f('ix_energy_scores_match_id'), table_name='energy_scores')
op.drop_index(op.f('ix_energy_scores_id'), table_name='energy_scores')
op.drop_index('idx_energy_scores_updated_at', table_name='energy_scores')
op.drop_index('idx_energy_scores_created_at', table_name='energy_scores')
op.drop_index('idx_energy_scores_confidence', table_name='energy_scores')
op.drop_index('idx_energy_scores_score', table_name='energy_scores')
op.drop_index('idx_energy_scores_match_team', table_name='energy_scores')
op.drop_table('energy_scores')

View File

@@ -0,0 +1,93 @@
"""Create matches and predictions tables
Revision ID: 0006
Revises: 0004
Create Date: 2026-01-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0006'
down_revision = '0004'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create matches and predictions tables."""
# Create matches table
op.create_table(
'matches',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('home_team', sa.String(length=255), nullable=False),
sa.Column('away_team', sa.String(length=255), nullable=False),
sa.Column('date', sa.DateTime(), nullable=False),
sa.Column('league', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_matches_id'), 'matches', ['id'], unique=False)
op.create_index(op.f('ix_matches_home_team'), 'matches', ['home_team'], unique=False)
op.create_index(op.f('ix_matches_away_team'), 'matches', ['away_team'], unique=False)
op.create_index(op.f('ix_matches_date'), 'matches', ['date'], unique=False)
op.create_index(op.f('ix_matches_league'), 'matches', ['league'], unique=False)
op.create_index(op.f('ix_matches_status'), 'matches', ['status'], unique=False)
op.create_index(op.f('ix_matches_date_league'), 'matches', ['date', 'league'], unique=False)
op.create_index(op.f('ix_matches_home_away'), 'matches', ['home_team', 'away_team'], unique=False)
# Create predictions table
op.create_table(
'predictions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('match_id', sa.Integer(), nullable=False),
sa.Column('energy_score', sa.String(length=50), nullable=False),
sa.Column('confidence', sa.String(length=50), nullable=False),
sa.Column('predicted_winner', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['match_id'], ['matches.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_predictions_id'), 'predictions', ['id'], unique=False)
op.create_index(op.f('ix_predictions_match_id'), 'predictions', ['match_id'], unique=False)
op.create_index(op.f('ix_predictions_created_at'), 'predictions', ['created_at'], unique=False)
op.create_index(op.f('ix_predictions_confidence'), 'predictions', ['confidence'], unique=False)
op.create_index(op.f('ix_predictions_match_id_created'), 'predictions', ['match_id', 'created_at'], unique=False)
# Add foreign key to tweets table to match matches
with op.batch_alter_table('tweets', schema=None) as batch_op:
batch_op.create_foreign_key(
'fk_tweets_match_id_matches',
'matches',
['match_id'],
['id'],
ondelete='CASCADE'
)
def downgrade() -> None:
"""Drop matches and predictions tables."""
# Drop predictions table
op.drop_index(op.f('ix_predictions_match_id_created'), table_name='predictions')
op.drop_index(op.f('ix_predictions_confidence'), table_name='predictions')
op.drop_index(op.f('ix_predictions_created_at'), table_name='predictions')
op.drop_index(op.f('ix_predictions_match_id'), table_name='predictions')
op.drop_index(op.f('ix_predictions_id'), table_name='predictions')
op.drop_table('predictions')
# Drop matches table
op.drop_index(op.f('ix_matches_home_away'), table_name='matches')
op.drop_index(op.f('ix_matches_date_league'), table_name='matches')
op.drop_index(op.f('ix_matches_status'), table_name='matches')
op.drop_index(op.f('ix_matches_league'), table_name='matches')
op.drop_index(op.f('ix_matches_date'), table_name='matches')
op.drop_index(op.f('ix_matches_away_team'), table_name='matches')
op.drop_index(op.f('ix_matches_home_team'), table_name='matches')
op.drop_index(op.f('ix_matches_id'), table_name='matches')
op.drop_table('matches')
# Remove foreign key from tweets table
with op.batch_alter_table('tweets', schema=None) as batch_op:
batch_op.drop_constraint('fk_tweets_match_id_matches', type_='foreignkey')

View File

@@ -0,0 +1,41 @@
"""Add actual_winner column to matches table
Revision ID: 0006
Revises: 0005
Create Date: 2026-01-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0006'
down_revision = '0005'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add actual_winner column to matches table."""
op.add_column(
'matches',
sa.Column(
'actual_winner',
sa.String(255),
nullable=True,
comment='Actual winner of the match: home, away, or draw'
)
)
# Create index for faster queries
op.create_index(
'ix_matches_actual_winner',
'matches',
['actual_winner']
)
def downgrade() -> None:
"""Remove actual_winner column from matches table."""
op.drop_index('ix_matches_actual_winner', table_name='matches')
op.drop_column('matches', 'actual_winner')

View File

@@ -0,0 +1,53 @@
"""create rss_articles table
Revision ID: 20260117_0007
Revises: 20260117_0006
Create Date: 2026-01-17
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import create_engine
# revision identifiers, used by Alembic.
revision = '20260117_0007'
down_revision = '20260117_0006'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create rss_articles table."""
op.create_table(
'rss_articles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('article_id', sa.String(length=255), nullable=False),
sa.Column('title', sa.String(length=500), nullable=False),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('published_at', sa.DateTime(), nullable=False),
sa.Column('source_url', sa.String(length=1000), nullable=False),
sa.Column('match_id', sa.Integer(), nullable=True),
sa.Column('source', sa.String(length=100), nullable=True, server_default='rss'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_rss_articles_id', 'rss_articles', ['id'])
op.create_index('ix_rss_articles_article_id', 'rss_articles', ['article_id'])
op.create_index('ix_rss_articles_published_at', 'rss_articles', ['published_at'])
op.create_index('ix_rss_articles_match_id', 'rss_articles', ['match_id'])
op.create_index('idx_rss_articles_match_id_source', 'rss_articles', ['match_id', 'source'])
op.create_index('idx_rss_articles_published_at', 'rss_articles', ['published_at'])
op.create_index('idx_rss_articles_source_url', 'rss_articles', ['source_url'])
def downgrade() -> None:
"""Drop rss_articles table."""
op.drop_index('idx_rss_articles_source_url', table_name='rss_articles')
op.drop_index('idx_rss_articles_published_at', table_name='rss_articles')
op.drop_index('idx_rss_articles_match_id_source', table_name='rss_articles')
op.drop_index('ix_rss_articles_match_id', table_name='rss_articles')
op.drop_index('ix_rss_articles_published_at', table_name='rss_articles')
op.drop_index('ix_rss_articles_article_id', table_name='rss_articles')
op.drop_index('ix_rss_articles_id', table_name='rss_articles')
op.drop_table('rss_articles')

View File

@@ -0,0 +1,49 @@
"""create api_keys table
Revision ID: 20260118_0008
Revises: add_user_predictions_tracking
Create Date: 2026-01-18 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260118_0008'
down_revision = 'add_user_predictions_tracking'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create api_keys table."""
op.create_table(
'api_keys',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('key_hash', sa.String(255), nullable=False),
sa.Column('key_prefix', sa.String(8), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('rate_limit', sa.Integer(), nullable=False, server_default='100'),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key_hash')
)
# Create indexes
op.create_index(op.f('ix_api_keys_id'), 'api_keys', ['id'], unique=False)
op.create_index(op.f('ix_api_keys_user_id'), 'api_keys', ['user_id'], unique=False)
op.create_index(op.f('ix_api_keys_key_hash'), 'api_keys', ['key_hash'], unique=True)
op.create_index(op.f('ix_api_keys_key_prefix'), 'api_keys', ['key_prefix'], unique=False)
def downgrade() -> None:
"""Drop api_keys table."""
op.drop_index(op.f('ix_api_keys_key_prefix'), table_name='api_keys')
op.drop_index(op.f('ix_api_keys_key_hash'), table_name='api_keys')
op.drop_index(op.f('ix_api_keys_user_id'), table_name='api_keys')
op.drop_index(op.f('ix_api_keys_id'), table_name='api_keys')
op.drop_table('api_keys')

View File

@@ -0,0 +1,50 @@
"""add user predictions tracking
Revision ID: add_user_predictions_tracking
Revises:
Create Date: 2026-01-18 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = 'add_user_predictions_tracking'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
"""Create user_predictions table to track predictions viewed by users."""
op.create_table(
'user_predictions',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('prediction_id', sa.Integer(), nullable=False),
sa.Column('viewed_at', sa.DateTime(), nullable=False),
sa.Column('was_correct', sa.Boolean(), nullable=True, comment='True if prediction was correct, False if incorrect, NULL if match not completed'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['prediction_id'], ['predictions.id'], ondelete='CASCADE'),
sa.Index('idx_user_predictions_user_id', 'user_id'),
sa.Index('idx_user_predictions_prediction_id', 'prediction_id'),
sa.Index('idx_user_predictions_viewed_at', 'viewed_at'),
sa.UniqueConstraint('user_id', 'prediction_id', name='uq_user_predictions_user_prediction')
)
# Create view timestamp in ISO format
op.execute('''
CREATE TRIGGER update_user_predictions_viewed_at
BEFORE INSERT ON user_predictions
FOR EACH ROW
BEGIN
SELECT datetime('now') INTO NEW.viewed_at;
END;
''')
def downgrade():
"""Drop user_predictions table."""
op.execute('DROP TRIGGER IF EXISTS update_user_predictions_viewed_at')
op.drop_table('user_predictions')

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Application FastAPI Chartbastan."""

View File

@@ -0,0 +1 @@
"""Routes API de l'application."""

View File

@@ -0,0 +1,95 @@
"""
API dependencies.
This module provides common dependencies for API endpoints.
"""
from typing import Optional
from fastapi import Depends, HTTPException, status, Header
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.apiKeyService import ApiKeyService
async def get_api_key(
x_api_key: Optional[str] = Header(None, description="API Key for authentication"),
db: Session = Depends(get_db)
) -> int:
"""
Dependency to validate API key from header.
Args:
x_api_key: API key from X-API-Key header
db: Database session
Returns:
User ID associated with valid API key
Raises:
401: If API key is missing or invalid
Example:
from fastapi import Depends, APIRouter
@router.get("/protected")
async def protected_endpoint(user_id: int = Depends(get_api_key)):
return {"user_id": user_id}
"""
if not x_api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"code": "MISSING_API_KEY",
"message": "X-API-Key header is required for this endpoint"
}
)
service = ApiKeyService(db)
api_key_record = service.validate_api_key(x_api_key)
if not api_key_record:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"code": "INVALID_API_KEY",
"message": "Invalid API key"
}
)
return api_key_record.user_id
async def get_api_key_optional(
x_api_key: Optional[str] = Header(None, description="Optional API Key for authentication"),
db: Session = Depends(get_db)
) -> Optional[int]:
"""
Optional dependency to validate API key from header.
Args:
x_api_key: API key from X-API-Key header (optional)
db: Database session
Returns:
User ID if API key is valid, None otherwise
Example:
from fastapi import Depends, APIRouter
@router.get("/semi-public")
async def semi_public_endpoint(user_id: Optional[int] = Depends(get_api_key_optional)):
if user_id:
return {"message": "Authenticated", "user_id": user_id}
return {"message": "Unauthenticated"}
"""
if not x_api_key:
return None
service = ApiKeyService(db)
api_key_record = service.validate_api_key(x_api_key)
if not api_key_record:
return None
return api_key_record.user_id

View File

@@ -0,0 +1,5 @@
"""
Public API package.
This module provides public API endpoints with OpenAPI documentation.
"""

View File

@@ -0,0 +1,5 @@
"""
Public API v1 package.
This module provides v1 public API endpoints.
"""

View File

@@ -0,0 +1,162 @@
"""
Public API endpoints for matches.
This module provides public endpoints for retrieving matches without authentication.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Query, HTTPException, status, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.match import Match
from app.schemas.public import (
PublicMatchResponse,
SuccessResponse,
SuccessMeta
)
router = APIRouter(prefix="/api/public/v1", tags=["public-matches"])
@router.get("/matches", response_model=SuccessResponse)
def get_public_matches(
limit: int = Query(20, ge=1, le=100, description="Maximum number of matches to return (max 100)"),
offset: int = Query(0, ge=0, description="Number of matches to skip"),
league: Optional[str] = Query(None, description="Filter by league name (case-insensitive)"),
status_filter: Optional[str] = Query(None, alias="status", description="Filter by match status"),
db: Session = Depends(get_db)
):
"""
Get public matches with pagination and filters.
This endpoint provides publicly accessible matches without authentication.
Data is limited to non-sensitive information only.
Args:
limit: Maximum number of matches to return (1-100, default: 20)
offset: Number of matches to skip (default: 0)
league: Optional filter by league name (case-insensitive)
status: Optional filter by match status (e.g., "scheduled", "completed", "ongoing")
db: Database session (injected)
Returns:
Paginated list of public matches
Example Requests:
GET /api/public/v1/matches
GET /api/public/v1/matches?limit=10&offset=0
GET /api/public/v1/matches?league=Ligue%201
GET /api/public/v1/matches?status=scheduled
Example Response:
{
"data": [
{
"id": 1,
"home_team": "PSG",
"away_team": "Olympique de Marseille",
"date": "2026-01-18T20:00:00Z",
"league": "Ligue 1",
"status": "scheduled"
}
],
"meta": {
"total": 45,
"limit": 20,
"offset": 0,
"timestamp": "2026-01-17T14:30:00Z",
"version": "v1"
}
}
"""
# Build query
query = db.query(Match)
# Apply filters
if league:
query = query.filter(Match.league.ilike(f"%{league}%"))
if status_filter:
query = query.filter(Match.status == status_filter)
# Get total count
total = query.count()
# Apply pagination
matches = query.order_by(Match.date).offset(offset).limit(limit).all()
# Build response
match_responses = []
for match in matches:
match_responses.append({
"id": match.id,
"home_team": match.home_team,
"away_team": match.away_team,
"date": match.date.isoformat() if match.date else None,
"league": match.league,
"status": match.status
})
return {
"data": match_responses,
"meta": {
"total": total,
"limit": limit,
"offset": offset,
"timestamp": datetime.utcnow().isoformat() + "Z",
"version": "v1"
}
}
@router.get("/matches/{match_id}", response_model=PublicMatchResponse)
def get_public_match(
match_id: int,
db: Session = Depends(get_db)
):
"""
Get a specific public match by ID.
This endpoint provides a publicly accessible match without authentication.
Args:
match_id: ID of match
db: Database session (injected)
Returns:
Public match details
Raises:
404: If match doesn't exist
Example Request:
GET /api/public/v1/matches/1
Example Response:
{
"id": 1,
"home_team": "PSG",
"away_team": "Olympique de Marseille",
"date": "2026-01-18T20:00:00Z",
"league": "Ligue 1",
"status": "scheduled"
}
"""
match = db.query(Match).filter(Match.id == match_id).first()
if not match:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Match with id {match_id} not found"
)
return {
"id": match.id,
"home_team": match.home_team,
"away_team": match.away_team,
"date": match.date.isoformat() if match.date else None,
"league": match.league,
"status": match.status
}

View File

@@ -0,0 +1,189 @@
"""
Public API endpoints for predictions.
This module provides public endpoints for retrieving predictions without authentication.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Query, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.prediction_service import PredictionService
from app.schemas.public import (
PublicPredictionResponse,
SuccessResponse,
SuccessMeta
)
router = APIRouter(prefix="/api/public/v1", tags=["public-predictions"])
@router.get("/predictions", response_model=SuccessResponse)
def get_public_predictions(
limit: int = Query(20, ge=1, le=100, description="Maximum number of predictions to return (max 100)"),
offset: int = Query(0, ge=0, description="Number of predictions to skip"),
league: Optional[str] = Query(None, description="Filter by league name (case-insensitive)"),
db: Session = Depends(get_db)
):
"""
Get public predictions with pagination and filters.
This endpoint provides publicly accessible predictions without authentication.
Data is limited to non-sensitive information only.
Args:
limit: Maximum number of predictions to return (1-100, default: 20)
offset: Number of predictions to skip (default: 0)
league: Optional filter by league name (case-insensitive partial match)
db: Database session (injected)
Returns:
Paginated list of public predictions with match details
Example Requests:
GET /api/public/v1/predictions
GET /api/public/v1/predictions?limit=10&offset=0
GET /api/public/v1/predictions?league=Ligue%201
Example Response:
{
"data": [
{
"id": 1,
"match_id": 1,
"match": {
"id": 1,
"home_team": "PSG",
"away_team": "Olympique de Marseille",
"date": "2026-01-18T20:00:00Z",
"league": "Ligue 1",
"status": "scheduled"
},
"energy_score": "high",
"confidence": "65.0%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T12:00:00Z"
}
],
"meta": {
"total": 45,
"limit": 20,
"offset": 0,
"timestamp": "2026-01-17T14:30:00Z",
"version": "v1"
}
}
"""
service = PredictionService(db)
predictions, total = service.get_predictions_with_pagination(
limit=limit,
offset=offset,
team_id=None, # No team filter for public API
league=league,
date_min=None, # No date filter for public API
date_max=None
)
# Build response with match details
prediction_responses = []
for prediction in predictions:
match = prediction.match
prediction_responses.append({
"id": prediction.id,
"match_id": prediction.match_id,
"match": {
"id": match.id,
"home_team": match.home_team,
"away_team": match.away_team,
"date": match.date.isoformat() if match.date else None,
"league": match.league,
"status": match.status
},
"energy_score": prediction.energy_score,
"confidence": prediction.confidence,
"predicted_winner": prediction.predicted_winner,
"created_at": prediction.created_at.isoformat() if prediction.created_at else None
})
return {
"data": prediction_responses,
"meta": {
"total": total,
"limit": limit,
"offset": offset,
"timestamp": datetime.utcnow().isoformat() + "Z",
"version": "v1"
}
}
@router.get("/predictions/{prediction_id}", response_model=PublicPredictionResponse)
def get_public_prediction(
prediction_id: int,
db: Session = Depends(get_db)
):
"""
Get a specific public prediction by ID.
This endpoint provides a publicly accessible prediction without authentication.
Args:
prediction_id: ID of the prediction
db: Database session (injected)
Returns:
Public prediction with match details
Raises:
404: If prediction doesn't exist
Example Request:
GET /api/public/v1/predictions/1
Example Response:
{
"id": 1,
"match_id": 1,
"match": {
"id": 1,
"home_team": "PSG",
"away_team": "Olympique de Marseille",
"date": "2026-01-18T20:00:00Z",
"league": "Ligue 1",
"status": "scheduled"
},
"energy_score": "high",
"confidence": "65.0%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T12:00:00Z"
}
"""
service = PredictionService(db)
prediction = service.get_prediction_by_id(prediction_id)
if not prediction:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Prediction with id {prediction_id} not found"
)
match = prediction.match
return {
"id": prediction.id,
"match_id": prediction.match_id,
"match": {
"id": match.id,
"home_team": match.home_team,
"away_team": match.away_team,
"date": match.date.isoformat() if match.date else None,
"league": match.league,
"status": match.status
},
"energy_score": prediction.energy_score,
"confidence": prediction.confidence,
"predicted_winner": prediction.predicted_winner,
"created_at": prediction.created_at.isoformat() if prediction.created_at else None
}

View File

@@ -0,0 +1,30 @@
"""Routes API v1 de l'application."""
from fastapi import APIRouter
from .predictions import router as predictions_router
from .users import router as users_router
from .backtesting import router as backtesting_router
from .user_predictions import router as user_predictions_router
from .badges import router as badges_router
from .leaderboard import router as leaderboard_router
# #region agent log
def write_debug_log(hypothesisId: str, location: str, message: str, data: dict = None):
"""Écrit un log NDJSON pour le debug."""
import json
from datetime import datetime
log_entry = {
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": hypothesisId,
"location": location,
"message": message,
"data": data or {},
"timestamp": datetime.now().timestamp() * 1000
}
with open(r"d:\\dev_new_pc\\chartbastan\\.cursor\\debug.log", "a") as f:
f.write(json.dumps(log_entry) + "\n")
write_debug_log("C", "__init__.py:20", "API v1 routers initialized without main v1 router", {"routers": ["users", "auth", "predictions", "backtesting", "leaderboard", "badges", "user_predictions"]})
# #endregion
__all__ = ["predictions_router", "users_router", "backtesting_router", "user_predictions_router", "badges_router", "leaderboard_router", "auth_router"]

261
backend/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,261 @@
"""
Authentication API Endpoints.
This module provides endpoints for user authentication including
login, registration, and logout.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from passlib.context import CryptContext
from app.database import get_db
from app.models.user import User
from app.models.badge import Badge, UserBadge
from app.schemas.auth import (
LoginRequest,
RegisterRequest,
AuthResponse,
ErrorResponse
)
# #region agent log
def write_debug_log(hypothesisId: str, location: str, message: str, data: dict = None):
"""Écrit un log NDJSON pour le debug."""
import json
log_entry = {
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": hypothesisId,
"location": location,
"message": message,
"data": data or {},
"timestamp": datetime.now().timestamp() * 1000
}
with open(r"d:\\dev_new_pc\\chartbastan\\.cursor\\debug.log", "a") as f:
f.write(json.dumps(log_entry) + "\n")
# #endregion
# Configuration du hashage de mot de passe
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
router = APIRouter(tags=["authentication"])
# #region agent log
write_debug_log("C", "auth.py:29", "Auth router initialized", {"prefix": "/api/v1/auth", "tags": ["authentication"]})
# #endregion
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Vérifie un mot de passe en clair contre le hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Génère un hash sécurisé pour un mot de passe"""
return pwd_context.hash(password)
def generate_referral_code() -> str:
"""Génère un code de parrainage unique de 8 caractères"""
import secrets
import string
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
@router.post("/login", response_model=AuthResponse, responses={401: {"model": ErrorResponse}})
def login_user(
request: LoginRequest,
db: Session = Depends(get_db)
):
"""
Connecter un utilisateur avec email et mot de passe.
Args:
request: Email et mot de passe de l'utilisateur
db: Session de base de données
Returns:
AuthResponse avec token et données utilisateur
Raises:
401: Si email ou mot de passe incorrect
500: Si erreur serveur
"""
# #region agent log
write_debug_log("D", "auth.py:48", "Login endpoint called", {"email": request.email})
# #endregion
user = db.query(User).filter(User.email == request.email).first()
# #region agent log
write_debug_log("D", "auth.py:67", "User lookup", {"email": request.email, "user_found": user is not None})
# #endregion
# Vérifier si l'utilisateur existe
if not user:
# #region agent log
write_debug_log("D", "auth.py:70", "User not found", {"email": request.email})
# #endregion
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
# Vérifier le mot de passe
if not user.password_hash:
# #region agent log
write_debug_log("D", "auth.py:81", "No password hash", {"email": request.email, "user_id": user.id})
# #endregion
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
password_valid = verify_password(request.password, user.password_hash)
# #region agent log
write_debug_log("D", "auth.py:91", "Password verification", {"email": request.email, "user_id": user.id, "password_valid": password_valid})
# #endregion
if not password_valid:
# #region agent log
write_debug_log("D", "auth.py:96", "Invalid password", {"email": request.email, "user_id": user.id})
# #endregion
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
# Retourner les données utilisateur (sans le hash de mot de passe)
# #region agent log
write_debug_log("D", "auth.py:107", "Login successful", {"email": request.email, "user_id": user.id})
# #endregion
return {
"data": {
"id": user.id,
"email": user.email,
"name": user.name,
"is_premium": user.is_premium,
"referral_code": user.referral_code,
"created_at": user.created_at,
},
"meta": {
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
}
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
def register_user(
request: RegisterRequest,
db: Session = Depends(get_db)
):
"""
Inscrire un nouvel utilisateur.
Args:
request: Email, mot de passe, nom optionnel et code de parrainage
db: Session de base de données
Returns:
AuthResponse avec données utilisateur
Raises:
400: Si validation échoue
409: Si email déjà utilisé
500: Si erreur serveur
"""
# Vérifier si l'email existe déjà
existing_user = db.query(User).filter(User.email == request.email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cet email est déjà utilisé"
)
# Valider le code de parrainage si fourni
referrer: Optional[User] = None
if request.referral_code:
referrer = db.query(User).filter(
User.referral_code == request.referral_code
).first()
if not referrer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Code de parrainage invalide"
)
# Générer un code de parrainage unique
referral_code = generate_referral_code()
while db.query(User).filter(User.referral_code == referral_code).first():
referral_code = generate_referral_code()
# Hasher le mot de passe
password_hash = get_password_hash(request.password)
# Créer l'utilisateur
new_user = User(
email=request.email,
password_hash=password_hash,
name=request.name,
is_premium=False,
referral_code=referral_code,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# Attribuer le badge de bienvenue (première connexion)
welcome_badge = db.query(Badge).filter(
Badge.badge_id == "first_login"
).first()
if welcome_badge:
user_badge = UserBadge(
user_id=new_user.id,
badge_id=welcome_badge.id,
unlocked_at=datetime.utcnow()
)
db.add(user_badge)
db.commit()
return {
"data": {
"id": new_user.id,
"email": new_user.email,
"name": new_user.name,
"is_premium": new_user.is_premium,
"referral_code": new_user.referral_code,
"created_at": new_user.created_at,
},
"meta": {
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
}
@router.post("/logout")
def logout_user():
"""
Déconnecter l'utilisateur.
Note: Le backend utilise des cookies côté client.
Cette endpoint est disponible pour compatibilité future avec JWT.
"""
return {
"data": {
"message": "Déconnexion réussie"
},
"meta": {
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
}

View File

@@ -0,0 +1,177 @@
"""
Backtesting API Endpoints.
This module provides API endpoints for running backtesting
on historical match data and exporting results.
"""
import logging
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.backtesting_service import BacktestingService
from app.schemas.backtesting import (
BacktestingRequest,
BacktestingResponse,
BacktestingErrorResponse
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/backtesting", tags=["backtesting"])
@router.post("/run", response_model=BacktestingResponse, responses={400: {"model": BacktestingErrorResponse}})
def run_backtesting(
request: BacktestingRequest,
db: Session = Depends(get_db)
):
"""
Run backtesting on historical matches.
Analyzes historical match predictions against actual results to calculate
accuracy metrics and validate prediction system performance.
- **leagues**: Optional list of leagues to filter by (e.g., ['Ligue 1', 'Premier League'])
- **start_date**: Optional start date for filtering matches (ISO 8601 format)
- **end_date**: Optional end date for filtering matches (ISO 8601 format)
- **export_format**: Optional export format ('json', 'csv', 'html')
Returns:
Comprehensive backtesting report including:
- Total matches analyzed
- Correct/incorrect predictions
- Overall accuracy percentage
- Validation status (VALIDATED, REVISION_REQUIRED, BELOW_TARGET)
- Detailed results per match
- Metrics breakdown by league
Example:
```json
{
"leagues": ["Ligue 1", "Premier League"],
"start_date": "2025-01-01T00:00:00Z",
"end_date": "2025-12-31T23:59:59Z",
"export_format": "html"
}
```
Raises:
400: If no matches found with specified filters
500: If internal server error occurs
"""
try:
logger.info("Backtesting request received")
# Parse dates if provided
start_date = None
end_date = None
if request.start_date:
try:
start_date = datetime.fromisoformat(request.start_date.replace('Z', '+00:00'))
except ValueError as e:
raise HTTPException(
status_code=400,
detail=f"Invalid start_date format. Use ISO 8601 format: {str(e)}"
)
if request.end_date:
try:
end_date = datetime.fromisoformat(request.end_date.replace('Z', '+00:00'))
except ValueError as e:
raise HTTPException(
status_code=400,
detail=f"Invalid end_date format. Use ISO 8601 format: {str(e)}"
)
# Initialize backtesting service
backtesting_service = BacktestingService(db)
# Run backtesting
result = backtesting_service.run_backtesting(
leagues=request.leagues,
start_date=start_date,
end_date=end_date
)
logger.info(
f"Backtesting completed: {result['total_matches']} matches, "
f"{result['accuracy']:.2f}% accuracy"
)
# Export results if format specified
if request.export_format:
try:
exported = backtesting_service.export_results(result, request.export_format)
result['export'] = {
'format': request.export_format,
'data': exported
}
except ValueError as e:
logger.warning(f"Export failed: {str(e)}")
# Continue without export, just warn
# Wrap response with metadata
response = {
'data': result,
'meta': {
'timestamp': result.get('timestamp'),
'version': 'v1'
}
}
return response
except ValueError as e:
logger.error(f"Backtesting value error: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Backtesting error: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Internal server error during backtesting: {str(e)}"
)
@router.get("/status")
def get_backtesting_status():
"""
Get backtesting service status.
Returns information about the backtesting system configuration
and validation thresholds.
Returns:
Status information including:
- Available leagues
- Validation thresholds
- Service health status
"""
from app.ml.backtesting import (
ACCURACY_VALIDATED_THRESHOLD,
ACCURACY_ALERT_THRESHOLD
)
return {
'data': {
'status': 'operational',
'validation_thresholds': {
'validated_threshold': ACCURACY_VALIDATED_THRESHOLD,
'alert_threshold': ACCURACY_ALERT_THRESHOLD
},
'supported_export_formats': ['json', 'csv', 'html'],
'available_filters': {
'leagues': True,
'date_range': True
}
},
'meta': {
'timestamp': datetime.utcnow().isoformat(),
'version': 'v1'
}
}

View File

@@ -0,0 +1,166 @@
"""
API endpoints pour les badges
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime
from app.database import get_db
from app.models.badge import Badge, UserBadge
from app.models.user import User
from app.schemas.badge import (
BadgeResponse,
BadgeListResponse,
BadgeCheckResponse,
UserBadgeResponse,
UserBadgeListResponse,
BadgeUnlockRequest,
)
from app.services.badge_service import BadgeService
router = APIRouter()
@router.get("/", response_model=BadgeListResponse)
def get_all_badges(
db: Session = Depends(get_db),
user_id: Optional[int] = None,
):
"""
Récupère tous les badges disponibles
- Indique les badges débloqués par l'utilisateur si user_id est fourni
- Retourne les critères de débloquage
"""
# Récupérer tous les badges
badges = db.query(Badge).all()
# Si user_id est fourni, récupérer les badges débloqués
unlocked_badge_ids = set()
if user_id:
unlocked_badges = db.query(UserBadge).filter(UserBadge.user_id == user_id).all()
unlocked_badge_ids = set(ub.badge_id for ub in unlocked_badges)
# Formater la réponse
badge_responses = []
for badge in badges:
badge_responses.append(
BadgeResponse(
id=badge.id,
badgeId=badge.badge_id,
name=badge.name,
description=badge.description,
icon=badge.icon,
category=badge.category,
criteriaType=badge.criteria_type,
criteriaValue=badge.criteria_value,
criteriaDescription=badge.criteria_description,
rarity=badge.rarity,
points=badge.points,
createdAt=badge.created_at.isoformat(),
unlocked=badge.badge_id in unlocked_badge_ids if user_id else None,
)
)
return BadgeListResponse(
data=badge_responses,
meta={
"count": len(badge_responses),
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
)
@router.post("/check", response_model=BadgeCheckResponse)
def check_badges(
request: BadgeUnlockRequest,
db: Session = Depends(get_db),
):
"""
Vérifie et débloque les nouveaux badges pour un utilisateur
- Vérifie les critères de badges de l'utilisateur
- Débloque les nouveaux badges atteints
- Retourne les badges débloqués
- Envoie les notifications
"""
# Vérifier que l'utilisateur existe
user = db.query(User).filter(User.id == request.userId).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Utilisateur non trouvé"
)
# Utiliser le service pour vérifier et débloquer les badges
badge_service = BadgeService(db)
result = badge_service.check_and_unlock_badges(request.userId)
return BadgeCheckResponse(
data={
"new_badges": result["new_badges"],
"total_badges": result["total_badges"],
"message": result["message"],
},
meta={
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
)
@router.get("/users/{user_id}", response_model=UserBadgeListResponse)
def get_user_badges(
user_id: int,
db: Session = Depends(get_db),
):
"""
Récupère tous les badges débloqués par un utilisateur
"""
# Vérifier que l'utilisateur existe
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Utilisateur non trouvé"
)
# Récupérer les badges débloqués
user_badges = db.query(UserBadge).filter(UserBadge.user_id == user_id).all()
# Formater la réponse
badge_responses = []
for user_badge in user_badges:
badge = db.query(Badge).filter(Badge.id == user_badge.badge_id).first()
if badge:
badge_responses.append(
UserBadgeResponse(
id=user_badge.id,
userId=user_badge.user_id,
badgeId=user_badge.badge_id,
unlockedAt=user_badge.unlocked_at.isoformat(),
badge={
"id": badge.id,
"badgeId": badge.badge_id,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"category": badge.category,
"criteriaType": badge.criteria_type,
"criteriaValue": badge.criteria_value,
"criteriaDescription": badge.criteria_description,
"rarity": badge.rarity,
"points": badge.points,
"createdAt": badge.created_at.isoformat(),
}
)
)
return UserBadgeListResponse(
data={"badges": badge_responses},
meta={
"count": len(badge_responses),
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
)

View File

@@ -0,0 +1,142 @@
"""
Leaderboard API Routes.
This module provides REST endpoints for retrieving user rankings
and leaderboards based on prediction accuracy.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.leaderboard_service import LeaderboardService
from app.schemas.leaderboard import LeaderboardResponse, LeaderboardEntry, PersonalRankData
router = APIRouter(prefix="/api/v1/leaderboard", tags=["leaderboard"])
@router.get("", response_model=LeaderboardResponse)
def get_leaderboard(
limit: int = Query(100, ge=1, le=100, description="Maximum number of users to return (max 100)"),
user_id: Optional[int] = Query(None, description="Current user ID to include personal rank data"),
db: Session = Depends(get_db)
):
"""
Get leaderboard with top 100 users sorted by accuracy.
This endpoint retrieves the top users based on their prediction accuracy
and includes personal rank data if user_id is provided.
Ranking criteria:
- Primary: Accuracy percentage (higher is better)
- Secondary: Number of predictions viewed (more is better, used as tie-breaker)
Args:
limit: Maximum number of users to return (1-100, default: 100)
user_id: Optional current user ID to include personal rank data
db: Database session (injected)
Returns:
Leaderboard with top users and optional personal rank data
Raises:
404: If user_id provided but user doesn't exist
Example Request:
GET /api/v1/leaderboard
GET /api/v1/leaderboard?user_id=1
Example Response:
{
"data": [
{
"user_id": 1,
"username": "JohnDoe",
"accuracy": 95.5,
"predictions_count": 100
},
{
"user_id": 2,
"username": "JaneSmith",
"accuracy": 90.0,
"predictions_count": 85
}
],
"personal_data": {
"rank": 42,
"accuracy": 75.5,
"predictions_count": 25
},
"meta": {
"total": 2,
"limit": 100,
"timestamp": "2026-01-18T10:30:00Z",
"version": "v1"
}
}
"""
service = LeaderboardService(db)
leaderboard = service.get_leaderboard(limit=limit)
# Get personal rank if user_id provided
personal_data = None
if user_id:
personal_rank = service.get_personal_rank(user_id)
if personal_rank:
personal_data = PersonalRankData(**personal_rank)
# Format leaderboard entries
entries = [LeaderboardEntry(**entry) for entry in leaderboard]
return {
"data": entries,
"personal_data": personal_data,
"meta": {
"total": len(entries),
"limit": limit,
"timestamp": datetime.utcnow().isoformat() + "Z",
"version": "v1"
}
}
@router.get("/personal/{user_id}", response_model=PersonalRankData)
def get_personal_rank(
user_id: int,
db: Session = Depends(get_db)
):
"""
Get personal rank data for a specific user.
Args:
user_id: ID of the user
db: Database session (injected)
Returns:
Personal rank data with rank, accuracy, and predictions count
Raises:
404: If user doesn't exist or has no completed predictions
Example Request:
GET /api/v1/leaderboard/personal/1
Example Response:
{
"rank": 42,
"accuracy": 75.5,
"predictions_count": 25
}
"""
service = LeaderboardService(db)
personal_rank = service.get_personal_rank(user_id)
if not personal_rank:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found or has no completed predictions"
)
return PersonalRankData(**personal_rank)

View File

@@ -0,0 +1,425 @@
"""
Prediction API Routes.
This module provides REST endpoints for creating and retrieving match predictions.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.prediction_service import PredictionService
from app.schemas.prediction import PredictionListResponse, PredictionResponse
router = APIRouter(prefix="/api/v1/predictions", tags=["predictions"])
@router.get("", response_model=PredictionListResponse)
def get_predictions(
limit: int = Query(20, ge=1, le=100, description="Maximum number of predictions to return (max 100)"),
offset: int = Query(0, ge=0, description="Number of predictions to skip"),
team_id: Optional[int] = Query(None, description="Filter by team ID"),
league: Optional[str] = Query(None, description="Filter by league name (case-insensitive)"),
date_min: Optional[datetime] = Query(None, description="Filter for matches after this date (ISO 8601)"),
date_max: Optional[datetime] = Query(None, description="Filter for matches before this date (ISO 8601)"),
db: Session = Depends(get_db)
):
"""
Get all predictions with pagination and filters.
This endpoint retrieves predictions joined with match data, applies optional filters,
and returns paginated results sorted by match date (upcoming matches first).
Args:
limit: Maximum number of predictions to return (1-100, default: 20)
offset: Number of predictions to skip (default: 0)
team_id: Optional filter by team ID (matches where team is home or away)
league: Optional filter by league name (case-insensitive partial match)
date_min: Optional filter for matches after this date
date_max: Optional filter for matches before this date
db: Database session (injected)
Returns:
Paginated list of predictions with match details and metadata
Example Requests:
GET /api/v1/predictions
GET /api/v1/predictions?limit=10&offset=0
GET /api/v1/predictions?league=Ligue%201
GET /api/v1/predictions?team_id=1&limit=5
GET /api/v1/predictions?date_min=2026-01-15T00:00:00Z&date_max=2026-01-20T23:59:59Z
Example Response:
{
"data": [
{
"id": 1,
"match_id": 1,
"match": {
"id": 1,
"home_team": "PSG",
"away_team": "Olympique de Marseille",
"date": "2026-01-18T20:00:00Z",
"league": "Ligue 1",
"status": "scheduled"
},
"energy_score": "high",
"confidence": "65.0%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T12:00:00Z"
}
],
"meta": {
"total": 45,
"limit": 20,
"offset": 0,
"timestamp": "2026-01-17T14:30:00Z",
"version": "v1"
}
}
"""
service = PredictionService(db)
predictions, total = service.get_predictions_with_pagination(
limit=limit,
offset=offset,
team_id=team_id,
league=league,
date_min=date_min,
date_max=date_max
)
# Build response with match details
prediction_responses = []
for prediction in predictions:
match = prediction.match
prediction_responses.append({
"id": prediction.id,
"match_id": prediction.match_id,
"match": {
"id": match.id,
"home_team": match.home_team,
"away_team": match.away_team,
"date": match.date.isoformat() if match.date else None,
"league": match.league,
"status": match.status
},
"energy_score": prediction.energy_score,
"confidence": prediction.confidence,
"predicted_winner": prediction.predicted_winner,
"created_at": prediction.created_at.isoformat() if prediction.created_at else None
})
return {
"data": prediction_responses,
"meta": {
"total": total,
"limit": limit,
"offset": offset,
"timestamp": datetime.utcnow().isoformat() + "Z",
"version": "v1"
}
}
@router.get("/match/{match_id}")
def get_prediction_by_match_id(
match_id: int,
db: Session = Depends(get_db)
):
"""
Get prediction details for a specific match.
This endpoint retrieves the latest prediction for a match and includes
full match details, energy score information, and historical predictions.
Args:
match_id: ID of the match
db: Database session (injected)
Returns:
Prediction with full details including match info and history
Raises:
404: If match or prediction not found
Example Request:
GET /api/v1/predictions/match/1
Example Response:
{
"id": 3,
"match_id": 1,
"match": {
"id": 1,
"home_team": "PSG",
"away_team": "Olympique de Marseille",
"date": "2026-01-18T20:00:00Z",
"league": "Ligue 1",
"status": "scheduled",
"actual_winner": null
},
"energy_score": "high",
"confidence": "70.5%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T14:00:00Z",
"history": [
{
"id": 3,
"energy_score": "high",
"confidence": "70.5%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T14:00:00Z"
},
{
"id": 2,
"energy_score": "medium",
"confidence": "60.0%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T12:00:00Z"
}
]
}
"""
service = PredictionService(db)
prediction_details = service.get_prediction_with_details(match_id)
if not prediction_details:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No predictions found for match {match_id}"
)
return prediction_details
@router.post("/matches/{match_id}/predict", response_model=PredictionResponse, status_code=status.HTTP_201_CREATED)
def create_prediction(
match_id: int,
home_energy: float,
away_energy: float,
energy_score_label: str | None = None,
db: Session = Depends(get_db)
):
"""
Create a prediction for a specific match.
This endpoint calculates a prediction based on energy scores for both teams
and stores it in the database.
Args:
match_id: ID of the match to predict
home_energy: Energy score of the home team (0.0+)
away_energy: Energy score of the away team (0.0+)
energy_score_label: Optional label for energy score (e.g., "high", "medium", "low")
db: Database session (injected)
Returns:
Created prediction object
Raises:
404: If match doesn't exist
400: If energy scores are invalid
422: If validation fails
Example Request:
POST /api/v1/predictions/matches/1/predict?home_energy=65.0&away_energy=45.0
Example Response:
{
"id": 1,
"match_id": 1,
"energy_score": "high",
"confidence": "40.0%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T12:00:00Z"
}
"""
try:
service = PredictionService(db)
prediction = service.create_prediction_for_match(
match_id=match_id,
home_energy=home_energy,
away_energy=away_energy,
energy_score_label=energy_score_label
)
return prediction.to_dict()
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create prediction: {str(e)}"
)
@router.get("/{prediction_id}", response_model=PredictionResponse)
def get_prediction(
prediction_id: int,
db: Session = Depends(get_db)
):
"""
Get a prediction by its ID.
Args:
prediction_id: ID of the prediction to retrieve
db: Database session (injected)
Returns:
Prediction object
Raises:
404: If prediction doesn't exist
Example Request:
GET /api/v1/predictions/1
Example Response:
{
"id": 1,
"match_id": 1,
"energy_score": "high",
"confidence": "40.0%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T12:00:00Z"
}
"""
service = PredictionService(db)
prediction = service.get_prediction_by_id(prediction_id)
if not prediction:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Prediction with id {prediction_id} not found"
)
return prediction.to_dict()
@router.get("/matches/{match_id}", response_model=PredictionListResponse)
def get_predictions_for_match(
match_id: int,
db: Session = Depends(get_db)
):
"""
Get all predictions for a specific match.
Args:
match_id: ID of the match
db: Database session (injected)
Returns:
List of predictions for the match
Example Request:
GET /api/v1/predictions/matches/1
Example Response:
{
"data": [
{
"id": 1,
"match_id": 1,
"energy_score": "high",
"confidence": "40.0%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T12:00:00Z"
}
],
"count": 1,
"meta": {}
}
"""
service = PredictionService(db)
predictions = service.get_predictions_for_match(match_id)
return {
"data": [pred.to_dict() for pred in predictions],
"count": len(predictions),
"meta": {
"match_id": match_id
}
}
@router.get("/matches/{match_id}/latest", response_model=PredictionResponse)
def get_latest_prediction_for_match(
match_id: int,
db: Session = Depends(get_db)
):
"""
Get the most recent prediction for a specific match.
Args:
match_id: ID of the match
db: Database session (injected)
Returns:
Latest prediction object
Raises:
404: If no predictions exist for the match
Example Request:
GET /api/v1/predictions/matches/1/latest
Example Response:
{
"id": 3,
"match_id": 1,
"energy_score": "high",
"confidence": "45.0%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T14:00:00Z"
}
"""
service = PredictionService(db)
prediction = service.get_latest_prediction_for_match(match_id)
if not prediction:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No predictions found for match {match_id}"
)
return prediction.to_dict()
@router.delete("/{prediction_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_prediction(
prediction_id: int,
db: Session = Depends(get_db)
):
"""
Delete a prediction by its ID.
Args:
prediction_id: ID of the prediction to delete
db: Database session (injected)
Returns:
No content (204)
Raises:
404: If prediction doesn't exist
Example Request:
DELETE /api/v1/predictions/1
Example Response:
(HTTP 204 No Content)
"""
service = PredictionService(db)
deleted = service.delete_prediction(prediction_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Prediction with id {prediction_id} not found"
)
return None

View File

@@ -0,0 +1,241 @@
"""
User Prediction API Routes.
This module provides REST endpoints for tracking user predictions
and retrieving user statistics.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.user_prediction_service import UserPredictionService
from app.schemas.user_prediction import (
UserPredictionResponse,
UserPredictionListResponse,
UserStatsResponse
)
router = APIRouter(prefix="/api/v1/user-predictions", tags=["user-predictions"])
@router.post("", status_code=status.HTTP_201_CREATED)
def record_prediction_view(
user_id: int,
prediction_id: int,
db: Session = Depends(get_db)
):
"""
Record that a user viewed a prediction.
This endpoint tracks when a user views a prediction for ROI and accuracy calculations.
Duplicate views are ignored (unique constraint on user_id + prediction_id).
Args:
user_id: ID of the user
prediction_id: ID of the prediction viewed
db: Database session (injected)
Returns:
Created user prediction record
Raises:
404: If user or prediction doesn't exist
422: If validation fails
Example Request:
POST /api/v1/user-predictions?user_id=1&prediction_id=5
Example Response:
{
"id": 1,
"user_id": 1,
"prediction_id": 5,
"viewed_at": "2026-01-18T10:00:00Z",
"was_correct": null
}
"""
try:
service = UserPredictionService(db)
user_prediction = service.record_prediction_view(user_id, prediction_id)
return user_prediction.to_dict()
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to record prediction view: {str(e)}"
)
@router.get("/history/{user_id}", response_model=UserPredictionListResponse)
def get_prediction_history(
user_id: int,
limit: int = Query(50, ge=1, le=100, description="Maximum number of records to return (max 100)"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
db: Session = Depends(get_db)
):
"""
Get a user's prediction viewing history.
This endpoint retrieves all predictions a user has viewed, sorted by most recent.
Includes full prediction and match details.
Args:
user_id: ID of the user
limit: Maximum number of records to return (1-100, default: 50)
offset: Number of records to skip (default: 0)
db: Database session (injected)
Returns:
Paginated list of user predictions with full details
Example Request:
GET /api/v1/user-predictions/history/1?limit=10&offset=0
Example Response:
{
"data": [
{
"id": 5,
"user_id": 1,
"prediction_id": 10,
"viewed_at": "2026-01-18T10:00:00Z",
"was_correct": true,
"prediction": {
"id": 10,
"match_id": 3,
"energy_score": "high",
"confidence": "75.0%",
"predicted_winner": "PSG",
"created_at": "2026-01-17T12:00:00Z"
},
"match": {
"id": 3,
"home_team": "PSG",
"away_team": "Marseille",
"date": "2026-01-18T20:00:00Z",
"league": "Ligue 1",
"status": "completed",
"actual_winner": "PSG"
}
}
],
"meta": {
"total": 25,
"limit": 10,
"offset": 0,
"timestamp": "2026-01-18T15:30:00Z"
}
}
"""
from datetime import datetime
service = UserPredictionService(db)
predictions, total = service.get_user_prediction_history(
user_id=user_id,
limit=limit,
offset=offset
)
return {
"data": predictions,
"meta": {
"total": total,
"limit": limit,
"offset": offset,
"timestamp": datetime.utcnow().isoformat() + "Z"
}
}
@router.get("/stats/{user_id}", response_model=UserStatsResponse)
def get_user_statistics(
user_id: int,
db: Session = Depends(get_db)
):
"""
Get user statistics including accuracy and ROI.
This endpoint calculates:
- Total predictions viewed
- Number of correct/incorrect predictions
- Accuracy rate percentage
- ROI (Return on Investment) in EUR
Args:
user_id: ID of the user
db: Database session (injected)
Returns:
User statistics
Raises:
404: If user doesn't exist
Example Request:
GET /api/v1/user-predictions/stats/1
Example Response:
{
"total_predictions_viewed": 25,
"correct_predictions": 18,
"incorrect_predictions": 5,
"accuracy_rate": 78.3,
"roi": 1550.0
}
"""
service = UserPredictionService(db)
stats = service.get_user_stats(user_id)
return UserStatsResponse(**stats)
@router.put("/result/{prediction_id}")
def update_prediction_result(
prediction_id: int,
actual_winner: Optional[str] = Query(None, description="Actual winner: home, away, draw, or null"),
db: Session = Depends(get_db)
):
"""
Update prediction result when match completes.
This endpoint is called when a match finishes to update all user
predictions that referenced this prediction.
Args:
prediction_id: ID of the prediction
actual_winner: Actual winner of the match (home/away/draw or null)
db: Database session (injected)
Returns:
Success message
Example Request:
PUT /api/v1/user-predictions/result/10?actual_winner=home
Example Response:
{
"message": "Prediction results updated successfully",
"prediction_id": 10,
"actual_winner": "home"
}
"""
try:
service = UserPredictionService(db)
service.update_prediction_result(prediction_id, actual_winner)
return {
"message": "Prediction results updated successfully",
"prediction_id": prediction_id,
"actual_winner": actual_winner
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update prediction result: {str(e)}"
)

153
backend/app/api/v1/users.py Normal file
View File

@@ -0,0 +1,153 @@
"""Routes API pour les utilisateurs."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from passlib.context import CryptContext
from app.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserResponse, UserLoginRequest
# Configuration du hashage de mot de passe
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
router = APIRouter()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Vérifie un mot de passe en clair contre le hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Génère un hash sécurisé pour un mot de passe"""
return pwd_context.hash(password)
@router.post("/", response_model=UserResponse)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
"""
Créer un nouvel utilisateur (inscription).
Args:
user: Données utilisateur à créer.
db: Session de base de données.
Returns:
UserResponse: L'utilisateur créé.
Raises:
400: Si validation échoue
409: Si email déjà utilisé
"""
# Vérifier si l'email existe déjà
existing_user = db.query(User).filter(User.email == user.email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cet email est déjà utilisé"
)
# Hasher le mot de passe
password_hash = get_password_hash(user.password)
# Générer un code de parrainage unique
import secrets
import string
referral_code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
while db.query(User).filter(User.referral_code == referral_code).first():
referral_code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
# Créer l'utilisateur avec le mot de passe hashé
new_user = User(
email=user.email,
name=user.name,
password_hash=password_hash,
is_premium=False,
referral_code=referral_code
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return UserResponse(
id=new_user.id,
email=new_user.email,
name=new_user.name,
created_at=new_user.created_at,
updated_at=new_user.updated_at
)
@router.post("/login", response_model=UserResponse)
def login_user(user: UserLoginRequest, db: Session = Depends(get_db)):
"""
Connecter un utilisateur.
Args:
user: Email et mot de passe de l'utilisateur.
db: Session de base de données.
Returns:
UserResponse: L'utilisateur connecté.
Raises:
401: Si email ou mot de passe incorrect
"""
# Vérifier si l'utilisateur existe
db_user = db.query(User).filter(User.email == user.email).first()
if not db_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
# Vérifier le mot de passe
if not verify_password(user.password, db_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
return UserResponse(
id=db_user.id,
email=db_user.email,
name=db_user.name,
created_at=db_user.created_at,
updated_at=db_user.updated_at
)
@router.get("/", response_model=List[UserResponse])
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
"""
Récupérer la liste des utilisateurs.
Args:
skip: Nombre d'éléments à sauter.
limit: Nombre maximum d'éléments à retourner.
db: Session de base de données.
Returns:
List[UserResponse]: Liste des utilisateurs.
"""
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.get("/{user_id}", response_model=UserResponse)
def read_user(user_id: int, db: Session = Depends(get_db)):
"""
Récupérer un utilisateur par son ID.
Args:
user_id: ID de l'utilisateur.
db: Session de base de données.
Returns:
UserResponse: L'utilisateur trouvé.
"""
db_user = db.query(User).filter(User.id == user_id).first()
if db_user is None:
raise HTTPException(status_code=404, detail="Utilisateur non trouvé")
return db_user

185
backend/app/app_new.py Normal file
View File

@@ -0,0 +1,185 @@
"""
Application FastAPI avec authentification intégrée.
Ce fichier remplace main.py pour résoudre le problème de 404.
"""
import json
import secrets
import string
from datetime import datetime
from typing import Optional
from fastapi import FastAPI, Depends, HTTPException, status, Request
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from passlib.context import CryptContext
from app.database import get_db
from app.models.user import User
# App FastAPI
app = FastAPI(
title="Chartbastan API",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Password hashing - using pbkdf2_sha256 instead of bcrypt for Windows compatibility
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
# ==================== SCHEMAS ====================
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: Optional[str] = None
referral_code: Optional[str] = None
# ==================== HELPER FUNCTIONS ====================
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def generate_referral_code() -> str:
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
# ==================== ENDPOINTS ====================
@app.get("/")
def read_root():
return {"message": "Chartbastan API"}
@app.get("/health")
def health_check():
return {"status": "healthy"}
@app.post("/api/v1/auth/login")
def login(request: LoginRequest, db: Session = Depends(get_db)):
"""Login endpoint."""
user = db.query(User).filter(User.email == request.email).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
if not user.password_hash:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
if not verify_password(request.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
return {
"data": {
"id": user.id,
"email": user.email,
"name": user.name,
"is_premium": user.is_premium,
"referral_code": user.referral_code,
"created_at": str(user.created_at) if user.created_at else None,
},
"meta": {
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
}
@app.post("/api/v1/auth/register", status_code=status.HTTP_201_CREATED)
def register(request: RegisterRequest, db: Session = Depends(get_db)):
"""Register endpoint."""
existing = db.query(User).filter(User.email == request.email).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cet email est déjà utilisé"
)
referral_code = generate_referral_code()
while db.query(User).filter(User.referral_code == referral_code).first():
referral_code = generate_referral_code()
new_user = User(
email=request.email,
password_hash=get_password_hash(request.password),
name=request.name,
is_premium=False,
referral_code=referral_code,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return {
"data": {
"id": new_user.id,
"email": new_user.email,
"name": new_user.name,
"is_premium": new_user.is_premium,
"referral_code": new_user.referral_code,
"created_at": str(new_user.created_at),
},
"meta": {
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
}
@app.post("/api/v1/auth/logout")
def logout():
"""Logout endpoint."""
return {
"data": {"message": "Déconnexion réussie"},
"meta": {
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
}
# ==================== AUTRES ROUTERS ====================
from app.api.v1 import users, predictions, backtesting, leaderboard, badges
from app.api.public.v1 import predictions as public_predictions
from app.api.public.v1 import matches as public_matches
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
app.include_router(predictions.router)
app.include_router(backtesting.router)
app.include_router(leaderboard.router)
app.include_router(badges.router, prefix="/api/v1/badges", tags=["badges"])
app.include_router(public_predictions.router)
app.include_router(public_matches.router)

32
backend/app/config_db.py Normal file
View File

@@ -0,0 +1,32 @@
"""
Configuration centralisée de la base de données.
Ce fichier gère le chemin de la base de données et s'assure que
le frontend et le backend utilisent la MÊME base de données.
"""
import os
from pathlib import Path
# Chemin racine du projet
PROJECT_ROOT = Path(__file__).parent.parent
# Nom de la base de données SQLite
DB_FILENAME = "chartbastan.db"
# Chemins possibles pour la base de données (ordre de priorité)
DB_PATHS = [
PROJECT_ROOT / DB_FILENAME, # Racine : chartbastan/chartbastan.db (frontend + backend)
PROJECT_ROOT / "backend" / DB_FILENAME, # backend/chartbastan.db (compatibilité avec fichiers existants)
PROJECT_ROOT / "chartbastan" / DB_FILENAME, # chartbastan/chartbastan.db (frontend seulement)
]
# Chemin absolu de la base de données
DB_PATH = DB_PATHS[0] # Utiliser la base de données dans le dossier racine (PARTAGÉE par frontend et backend)
# Configuration SQLAlchemy (pour backend)
DATABASE_URL = f"sqlite:///{DB_PATH.as_posix()}"
DATABASE_URL_BACKEND_ONLY = f"sqlite:///{DB_PATHS[1].as_posix()}"
# Configuration Frontend Drizzle (pour frontend)
# Le frontend utilise chartbastan.db à la racine (même que le backend)

83
backend/app/database.py Normal file
View File

@@ -0,0 +1,83 @@
"""Configuration centralisée de la base de données.
Ce fichier gère le chemin de la base de données et définit les tables SQLAlchemy.
Le frontend et le backend utilisent la MÊME base de données partagée.
"""
import os
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime
# #region agent log
def write_debug_log(hypothesisId: str, location: str, message: str, data: dict = None):
"""Écrit un log NDJSON pour le debug."""
import json
log_entry = {
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": hypothesisId,
"location": location,
"message": message,
"data": data or {},
"timestamp": datetime.now().timestamp() * 1000
}
with open(r"d:\\\\dev_new_pc\\\\chartbastan\\\\.cursor\\\\debug.log", "a") as f:
f.write(json.dumps(log_entry) + "\\n")
# #endregion
# SQLite database (PARTAGÉE entre Next.js et FastAPI)
# Chemin absolu à la racine du projet pour que les deux utilisent la MÊME base
PROJECT_ROOT = Path(__file__).parent.parent
DB_FILENAME = "chartbastan.db"
DATABASE_PATH = PROJECT_ROOT / DB_FILENAME
# Configuration SQLAlchemy
engine = create_engine(f"sqlite:///{DATABASE_PATH.as_posix()}", connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Déclarative base pour définir les tables
Base = declarative_base()
# #region agent log
write_debug_log("D", "database.py:19", "Database engine created", {"DATABASE_PATH": str(DATABASE_PATH)})
# #endregion
# Créer un engine à part pour les models
# Les models (User, Match, Prediction, etc.) hériteront de Base
model_engine = create_engine(f"sqlite:///{DATABASE_PATH.as_posix()}", connect_args={"check_same_thread": False})
ModelBase = declarative_base()
def get_db():
"""
Obtenir une session de base de données.
Yields:
Session de base de données SQLAlchemy.
"""
# #region agent log
write_debug_log("D", "database.py:39", "get_db called", {})
# #endregion
db = SessionLocal()
try:
yield db
finally:
db.close()
# #region agent log
write_debug_log("D", "database.py:46", "Database session closed", {})
# #endregion
if __name__ == "__main__":
# #region agent log
write_debug_log("D", "database.py:32", "Database module loaded", {"status": "ready"})
# #endregion
print("🗄️ Configuration de la Base de Données Chartbastan")
print("=" * 60)
print(f"📁 Racine du projet : {PROJECT_ROOT}")
print(f"📄 Fichier de base de données : {DATABASE_PATH}")
print(f"🔗 URL SQLAlchemy : sqlite:///{DATABASE_PATH.as_posix()}")
print("=" * 60)

300
backend/app/lib/badges.py Normal file
View File

@@ -0,0 +1,300 @@
"""
Définitions des badges
Contient la liste de tous les badges avec leurs critères de déblocage
"""
BADGES = [
# ===== BADGES PRÉDICTIONS =====
{
"id": "debutant-prophete",
"name": "Débutant Prophète",
"description": "Consultez vos 10 premières prédictions",
"icon": "🔮",
"category": "predictions",
"criteria": {
"type": "predictions_count",
"value": 10,
"description": "10 prédictions consultées"
},
"rarity": "common",
"points": 10
},
{
"id": "observateur-augure",
"name": "Observateur Augure",
"description": "Consultez 50 prédictions",
"icon": "👁️",
"category": "predictions",
"criteria": {
"type": "predictions_count",
"value": 50,
"description": "50 prédictions consultées"
},
"rarity": "common",
"points": 25
},
{
"id": "oracle-averti",
"name": "Oracle Averti",
"description": "Consultez 100 prédictions",
"icon": "🏛️",
"category": "predictions",
"criteria": {
"type": "predictions_count",
"value": 100,
"description": "100 prédictions consultées"
},
"rarity": "rare",
"points": 50
},
{
"id": "maitre-des-destins",
"name": "Maître des Destins",
"description": "Consultez 500 prédictions",
"icon": "🌟",
"category": "predictions",
"criteria": {
"type": "predictions_count",
"value": 500,
"description": "500 prédictions consultées"
},
"rarity": "epic",
"points": 150
},
# ===== BADGES PRÉCISION =====
{
"id": "premiere-prediction",
"name": "Première Prédiction",
"description": "Obtenez votre première prédiction correcte",
"icon": "",
"category": "accuracy",
"criteria": {
"type": "correct_predictions",
"value": 1,
"description": "1 prédiction correcte"
},
"rarity": "common",
"points": 15
},
{
"id": "voyant-debrouille",
"name": "Voyant Debrouillé",
"description": "Obtenez 5 prédictions correctes",
"icon": "🎯",
"category": "accuracy",
"criteria": {
"type": "correct_predictions",
"value": 5,
"description": "5 prédictions correctes"
},
"rarity": "common",
"points": 30
},
{
"id": "expert-energie",
"name": "Expert Énergie",
"description": "Obtenez 20 prédictions correctes",
"icon": "",
"category": "accuracy",
"criteria": {
"type": "correct_predictions",
"value": 20,
"description": "20 prédictions correctes"
},
"rarity": "rare",
"points": 75
},
{
"id": "sage-infaillible",
"name": "Sage Infaillible",
"description": "Obtenez 50 prédictions correctes",
"icon": "👑",
"category": "accuracy",
"criteria": {
"type": "correct_predictions",
"value": 50,
"description": "50 prédictions correctes"
},
"rarity": "epic",
"points": 200
},
{
"id": "legendaire-prophete",
"name": "Légendaire Prophète",
"description": "Obtenez 100 prédictions correctes",
"icon": "🏆",
"category": "accuracy",
"criteria": {
"type": "correct_predictions",
"value": 100,
"description": "100 prédictions correctes"
},
"rarity": "legendary",
"points": 500
},
# ===== BADGES ENGAGEMENT =====
{
"id": "habitude-journalier",
"name": "Habitué Journalier",
"description": "Visitez l'app pendant 7 jours consécutifs",
"icon": "📅",
"category": "engagement",
"criteria": {
"type": "streak_days",
"value": 7,
"description": "7 jours consécutifs"
},
"rarity": "common",
"points": 35
},
{
"id": "fidele-devoue",
"name": "Fidèle Dévoué",
"description": "Visitez l'app pendant 30 jours consécutifs",
"icon": "💎",
"category": "engagement",
"criteria": {
"type": "streak_days",
"value": 30,
"description": "30 jours consécutifs"
},
"rarity": "rare",
"points": 100
},
{
"id": "pilier-communaute",
"name": "Pilier de la Communauté",
"description": "Visitez l'app pendant 90 jours consécutifs",
"icon": "🏛️",
"category": "engagement",
"criteria": {
"type": "streak_days",
"value": 90,
"description": "90 jours consécutifs"
},
"rarity": "epic",
"points": 300
},
# ===== BADGES SOCIAL =====
{
"id": "partageur-ambitieux",
"name": "Partageur Ambitieux",
"description": "Partagez 5 de vos réussites",
"icon": "📤",
"category": "social",
"criteria": {
"type": "share_count",
"value": 5,
"description": "5 partages de réussites"
},
"rarity": "common",
"points": 20
},
{
"id": "influenceur-montant",
"name": "Influenceur Montant",
"description": "Partagez 20 de vos réussites",
"icon": "📢",
"category": "social",
"criteria": {
"type": "share_count",
"value": 20,
"description": "20 partages de réussites"
},
"rarity": "rare",
"points": 80
},
{
"id": "parrain-bienveillant",
"name": "Parrain Bienveillant",
"description": "Parrainez 1 utilisateur",
"icon": "🤝",
"category": "social",
"criteria": {
"type": "referral_count",
"value": 1,
"description": "1 utilisateur parrainé"
},
"rarity": "common",
"points": 40
},
{
"id": "ambassadeur-charmant",
"name": "Ambassadeur Charmant",
"description": "Parrainez 5 utilisateurs",
"icon": "🎊",
"category": "social",
"criteria": {
"type": "referral_count",
"value": 5,
"description": "5 utilisateurs parrainés"
},
"rarity": "rare",
"points": 150
},
{
"id": "grand-seigneur",
"name": "Grand Seigneur",
"description": "Parrainez 10 utilisateurs",
"icon": "👔",
"category": "social",
"criteria": {
"type": "referral_count",
"value": 10,
"description": "10 utilisateurs parrainés"
},
"rarity": "epic",
"points": 350
},
{
"id": "mecene-generaux",
"name": "Mécène Généreux",
"description": "Parrainez 25 utilisateurs",
"icon": "🎖️",
"category": "social",
"criteria": {
"type": "referral_count",
"value": 25,
"description": "25 utilisateurs parrainés"
},
"rarity": "legendary",
"points": 750
}
]
def isBadgeUnlocked(badge: dict, user_criteria: dict) -> bool:
"""
Vérifie si un badge peut être débloqué selon les critères de l'utilisateur
"""
criteria_type = badge["criteria"]["type"]
criteria_value = badge["criteria"]["value"]
current_value = user_criteria.get(criteria_type, 0)
return current_value >= criteria_value
def getBadgeById(badge_id: str) -> dict | None:
"""
Récupère un badge par son ID
"""
for badge in BADGES:
if badge["id"] == badge_id:
return badge
return None
def getBadgesByCategory(category: str) -> list:
"""
Récupère tous les badges d'une catégorie
"""
return [badge for badge in BADGES if badge["category"] == category]
def getBadgesByRarity(rarity: str) -> list:
"""
Récupère tous les badges d'une rareté
"""
return [badge for badge in BADGES if badge["rarity"] == rarity]

263
backend/app/main.py Normal file
View File

@@ -0,0 +1,263 @@
"""Application FastAPI principale."""
import json
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from datetime import datetime
# #region agent log
def write_debug_log(hypothesisId: str, location: str, message: str, data: dict = None):
"""Écrit un log NDJSON pour le debug."""
log_entry = {
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": hypothesisId,
"location": location,
"message": message,
"data": data or {},
"timestamp": datetime.now().timestamp() * 1000
}
with open(r"d:\\dev_new_pc\\chartbastan\\.cursor\\debug.log", "a") as f:
f.write(json.dumps(log_entry) + "\n")
# #endregion
app = FastAPI(
title="Chartbastan API",
description="""
API publique pour les prédictions de matchs basées sur l'énergie collective.
## Endpoints Publics
- **Prédictions**: Accès aux prédictions de matchs avec filtres
- **Matchs**: Liste des matchs avec filtres par ligue et statut
## Authentification
Les endpoints publics ne nécessitent pas d'authentification.
Pour un usage intensif, générez une clé API via le dashboard développeur.
""",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json"
)
# #region agent log
write_debug_log("A", "main.py:1", "FastAPI app initialized", {"app_title": "Chartbastan API"})
# #endregion
# Rate limiting middleware DÉSACTIVÉ PERMANENTMENT
# app.add_middleware(RateLimitMiddleware, public_limit=10, authenticated_limit=100)
# #region agent log
write_debug_log("E", "main.py:27", "RateLimitMiddleware DISABLED permanently", {"status": "disabled"})
# #endregion
# CORS configuration
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for public API
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# #region agent log
write_debug_log("E", "main.py:35", "CORSMiddleware configured", {"allow_origins": ["*"], "allow_methods": ["*"], "allow_headers": ["*"]})
# #endregion
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Log toutes les requêtes entrantes pour diagnostic."""
start_time = datetime.now()
write_debug_log("A", "main.py:44", "Incoming request", {
"method": request.method,
"url": str(request.url),
"path": request.url.path,
"headers": dict(request.headers)
})
response = await call_next(request)
process_time = (datetime.now() - start_time).total_seconds()
write_debug_log("A", "main.py:44", "Request completed", {
"method": request.method,
"url": str(request.url),
"path": request.url.path,
"status_code": response.status_code,
"process_time": process_time
})
return response
@app.get("/")
def read_root():
"""Endpoint racine de l'API."""
write_debug_log("A", "main.py:58", "Root endpoint called", {"path": "/"})
return {"message": "Chartbastan API"}
@app.get("/health")
def health_check():
"""Endpoint de vérification de santé."""
return {"status": "healthy"}
# ============================================================
# AUTH ENDPOINTS - Définis directement ici pour garantir l'enregistrement
# ============================================================
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
from passlib.context import CryptContext
from app.database import get_db
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: Optional[str] = None
referral_code: Optional[str] = None
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
@app.post("/api/v1/auth/login")
def login_user(request: LoginRequest, db: Session = Depends(get_db)):
"""Connecter un utilisateur."""
user = db.query(User).filter(User.email == request.email).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
if not user.password_hash:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
if not verify_password(request.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect"
)
return {
"data": {
"id": user.id,
"email": user.email,
"name": user.name,
"is_premium": user.is_premium,
"referral_code": user.referral_code,
"created_at": str(user.created_at),
},
"meta": {
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
}
@app.post("/api/v1/auth/register", status_code=status.HTTP_201_CREATED)
def register_user(request: RegisterRequest, db: Session = Depends(get_db)):
"""Inscrire un nouvel utilisateur."""
import secrets
import string
existing_user = db.query(User).filter(User.email == request.email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cet email est déjà utilisé"
)
# Générer code de parrainage
alphabet = string.ascii_uppercase + string.digits
referral_code = ''.join(secrets.choice(alphabet) for _ in range(8))
password_hash = get_password_hash(request.password)
new_user = User(
email=request.email,
password_hash=password_hash,
name=request.name,
is_premium=False,
referral_code=referral_code,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return {
"data": {
"id": new_user.id,
"email": new_user.email,
"name": new_user.name,
"is_premium": new_user.is_premium,
"referral_code": new_user.referral_code,
"created_at": str(new_user.created_at),
},
"meta": {
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
}
@app.post("/api/v1/auth/logout")
def logout_user():
"""Déconnecter l'utilisateur."""
return {
"data": {"message": "Déconnexion réussie"},
"meta": {
"timestamp": datetime.utcnow().isoformat(),
"version": "v1"
}
}
# ============================================================
# FIN AUTH ENDPOINTS
# ============================================================
# Include API routers
from app.api.v1 import users
from app.api.v1 import auth
from app.api.v1 import predictions
from app.api.v1 import backtesting
from app.api.v1 import leaderboard
from app.api.v1 import badges
from app.api.public.v1 import predictions as public_predictions
from app.api.public.v1 import matches as public_matches
# #region agent log
write_debug_log("C", "main.py:101", "Including routers", {
"routers": ["users", "auth", "predictions", "backtesting", "leaderboard", "badges", "public_predictions", "public_matches"]
})
# #endregion
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(predictions.router)
app.include_router(backtesting.router)
app.include_router(leaderboard.router)
app.include_router(badges.router, prefix="/api/v1/badges", tags=["badges"])
# Public API routers
app.include_router(public_predictions.router)
app.include_router(public_matches.router)
# Trigger reload

View File

@@ -0,0 +1,3 @@
"""
Middleware package for the application.
"""

View File

@@ -0,0 +1,154 @@
"""
Rate limiting middleware for API.
This module provides rate limiting functionality for API endpoints.
"""
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Dict, Optional
from fastapi import HTTPException, status, Request
from starlette.middleware.base import BaseHTTPMiddleware
class InMemoryRateLimiter:
"""
In-memory rate limiter using sliding window algorithm.
For production, consider using Redis or a similar distributed cache.
"""
def __init__(self):
# Stores: {key: [(timestamp, count), ...]}
self.requests: Dict[str, list[tuple[datetime, int]]] = defaultdict(list)
# Stores last cleanup time
self.last_cleanup: Dict[str, datetime] = {}
def _cleanup_old_requests(self, key: str, window_seconds: int = 60):
"""Remove requests older than the time window."""
if key not in self.requests:
return
cutoff_time = datetime.utcnow() - timedelta(seconds=window_seconds)
self.requests[key] = [
(ts, count) for ts, count in self.requests[key]
if ts > cutoff_time
]
def _get_request_count(self, key: str, window_seconds: int = 60) -> int:
"""Count requests in the time window."""
self._cleanup_old_requests(key, window_seconds)
return sum(count for _, count in self.requests[key])
def is_allowed(self, key: str, limit: int, window_seconds: int = 60) -> bool:
"""
Check if request is allowed based on rate limit.
Args:
key: Unique identifier (e.g., user_id or IP address)
limit: Maximum requests per window
window_seconds: Time window in seconds (default: 60)
Returns:
True if allowed, False otherwise
"""
current_count = self._get_request_count(key, window_seconds)
return current_count < limit
def record_request(self, key: str):
"""Record a request for a key."""
self.requests[key].append((datetime.utcnow(), 1))
def get_remaining(self, key: str, limit: int, window_seconds: int = 60) -> int:
"""
Get remaining requests for a key.
Args:
key: Unique identifier
limit: Maximum requests per window
window_seconds: Time window in seconds (default: 60)
Returns:
Number of remaining requests
"""
current_count = self._get_request_count(key, window_seconds)
return max(0, limit - current_count)
# Global rate limiter instance
rate_limiter = InMemoryRateLimiter()
class RateLimitMiddleware(BaseHTTPMiddleware):
"""
Middleware to enforce rate limits on API requests.
This middleware applies rate limiting based on:
- If API key is present: use user_id from API key
- If no API key: use IP address (basic protection)
"""
def __init__(self, app, public_limit: int = 10, authenticated_limit: int = 100):
super().__init__(app)
self.public_limit = public_limit # 10 req/min for unauthenticated
self.authenticated_limit = authenticated_limit # 100 req/min for authenticated
async def dispatch(self, request: Request, call_next):
"""
Process request with rate limiting.
Args:
request: Incoming request
call_next: Next middleware/handler
Returns:
Response with rate limit headers
"""
# Get client IP for fallback
client_ip = request.client.host if request.client else "unknown"
# Get API key if present
api_key = request.headers.get("X-API-Key")
# Determine rate limit key
if api_key:
# For authenticated requests, we'd validate the key here
# For now, use API key hash as identifier
import hashlib
key_id = hashlib.sha256(api_key.encode()).hexdigest()[:16]
limit = self.authenticated_limit
else:
# Use IP address for unauthenticated requests
key_id = client_ip
limit = self.public_limit
# Check if request is allowed
if not rate_limiter.is_allowed(key_id, limit):
remaining = rate_limiter.get_remaining(key_id, limit)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={
"code": "RATE_LIMIT_EXCEEDED",
"message": f"Rate limit exceeded. Maximum {limit} requests per minute.",
"details": {
"limit": limit,
"remaining": remaining,
"retry_after": 60
}
}
)
# Record the request
rate_limiter.record_request(key_id)
# Process request
response = await call_next(request)
# Add rate limit headers to response
remaining = rate_limiter.get_remaining(key_id, limit)
response.headers["X-RateLimit-Limit"] = str(limit)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(60) # Reset after 60 seconds
return response

View File

@@ -0,0 +1,2 @@
# ML Module
# This module contains machine learning components for sentiment analysis and energy calculations

View File

@@ -0,0 +1,619 @@
"""
Backtesting Module.
This module provides functions to run backtesting on historical match data,
comparing predictions with actual results to calculate accuracy metrics.
"""
import json
import csv
from datetime import datetime
from typing import Dict, List, Any, Optional
from io import StringIO
from app.ml.prediction_calculator import calculate_prediction
# Validation thresholds
ACCURACY_VALIDATED_THRESHOLD = 60.0 # >= 60%: System validated
ACCURACY_ALERT_THRESHOLD = 55.0 # < 55%: Revision required
def validate_accuracy(accuracy: float) -> str:
"""
Validate the accuracy of the prediction system.
Args:
accuracy: Accuracy percentage (0.0 - 100.0)
Returns:
'VALIDATED' if accuracy >= 60%,
'REVISION_REQUIRED' if accuracy < 55%,
'BELOW_TARGET' if 55% <= accuracy < 60%
Examples:
>>> validate_accuracy(70.0)
'VALIDATED'
>>> validate_accuracy(50.0)
'REVISION_REQUIRED'
>>> validate_accuracy(58.0)
'BELOW_TARGET'
"""
if accuracy >= ACCURACY_VALIDATED_THRESHOLD:
return 'VALIDATED'
elif accuracy < ACCURACY_ALERT_THRESHOLD:
return 'REVISION_REQUIRED'
else:
return 'BELOW_TARGET'
def compare_prediction(predicted_winner: str, actual_winner: str) -> bool:
"""
Compare predicted winner with actual match result.
Args:
predicted_winner: 'home', 'away', or 'draw'
actual_winner: 'home', 'away', or 'draw'
Returns:
True if prediction was correct, False otherwise
Examples:
>>> compare_prediction('home', 'home')
True
>>> compare_prediction('home', 'away')
False
"""
return predicted_winner.lower() == actual_winner.lower()
def run_backtesting_single_match(
match_id: int,
home_team: str,
away_team: str,
home_energy: float,
away_energy: float,
actual_winner: str
) -> Dict[str, Any]:
"""
Run backtesting for a single historical match.
Calculates prediction and compares it with the actual result.
Args:
match_id: Unique match identifier
home_team: Name of the home team
away_team: Name of the away team
home_energy: Energy score of the home team
away_energy: Energy score of the away team
actual_winner: Actual result ('home', 'away', or 'draw')
Returns:
Dictionary containing match details, prediction, and comparison result
Examples:
>>> result = run_backtesting_single_match(1, 'PSG', 'OM', 65.0, 45.0, 'home')
>>> result['correct']
True
"""
# Calculate prediction
prediction = calculate_prediction(home_energy, away_energy)
# Compare with actual result
is_correct = compare_prediction(prediction['predicted_winner'], actual_winner)
return {
'match_id': match_id,
'home_team': home_team,
'away_team': away_team,
'home_energy': home_energy,
'away_energy': away_energy,
'prediction': prediction,
'actual_winner': actual_winner,
'correct': is_correct
}
def run_backtesting_batch(matches: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Run backtesting on a batch of historical matches.
Processes multiple matches, calculates predictions, compares with actual
results, and generates accuracy metrics and detailed report.
Args:
matches: List of match dictionaries with keys:
- match_id (int)
- home_team (str)
- away_team (str)
- home_energy (float)
- away_energy (float)
- actual_winner (str)
- league (str, optional)
- date (datetime, optional)
Returns:
Dictionary containing:
- total_matches: Number of matches processed
- correct_predictions: Number of correct predictions
- incorrect_predictions: Number of incorrect predictions
- accuracy: Accuracy percentage
- status: Validation status (VALIDATED, REVISION_REQUIRED, BELOW_TARGET)
- results: List of individual match results
- metrics_by_league: Accuracy breakdown by league
- timestamp: When the backtesting was run
Examples:
>>> matches = [
... {'match_id': 1, 'home_team': 'PSG', 'away_team': 'OM',
... 'home_energy': 65.0, 'away_energy': 45.0, 'actual_winner': 'home'},
... ]
>>> result = run_backtesting_batch(matches)
>>> result['accuracy']
100.0
"""
results = []
correct_predictions = 0
incorrect_predictions = 0
# Track metrics by league
league_metrics: Dict[str, Dict[str, Any]] = {}
for match in matches:
# Validate required fields
required_fields = ['match_id', 'home_team', 'away_team',
'home_energy', 'away_energy', 'actual_winner']
if not all(field in match for field in required_fields):
raise ValueError(f"Match missing required fields: {match}")
# Extract league and date if available
league = match.get('league', 'unknown')
match_date = match.get('date')
# Run backtesting for this match
result = run_backtesting_single_match(
match_id=match['match_id'],
home_team=match['home_team'],
away_team=match['away_team'],
home_energy=match['home_energy'],
away_energy=match['away_energy'],
actual_winner=match['actual_winner']
)
# Add league and date to result
result['league'] = league
result['date'] = match_date.isoformat() if match_date else None
# Track correctness
if result['correct']:
correct_predictions += 1
else:
incorrect_predictions += 1
# Update league metrics
if league not in league_metrics:
league_metrics[league] = {
'total': 0,
'correct': 0,
'accuracy': 0.0
}
league_metrics[league]['total'] += 1
if result['correct']:
league_metrics[league]['correct'] += 1
results.append(result)
# Calculate overall accuracy
total_matches = len(matches)
accuracy = (correct_predictions / total_matches * 100.0) if total_matches > 0 else 0.0
# Calculate accuracy per league
for league, metrics in league_metrics.items():
if metrics['total'] > 0:
metrics['accuracy'] = (metrics['correct'] / metrics['total'] * 100.0)
# Get validation status
status = validate_accuracy(accuracy)
return {
'total_matches': total_matches,
'correct_predictions': correct_predictions,
'incorrect_predictions': incorrect_predictions,
'accuracy': round(accuracy, 2),
'status': status,
'results': results,
'metrics_by_league': league_metrics,
'timestamp': datetime.utcnow().isoformat(),
'validation_thresholds': {
'validated': ACCURACY_VALIDATED_THRESHOLD,
'alert': ACCURACY_ALERT_THRESHOLD
}
}
def export_to_json(backtesting_result: Dict[str, Any]) -> str:
"""
Export backtesting results to JSON format.
Args:
backtesting_result: Result from run_backtesting_batch
Returns:
JSON formatted string
Examples:
>>> result = run_backtesting_batch(matches)
>>> json_output = export_to_json(result)
>>> isinstance(json_output, str)
True
"""
return json.dumps(backtesting_result, indent=2, default=str)
def export_to_csv(backtesting_result: Dict[str, Any]) -> str:
"""
Export backtesting results to CSV format.
Args:
backtesting_result: Result from run_backtesting_batch
Returns:
CSV formatted string
Examples:
>>> result = run_backtesting_batch(matches)
>>> csv_output = export_to_csv(result)
>>> isinstance(csv_output, str)
True
"""
output = StringIO()
fieldnames = [
'match_id', 'league', 'date', 'home_team', 'away_team',
'home_energy', 'away_energy', 'predicted_winner',
'confidence', 'actual_winner', 'correct'
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for result in backtesting_result.get('results', []):
row = {
'match_id': result['match_id'],
'league': result.get('league', ''),
'date': result.get('date', ''),
'home_team': result['home_team'],
'away_team': result['away_team'],
'home_energy': result['home_energy'],
'away_energy': result['away_energy'],
'predicted_winner': result['prediction']['predicted_winner'],
'confidence': result['prediction']['confidence'],
'actual_winner': result['actual_winner'],
'correct': result['correct']
}
writer.writerow(row)
return output.getvalue()
def export_to_html(backtesting_result: Dict[str, Any]) -> str:
"""
Export backtesting results to HTML format for publication.
Args:
backtesting_result: Result from run_backtesting_batch
Returns:
HTML formatted string with styling and charts
Examples:
>>> result = run_backtesting_batch(matches)
>>> html_output = export_to_html(result)
>>> '<html>' in html_output
True
"""
status_colors = {
'VALIDATED': '#10B981', # Green
'BELOW_TARGET': '#F59E0B', # Orange
'REVISION_REQUIRED': '#EF4444' # Red
}
status = backtesting_result['status']
accuracy = backtesting_result['accuracy']
total_matches = backtesting_result['total_matches']
correct_predictions = backtesting_result['correct_predictions']
incorrect_predictions = backtesting_result['incorrect_predictions']
# Build HTML
html = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backtesting Report - ChartBastan</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
min-height: 100vh;
}}
.container {{
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
}}
.header {{
text-align: center;
margin-bottom: 40px;
}}
.header h1 {{
font-size: 2.5em;
color: #667eea;
margin-bottom: 10px;
}}
.header p {{
color: #666;
font-size: 1.1em;
}}
.summary {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}}
.card {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 25px;
border-radius: 15px;
text-align: center;
}}
.card h3 {{
font-size: 0.9em;
opacity: 0.9;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 1px;
}}
.card .value {{
font-size: 2.5em;
font-weight: bold;
margin-bottom: 5px;
}}
.card .sub {{
font-size: 0.9em;
opacity: 0.9;
}}
.status-badge {{
display: inline-block;
padding: 10px 25px;
border-radius: 25px;
color: white;
font-weight: bold;
font-size: 1.2em;
margin: 20px 0;
}}
.section {{
margin-bottom: 40px;
}}
.section h2 {{
font-size: 1.8em;
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 3px solid #667eea;
}}
table {{
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}}
th, td {{
padding: 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}}
th {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
text-transform: uppercase;
font-size: 0.85em;
letter-spacing: 0.5px;
}}
tr:hover {{
background: #f5f5f5;
}}
.correct {{
color: #10B981;
font-weight: bold;
}}
.incorrect {{
color: #EF4444;
font-weight: bold;
}}
.footer {{
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 2px solid #ddd;
color: #666;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 Backtesting Report</h1>
<p>ChartBastan Prediction System Performance Analysis</p>
<p style="margin-top: 10px; font-size: 0.9em;">
Generated: {backtesting_result.get('timestamp', 'N/A')}
</p>
</div>
<div class="summary">
<div class="card">
<h3>Total Matches</h3>
<div class="value">{total_matches}</div>
<div class="sub">matches analyzed</div>
</div>
<div class="card">
<h3>Accuracy</h3>
<div class="value">{accuracy}%</div>
<div class="sub">prediction accuracy</div>
</div>
<div class="card">
<h3>Correct</h3>
<div class="value">{correct_predictions}</div>
<div class="sub">predictions</div>
</div>
<div class="card">
<h3>Incorrect</h3>
<div class="value">{incorrect_predictions}</div>
<div class="sub">predictions</div>
</div>
</div>
<div style="text-align: center;">
<div class="status-badge" style="background-color: {status_colors.get(status, '#666')};">
Status: {status}
</div>
</div>
<div class="section">
<h2>📈 Metrics by League</h2>
<table>
<thead>
<tr>
<th>League</th>
<th>Matches</th>
<th>Correct</th>
<th>Accuracy</th>
</tr>
</thead>
<tbody>
"""
# Add league metrics
for league, metrics in backtesting_result.get('metrics_by_league', {}).items():
html += f"""
<tr>
<td>{league}</td>
<td>{metrics['total']}</td>
<td>{metrics['correct']}</td>
<td>{metrics['accuracy']:.2f}%</td>
</tr>
"""
html += """
</tbody>
</table>
</div>
<div class="section">
<h2>📋 Detailed Results</h2>
<table>
<thead>
<tr>
<th>Match ID</th>
<th>League</th>
<th>Home vs Away</th>
<th>Prediction</th>
<th>Confidence</th>
<th>Actual</th>
<th>Result</th>
</tr>
</thead>
<tbody>
"""
# Add detailed results
for result in backtesting_result.get('results', []):
result_class = 'correct' if result['correct'] else 'incorrect'
html += f"""
<tr>
<td>{result['match_id']}</td>
<td>{result.get('league', 'N/A')}</td>
<td>{result['home_team']} vs {result['away_team']}</td>
<td>{result['prediction']['predicted_winner']}</td>
<td>{result['prediction']['confidence']:.1f}%</td>
<td>{result['actual_winner']}</td>
<td class="{result_class}">{'✓ Correct' if result['correct'] else '✗ Incorrect'}</td>
</tr>
"""
html += """
</tbody>
</table>
</div>
<div class="footer">
<p>🎯 ChartBastan - Football Match Prediction System</p>
<p>© 2026 All rights reserved</p>
</div>
</div>
</body>
</html>
"""
return html
def filter_matches_by_league(matches: List[Dict[str, Any]], leagues: List[str]) -> List[Dict[str, Any]]:
"""
Filter matches by league(s).
Args:
matches: List of match dictionaries
leagues: List of league names to include
Returns:
Filtered list of matches
Examples:
>>> matches = [{'league': 'Ligue 1', 'home_team': 'PSG', ...}]
>>> filtered = filter_matches_by_league(matches, ['Ligue 1'])
>>> len(filtered)
1
"""
if not leagues:
return matches
return [m for m in matches if m.get('league') in leagues]
def filter_matches_by_period(
matches: List[Dict[str, Any]],
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""
Filter matches by date period.
Args:
matches: List of match dictionaries
start_date: Start date (inclusive), or None for no lower bound
end_date: End date (inclusive), or None for no upper bound
Returns:
Filtered list of matches
Examples:
>>> from datetime import datetime
>>> matches = [{'date': datetime(2026, 1, 1), ...}]
>>> filtered = filter_matches_by_period(matches, datetime(2025, 1, 1))
"""
filtered = matches
if start_date:
filtered = [m for m in filtered if m.get('date') and m['date'] >= start_date]
if end_date:
filtered = [m for m in filtered if m.get('date') and m['date'] <= end_date]
return filtered

View File

@@ -0,0 +1,356 @@
"""
Energy Calculator Module.
This module calculates collective energy scores based on sentiment analysis
from multiple sources (Twitter, Reddit, RSS) using a weighted formula.
Formula: Score = (Positive - Negative) × Volume × Virality
"""
from datetime import datetime
from typing import Dict, List, Optional
from logging import getLogger
logger = getLogger(__name__)
# Source weights as specified in requirements
SOURCE_WEIGHTS = {
'twitter': 0.60,
'reddit': 0.25,
'rss': 0.15
}
# Temporal weighting parameters
TEMPORAL_DECAY_HOURS = 48 # Full decay over 48 hours
MIN_TEMPORAL_WEIGHT = 0.5 # Minimum weight for old tweets
def calculate_energy_score(
match_id: int,
team_id: int,
twitter_sentiments: List[Dict[str, float]] = None,
reddit_sentiments: List[Dict[str, float]] = None,
rss_sentiments: List[Dict[str, float]] = None,
tweets_with_timestamps: List[Dict] = None
) -> Dict[str, any]:
"""
Calculate energy score for a team based on multi-source sentiment data.
Args:
match_id: ID of the match
team_id: ID of the team
twitter_sentiments: List of Twitter sentiment scores
reddit_sentiments: List of Reddit sentiment scores
rss_sentiments: List of RSS sentiment scores
tweets_with_timestamps: List of tweets with timestamps for temporal weighting
Returns:
Dictionary containing:
- score: Final energy score (0-100)
- confidence: Confidence level (0-1)
- sources_used: List of sources used in calculation
"""
# Initialize with empty lists if None
twitter_sentiments = twitter_sentiments or []
reddit_sentiments = reddit_sentiments or []
rss_sentiments = rss_sentiments or []
tweets_with_timestamps = tweets_with_timestamps or []
# Calculate energy scores for each source using the formula
twitter_energy_score = _calculate_source_energy(twitter_sentiments)
reddit_energy_score = _calculate_source_energy(reddit_sentiments)
rss_energy_score = _calculate_source_energy(rss_sentiments)
# Determine available sources
available_sources = []
if twitter_sentiments:
available_sources.append('twitter')
if reddit_sentiments:
available_sources.append('reddit')
if rss_sentiments:
available_sources.append('rss')
# Check if no sentiment data is available
if not available_sources:
logger.warning(f"No sentiment data available for match_id={match_id}, team_id={team_id}")
return {
'score': 0.0,
'confidence': 0.0,
'sources_used': []
}
# Apply source weights (with degraded mode adjustment)
weighted_score = apply_source_weights(
twitter_score=twitter_energy_score,
reddit_score=reddit_energy_score,
rss_score=rss_energy_score,
available_sources=available_sources
)
# Apply temporal weighting if tweets with timestamps are available
time_weighted_score = weighted_score
if tweets_with_timestamps and available_sources:
time_weighted_score = apply_temporal_weighting(
base_score=weighted_score,
tweets_with_timestamps=tweets_with_timestamps
)
# Normalize score to 0-100 range
final_score = normalize_score(time_weighted_score)
# Calculate confidence level
total_weight = sum(SOURCE_WEIGHTS[s] for s in available_sources)
confidence = calculate_confidence(
available_sources=available_sources,
total_weight=total_weight
)
return {
'score': final_score,
'confidence': confidence,
'sources_used': available_sources
}
def _calculate_source_energy(sentiments: List[Dict[str, float]]) -> float:
"""
Calculate energy score for a single source using the formula:
Score = (Positive - Negative) × Volume × Virality
Args:
sentiments: List of sentiment scores with 'positive' and 'negative' keys
Returns:
Energy score for the source (can be negative or positive)
"""
if not sentiments:
return 0.0
# Calculate aggregated metrics
total_count = len(sentiments)
positive_ratio = sum(s.get('positive', 0) for s in sentiments) / total_count
negative_ratio = sum(s.get('negative', 0) for s in sentiments) / total_count
# Volume: total number of sentiments
volume = total_count
# Virality: average absolute compound score (intensity of sentiment)
virality = sum(abs(s.get('compound', 0)) for s in sentiments) / total_count
# Apply the energy formula
energy = (positive_ratio - negative_ratio) * volume * virality
return energy
def apply_source_weights(
twitter_score: float,
reddit_score: float,
rss_score: float,
available_sources: List[str]
) -> float:
"""
Apply source weights to calculate weighted score.
Args:
twitter_score: Energy score from Twitter
reddit_score: Energy score from Reddit
rss_score: Energy score from RSS
available_sources: List of available sources
Returns:
Weighted energy score
"""
if not available_sources:
return 0.0
# Adjust weights for degraded mode
adjusted_weights = adjust_weights_for_degraded_mode(
original_weights=SOURCE_WEIGHTS,
available_sources=available_sources
)
# Calculate weighted score
weighted_score = 0.0
if 'twitter' in available_sources:
weighted_score += twitter_score * adjusted_weights['twitter']
if 'reddit' in available_sources:
weighted_score += reddit_score * adjusted_weights['reddit']
if 'rss' in available_sources:
weighted_score += rss_score * adjusted_weights['rss']
return weighted_score
def adjust_weights_for_degraded_mode(
original_weights: Dict[str, float],
available_sources: List[str]
) -> Dict[str, float]:
"""
Adjust weights proportionally when sources are unavailable.
Args:
original_weights: Original source weights
available_sources: List of available sources
Returns:
Adjusted weights that sum to 1.0
"""
if not available_sources:
return {}
# Calculate total weight of available sources
total_weight = sum(original_weights[s] for s in available_sources)
# Adjust weights proportionally
adjusted_weights = {}
for source in available_sources:
adjusted_weights[source] = original_weights[source] / total_weight
logger.info(f"Adjusted weights for degraded mode: {adjusted_weights}")
return adjusted_weights
def apply_temporal_weighting(
base_score: float,
tweets_with_timestamps: List[Dict]
) -> float:
"""
Apply temporal weighting to energy score based on tweet recency.
Recent tweets (within 1 hour) have higher weight (1.0)
Old tweets (24+ hours) have lower weight (0.5)
Decay happens over 48 hours.
Args:
base_score: Base energy score
tweets_with_timestamps: List of tweets with 'created_at' timestamps
Returns:
Temporally weighted energy score
"""
if not tweets_with_timestamps:
return base_score
now = datetime.utcnow()
weighted_sum = 0.0
total_weight = 0.0
for tweet in tweets_with_timestamps:
# Parse timestamp
created_at = tweet.get('created_at')
if not created_at:
continue
# Calculate time difference in hours
if isinstance(created_at, str):
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
hours_ago = (now - created_at).total_seconds() / 3600
# Calculate temporal weight (linear decay from 1.0 to 0.5 over 48 hours)
time_weight = max(MIN_TEMPORAL_WEIGHT, 1.0 - (hours_ago / TEMPORAL_DECAY_HOURS))
# Weight the tweet's contribution by its temporal weight
sentiment_contribution = tweet.get('compound', 0)
weighted_sum += sentiment_contribution * time_weight
total_weight += time_weight
# Calculate weighted average
if total_weight > 0:
# Adjust base score by temporal factor
temporal_factor = weighted_sum / total_weight
# Apply temporal weighting to the base score
time_weighted_score = base_score * (1 + abs(temporal_factor))
else:
time_weighted_score = base_score
return time_weighted_score
def normalize_score(score: float) -> float:
"""
Normalize energy score to 0-100 range.
Args:
score: Raw energy score
Returns:
Normalized score between 0 and 100
"""
# Clamp score to 0-100 range
normalized = max(0.0, min(100.0, score))
return normalized
def calculate_confidence(
available_sources: List[str],
total_weight: float
) -> float:
"""
Calculate confidence level based on available sources.
Args:
available_sources: List of available sources
total_weight: Total weight of available sources
Returns:
Confidence level between 0 and 1
"""
if not available_sources:
return 0.0
# Confidence is based on total weight of available sources
# All sources: 0.6 + 0.25 + 0.15 = 1.0 → confidence ~1.0
# Single source (Twitter): 0.6 → confidence ~0.6
# Single source (RSS): 0.15 → confidence ~0.15
confidence = total_weight
return confidence
def calculate_energy_score_by_source(
source: str,
sentiments: List[Dict[str, float]]
) -> float:
"""
Calculate energy score for a single source.
Args:
source: Source name ('twitter', 'reddit', or 'rss')
sentiments: List of sentiment scores
Returns:
Energy score for the source
"""
if source not in SOURCE_WEIGHTS:
logger.warning(f"Unknown source: {source}")
return 0.0
energy_score = _calculate_source_energy(sentiments)
return energy_score
def get_source_weights() -> Dict[str, float]:
"""
Get the current source weights.
Returns:
Dictionary of source weights
"""
return SOURCE_WEIGHTS.copy()
def get_temporal_weighting_parameters() -> Dict[str, float]:
"""
Get the current temporal weighting parameters.
Returns:
Dictionary of temporal weighting parameters
"""
return {
'decay_hours': TEMPORAL_DECAY_HOURS,
'min_weight': MIN_TEMPORAL_WEIGHT
}

View File

@@ -0,0 +1,146 @@
"""
Prediction Calculator Module.
This module provides functions to calculate match predictions based on
energy scores from sentiment analysis.
"""
from typing import Dict, Any
def calculate_confidence_meter(home_energy: float, away_energy: float) -> float:
"""
Calculate the Confidence Meter (0-100%) based on energy difference.
The Confidence Meter represents how confident we are in the prediction
based on the difference in energy scores between the two teams.
Formula: min(100, abs(home_energy - away_energy) * 2)
Args:
home_energy: Energy score of the home team (float, any value)
away_energy: Energy score of the away team (float, any value)
Returns:
Confidence score between 0.0 and 100.0
Examples:
>>> calculate_confidence_meter(50.0, 50.0)
0.0
>>> calculate_confidence_meter(60.0, 50.0)
20.0
>>> calculate_confidence_meter(100.0, 50.0)
100.0
"""
energy_diff = abs(home_energy - away_energy)
confidence = min(100.0, energy_diff * 2.0)
return confidence
def determine_winner(home_energy: float, away_energy: float) -> str:
"""
Determine the predicted winner based on energy scores.
Args:
home_energy: Energy score of the home team
away_energy: Energy score of the away team
Returns:
'home' if home team has higher energy,
'away' if away team has higher energy,
'draw' if energies are equal
Examples:
>>> determine_winner(60.0, 40.0)
'home'
>>> determine_winner(40.0, 60.0)
'away'
>>> determine_winner(50.0, 50.0)
'draw'
"""
if home_energy > away_energy:
return 'home'
elif away_energy > home_energy:
return 'away'
else:
return 'draw'
def calculate_prediction(home_energy: float, away_energy: float) -> Dict[str, Any]:
"""
Calculate a complete match prediction based on energy scores.
This function combines confidence calculation and winner determination
to provide a comprehensive prediction result.
Args:
home_energy: Energy score of the home team
away_energy: Energy score of the away team
Returns:
Dictionary containing:
- confidence: Confidence score (0.0 - 100.0)
- predicted_winner: 'home', 'away', or 'draw'
- home_energy: Original home energy score
- away_energy: Original away energy score
Examples:
>>> calculate_prediction(65.0, 45.0)
{'confidence': 40.0, 'predicted_winner': 'home',
'home_energy': 65.0, 'away_energy': 45.0}
"""
confidence = calculate_confidence_meter(home_energy, away_energy)
predicted_winner = determine_winner(home_energy, away_energy)
return {
'confidence': confidence,
'predicted_winner': predicted_winner,
'home_energy': home_energy,
'away_energy': away_energy
}
def validate_prediction_result(result: Dict[str, Any]) -> bool:
"""
Validate that a prediction result contains all required fields and valid values.
Args:
result: Dictionary to validate
Returns:
True if valid, False otherwise
Examples:
>>> validate_prediction_result({'confidence': 75.0, 'predicted_winner': 'home',
... 'home_energy': 65.0, 'away_energy': 45.0})
True
>>> validate_prediction_result({'confidence': -10.0, 'predicted_winner': 'home',
... 'home_energy': 65.0, 'away_energy': 45.0})
False
"""
# Check required fields
required_fields = ['confidence', 'predicted_winner', 'home_energy', 'away_energy']
if not all(field in result for field in required_fields):
return False
# Validate confidence
confidence = result['confidence']
if not isinstance(confidence, (int, float)):
return False
if confidence < 0.0 or confidence > 100.0:
return False
# Validate predicted_winner
winner = result['predicted_winner']
if winner not in ['home', 'away', 'draw']:
return False
# Validate energy scores (should be non-negative)
home_energy = result['home_energy']
away_energy = result['away_energy']
if not isinstance(home_energy, (int, float)) or not isinstance(away_energy, (int, float)):
return False
if home_energy < 0.0 or away_energy < 0.0:
return False
return True

View File

@@ -0,0 +1,178 @@
"""
Sentiment Analyzer Module
Uses VADER (Valence Aware Dictionary and sEntiment Reasoner) for sentiment analysis.
"""
from typing import Dict, List, Optional
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
# Initialize the VADER analyzer globally for better performance
_analyzer = SentimentIntensityAnalyzer()
def classify_sentiment(compound: float) -> str:
"""
Classify sentiment based on compound score.
Args:
compound: Compound sentiment score (-1 to 1)
Returns:
Sentiment classification: 'positive', 'negative', or 'neutral'
"""
if compound >= 0.05:
return 'positive'
elif compound <= -0.05:
return 'negative'
else:
return 'neutral'
def analyze_sentiment(text: str) -> Dict[str, float]:
"""
Analyze sentiment of a text using VADER.
Args:
text: Text to analyze
Returns:
Dictionary with sentiment scores:
- compound: Overall compound score (-1 to 1)
- positive: Positive proportion (0 to 1)
- negative: Negative proportion (0 to 1)
- neutral: Neutral proportion (0 to 1)
- sentiment: Classification ('positive', 'negative', or 'neutral')
"""
if not text or not isinstance(text, str):
raise ValueError("Text must be a non-empty string")
scores = _analyzer.polarity_scores(text)
sentiment = classify_sentiment(scores['compound'])
return {
'compound': scores['compound'],
'positive': scores['pos'],
'negative': scores['neg'],
'neutral': scores['neu'],
'sentiment': sentiment
}
def analyze_sentiment_batch(texts: List[str]) -> List[Dict[str, float]]:
"""
Analyze sentiment of multiple texts in batch for better performance.
Args:
texts: List of texts to analyze
Returns:
List of sentiment score dictionaries
"""
results = []
for text in texts:
try:
result = analyze_sentiment(text)
results.append(result)
except ValueError as e:
# Log error but continue processing other texts
print(f"Error analyzing text: {e}")
results.append({
'compound': 0.0,
'positive': 0.0,
'negative': 0.0,
'neutral': 1.0,
'sentiment': 'neutral'
})
return results
def calculate_aggregated_metrics(sentiments: List[Dict[str, float]]) -> Dict[str, float]:
"""
Calculate aggregated metrics from a list of sentiment analyses.
Args:
sentiments: List of sentiment score dictionaries
Returns:
Dictionary with aggregated metrics:
- total_count: Total number of sentiments
- positive_count: Count of positive sentiments
- negative_count: Count of negative sentiments
- neutral_count: Count of neutral sentiments
- positive_ratio: Ratio of positive sentiments (0 to 1)
- negative_ratio: Ratio of negative sentiments (0 to 1)
- neutral_ratio: Ratio of neutral sentiments (0 to 1)
- average_compound: Average compound score
"""
if not sentiments:
return {
'total_count': 0,
'positive_count': 0,
'negative_count': 0,
'neutral_count': 0,
'positive_ratio': 0.0,
'negative_ratio': 0.0,
'neutral_ratio': 0.0,
'average_compound': 0.0
}
total_count = len(sentiments)
positive_count = sum(1 for s in sentiments if s['sentiment'] == 'positive')
negative_count = sum(1 for s in sentiments if s['sentiment'] == 'negative')
neutral_count = sum(1 for s in sentiments if s['sentiment'] == 'neutral')
average_compound = sum(s['compound'] for s in sentiments) / total_count
return {
'total_count': total_count,
'positive_count': positive_count,
'negative_count': negative_count,
'neutral_count': neutral_count,
'positive_ratio': positive_count / total_count,
'negative_ratio': negative_count / total_count,
'neutral_ratio': neutral_count / total_count,
'average_compound': average_compound
}
def test_analyzer_performance(num_tweets: int = 1000) -> float:
"""
Test the performance of the sentiment analyzer.
Args:
num_tweets: Number of tweets to test with (default: 1000)
Returns:
Time taken to analyze the tweets in seconds
"""
import time
import random
# Generate sample tweets
sample_tweets = [
"I love this game! Best match ever!",
"Terrible performance. Worst team ever.",
"It's okay, nothing special.",
"Amazing goal! What a comeback!",
"Disappointed with the result.",
"Great teamwork out there!",
"Could have been better.",
"Absolutely fantastic!",
"Not good enough today.",
"Well played both teams."
]
tweets = [random.choice(sample_tweets) for _ in range(num_tweets)]
# Measure time
start_time = time.time()
results = analyze_sentiment_batch(tweets)
end_time = time.time()
time_taken = end_time - start_time
print(f"Analyzed {len(results)} tweets in {time_taken:.4f} seconds")
print(f"Performance: {num_tweets / time_taken:.2f} tweets/second")
return time_taken

View File

@@ -0,0 +1,15 @@
"""Modèles SQLAlchemy de l'application."""
from .user import User
from .tweet import Tweet
from .reddit_post import RedditPost, RedditComment
from .rss_article import RSSArticle
from .sentiment_score import SentimentScore
from .energy_score import EnergyScore
from .match import Match
from .prediction import Prediction
from .user_prediction import UserPrediction
from .badge import Badge, UserBadge
from .api_key import ApiKey
__all__ = ["User", "Tweet", "RedditPost", "RedditComment", "RSSArticle", "SentimentScore", "EnergyScore", "Match", "Prediction", "UserPrediction", "Badge", "UserBadge", "ApiKey"]

View File

@@ -0,0 +1,55 @@
"""
SQLAlchemy model for API keys.
This module defines the database model for storing API keys used for public API authentication.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from app.database import Base
class ApiKey(Base):
"""
Model for storing user API keys.
Attributes:
id: Primary key
user_id: Foreign key to users table
key_hash: Hashed API key (never store plain keys)
key_prefix: First 8 characters of key for identification
is_active: Whether the key is active
rate_limit: Rate limit per minute for this key
last_used_at: Timestamp of last API usage
created_at: Timestamp when key was created
"""
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
key_hash = Column(String(255), nullable=False, unique=True, index=True)
key_prefix = Column(String(8), nullable=False, index=True)
is_active = Column(Boolean, default=True, nullable=False)
rate_limit = Column(Integer, default=100, nullable=False) # Default: 100 req/min
last_used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="api_keys")
def __repr__(self) -> str:
return f"<ApiKey(id={self.id}, user_id={self.user_id}, prefix={self.key_prefix})>"
def to_dict(self) -> dict:
"""Convert API key model to dictionary (safe version)."""
return {
'id': self.id,
'user_id': self.user_id,
'key_prefix': self.key_prefix,
'is_active': self.is_active,
'rate_limit': self.rate_limit,
'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None
}

View File

@@ -0,0 +1,45 @@
"""
Badge models for SQLAlchemy ORM
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text
from sqlalchemy.orm import relationship
from app.database import Base
class Badge(Base):
"""Badge definition model"""
__tablename__ = "badges"
id = Column(Integer, primary_key=True, autoincrement=True)
badge_id = Column(String(50), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=False)
icon = Column(String(10), nullable=False) # emoji or icon string
category = Column(String(50), nullable=False) # predictions, accuracy, engagement, social
criteria_type = Column(String(50), nullable=False) # predictions_count, correct_predictions, etc.
criteria_value = Column(Integer, nullable=False)
criteria_description = Column(String(200), nullable=False)
rarity = Column(String(20), nullable=False) # common, rare, epic, legendary
points = Column(Integer, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user_badges = relationship("UserBadge", back_populates="badge")
class UserBadge(Base):
"""User's unlocked badges"""
__tablename__ = "user_badges"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
badge_id = Column(Integer, ForeignKey("badges.id"), nullable=False, index=True)
unlocked_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="badges")
badge = relationship("Badge", back_populates="user_badges")
def __repr__(self):
return f"<UserBadge user_id={self.user_id} badge_id={self.badge_id}>"

View File

@@ -0,0 +1,101 @@
"""
SQLAlchemy model for energy scores.
This module defines the database model for storing energy score calculations
from multiple sources (Twitter, Reddit, RSS).
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, DateTime, Index, ForeignKey, JSON
from sqlalchemy.orm import relationship
from app.database import Base
class EnergyScore(Base):
"""
Model for storing energy score calculations.
Attributes:
id: Primary key
match_id: Foreign key to the match
team_id: Foreign key to the team
score: Final energy score (0-100)
confidence: Confidence level (0-1)
sources_used: List of sources used in calculation (JSON)
twitter_score: Energy score from Twitter component
reddit_score: Energy score from Reddit component
rss_score: Energy score from RSS component
temporal_factor: Temporal weighting factor applied
twitter_weight: Adjusted weight for Twitter (in degraded mode)
reddit_weight: Adjusted weight for Reddit (in degraded mode)
rss_weight: Adjusted weight for RSS (in degraded mode)
created_at: Timestamp when the energy score was calculated
updated_at: Timestamp when the energy score was last updated
"""
__tablename__ = "energy_scores"
id = Column(Integer, primary_key=True, index=True)
match_id = Column(Integer, nullable=False, index=True, comment="ID of the match")
team_id = Column(Integer, nullable=False, index=True, comment="ID of the team")
score = Column(Float, nullable=False, index=True, comment="Final energy score (0-100)")
confidence = Column(Float, nullable=False, default=0.0, comment="Confidence level (0-1)")
# Sources used (stored as JSON array)
sources_used = Column(JSON, nullable=False, default=list, comment="List of sources used")
# Component scores
twitter_score = Column(Float, nullable=True, comment="Energy score from Twitter component")
reddit_score = Column(Float, nullable=True, comment="Energy score from Reddit component")
rss_score = Column(Float, nullable=True, comment="Energy score from RSS component")
# Temporal weighting
temporal_factor = Column(Float, nullable=True, comment="Temporal weighting factor applied")
# Adjusted weights (for degraded mode tracking)
twitter_weight = Column(Float, nullable=True, comment="Adjusted weight for Twitter")
reddit_weight = Column(Float, nullable=True, comment="Adjusted weight for Reddit")
rss_weight = Column(Float, nullable=True, comment="Adjusted weight for RSS")
# Timestamps
created_at = Column(DateTime, nullable=False, index=True, default=datetime.utcnow, comment="Creation timestamp")
updated_at = Column(DateTime, nullable=False, index=True, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last update timestamp")
# Indexes for performance
__table_args__ = (
Index('idx_energy_scores_match_team', 'match_id', 'team_id'),
Index('idx_energy_scores_score', 'score'),
Index('idx_energy_scores_confidence', 'confidence'),
Index('idx_energy_scores_created_at', 'created_at'),
Index('idx_energy_scores_updated_at', 'updated_at'),
)
def __repr__(self) -> str:
return (f"<EnergyScore(id={self.id}, match_id={self.match_id}, "
f"team_id={self.team_id}, score={self.score}, "
f"confidence={self.confidence})>")
def to_dict(self) -> dict:
"""
Convert energy score model to dictionary.
Returns:
Dictionary representation of the energy score
"""
return {
'id': self.id,
'match_id': self.match_id,
'team_id': self.team_id,
'score': self.score,
'confidence': self.confidence,
'sources_used': self.sources_used,
'twitter_score': self.twitter_score,
'reddit_score': self.reddit_score,
'rss_score': self.rss_score,
'temporal_factor': self.temporal_factor,
'twitter_weight': self.twitter_weight,
'reddit_weight': self.reddit_weight,
'rss_weight': self.rss_weight,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}

View File

@@ -0,0 +1,62 @@
"""
SQLAlchemy model for matches.
This module defines the database model for storing match information.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Index
from sqlalchemy.orm import relationship
from app.database import Base
class Match(Base):
"""
Model for storing match information.
Attributes:
id: Primary key
home_team: Name of the home team
away_team: Name of the away team
date: Match date and time
league: League name
status: Match status (scheduled, in_progress, completed, etc.)
"""
__tablename__ = "matches"
id = Column(Integer, primary_key=True, index=True)
home_team = Column(String(255), nullable=False, index=True)
away_team = Column(String(255), nullable=False, index=True)
date = Column(DateTime, nullable=False, index=True)
league = Column(String(255), nullable=False, index=True)
status = Column(String(50), nullable=False, index=True)
actual_winner = Column(String(255), nullable=True, index=True, comment='Actual winner: home, away, or draw')
# Relationships
predictions = relationship("Prediction", back_populates="match", cascade="all, delete-orphan")
tweets = relationship("Tweet", back_populates="match", cascade="all, delete-orphan")
posts_reddit = relationship("RedditPost", back_populates="match", cascade="all, delete-orphan")
rss_articles = relationship("RSSArticle", back_populates="match", cascade="all, delete-orphan")
# Indexes for performance
__table_args__ = (
Index('idx_matches_date_league', 'date', 'league'),
Index('idx_matches_home_away', 'home_team', 'away_team'),
Index('idx_matches_actual_winner', 'actual_winner'),
)
def __repr__(self) -> str:
return f"<Match(id={self.id}, {self.home_team} vs {self.away_team}, date={self.date})>"
def to_dict(self) -> dict:
"""Convert match model to dictionary."""
return {
'id': self.id,
'home_team': self.home_team,
'away_team': self.away_team,
'date': self.date.isoformat() if self.date else None,
'league': self.league,
'status': self.status,
'actual_winner': self.actual_winner
}

View File

@@ -0,0 +1,57 @@
"""
SQLAlchemy model for predictions.
This module defines the database model for storing match predictions.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index
from sqlalchemy.orm import relationship
from app.database import Base
class Prediction(Base):
"""
Model for storing match predictions.
Attributes:
id: Primary key
match_id: Foreign key to matches table
energy_score: Energy score for the prediction
confidence: Confidence level of the prediction
predicted_winner: Predicted winner team name
created_at: Timestamp when prediction was created
"""
__tablename__ = "predictions"
id = Column(Integer, primary_key=True, index=True)
match_id = Column(Integer, ForeignKey("matches.id", ondelete="CASCADE"), nullable=False, index=True)
energy_score = Column(String(50), nullable=False)
confidence = Column(String(50), nullable=False)
predicted_winner = Column(String(255), nullable=False)
created_at = Column(DateTime, nullable=False, index=True)
# Relationships
match = relationship("Match", back_populates="predictions")
user_predictions = relationship("UserPrediction", back_populates="prediction", cascade="all, delete-orphan")
# Indexes for performance
__table_args__ = (
Index('idx_predictions_match_id_created', 'match_id', 'created_at'),
Index('idx_predictions_confidence', 'confidence'),
)
def __repr__(self) -> str:
return f"<Prediction(id={self.id}, match_id={self.match_id}, confidence={self.confidence})>"
def to_dict(self) -> dict:
"""Convert prediction model to dictionary."""
return {
'id': self.id,
'match_id': self.match_id,
'energy_score': self.energy_score,
'confidence': self.confidence,
'predicted_winner': self.predicted_winner,
'created_at': self.created_at.isoformat() if self.created_at else None
}

View File

@@ -0,0 +1,118 @@
"""
SQLAlchemy model for Reddit posts.
This module defines database models for storing posts and comments
collected from Reddit API.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Index, Text, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class RedditPost(Base):
"""
Model for storing Reddit posts about football matches.
Attributes:
id: Primary key
post_id: Unique identifier from Reddit
title: Post title
text: Post content
upvotes: Number of upvotes
created_at: Timestamp when post was created
match_id: Foreign key to matches table
subreddit: Subreddit name
source: Source platform (reddit)
"""
__tablename__ = "posts_reddit"
id = Column(Integer, primary_key=True, index=True)
post_id = Column(String(255), unique=True, nullable=False, index=True)
title = Column(String(500), nullable=False)
text = Column(Text, nullable=True)
upvotes = Column(Integer, default=0)
created_at = Column(DateTime, nullable=False, index=True)
match_id = Column(Integer, ForeignKey('matches.id'), nullable=True, index=True)
subreddit = Column(String(100), nullable=False)
source = Column(String(50), default="reddit")
# Indexes for performance
__table_args__ = (
Index('idx_posts_reddit_match_id', 'match_id'),
Index('idx_posts_reddit_created_at', 'created_at'),
Index('idx_posts_reddit_subreddit', 'subreddit'),
)
# Relationship with match
match = relationship("Match", back_populates="posts_reddit")
# Relationship with comments
comments = relationship("RedditComment", back_populates="post", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<RedditPost(id={self.id}, post_id={self.post_id}, subreddit={self.subreddit})>"
def to_dict(self) -> dict:
"""Convert Reddit post model to dictionary."""
return {
'id': self.id,
'post_id': self.post_id,
'title': self.title,
'text': self.text,
'upvotes': self.upvotes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'match_id': self.match_id,
'subreddit': self.subreddit,
'source': self.source
}
class RedditComment(Base):
"""
Model for storing Reddit comments.
Attributes:
id: Primary key
comment_id: Unique identifier from Reddit
post_id: Foreign key to posts_reddit table
text: Comment content
upvotes: Number of upvotes
created_at: Timestamp when comment was created
source: Source platform (reddit)
"""
__tablename__ = "comments_reddit"
id = Column(Integer, primary_key=True, index=True)
comment_id = Column(String(255), unique=True, nullable=False, index=True)
post_id = Column(Integer, ForeignKey('posts_reddit.id'), nullable=False, index=True)
text = Column(Text, nullable=False)
upvotes = Column(Integer, default=0)
created_at = Column(DateTime, nullable=False, index=True)
source = Column(String(50), default="reddit")
# Indexes for performance
__table_args__ = (
Index('idx_comments_reddit_post_id', 'post_id'),
Index('idx_comments_reddit_created_at', 'created_at'),
)
# Relationship with post
post = relationship("RedditPost", back_populates="comments")
def __repr__(self) -> str:
return f"<RedditComment(id={self.id}, comment_id={self.comment_id}, post_id={self.post_id})>"
def to_dict(self) -> dict:
"""Convert Reddit comment model to dictionary."""
return {
'id': self.id,
'comment_id': self.comment_id,
'post_id': self.post_id,
'text': self.text,
'upvotes': self.upvotes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'source': self.source
}

View File

@@ -0,0 +1,64 @@
"""
SQLAlchemy model for RSS articles.
This module defines the database model for storing RSS articles
collected from various RSS feeds.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Text, Index, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class RSSArticle(Base):
"""
Model for storing RSS articles collected from sports feeds.
Attributes:
id: Primary key
article_id: Unique identifier from RSS feed
title: Article title
content: Article content/description
published_at: Timestamp when article was published
source_url: URL of the RSS feed source
match_id: Foreign key to matches table
source: Source feed name (ESPN, BBC Sport, etc.)
"""
__tablename__ = "rss_articles"
id = Column(Integer, primary_key=True, index=True)
article_id = Column(String(255), unique=True, nullable=False, index=True)
title = Column(String(500), nullable=False)
content = Column(Text, nullable=True)
published_at = Column(DateTime, nullable=False, index=True)
source_url = Column(String(1000), nullable=False)
match_id = Column(Integer, ForeignKey('matches.id'), nullable=True, index=True)
source = Column(String(100), default="rss")
# Indexes for performance
__table_args__ = (
Index('idx_rss_articles_match_id_source', 'match_id', 'source'),
Index('idx_rss_articles_published_at', 'published_at'),
Index('idx_rss_articles_source_url', 'source_url'),
)
# Relationship with match
match = relationship("Match", back_populates="rss_articles")
def __repr__(self) -> str:
return f"<RSSArticle(id={self.id}, article_id={self.article_id}, match_id={self.match_id})>"
def to_dict(self) -> dict:
"""Convert RSS article model to dictionary."""
return {
'id': self.id,
'article_id': self.article_id,
'title': self.title,
'content': self.content,
'published_at': self.published_at.isoformat() if self.published_at else None,
'source_url': self.source_url,
'match_id': self.match_id,
'source': self.source
}

View File

@@ -0,0 +1,65 @@
"""
SQLAlchemy model for sentiment scores.
This module defines the database model for storing sentiment analysis results
from tweets and posts.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, DateTime, Index, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class SentimentScore(Base):
"""
Model for storing sentiment analysis results.
Attributes:
id: Primary key
entity_id: Foreign key to the entity being analyzed (tweet_id or post_id)
entity_type: Type of entity ('tweet' or 'reddit_post')
score: Overall compound sentiment score (-1 to 1)
sentiment_type: Classification ('positive', 'negative', or 'neutral')
positive: Positive proportion score (0 to 1)
negative: Negative proportion score (0 to 1)
neutral: Neutral proportion score (0 to 1)
created_at: Timestamp when the sentiment was analyzed
"""
__tablename__ = "sentiment_scores"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(String(255), nullable=False, index=True)
entity_type = Column(String(50), nullable=False, index=True) # 'tweet' or 'reddit_post'
score = Column(Float, nullable=False, index=True) # Compound score
sentiment_type = Column(String(20), nullable=False, index=True) # 'positive', 'negative', 'neutral'
positive = Column(Float, nullable=False, default=0.0)
negative = Column(Float, nullable=False, default=0.0)
neutral = Column(Float, nullable=False, default=0.0)
created_at = Column(DateTime, nullable=False, index=True, default=datetime.utcnow)
# Indexes for performance
__table_args__ = (
Index('idx_sentiment_scores_entity', 'entity_id', 'entity_type'),
Index('idx_sentiment_scores_score', 'score'),
Index('idx_sentiment_scores_type', 'sentiment_type'),
Index('idx_sentiment_scores_created_at', 'created_at'),
)
def __repr__(self) -> str:
return f"<SentimentScore(id={self.id}, entity_id={self.entity_id}, sentiment_type={self.sentiment_type})>"
def to_dict(self) -> dict:
"""Convert sentiment score model to dictionary."""
return {
'id': self.id,
'entity_id': self.entity_id,
'entity_type': self.entity_type,
'score': self.score,
'sentiment_type': self.sentiment_type,
'positive': self.positive,
'negative': self.negative,
'neutral': self.neutral,
'created_at': self.created_at.isoformat() if self.created_at else None
}

View File

@@ -0,0 +1,63 @@
"""
SQLAlchemy model for tweets.
This module defines the database model for storing tweets collected
from Twitter, Reddit, and RSS sources.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Index, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class Tweet(Base):
"""
Model for storing tweets collected from social media sources.
Attributes:
id: Primary key
tweet_id: Unique identifier from the source platform
text: Tweet content
created_at: Timestamp when the tweet was created
retweet_count: Number of retweets
like_count: Number of likes
match_id: Foreign key to matches table
source: Source platform (twitter, reddit, rss)
"""
__tablename__ = "tweets"
id = Column(Integer, primary_key=True, index=True)
tweet_id = Column(String(255), unique=True, nullable=False, index=True)
text = Column(String(1000), nullable=False)
created_at = Column(DateTime, nullable=False, index=True)
retweet_count = Column(Integer, default=0)
like_count = Column(Integer, default=0)
match_id = Column(Integer, ForeignKey('matches.id'), nullable=True, index=True)
source = Column(String(50), default="twitter")
# Indexes for performance
__table_args__ = (
Index('idx_tweets_match_id_source', 'match_id', 'source'),
Index('idx_tweets_created_at', 'created_at'),
)
# Relationship with match
match = relationship("Match", back_populates="tweets")
def __repr__(self) -> str:
return f"<Tweet(id={self.id}, tweet_id={self.tweet_id}, match_id={self.match_id})>"
def to_dict(self) -> dict:
"""Convert tweet model to dictionary."""
return {
'id': self.id,
'tweet_id': self.tweet_id,
'text': self.text,
'created_at': self.created_at.isoformat() if self.created_at else None,
'retweet_count': self.retweet_count,
'like_count': self.like_count,
'match_id': self.match_id,
'source': self.source
}

View File

@@ -0,0 +1,27 @@
"""Modèle SQLAlchemy pour les utilisateurs."""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.orm import relationship
from app.database import Base
class User(Base):
"""Modèle de base de données pour les utilisateurs."""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
name = Column(String, nullable=True)
password_hash = Column(String, nullable=True)
is_premium = Column(Boolean, default=False, nullable=False)
referral_code = Column(String, unique=True, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
daily_predictions_count = Column(Integer, default=0)
last_prediction_date = Column(DateTime, nullable=True)
# Relationships
user_predictions = relationship("UserPrediction", back_populates="user", cascade="all, delete-orphan")
badges = relationship("UserBadge", back_populates="user", cascade="all, delete-orphan")
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")

View File

@@ -0,0 +1,62 @@
"""
SQLAlchemy model for user_predictions.
This module defines the database model for tracking which predictions
each user has viewed and their accuracy.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, DateTime, Boolean, ForeignKey, Index, UniqueConstraint
from sqlalchemy.orm import relationship
from app.database import Base
class UserPrediction(Base):
"""
Model for tracking predictions viewed by users.
This table tracks:
- Which predictions each user has viewed
- When they viewed them
- Whether the prediction was correct (when match result is known)
Attributes:
id: Primary key
user_id: Foreign key to users table
prediction_id: Foreign key to predictions table
viewed_at: Timestamp when user viewed the prediction
was_correct: True if prediction was correct, False if incorrect, NULL if match not completed
"""
__tablename__ = "user_predictions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
prediction_id = Column(Integer, ForeignKey("predictions.id", ondelete="CASCADE"), nullable=False, index=True)
viewed_at = Column(DateTime, nullable=False, index=True)
was_correct = Column(Boolean, nullable=True, comment='True if prediction was correct, False if incorrect, NULL if match not completed')
# Relationships
user = relationship("User", back_populates="user_predictions")
prediction = relationship("Prediction", back_populates="user_predictions")
# Constraints and indexes
__table_args__ = (
Index('idx_user_predictions_user_id', 'user_id'),
Index('idx_user_predictions_prediction_id', 'prediction_id'),
Index('idx_user_predictions_viewed_at', 'viewed_at'),
UniqueConstraint('user_id', 'prediction_id', name='uq_user_predictions_user_prediction'),
)
def __repr__(self) -> str:
return f"<UserPrediction(id={self.id}, user_id={self.user_id}, prediction_id={self.prediction_id}, viewed_at={self.viewed_at})>"
def to_dict(self) -> dict:
"""Convert user prediction model to dictionary."""
return {
'id': self.id,
'user_id': self.user_id,
'prediction_id': self.prediction_id,
'viewed_at': self.viewed_at.isoformat() if self.viewed_at else None,
'was_correct': self.was_correct
}

View File

@@ -0,0 +1,36 @@
"""
Queue management module.
This module provides functionality for managing RabbitMQ queues
and asynchronous task processing.
"""
from app.queues.rabbitmq_client import (
RabbitMQClient,
create_rabbitmq_client
)
from app.queues.producers import (
publish_scraping_task,
publish_sentiment_analysis_task,
publish_energy_calculation_task,
publish_result
)
from app.queues.consumers import (
consume_scraping_tasks,
consume_sentiment_analysis_tasks,
consume_energy_calculation_tasks,
consume_results
)
__all__ = [
'RabbitMQClient',
'create_rabbitmq_client',
'publish_scraping_task',
'publish_sentiment_analysis_task',
'publish_energy_calculation_task',
'publish_result',
'consume_scraping_tasks',
'consume_sentiment_analysis_tasks',
'consume_energy_calculation_tasks',
'consume_results'
]

View File

@@ -0,0 +1,246 @@
"""
Message consumers module.
This module provides functions to consume and process tasks from RabbitMQ queues.
"""
import json
import logging
from typing import Callable, Dict
from sqlalchemy.orm import Session
from app.queues.rabbitmq_client import RabbitMQClient
logger = logging.getLogger(__name__)
def consume_scraping_tasks(
client: RabbitMQClient,
callback: Callable[[Dict], Dict],
db_session_factory: Callable[[], Session]
) -> None:
"""
Consume scraping tasks from queue and process them.
Args:
client: RabbitMQ client instance
callback: Function to process scraping tasks
db_session_factory: Factory function to create DB sessions
"""
def on_message(ch, method, properties, body):
try:
# Parse message
message = json.loads(body)
task_data = message.get('data', {})
logger.info(
f"📥 Received scraping task: match_id={task_data.get('match_id')}, "
f"source={task_data.get('source')}"
)
# Create database session
db = db_session_factory()
try:
# Process task
result = callback(task_data, db)
# Publish result
from app.queues.producers import publish_scraping_result
publish_scraping_result(
client=client,
match_id=task_data.get('match_id'),
source=task_data.get('source'),
collected_count=result.get('collected_count', 0),
metadata=result.get('metadata', {})
)
# Acknowledge message
ch.basic_ack(delivery_tag=method.delivery_tag)
logger.info(f"✅ Completed scraping task for match {task_data.get('match_id')}")
except Exception as e:
logger.error(f"❌ Error processing scraping task: {e}")
ch.basic_reject(delivery_tag=method.delivery_tag, requeue=False)
finally:
db.close()
except Exception as e:
logger.error(f"❌ Error parsing scraping task message: {e}")
ch.basic_reject(delivery_tag=method.delivery_tag, requeue=False)
# Start consuming
client.consume_messages(queue_name='scraping_tasks', callback=on_message)
def consume_sentiment_analysis_tasks(
client: RabbitMQClient,
callback: Callable[[Dict], Dict],
db_session_factory: Callable[[], Session]
) -> None:
"""
Consume sentiment analysis tasks from queue and process them.
Args:
client: RabbitMQ client instance
callback: Function to process sentiment analysis tasks
db_session_factory: Factory function to create DB sessions
"""
def on_message(ch, method, properties, body):
try:
# Parse message
message = json.loads(body)
task_data = message.get('data', {})
logger.info(
f"📥 Received sentiment analysis task: "
f"match_id={task_data.get('match_id')}, "
f"source={task_data.get('source')}"
)
# Create database session
db = db_session_factory()
try:
# Process task
result = callback(task_data, db)
# Publish result
from app.queues.producers import publish_sentiment_analysis_result
publish_sentiment_analysis_result(
client=client,
match_id=task_data.get('match_id'),
source=task_data.get('source'),
analyzed_count=result.get('analyzed_count', 0),
metrics=result.get('metrics', {}),
metadata=result.get('metadata', {})
)
# Acknowledge message
ch.basic_ack(delivery_tag=method.delivery_tag)
logger.info(
f"✅ Completed sentiment analysis task for "
f"match {task_data.get('match_id')}"
)
except Exception as e:
logger.error(f"❌ Error processing sentiment analysis task: {e}")
ch.basic_reject(delivery_tag=method.delivery_tag, requeue=False)
finally:
db.close()
except Exception as e:
logger.error(f"❌ Error parsing sentiment analysis task message: {e}")
ch.basic_reject(delivery_tag=method.delivery_tag, requeue=False)
# Start consuming
client.consume_messages(
queue_name='sentiment_analysis_tasks',
callback=on_message
)
def consume_energy_calculation_tasks(
client: RabbitMQClient,
callback: Callable[[Dict], Dict],
db_session_factory: Callable[[], Session]
) -> None:
"""
Consume energy calculation tasks from queue and process them.
Args:
client: RabbitMQ client instance
callback: Function to process energy calculation tasks
db_session_factory: Factory function to create DB sessions
"""
def on_message(ch, method, properties, body):
try:
# Parse message
message = json.loads(body)
task_data = message.get('data', {})
logger.info(
f"📥 Received energy calculation task: "
f"match_id={task_data.get('match_id')}, "
f"team_id={task_data.get('team_id')}"
)
# Create database session
db = db_session_factory()
try:
# Process task
result = callback(task_data, db)
# Publish result
from app.queues.producers import publish_energy_calculation_result
publish_energy_calculation_result(
client=client,
match_id=task_data.get('match_id'),
team_id=task_data.get('team_id'),
energy_score=result.get('energy_score', 0.0),
confidence=result.get('confidence', 0.0),
sources_used=result.get('sources_used', []),
metadata=result.get('metadata', {})
)
# Acknowledge message
ch.basic_ack(delivery_tag=method.delivery_tag)
logger.info(
f"✅ Completed energy calculation task for "
f"match {task_data.get('match_id')}, "
f"team {task_data.get('team_id')}"
)
except Exception as e:
logger.error(f"❌ Error processing energy calculation task: {e}")
ch.basic_reject(delivery_tag=method.delivery_tag, requeue=False)
finally:
db.close()
except Exception as e:
logger.error(f"❌ Error parsing energy calculation task message: {e}")
ch.basic_reject(delivery_tag=method.delivery_tag, requeue=False)
# Start consuming
client.consume_messages(
queue_name='energy_calculation_tasks',
callback=on_message
)
def consume_results(
client: RabbitMQClient,
callback: Callable[[Dict], None]
) -> None:
"""
Consume results from results queue.
Args:
client: RabbitMQ client instance
callback: Function to process results
"""
def on_message(ch, method, properties, body):
try:
# Parse message
message = json.loads(body)
result_data = message.get('data', {})
logger.info(
f"📥 Received result: type={result_data.get('result_type')}, "
f"match_id={result_data.get('data', {}).get('match_id')}"
)
# Process result
callback(result_data)
# Acknowledge message
ch.basic_ack(delivery_tag=method.delivery_tag)
logger.info(f"✅ Processed {result_data.get('result_type')} result")
except Exception as e:
logger.error(f"❌ Error processing result: {e}")
ch.basic_reject(delivery_tag=method.delivery_tag, requeue=False)
# Start consuming
client.consume_messages(queue_name='results', callback=on_message)

View File

@@ -0,0 +1,268 @@
"""
Message producers module.
This module provides functions to publish tasks and results to RabbitMQ queues.
"""
import logging
from typing import Dict, List, Optional
from datetime import datetime
from app.queues.rabbitmq_client import RabbitMQClient
logger = logging.getLogger(__name__)
def publish_scraping_task(
client: RabbitMQClient,
match_id: int,
source: str,
keywords: Optional[List[str]] = None,
priority: str = "normal"
) -> None:
"""
Publish a scraping task to the queue.
Args:
client: RabbitMQ client instance
match_id: Match identifier
source: Source to scrape ('twitter', 'reddit', 'rss')
keywords: Optional list of keywords for filtering
priority: Task priority ('low', 'normal', 'high', 'vip')
"""
task = {
"task_type": "scraping",
"match_id": match_id,
"source": source,
"keywords": keywords or [],
"priority": priority,
"created_at": datetime.utcnow().isoformat()
}
client.publish_message(
queue_name='scraping_tasks',
data=task,
event_type="scraping.task.created"
)
logger.info(
f"📤 Published scraping task for match {match_id} "
f"(source: {source}, priority: {priority})"
)
def publish_sentiment_analysis_task(
client: RabbitMQClient,
match_id: int,
source: str,
entity_ids: List[str],
texts: Optional[List[str]] = None
) -> None:
"""
Publish a sentiment analysis task to the queue.
Args:
client: RabbitMQ client instance
match_id: Match identifier
source: Source type ('twitter', 'reddit')
entity_ids: List of entity IDs to analyze
texts: Optional list of texts (if not fetched from DB)
"""
task = {
"task_type": "sentiment_analysis",
"match_id": match_id,
"source": source,
"entity_ids": entity_ids,
"texts": texts or [],
"created_at": datetime.utcnow().isoformat()
}
client.publish_message(
queue_name='sentiment_analysis_tasks',
data=task,
event_type="sentiment_analysis.task.created"
)
logger.info(
f"📤 Published sentiment analysis task for match {match_id} "
f"(source: {source}, entities: {len(entity_ids)})"
)
def publish_energy_calculation_task(
client: RabbitMQClient,
match_id: int,
team_id: int,
twitter_sentiments: Optional[List[Dict]] = None,
reddit_sentiments: Optional[List[Dict]] = None,
rss_sentiments: Optional[List[Dict]] = None,
tweets_with_timestamps: Optional[List[Dict]] = None
) -> None:
"""
Publish an energy calculation task to the queue.
Args:
client: RabbitMQ client instance
match_id: Match identifier
team_id: Team identifier
twitter_sentiments: Optional list of Twitter sentiment scores
reddit_sentiments: Optional list of Reddit sentiment scores
rss_sentiments: Optional list of RSS sentiment scores
tweets_with_timestamps: Optional list of tweets with timestamps
"""
task = {
"task_type": "energy_calculation",
"match_id": match_id,
"team_id": team_id,
"twitter_sentiments": twitter_sentiments or [],
"reddit_sentiments": reddit_sentiments or [],
"rss_sentiments": rss_sentiments or [],
"tweets_with_timestamps": tweets_with_timestamps or [],
"created_at": datetime.utcnow().isoformat()
}
client.publish_message(
queue_name='energy_calculation_tasks',
data=task,
event_type="energy_calculation.task.created"
)
logger.info(
f"📤 Published energy calculation task for match {match_id}, "
f"team {team_id}"
)
def publish_result(
client: RabbitMQClient,
result_type: str,
data: Dict
) -> None:
"""
Publish a result to the results queue.
Args:
client: RabbitMQ client instance
result_type: Type of result ('scraping', 'sentiment', 'energy')
data: Result data
"""
result = {
"result_type": result_type,
"data": data,
"created_at": datetime.utcnow().isoformat()
}
client.publish_message(
queue_name='results',
data=result,
event_type="result.published"
)
logger.info(
f"📤 Published {result_type} result to results queue"
)
def publish_scraping_result(
client: RabbitMQClient,
match_id: int,
source: str,
collected_count: int,
metadata: Optional[Dict] = None
) -> None:
"""
Publish a scraping result.
Args:
client: RabbitMQ client instance
match_id: Match identifier
source: Source scraped ('twitter', 'reddit', 'rss')
collected_count: Number of items collected
metadata: Optional additional metadata
"""
result_data = {
"match_id": match_id,
"source": source,
"collected_count": collected_count,
"status": "success",
"metadata": metadata or {}
}
publish_result(
client=client,
result_type="scraping",
data=result_data
)
def publish_sentiment_analysis_result(
client: RabbitMQClient,
match_id: int,
source: str,
analyzed_count: int,
metrics: Dict,
metadata: Optional[Dict] = None
) -> None:
"""
Publish a sentiment analysis result.
Args:
client: RabbitMQ client instance
match_id: Match identifier
source: Source analyzed ('twitter', 'reddit')
analyzed_count: Number of items analyzed
metrics: Aggregated sentiment metrics
metadata: Optional additional metadata
"""
result_data = {
"match_id": match_id,
"source": source,
"analyzed_count": analyzed_count,
"metrics": metrics,
"status": "success",
"metadata": metadata or {}
}
publish_result(
client=client,
result_type="sentiment",
data=result_data
)
def publish_energy_calculation_result(
client: RabbitMQClient,
match_id: int,
team_id: int,
energy_score: float,
confidence: float,
sources_used: List[str],
metadata: Optional[Dict] = None
) -> None:
"""
Publish an energy calculation result.
Args:
client: RabbitMQ client instance
match_id: Match identifier
team_id: Team identifier
energy_score: Calculated energy score
confidence: Confidence level
sources_used: List of sources used in calculation
metadata: Optional additional metadata
"""
result_data = {
"match_id": match_id,
"team_id": team_id,
"energy_score": energy_score,
"confidence": confidence,
"sources_used": sources_used,
"status": "success",
"metadata": metadata or {}
}
publish_result(
client=client,
result_type="energy",
data=result_data
)

View File

@@ -0,0 +1,238 @@
"""
RabbitMQ client module.
This module provides a RabbitMQ client with connection management
and queue declaration functionality.
"""
import json
import logging
from datetime import datetime
from typing import Dict, Optional, Callable
import pika
from pika.exceptions import AMQPConnectionError, AMQPChannelError
logger = logging.getLogger(__name__)
class RabbitMQClient:
"""
RabbitMQ client with connection management and queue declaration.
Features:
- Connection management with reconnection logic
- Queue declaration with durability
- Message publishing with standard event format
- Task consumption with error handling
- Automatic acknowledgment management
"""
def __init__(
self,
rabbitmq_url: str = "amqp://guest:guest@localhost:5672",
prefetch_count: int = 1
):
"""
Initialize RabbitMQ client.
Args:
rabbitmq_url: RabbitMQ connection URL
prefetch_count: Number of unacknowledged messages to prefetch
"""
self.rabbitmq_url = rabbitmq_url
self.prefetch_count = prefetch_count
self.connection: Optional[pika.BlockingConnection] = None
self.channel: Optional[pika.adapters.blocking_connection.BlockingChannel] = None
# Queue names
self.queues = {
'scraping_tasks': 'scraping_tasks',
'sentiment_analysis_tasks': 'sentiment_analysis_tasks',
'energy_calculation_tasks': 'energy_calculation_tasks',
'results': 'results'
}
def connect(self) -> None:
"""
Establish connection to RabbitMQ server.
Raises:
AMQPConnectionError: If connection fails
"""
try:
logger.info(f"🔗 Connecting to RabbitMQ at {self.rabbitmq_url}")
# Create connection
self.connection = pika.BlockingConnection(
pika.URLParameters(self.rabbitmq_url)
)
self.channel = self.connection.channel()
# Set prefetch count for fair dispatch
self.channel.basic_qos(prefetch_count=self.prefetch_count)
# Declare queues
self._declare_queues()
logger.info("✅ Connected to RabbitMQ successfully")
except AMQPConnectionError as e:
logger.error(f"❌ Failed to connect to RabbitMQ: {e}")
raise
except AMQPChannelError as e:
logger.error(f"❌ Failed to create RabbitMQ channel: {e}")
raise
def _declare_queues(self) -> None:
"""
Declare all required queues with durability.
"""
for queue_name in self.queues.values():
self.channel.queue_declare(
queue=queue_name,
durable=True
)
logger.debug(f"✅ Declared queue: {queue_name}")
def close(self) -> None:
"""Close connection to RabbitMQ server."""
if self.channel:
self.channel.close()
if self.connection:
self.connection.close()
logger.info("🔌 Closed RabbitMQ connection")
def publish_message(
self,
queue_name: str,
data: Dict,
event_type: str = "task.created",
version: str = "1.0"
) -> None:
"""
Publish a message to a queue with standard event format.
Args:
queue_name: Target queue name
data: Message payload (will be JSON serialized)
event_type: Event type for message header
version: Event version
"""
if not self.channel:
raise RuntimeError("RabbitMQ channel not initialized. Call connect() first.")
# Create standard event message format
message = {
"event": event_type,
"version": version,
"timestamp": datetime.utcnow().isoformat(),
"data": data,
"metadata": {
"source": "api",
"queue": queue_name
}
}
try:
self.channel.basic_publish(
exchange='',
routing_key=queue_name,
body=json.dumps(message, ensure_ascii=False, default=str),
properties=pika.BasicProperties(
delivery_mode=2, # Make message persistent
content_type='application/json'
)
)
logger.debug(f"📤 Published message to {queue_name}: {event_type}")
except Exception as e:
logger.error(f"❌ Failed to publish message to {queue_name}: {e}")
raise
def consume_messages(
self,
queue_name: str,
callback: Callable
) -> None:
"""
Start consuming messages from a queue.
Args:
queue_name: Queue to consume from
callback: Callback function to process messages
"""
if not self.channel:
raise RuntimeError("RabbitMQ channel not initialized. Call connect() first.")
try:
logger.info(f"👂 Starting to consume from queue: {queue_name}")
self.channel.basic_consume(
queue=queue_name,
on_message_callback=callback,
auto_ack=False
)
self.channel.start_consuming()
except KeyboardInterrupt:
logger.info("⏹️ Stopping consumer...")
self.channel.stop_consuming()
except Exception as e:
logger.error(f"❌ Error consuming from {queue_name}: {e}")
raise
def ack_message(self, delivery_tag: int) -> None:
"""
Acknowledge a message as processed.
Args:
delivery_tag: Message delivery tag
"""
if not self.channel:
raise RuntimeError("RabbitMQ channel not initialized.")
self.channel.basic_ack(delivery_tag=delivery_tag)
def reject_message(
self,
delivery_tag: int,
requeue: bool = False
) -> None:
"""
Reject a message (e.g., on processing failure).
Args:
delivery_tag: Message delivery tag
requeue: Whether to requeue the message
"""
if not self.channel:
raise RuntimeError("RabbitMQ channel not initialized.")
self.channel.basic_reject(delivery_tag=delivery_tag, requeue=requeue)
def create_rabbitmq_client(
rabbitmq_url: Optional[str] = None,
prefetch_count: int = 1
) -> RabbitMQClient:
"""
Factory function to create a RabbitMQ client.
Args:
rabbitmq_url: Optional RabbitMQ connection URL (defaults to localhost)
prefetch_count: Number of unacknowledged messages to prefetch
Returns:
Configured RabbitMQClient instance
"""
if rabbitmq_url is None:
# TODO: Load from environment variables or config file
rabbitmq_url = "amqp://guest:guest@localhost:5672"
client = RabbitMQClient(
rabbitmq_url=rabbitmq_url,
prefetch_count=prefetch_count
)
return client

View File

@@ -0,0 +1,119 @@
"""Schémas Pydantic de l'application."""
from .user import UserBase, UserCreate, UserResponse
from .tweet import (
TweetBase,
TweetCreate,
TweetResponse,
TweetListResponse,
TweetStatsResponse
)
from .reddit_post import (
RedditPostBase,
RedditPostCreate,
RedditPostResponse,
RedditPostListResponse,
RedditCommentBase,
RedditCommentCreate,
RedditCommentResponse,
RedditCommentListResponse,
RedditStatsResponse
)
from .rss_article import (
RSSArticleBase,
RSSArticleCreate,
RSSArticleResponse,
RSSArticleListResponse,
RSSArticleStatsResponse
)
from .sentiment_score import (
SentimentScoreBase,
SentimentScoreCreate,
SentimentScoreResponse,
SentimentAnalysisRequest,
SentimentAnalysisResponse,
BatchSentimentAnalysisRequest,
BatchSentimentAnalysisResponse,
AggregatedSentimentMetrics,
SentimentScoreListResponse
)
from .energy_score import (
EnergyScoreBase,
EnergyScoreCreate,
EnergyScoreUpdate,
EnergyScoreResponse,
EnergyScoreCalculationRequest,
EnergyScoreCalculationResponse,
EnergyScoreListResponse,
EnergyScoreQueryParams
)
from .match import (
MatchBase,
MatchCreate,
MatchUpdate,
MatchResponse,
MatchListResponse,
MatchStatsResponse
)
from .prediction import (
PredictionBase,
PredictionCreate,
PredictionUpdate,
PredictionResponse,
PredictionListResponse,
PredictionStatsResponse
)
__all__ = [
"UserBase",
"UserCreate",
"UserResponse",
"TweetBase",
"TweetCreate",
"TweetResponse",
"TweetListResponse",
"TweetStatsResponse",
"RedditPostBase",
"RedditPostCreate",
"RedditPostResponse",
"RedditPostListResponse",
"RedditCommentBase",
"RedditCommentCreate",
"RedditCommentResponse",
"RedditCommentListResponse",
"RedditStatsResponse",
"RSSArticleBase",
"RSSArticleCreate",
"RSSArticleResponse",
"RSSArticleListResponse",
"RSSArticleStatsResponse",
"SentimentScoreBase",
"SentimentScoreCreate",
"SentimentScoreResponse",
"SentimentAnalysisRequest",
"SentimentAnalysisResponse",
"BatchSentimentAnalysisRequest",
"BatchSentimentAnalysisResponse",
"AggregatedSentimentMetrics",
"SentimentScoreListResponse",
"EnergyScoreBase",
"EnergyScoreCreate",
"EnergyScoreUpdate",
"EnergyScoreResponse",
"EnergyScoreCalculationRequest",
"EnergyScoreCalculationResponse",
"EnergyScoreListResponse",
"EnergyScoreQueryParams",
"MatchBase",
"MatchCreate",
"MatchUpdate",
"MatchResponse",
"MatchListResponse",
"MatchStatsResponse",
"PredictionBase",
"PredictionCreate",
"PredictionUpdate",
"PredictionResponse",
"PredictionListResponse",
"PredictionStatsResponse",
]

View File

@@ -0,0 +1,37 @@
"""
Pydantic schemas for authentication.
"""
from typing import Optional
from pydantic import BaseModel, EmailStr, Field
class LoginRequest(BaseModel):
"""Schema pour la requête de connexion"""
email: EmailStr = Field(..., description="Email de l'utilisateur")
password: str = Field(..., min_length=1, description="Mot de passe de l'utilisateur")
class RegisterRequest(BaseModel):
"""Schema pour la requête d'inscription"""
email: EmailStr = Field(..., description="Email de l'utilisateur")
password: str = Field(..., min_length=8, description="Mot de passe (min 8 caractères)")
name: Optional[str] = Field(None, description="Nom de l'utilisateur (optionnel)")
referral_code: Optional[str] = Field(None, description="Code de parrainage (optionnel)")
class AuthResponse(BaseModel):
"""Schema pour la réponse d'authentification"""
data: dict
meta: dict
class Config:
from_attributes = True
class ErrorResponse(BaseModel):
"""Schema pour les réponses d'erreur"""
error: dict
class Config:
from_attributes = True

View File

@@ -0,0 +1,191 @@
"""
Backtesting Schemas.
Pydantic schemas for validating and serializing backtesting API requests and responses.
"""
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
class BacktestingRequest(BaseModel):
"""Request schema for running backtesting."""
leagues: Optional[List[str]] = Field(
None,
description="List of leagues to filter by (e.g., ['Ligue 1', 'Premier League'])",
example=["Ligue 1", "Premier League"]
)
start_date: Optional[str] = Field(
None,
description="Start date for filtering matches (ISO 8601 format)",
example="2025-01-01T00:00:00Z"
)
end_date: Optional[str] = Field(
None,
description="End date for filtering matches (ISO 8601 format)",
example="2025-12-31T23:59:59Z"
)
export_format: Optional[str] = Field(
None,
description="Export format for results: 'json', 'csv', or 'html'",
example="html"
)
class Config:
json_schema_extra = {
"example": {
"leagues": ["Ligue 1", "Premier League"],
"start_date": "2025-01-01T00:00:00Z",
"end_date": "2025-12-31T23:59:59Z",
"export_format": "html"
}
}
class MatchResultSchema(BaseModel):
"""Schema for single match backtesting result."""
match_id: int
league: Optional[str] = None
date: Optional[str] = None
home_team: str
away_team: str
home_energy: float
away_energy: float
prediction: Dict[str, Any]
actual_winner: str
correct: bool
class Config:
from_attributes = True
class LeagueMetricsSchema(BaseModel):
"""Schema for league-specific metrics."""
total: int
correct: int
accuracy: float
class Config:
from_attributes = True
class ValidationThresholdsSchema(BaseModel):
"""Schema for validation thresholds."""
validated: float
alert: float
class Config:
from_attributes = True
class BacktestingData(BaseModel):
"""Schema for backtesting result data."""
total_matches: int
correct_predictions: int
incorrect_predictions: int
accuracy: float
status: str
results: List[MatchResultSchema]
metrics_by_league: Dict[str, LeagueMetricsSchema]
timestamp: str
validation_thresholds: ValidationThresholdsSchema
export: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
class BacktestingResponse(BaseModel):
"""Response schema for backtesting API."""
data: BacktestingData
meta: Dict[str, Any]
class Config:
json_schema_extra = {
"example": {
"data": {
"total_matches": 100,
"correct_predictions": 65,
"incorrect_predictions": 35,
"accuracy": 65.0,
"status": "VALIDATED",
"results": [
{
"match_id": 1,
"league": "Ligue 1",
"date": "2025-01-15T20:00:00Z",
"home_team": "PSG",
"away_team": "OM",
"home_energy": 65.0,
"away_energy": 45.0,
"prediction": {
"confidence": 40.0,
"predicted_winner": "home",
"home_energy": 65.0,
"away_energy": 45.0
},
"actual_winner": "home",
"correct": True
}
],
"metrics_by_league": {
"Ligue 1": {"total": 50, "correct": 33, "accuracy": 66.0},
"Premier League": {"total": 50, "correct": 32, "accuracy": 64.0}
},
"timestamp": "2026-01-17T10:30:00Z",
"validation_thresholds": {
"validated": 60.0,
"alert": 55.0
}
},
"meta": {
"timestamp": "2026-01-17T10:30:00Z",
"version": "v1"
}
}
}
class ErrorDetail(BaseModel):
"""Schema for error details."""
code: str
message: str
details: Optional[Dict[str, Any]] = None
class BacktestingErrorResponse(BaseModel):
"""Response schema for backtesting API errors."""
error: ErrorDetail
meta: Dict[str, Any]
class Config:
json_schema_extra = {
"example": {
"error": {
"code": "NO_MATCHES_FOUND",
"message": "No historical matches found matching the specified filters",
"details": {
"filters": {
"leagues": ["Ligue 1"],
"start_date": "2025-01-01T00:00:00Z",
"end_date": "2025-12-31T23:59:59Z"
}
}
},
"meta": {
"timestamp": "2026-01-17T10:30:00Z",
"request_id": "req-abc123"
}
}
}

View File

@@ -0,0 +1,60 @@
"""
Schémas Pydantic pour les badges
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime
class BadgeBase(BaseModel):
"""Schéma de base pour un badge"""
name: str = Field(..., description="Nom du badge")
description: str = Field(..., description="Description du badge")
icon: str = Field(..., description="Icône ou emoji du badge")
category: str = Field(..., description="Catégorie du badge (predictions, accuracy, engagement, social)")
criteriaType: str = Field(..., description="Type de critère")
criteriaValue: int = Field(..., description="Valeur du critère")
criteriaDescription: str = Field(..., description="Description du critère")
rarity: str = Field(..., description="Rareté du badge (common, rare, epic, legendary)")
points: int = Field(..., description="Points attribués pour le badge")
class BadgeResponse(BadgeBase):
"""Réponse avec un badge"""
id: int
badgeId: str
createdAt: str
unlocked: Optional[bool] = None # Indique si le badge est débloqué par l'utilisateur
class BadgeListResponse(BaseModel):
"""Réponse avec une liste de badges"""
data: List[BadgeResponse]
meta: Dict[str, Any]
class UserBadgeResponse(BaseModel):
"""Réponse avec un badge d'utilisateur"""
id: int
userId: int
badgeId: int
unlockedAt: str
badge: Dict[str, Any]
class UserBadgeListResponse(BaseModel):
"""Réponse avec une liste de badges d'utilisateur"""
data: Dict[str, Any]
meta: Dict[str, Any]
class BadgeUnlockRequest(BaseModel):
"""Requête pour débloquer un badge"""
userId: int = Field(..., description="ID de l'utilisateur")
badgeId: str = Field(..., description="ID du badge à débloquer")
class BadgeCheckResponse(BaseModel):
"""Réponse après vérification des badges"""
data: Dict[str, Any]
meta: Dict[str, Any]

View File

@@ -0,0 +1,91 @@
"""
Pydantic schemas for energy scores.
This module defines request and response schemas for energy score operations.
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field, ConfigDict
class EnergyScoreBase(BaseModel):
"""Base schema for energy score data."""
match_id: int = Field(..., description="ID of the match")
team_id: int = Field(..., description="ID of the team")
score: float = Field(..., ge=0.0, le=100.0, description="Final energy score (0-100)")
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="Confidence level (0-1)")
sources_used: List[str] = Field(default_factory=list, description="List of sources used")
class EnergyScoreCreate(EnergyScoreBase):
"""Schema for creating a new energy score."""
twitter_score: Optional[float] = Field(None, description="Energy score from Twitter component")
reddit_score: Optional[float] = Field(None, description="Energy score from Reddit component")
rss_score: Optional[float] = Field(None, description="Energy score from RSS component")
temporal_factor: Optional[float] = Field(None, description="Temporal weighting factor applied")
twitter_weight: Optional[float] = Field(None, description="Adjusted weight for Twitter")
reddit_weight: Optional[float] = Field(None, description="Adjusted weight for Reddit")
rss_weight: Optional[float] = Field(None, description="Adjusted weight for RSS")
class EnergyScoreUpdate(BaseModel):
"""Schema for updating an energy score."""
score: Optional[float] = Field(None, ge=0.0, le=100.0, description="Final energy score (0-100)")
confidence: Optional[float] = Field(None, ge=0.0, le=1.0, description="Confidence level (0-1)")
sources_used: Optional[List[str]] = Field(None, description="List of sources used")
twitter_score: Optional[float] = Field(None, description="Energy score from Twitter component")
reddit_score: Optional[float] = Field(None, description="Energy score from Reddit component")
rss_score: Optional[float] = Field(None, description="Energy score from RSS component")
temporal_factor: Optional[float] = Field(None, description="Temporal weighting factor applied")
twitter_weight: Optional[float] = Field(None, description="Adjusted weight for Twitter")
reddit_weight: Optional[float] = Field(None, description="Adjusted weight for Reddit")
rss_weight: Optional[float] = Field(None, description="Adjusted weight for RSS")
class EnergyScoreResponse(EnergyScoreCreate):
"""Schema for energy score response."""
id: int = Field(..., description="Primary key")
created_at: datetime = Field(..., description="Timestamp when energy score was calculated")
updated_at: datetime = Field(..., description="Timestamp when energy score was last updated")
model_config = ConfigDict(from_attributes=True)
class EnergyScoreCalculationRequest(BaseModel):
"""Schema for requesting energy score calculation."""
match_id: int = Field(..., description="ID of the match")
team_id: int = Field(..., description="ID of the team")
twitter_sentiments: Optional[List[dict]] = Field(None, description="List of Twitter sentiment scores")
reddit_sentiments: Optional[List[dict]] = Field(None, description="List of Reddit sentiment scores")
rss_sentiments: Optional[List[dict]] = Field(None, description="List of RSS sentiment scores")
tweets_with_timestamps: Optional[List[dict]] = Field(None, description="List of tweets with timestamps")
class EnergyScoreCalculationResponse(BaseModel):
"""Schema for energy score calculation response."""
score: float = Field(..., ge=0.0, le=100.0, description="Final energy score (0-100)")
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence level (0-1)")
sources_used: List[str] = Field(..., description="List of sources used")
twitter_score: Optional[float] = Field(None, description="Energy score from Twitter component")
reddit_score: Optional[float] = Field(None, description="Energy score from Reddit component")
rss_score: Optional[float] = Field(None, description="Energy score from RSS component")
temporal_factor: Optional[float] = Field(None, description="Temporal weighting factor applied")
class EnergyScoreListResponse(BaseModel):
"""Schema for a list of energy scores."""
data: List[EnergyScoreResponse] = Field(..., description="List of energy scores")
count: int = Field(..., description="Total number of energy scores")
meta: dict = Field(default_factory=dict, description="Additional metadata")
class EnergyScoreQueryParams(BaseModel):
"""Schema for energy score query parameters."""
match_id: Optional[int] = Field(None, description="Filter by match ID")
team_id: Optional[int] = Field(None, description="Filter by team ID")
min_score: Optional[float] = Field(None, ge=0.0, le=100.0, description="Filter by minimum score")
max_score: Optional[float] = Field(None, ge=0.0, le=100.0, description="Filter by maximum score")
min_confidence: Optional[float] = Field(None, ge=0.0, le=1.0, description="Filter by minimum confidence")
source: Optional[str] = Field(None, description="Filter by source used")
limit: int = Field(default=10, ge=1, le=100, description="Maximum number of results")
offset: int = Field(default=0, ge=0, description="Offset for pagination")

View File

@@ -0,0 +1,81 @@
"""
Schema definitions for leaderboard API.
This module provides Pydantic models for leaderboard-related responses
and requests.
"""
from datetime import datetime
from pydantic import BaseModel, Field
class LeaderboardEntry(BaseModel):
"""Represents a single entry in the leaderboard."""
user_id: int
username: str | None = None
accuracy: float = Field(..., ge=0, le=100, description="Accuracy percentage (0-100)")
predictions_count: int = Field(..., ge=0, description="Total number of predictions viewed")
class Config:
json_schema_extra = {
"example": {
"user_id": 1,
"username": "JohnDoe",
"accuracy": 85.5,
"predictions_count": 42
}
}
class PersonalRankData(BaseModel):
"""Represents personal rank data for the current user."""
rank: int = Field(..., ge=1, description="User's rank in the leaderboard")
accuracy: float = Field(..., ge=0, le=100, description="User's accuracy percentage")
predictions_count: int = Field(..., ge=0, description="User's total predictions viewed")
class Config:
json_schema_extra = {
"example": {
"rank": 42,
"accuracy": 75.5,
"predictions_count": 25
}
}
class LeaderboardResponse(BaseModel):
"""Response model for leaderboard endpoint."""
data: list[LeaderboardEntry]
personal_data: PersonalRankData | None = None
meta: dict
class Config:
json_schema_extra = {
"example": {
"data": [
{
"user_id": 1,
"username": "JohnDoe",
"accuracy": 95.5,
"predictions_count": 100
},
{
"user_id": 2,
"username": "JaneSmith",
"accuracy": 90.0,
"predictions_count": 85
}
],
"personal_data": {
"rank": 42,
"accuracy": 75.5,
"predictions_count": 25
},
"meta": {
"total": 2,
"limit": 100,
"timestamp": "2026-01-18T10:30:00Z",
"version": "v1"
}
}
}

View File

@@ -0,0 +1,55 @@
"""
Pydantic schemas for matches.
This module defines request and response schemas for match-related operations.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class MatchBase(BaseModel):
"""Base schema for match data."""
home_team: str = Field(..., min_length=1, max_length=255, description="Name of the home team")
away_team: str = Field(..., min_length=1, max_length=255, description="Name of the away team")
date: datetime = Field(..., description="Match date and time")
league: str = Field(..., min_length=1, max_length=255, description="League name")
status: str = Field(..., min_length=1, max_length=50, description="Match status")
class MatchCreate(MatchBase):
"""Schema for creating a new match."""
pass
class MatchUpdate(BaseModel):
"""Schema for updating a match."""
home_team: Optional[str] = Field(None, min_length=1, max_length=255)
away_team: Optional[str] = Field(None, min_length=1, max_length=255)
date: Optional[datetime] = None
league: Optional[str] = Field(None, min_length=1, max_length=255)
status: Optional[str] = Field(None, min_length=1, max_length=50)
class MatchResponse(MatchBase):
"""Schema for match response."""
id: int = Field(..., description="Primary key")
model_config = ConfigDict(from_attributes=True)
class MatchListResponse(BaseModel):
"""Schema for a list of matches."""
data: list[MatchResponse]
count: int = Field(..., description="Total number of matches")
meta: dict = Field(default_factory=dict, description="Additional metadata")
class MatchStatsResponse(BaseModel):
"""Schema for match statistics."""
total_matches: int = Field(..., description="Total number of matches")
matches_by_league: dict = Field(..., description="Breakdown by league")
matches_by_status: dict = Field(..., description="Breakdown by status")
upcoming_matches: int = Field(..., description="Number of upcoming matches")
completed_matches: int = Field(..., description="Number of completed matches")

View File

@@ -0,0 +1,77 @@
"""
Pydantic schemas for predictions.
This module defines request and response schemas for prediction-related operations.
"""
from datetime import datetime
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict
class PredictionBase(BaseModel):
"""Base schema for prediction data."""
match_id: int = Field(..., description="Foreign key to matches table")
energy_score: str = Field(..., min_length=1, max_length=50, description="Energy score for the prediction")
confidence: str = Field(..., min_length=1, max_length=50, description="Confidence level of the prediction")
predicted_winner: str = Field(..., min_length=1, max_length=255, description="Predicted winner team name")
created_at: datetime = Field(..., description="Timestamp when prediction was created")
class PredictionCreate(PredictionBase):
"""Schema for creating a new prediction."""
pass
class PredictionUpdate(BaseModel):
"""Schema for updating a prediction."""
energy_score: Optional[str] = Field(None, min_length=1, max_length=50)
confidence: Optional[str] = Field(None, min_length=1, max_length=50)
predicted_winner: Optional[str] = Field(None, min_length=1, max_length=255)
class MatchInfo(BaseModel):
"""Schema for match information included in prediction response."""
id: int = Field(..., description="Match ID")
home_team: str = Field(..., description="Home team name")
away_team: str = Field(..., description="Away team name")
date: datetime = Field(..., description="Match date and time")
league: str = Field(..., description="League name")
status: str = Field(..., description="Match status")
class PredictionResponse(BaseModel):
"""Schema for prediction response with match details."""
id: int = Field(..., description="Primary key")
match_id: int = Field(..., description="Foreign key to matches table")
match: MatchInfo = Field(..., description="Match details")
energy_score: str = Field(..., description="Energy score for the prediction")
confidence: str = Field(..., description="Confidence level of the prediction")
predicted_winner: str = Field(..., description="Predicted winner team name")
created_at: datetime = Field(..., description="Timestamp when prediction was created")
model_config = ConfigDict(from_attributes=False)
class PredictionListMeta(BaseModel):
"""Schema for pagination metadata."""
total: int = Field(..., description="Total number of predictions matching filters")
limit: int = Field(..., description="Number of predictions returned")
offset: int = Field(..., description="Number of predictions skipped")
timestamp: str = Field(..., description="ISO 8601 timestamp of response")
version: str = Field(default="v1", description="API version")
class PredictionListResponse(BaseModel):
"""Schema for a list of predictions with standardized metadata."""
data: list[PredictionResponse] = Field(..., description="List of predictions")
meta: PredictionListMeta = Field(..., description="Pagination and metadata")
class PredictionStatsResponse(BaseModel):
"""Schema for prediction statistics."""
total_predictions: int = Field(..., description="Total number of predictions")
predictions_by_confidence: dict = Field(..., description="Breakdown by confidence level")
predictions_by_energy_score: dict = Field(..., description="Breakdown by energy score")
avg_confidence: float = Field(..., description="Average confidence level")
unique_matches_predicted: int = Field(..., description="Number of unique matches with predictions")

View File

@@ -0,0 +1,89 @@
"""
Pydantic schemas for public API.
This module defines request and response schemas for public API endpoints.
"""
from datetime import datetime
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict
class PublicMatchInfo(BaseModel):
"""Schema for match information in public API."""
id: int = Field(..., description="Match ID")
home_team: str = Field(..., description="Home team name")
away_team: str = Field(..., description="Away team name")
date: str = Field(..., description="Match date and time (ISO 8601)")
league: str = Field(..., description="League name")
status: str = Field(..., description="Match status")
class PublicPredictionResponse(BaseModel):
"""Schema for prediction response in public API."""
id: int = Field(..., description="Prediction ID")
match_id: int = Field(..., description="Match ID")
match: PublicMatchInfo = Field(..., description="Match details")
energy_score: str = Field(..., description="Energy score for the prediction")
confidence: str = Field(..., description="Confidence level of the prediction")
predicted_winner: str = Field(..., description="Predicted winner team name")
created_at: str = Field(..., description="Timestamp when prediction was created")
class PublicMatchResponse(BaseModel):
"""Schema for match response in public API."""
id: int = Field(..., description="Match ID")
home_team: str = Field(..., description="Home team name")
away_team: str = Field(..., description="Away team name")
date: str = Field(..., description="Match date and time (ISO 8601)")
league: str = Field(..., description="League name")
status: str = Field(..., description="Match status")
class SuccessMeta(BaseModel):
"""Schema for success response metadata."""
total: Optional[int] = Field(None, description="Total number of items")
limit: Optional[int] = Field(None, description="Number of items returned")
offset: Optional[int] = Field(None, description="Number of items skipped")
timestamp: str = Field(..., description="ISO 8601 timestamp of response")
version: str = Field(default="v1", description="API version")
class SuccessResponse(BaseModel):
"""Schema for standardized success response."""
data: Any = Field(..., description="Response data")
meta: SuccessMeta = Field(..., description="Response metadata")
class ErrorResponse(BaseModel):
"""Schema for error response."""
error: dict = Field(..., description="Error details")
meta: dict = Field(..., description="Response metadata")
class ErrorDetail(BaseModel):
"""Schema for error detail."""
code: str = Field(..., description="Error code")
message: str = Field(..., description="Human-readable error message")
details: Optional[dict] = Field(None, description="Additional error details")
class ApiKeyResponse(BaseModel):
"""Schema for API key response (includes plain key only on creation)."""
id: int = Field(..., description="API key ID")
user_id: int = Field(..., description="User ID")
key_prefix: str = Field(..., description="First 8 characters of API key")
is_active: bool = Field(..., description="Whether the key is active")
rate_limit: int = Field(..., description="Rate limit per minute")
last_used_at: Optional[str] = Field(None, description="Last usage timestamp")
created_at: str = Field(..., description="Creation timestamp")
api_key: Optional[str] = Field(None, description="Plain API key (only on creation)")
class ApiStatsResponse(BaseModel):
"""Schema for API usage statistics."""
total_requests: int = Field(..., description="Total API requests made")
requests_this_month: int = Field(..., description="Requests in current month")
rate_limit: int = Field(..., description="Rate limit per minute")
requests_remaining: int = Field(..., description="Requests remaining in rate limit window")

View File

@@ -0,0 +1,79 @@
"""
Pydantic schemas for Reddit posts and comments.
This module defines request and response schemas for Reddit-related operations.
"""
from datetime import datetime
from typing import Optional
from typing import List
from pydantic import BaseModel, Field, ConfigDict
class RedditPostBase(BaseModel):
"""Base schema for Reddit post data."""
post_id: str = Field(..., description="Unique identifier from Reddit")
title: str = Field(..., min_length=1, max_length=500, description="Post title")
text: Optional[str] = Field(None, description="Post content")
upvotes: int = Field(default=0, ge=0, description="Number of upvotes")
created_at: datetime = Field(..., description="Timestamp when post was created")
match_id: Optional[int] = Field(None, description="Foreign key to matches table")
subreddit: str = Field(..., description="Subreddit name")
source: str = Field(default="reddit", description="Source platform")
class RedditPostCreate(RedditPostBase):
"""Schema for creating a new Reddit post."""
pass
class RedditPostResponse(RedditPostBase):
"""Schema for Reddit post response."""
id: int = Field(..., description="Primary key")
model_config = ConfigDict(from_attributes=True)
class RedditCommentBase(BaseModel):
"""Base schema for Reddit comment data."""
comment_id: str = Field(..., description="Unique identifier from Reddit")
post_id: str = Field(..., description="Foreign key to posts_reddit table")
text: str = Field(..., min_length=1, description="Comment content")
upvotes: int = Field(default=0, ge=0, description="Number of upvotes")
created_at: datetime = Field(..., description="Timestamp when comment was created")
source: str = Field(default="reddit", description="Source platform")
class RedditCommentCreate(RedditCommentBase):
"""Schema for creating a new Reddit comment."""
pass
class RedditCommentResponse(RedditCommentBase):
"""Schema for Reddit comment response."""
id: int = Field(..., description="Primary key")
model_config = ConfigDict(from_attributes=True)
class RedditPostListResponse(BaseModel):
"""Schema for a list of Reddit posts."""
data: list[RedditPostResponse]
count: int = Field(..., description="Total number of posts")
meta: dict = Field(default_factory=dict, description="Additional metadata")
class RedditCommentListResponse(BaseModel):
"""Schema for a list of Reddit comments."""
data: list[RedditCommentResponse]
count: int = Field(..., description="Total number of comments")
meta: dict = Field(default_factory=dict, description="Additional metadata")
class RedditStatsResponse(BaseModel):
"""Schema for Reddit statistics."""
total_posts: int = Field(..., description="Total number of Reddit posts")
total_comments: int = Field(..., description="Total number of Reddit comments")
posts_by_subreddit: dict = Field(..., description="Breakdown by subreddit")
posts_by_match: dict = Field(..., description="Breakdown by match")
avg_post_upvotes: float = Field(..., description="Average post upvotes")
avg_comment_upvotes: float = Field(..., description="Average comment upvotes")

View File

@@ -0,0 +1,46 @@
"""
Pydantic schemas for RSS articles.
This module defines request and response schemas for RSS article-related operations.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class RSSArticleBase(BaseModel):
"""Base schema for RSS article data."""
article_id: str = Field(..., description="Unique identifier from RSS feed")
title: str = Field(..., min_length=1, max_length=500, description="Article title")
content: Optional[str] = Field(None, description="Article content/description")
published_at: datetime = Field(..., description="Timestamp when article was published")
source_url: str = Field(..., min_length=1, max_length=1000, description="URL of RSS feed source")
match_id: Optional[int] = Field(None, description="Foreign key to matches table")
source: str = Field(default="rss", description="Source feed name")
class RSSArticleCreate(RSSArticleBase):
"""Schema for creating a new RSS article."""
pass
class RSSArticleResponse(RSSArticleBase):
"""Schema for RSS article response."""
id: int = Field(..., description="Primary key")
model_config = ConfigDict(from_attributes=True)
class RSSArticleListResponse(BaseModel):
"""Schema for a list of RSS articles."""
data: list[RSSArticleResponse]
count: int = Field(..., description="Total number of articles")
meta: dict = Field(default_factory=dict, description="Additional metadata")
class RSSArticleStatsResponse(BaseModel):
"""Schema for RSS article statistics."""
total_articles: int = Field(..., description="Total number of articles")
articles_by_source: dict = Field(..., description="Breakdown by source feed")
articles_by_match: dict = Field(..., description="Breakdown by match")

View File

@@ -0,0 +1,77 @@
"""
Pydantic schemas for sentiment scores.
This module defines request and response schemas for sentiment analysis operations.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class SentimentScoreBase(BaseModel):
"""Base schema for sentiment score data."""
entity_id: str = Field(..., description="Foreign key to the entity being analyzed")
entity_type: str = Field(..., description="Type of entity ('tweet' or 'reddit_post')")
score: float = Field(..., ge=-1.0, le=1.0, description="Overall compound sentiment score")
sentiment_type: str = Field(..., description="Classification ('positive', 'negative', or 'neutral')")
positive: float = Field(default=0.0, ge=0.0, le=1.0, description="Positive proportion score")
negative: float = Field(default=0.0, ge=0.0, le=1.0, description="Negative proportion score")
neutral: float = Field(default=0.0, ge=0.0, le=1.0, description="Neutral proportion score")
class SentimentScoreCreate(SentimentScoreBase):
"""Schema for creating a new sentiment score."""
pass
class SentimentScoreResponse(SentimentScoreBase):
"""Schema for sentiment score response."""
id: int = Field(..., description="Primary key")
created_at: datetime = Field(..., description="Timestamp when the sentiment was analyzed")
model_config = ConfigDict(from_attributes=True)
class SentimentAnalysisRequest(BaseModel):
"""Schema for requesting sentiment analysis."""
text: str = Field(..., min_length=1, max_length=1000, description="Text to analyze")
class SentimentAnalysisResponse(BaseModel):
"""Schema for sentiment analysis response."""
compound: float = Field(..., ge=-1.0, le=1.0, description="Overall compound score")
positive: float = Field(..., ge=0.0, le=1.0, description="Positive proportion")
negative: float = Field(..., ge=0.0, le=1.0, description="Negative proportion")
neutral: float = Field(..., ge=0.0, le=1.0, description="Neutral proportion")
sentiment: str = Field(..., description="Classification ('positive', 'negative', or 'neutral')")
class BatchSentimentAnalysisRequest(BaseModel):
"""Schema for requesting batch sentiment analysis."""
texts: list[str] = Field(..., min_length=1, max_length=1000, description="List of texts to analyze")
class BatchSentimentAnalysisResponse(BaseModel):
"""Schema for batch sentiment analysis response."""
results: list[SentimentAnalysisResponse] = Field(..., description="List of sentiment analysis results")
total_count: int = Field(..., description="Total number of texts analyzed")
class AggregatedSentimentMetrics(BaseModel):
"""Schema for aggregated sentiment metrics."""
total_count: int = Field(..., ge=0, description="Total number of sentiments")
positive_count: int = Field(..., ge=0, description="Count of positive sentiments")
negative_count: int = Field(..., ge=0, description="Count of negative sentiments")
neutral_count: int = Field(..., ge=0, description="Count of neutral sentiments")
positive_ratio: float = Field(..., ge=0.0, le=1.0, description="Ratio of positive sentiments")
negative_ratio: float = Field(..., ge=0.0, le=1.0, description="Ratio of negative sentiments")
neutral_ratio: float = Field(..., ge=0.0, le=1.0, description="Ratio of neutral sentiments")
average_compound: float = Field(..., ge=-1.0, le=1.0, description="Average compound score")
class SentimentScoreListResponse(BaseModel):
"""Schema for a list of sentiment scores."""
data: list[SentimentScoreResponse]
count: int = Field(..., description="Total number of sentiment scores")
meta: dict = Field(default_factory=dict, description="Additional metadata")

View File

@@ -0,0 +1,48 @@
"""
Pydantic schemas for tweets.
This module defines request and response schemas for tweet-related operations.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class TweetBase(BaseModel):
"""Base schema for tweet data."""
tweet_id: str = Field(..., description="Unique identifier from the source platform")
text: str = Field(..., min_length=1, max_length=1000, description="Tweet content")
created_at: datetime = Field(..., description="Timestamp when the tweet was created")
retweet_count: int = Field(default=0, ge=0, description="Number of retweets")
like_count: int = Field(default=0, ge=0, description="Number of likes")
match_id: Optional[int] = Field(None, description="Foreign key to matches table")
source: str = Field(default="twitter", description="Source platform (twitter, reddit, rss)")
class TweetCreate(TweetBase):
"""Schema for creating a new tweet."""
pass
class TweetResponse(TweetBase):
"""Schema for tweet response."""
id: int = Field(..., description="Primary key")
model_config = ConfigDict(from_attributes=True)
class TweetListResponse(BaseModel):
"""Schema for a list of tweets."""
data: list[TweetResponse]
count: int = Field(..., description="Total number of tweets")
meta: dict = Field(default_factory=dict, description="Additional metadata")
class TweetStatsResponse(BaseModel):
"""Schema for tweet statistics."""
total_tweets: int = Field(..., description="Total number of tweets")
tweets_by_source: dict = Field(..., description="Breakdown by source platform")
tweets_by_match: dict = Field(..., description="Breakdown by match")
avg_retweet_count: float = Field(..., description="Average retweet count")
avg_like_count: float = Field(..., description="Average like count")

View File

@@ -0,0 +1,42 @@
"""Schémas Pydantic pour les utilisateurs."""
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
"""Schéma de base pour un utilisateur."""
email: EmailStr
name: str = Field(..., min_length=1, max_length=100)
class UserCreate(UserBase):
"""Schéma pour la création d'un utilisateur."""
password: str = Field(..., min_length=8, max_length=100)
referral_code: Optional[str] = Field(None, description="Code de parrainage (optionnel)")
class UserUpdate(BaseModel):
"""Schéma pour la mise à jour d'un utilisateur."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
is_premium: Optional[bool] = Field(None, description="Statut Premium")
class UserResponse(BaseModel):
"""Schéma pour la réponse d'un utilisateur."""
id: int
email: EmailStr
name: str
is_premium: bool = False
referral_code: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UserLoginRequest(BaseModel):
"""Schéma pour la connexion d'un utilisateur."""
email: EmailStr
password: str

View File

@@ -0,0 +1,75 @@
"""
Pydantic schemas for user_predictions.
This module defines request and response schemas for user prediction tracking operations.
"""
from datetime import datetime
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict
class UserPredictionBase(BaseModel):
"""Base schema for user prediction data."""
user_id: int = Field(..., description="Foreign key to users table")
prediction_id: int = Field(..., description="Foreign key to predictions table")
viewed_at: datetime = Field(..., description="Timestamp when user viewed prediction")
was_correct: Optional[bool] = Field(None, description="True if prediction was correct, False if incorrect, NULL if match not completed")
class UserPredictionCreate(UserPredictionBase):
"""Schema for creating a new user prediction record."""
pass
class UserPredictionUpdate(BaseModel):
"""Schema for updating a user prediction record."""
was_correct: Optional[bool] = Field(None, description="Update whether prediction was correct")
class PredictionMatchInfo(BaseModel):
"""Schema for match information included in user prediction response."""
id: int = Field(..., description="Match ID")
home_team: str = Field(..., description="Home team name")
away_team: str = Field(..., description="Away team name")
date: datetime = Field(..., description="Match date and time")
league: str = Field(..., description="League name")
status: str = Field(..., description="Match status")
actual_winner: Optional[str] = Field(None, description="Actual winner when match is completed")
class PredictionInfo(BaseModel):
"""Schema for prediction information included in user prediction response."""
id: int = Field(..., description="Prediction ID")
match_id: int = Field(..., description="Match ID")
energy_score: str = Field(..., description="Energy score")
confidence: str = Field(..., description="Confidence level")
predicted_winner: str = Field(..., description="Predicted winner")
created_at: datetime = Field(..., description="Prediction creation time")
class UserPredictionResponse(BaseModel):
"""Schema for user prediction response with full details."""
id: int = Field(..., description="Primary key")
user_id: int = Field(..., description="User ID")
prediction_id: int = Field(..., description="Prediction ID")
viewed_at: datetime = Field(..., description="When user viewed prediction")
was_correct: Optional[bool] = Field(None, description="True if prediction was correct")
prediction: PredictionInfo = Field(..., description="Full prediction details")
model_config = ConfigDict(from_attributes=False)
class UserPredictionListResponse(BaseModel):
"""Schema for a list of user predictions with metadata."""
data: list[UserPredictionResponse] = Field(..., description="List of user predictions")
meta: dict = Field(..., description="Metadata including totals")
class UserStatsResponse(BaseModel):
"""Schema for user statistics."""
total_predictions_viewed: int = Field(..., description="Total number of predictions viewed by user")
correct_predictions: int = Field(..., description="Number of correct predictions")
incorrect_predictions: int = Field(..., description="Number of incorrect predictions")
accuracy_rate: float = Field(..., description="Accuracy rate as percentage (0-100)")
roi: float = Field(..., description="Return on Investment in EUR")

View File

@@ -0,0 +1,144 @@
# Twitter Scraper
Module de scraping Twitter avec gestion des rate limiting et mode dégradé.
## Fonctionnalités
- ✅ Collecte de tweets pour les matchs de football
- ✅ Rate limiting (1000 req/heure) avec alertes prédictives (>90%)
- ✅ Mode dégradé avec priorisation des matchs VIP
- ✅ Retry avec backoff exponentiel
- ✅ Logging structuré pour monitoring
- ✅ Stockage en base de données partagée
## Installation
```bash
pip install tweepy==4.14.0
```
## Configuration
Variables d'environnement requises:
```bash
# Twitter API Bearer Token
TWITTER_BEARER_TOKEN="your_bearer_token_here"
```
## Utilisation
### Exemple de base
```python
from app.scrapers.twitter_scraper import create_twitter_scraper
from app.database import SessionLocal
# Créer le scraper
scraper = create_twitter_scraper(
bearer_token="your_bearer_token",
vip_match_ids=[1, 2, 3] # Matchs VIP pour mode dégradé
)
# Scrapper des tweets pour un match
db = SessionLocal()
try:
tweets = scraper.scrape_and_save(
match_id=1,
keywords=["#MatchName", "Team1 vs Team2"],
db=db,
max_results=100
)
print(f"{len(tweets)} tweets collectés")
finally:
db.close()
```
### Configuration avancée
```python
from app.scrapers.twitter_scraper import TwitterScraper
scraper = TwitterScraper(
bearer_token="your_bearer_token",
max_tweets_per_hour=1000, # Limite par défaut
rate_limit_alert_threshold=0.9, # Alert à 90%
vip_match_ids=[1, 2, 3, 4, 5]
)
```
## Architecture
### Rate Limiting
Le scraper implémente:
- **Tracking en temps réel** des appels API
- **Alertes prédictives** quand la limite est atteinte à >90%
- **Mode dégradé automatique** quand la limite est atteinte
- **Backoff exponentiel** pour éviter les blocages
### Mode Dégradé
Quand le rate limit est atteint:
- Le scraper passe en mode VIP seulement
- Seuls les matchs VIP sont scrapés
- Alertes loggées pour monitoring
- Les données sont sauvegardées avant arrêt
## Tests
Exécuter les tests:
```bash
cd backend
pytest tests/test_twitter_scraper.py -v
```
## Intégration
Le module s'intègre avec:
- **SQLite**: Base de données partagée avec Next.js
- **SQLAlchemy**: ORM pour le backend FastAPI
- **Drizzle ORM**: ORM pour le frontend Next.js
- **RabbitMQ** (Phase 2+): Queue asynchrone pour découplage
## Conventions de Code
- **Nommage Python**: `snake_case`
- **Nommage Base de données**: `snake_case`
- **Logging**: Structuré avec `logging` module
- **Type hints**: Obligatoires avec `typing`
## Documentation API
Voir [documentation Tweepy](https://docs.tweepy.org/) pour plus de détails sur l'API Twitter.
## Dépannage
### Erreur: "Twitter API authentication failed"
Vérifiez votre bearer token:
```python
client.get_me() # Devrait retourner vos infos utilisateur
```
### Rate limit atteint trop rapidement
Vérifiez l'utilisation:
```python
print(f"API calls: {scraper.api_calls_made}/{scraper.max_tweets_per_hour}")
```
### Mode dégradé activé sans raison
Vérifiez les seuils:
```python
print(f"Usage: {scraper.rate_limit_info.usage_percentage * 100:.1f}%")
```
## Prochaines Étapes
- [ ] Intégration avec RabbitMQ (Phase 2)
- [ ] Système de priorisation dynamique
- [ ] Dashboard de monitoring en temps réel
- [ ] Tests d'intégration E2E

View File

@@ -0,0 +1,12 @@
"""
Scrapers package for collecting data from various sources.
This package contains modules for scraping data from:
- Twitter
- Reddit (to be implemented)
- RSS feeds (to be implemented)
"""
from .twitter_scraper import TwitterScraper
__all__ = ["TwitterScraper"]

View File

@@ -0,0 +1,441 @@
"""
Reddit scraper module with robust error handling.
This module provides functionality to scrape Reddit posts and comments
about football matches, with built-in error handling and logging.
"""
import logging
from datetime import datetime, timezone
from typing import List, Dict, Optional
from dataclasses import dataclass
import praw
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@dataclass
class RedditPostData:
"""Structured data for a Reddit post."""
post_id: str
title: str
text: str
upvotes: int
created_at: datetime
match_id: int
subreddit: str
source: str = "reddit"
@dataclass
class RedditCommentData:
"""Structured data for a Reddit comment."""
comment_id: str
post_id: str
text: str
upvotes: int
created_at: datetime
source: str = "reddit"
class RedditScraper:
"""
Reddit scraper with robust error handling.
Features:
- Scrapes posts and comments from specified subreddits
- Error handling without stopping the process
- Continues with other sources on errors
- Structured logging
- Timeout configuration
"""
def __init__(
self,
client_id: str,
client_secret: str,
subreddits: List[str],
max_posts_per_subreddit: int = 100,
max_comments_per_post: int = 50,
user_agent: str = "Chartbastan/1.0"
):
"""
Initialize Reddit scraper.
Args:
client_id: Reddit API client ID
client_secret: Reddit API client secret
subreddits: List of subreddits to scrape
max_posts_per_subreddit: Maximum posts to collect per subreddit
max_comments_per_post: Maximum comments to collect per post
user_agent: User agent string for API requests
"""
self.client_id = client_id
self.client_secret = client_secret
self.subreddits = subreddits
self.max_posts_per_subreddit = max_posts_per_subreddit
self.max_comments_per_post = max_comments_per_post
self.user_agent = user_agent
# Initialize Reddit API client
self.reddit = praw.Reddit(
client_id=client_id,
client_secret=client_secret,
user_agent=user_agent
)
# Verify authentication
self._verify_authentication()
def _verify_authentication(self) -> None:
"""Verify Reddit API authentication."""
try:
# Try to get authenticated user
user = self.reddit.user.me()
if user:
logger.info(f"✅ Reddit API authenticated successfully as /u/{user.name}")
else:
logger.warning("⚠️ Reddit API authentication returned no user data")
except Exception as e:
logger.error(f"❌ Reddit API authentication failed: {e}")
raise
def scrape_posts(
self,
subreddit: str,
match_id: int,
keywords: Optional[List[str]] = None
) -> List[RedditPostData]:
"""
Scrape posts from a subreddit for a specific match.
Args:
subreddit: Subreddit name (e.g., "soccer")
match_id: Match identifier
keywords: Optional list of keywords to filter posts
Returns:
List of RedditPostData objects
"""
posts_data = []
try:
logger.info(f"🔍 Scraping posts from r/{subreddit} for match {match_id}")
# Get subreddit
sub = self.reddit.subreddit(subreddit)
# Fetch new posts
posts = list(sub.new(limit=self.max_posts_per_subreddit))
if not posts:
logger.info(f" No posts found in r/{subreddit}")
return posts_data
# Filter by keywords if provided
for post in posts:
# Skip if keywords provided and not matching
if keywords:
text_to_search = f"{post.title.lower()} {post.selftext.lower()}"
if not any(keyword.lower() in text_to_search for keyword in keywords):
continue
# Create post data
post_data = RedditPostData(
post_id=post.id,
title=post.title,
text=post.selftext if hasattr(post, 'selftext') else "",
upvotes=post.score,
created_at=datetime.fromtimestamp(post.created_utc, tz=timezone.utc),
match_id=match_id,
subreddit=subreddit,
source="reddit"
)
posts_data.append(post_data)
logger.info(f"✅ Collected {len(posts_data)} posts from r/{subreddit}")
except praw.exceptions.PRAWException as e:
logger.error(f"❌ Reddit API error while scraping r/{subreddit}: {e}")
except Exception as e:
logger.error(f"❌ Unexpected error while scraping r/{subreddit}: {e}")
return posts_data
def scrape_comments(
self,
post_id: str,
post,
max_comments: Optional[int] = None
) -> List[RedditCommentData]:
"""
Scrape comments from a Reddit post.
Args:
post_id: Reddit post ID
post: PRAW submission object
max_comments: Maximum number of comments to collect
Returns:
List of RedditCommentData objects
"""
comments_data = []
limit = max_comments or self.max_comments_per_post
try:
logger.info(f"💬 Scraping comments for post {post_id}")
# Get comments (replace_more removes "more comments" placeholders)
post.comments.replace_more(limit=0)
comments = list(post.comments.list())[:limit]
if not comments:
logger.info(f" No comments found for post {post_id}")
return comments_data
# Process comments
for comment in comments:
# Skip if comment doesn't have required attributes
if not hasattr(comment, 'id') or not hasattr(comment, 'body'):
continue
comment_data = RedditCommentData(
comment_id=comment.id,
post_id=post_id,
text=comment.body,
upvotes=comment.score,
created_at=datetime.fromtimestamp(comment.created_utc, tz=timezone.utc),
source="reddit"
)
comments_data.append(comment_data)
logger.info(f"✅ Collected {len(comments_data)} comments for post {post_id}")
except praw.exceptions.PRAWException as e:
logger.error(f"❌ Reddit API error while scraping comments for post {post_id}: {e}")
except Exception as e:
logger.error(f"❌ Unexpected error while scraping comments for post {post_id}: {e}")
return comments_data
def save_posts_to_db(self, posts: List[RedditPostData], db: Session) -> None:
"""
Save Reddit posts to database.
Args:
posts: List of RedditPostData objects
db: SQLAlchemy database session
"""
from app.models.reddit_post import RedditPost
saved_count = 0
for post_data in posts:
# Check if post already exists
existing = db.query(RedditPost).filter(
RedditPost.post_id == post_data.post_id
).first()
if existing:
logger.debug(f"Post {post_data.post_id} already exists, skipping")
continue
# Create new post
post = RedditPost(
post_id=post_data.post_id,
title=post_data.title,
text=post_data.text,
upvotes=post_data.upvotes,
created_at=post_data.created_at,
match_id=post_data.match_id,
subreddit=post_data.subreddit,
source=post_data.source
)
db.add(post)
saved_count += 1
# Commit changes
try:
db.commit()
logger.info(f"✅ Saved {saved_count} new Reddit posts to database")
except Exception as e:
db.rollback()
logger.error(f"❌ Failed to save Reddit posts to database: {e}")
raise
def save_comments_to_db(self, comments: List[RedditCommentData], db: Session) -> None:
"""
Save Reddit comments to database.
Args:
comments: List of RedditCommentData objects
db: SQLAlchemy database session
"""
from app.models.reddit_post import RedditComment
saved_count = 0
for comment_data in comments:
# Check if comment already exists
existing = db.query(RedditComment).filter(
RedditComment.comment_id == comment_data.comment_id
).first()
if existing:
logger.debug(f"Comment {comment_data.comment_id} already exists, skipping")
continue
# Create new comment
comment = RedditComment(
comment_id=comment_data.comment_id,
post_id=comment_data.post_id,
text=comment_data.text,
upvotes=comment_data.upvotes,
created_at=comment_data.created_at,
source=comment_data.source
)
db.add(comment)
saved_count += 1
# Commit changes
try:
db.commit()
logger.info(f"✅ Saved {saved_count} new Reddit comments to database")
except Exception as e:
db.rollback()
logger.error(f"❌ Failed to save Reddit comments to database: {e}")
raise
def scrape_reddit_match(
self,
match_id: int,
keywords: Optional[List[str]] = None,
scrape_comments: bool = True,
db: Optional[Session] = None
) -> Dict[str, List]:
"""
Scrape Reddit posts and comments for a specific match.
Args:
match_id: Match identifier
keywords: Optional list of keywords to filter posts
scrape_comments: Whether to scrape comments
db: Optional database session for immediate saving
Returns:
Dictionary with 'posts' and 'comments' lists
"""
all_posts = []
all_comments = []
# Scrape from all configured subreddits
for subreddit in self.subreddits:
try:
# Scrape posts
posts = self.scrape_posts(subreddit, match_id, keywords)
all_posts.extend(posts)
# Save posts if db session provided
if db and posts:
self.save_posts_to_db(posts, db)
# Scrape comments if requested
if scrape_comments and posts:
# Get PRAW post objects for comment scraping
sub = self.reddit.subreddit(subreddit)
praw_posts = list(sub.new(limit=self.max_posts_per_subreddit))
for post_data in posts:
# Find matching PRAW post
praw_post = next(
(p for p in praw_posts if p.id == post_data.post_id),
None
)
if praw_post:
comments = self.scrape_comments(post_data.post_id, praw_post)
all_comments.extend(comments)
# Save comments if db session provided
if db and comments:
self.save_comments_to_db(comments, db)
except Exception as e:
logger.error(
f"❌ Failed to scrape r/{subreddit} for match {match_id}: {e}. "
f"Continuing with other sources..."
)
continue
logger.info(
f"✅ Total collected for match {match_id}: "
f"{len(all_posts)} posts, {len(all_comments)} comments"
)
return {
'posts': all_posts,
'comments': all_comments
}
def scrape_and_save(
self,
match_id: int,
db: Session,
keywords: Optional[List[str]] = None,
scrape_comments: bool = True
) -> Dict[str, List]:
"""
Scrape Reddit data for a match and save to database.
Args:
match_id: Match identifier
db: SQLAlchemy database session
keywords: Optional list of keywords to filter posts
scrape_comments: Whether to scrape comments
Returns:
Dictionary with 'posts' and 'comments' lists
"""
try:
return self.scrape_reddit_match(
match_id=match_id,
keywords=keywords,
scrape_comments=scrape_comments,
db=db
)
except Exception as e:
logger.error(f"❌ Failed to scrape and save Reddit data for match {match_id}: {e}")
raise
def create_reddit_scraper(
client_id: str,
client_secret: str,
subreddits: Optional[List[str]] = None
) -> RedditScraper:
"""
Factory function to create a Reddit scraper instance.
Args:
client_id: Reddit API client ID
client_secret: Reddit API client secret
subreddits: Optional list of subreddits to scrape
Returns:
Configured RedditScraper instance
"""
# Default subreddits if not provided
if subreddits is None:
subreddits = ["soccer", "football", "Ligue1", "PremierLeague"]
scraper = RedditScraper(
client_id=client_id,
client_secret=client_secret,
subreddits=subreddits,
max_posts_per_subreddit=100,
max_comments_per_post=50
)
return scraper

View File

@@ -0,0 +1,380 @@
"""
RSS scraper module with robust error handling.
This module provides functionality to scrape RSS feeds from sports sources,
with built-in error handling and logging.
"""
import logging
import feedparser
from datetime import datetime, timezone
from typing import List, Dict, Optional
from dataclasses import dataclass
from urllib.parse import urlparse
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@dataclass
class RSSArticleData:
"""Structured data for an RSS article."""
article_id: str
title: str
content: str
published_at: datetime
source_url: str
match_id: Optional[int]
source: str = "rss"
class RSSScraper:
"""
RSS scraper with robust error handling.
Features:
- Scrapes RSS feeds from configured sports sources
- Error handling without stopping the process
- Continues with other sources on errors
- Structured logging
- Timeout configuration
- Filters relevant football articles
"""
# Default RSS sources for sports news
DEFAULT_RSS_SOURCES = [
"http://www.espn.com/espn/rss/news",
"http://feeds.bbci.co.uk/sport/football/rss.xml",
"https://www.goal.com/rss",
"https://www.skysports.com/rss/12040",
]
# Keywords to filter relevant football articles
FOOTBALL_KEYWORDS = [
"football", "soccer", "match", "goal", "premier league",
"la liga", "serie a", "bundesliga", "ligue 1", "champions league",
"euro", "world cup", "cup", "league", "team", "club", "player",
"coach", "manager", "score", "result", "transfer"
]
def __init__(
self,
rss_sources: Optional[List[str]] = None,
timeout: int = 30,
max_articles_per_source: int = 100,
keywords: Optional[List[str]] = None
):
"""
Initialize RSS scraper.
Args:
rss_sources: List of RSS feed URLs to scrape
timeout: Request timeout in seconds (default: 30)
max_articles_per_source: Maximum articles to collect per source
keywords: List of keywords to filter relevant articles
"""
self.rss_sources = rss_sources or self.DEFAULT_RSS_SOURCES
self.timeout = timeout
self.max_articles_per_source = max_articles_per_source
self.keywords = keywords or self.FOOTBALL_KEYWORDS
logger.info(f"📰 RSS Scraper initialized with {len(self.rss_sources)} sources")
for i, source in enumerate(self.rss_sources, 1):
domain = urlparse(source).netloc
logger.info(f" {i}. {domain}")
def _is_article_relevant(self, title: str, content: str) -> bool:
"""
Check if an article is relevant to football based on keywords.
Args:
title: Article title
content: Article content
Returns:
True if article is relevant, False otherwise
"""
text_to_check = f"{title.lower()} {content.lower()}"
# Check if any keyword is present
for keyword in self.keywords:
if keyword.lower() in text_to_check:
return True
return False
def _parse_published_date(self, published: str) -> datetime:
"""
Parse the published date from RSS feed.
Args:
published: Published date string from RSS feed
Returns:
Datetime object in UTC timezone
"""
try:
# feedparser automatically parses dates
parsed = feedparser.parse(published)
if hasattr(parsed, 'published_parsed') and parsed.published_parsed:
return datetime(*parsed.published_parsed[:6], tzinfo=timezone.utc)
# Fallback: try to parse as string
from email.utils import parsedate_to_datetime
return parsedate_to_datetime(published).astimezone(timezone.utc)
except Exception as e:
logger.warning(f"⚠️ Failed to parse date '{published}': {e}")
return datetime.now(timezone.utc)
def _parse_feed(
self,
source_url: str
) -> List[RSSArticleData]:
"""
Parse RSS feed and extract articles.
Args:
source_url: URL of the RSS feed
Returns:
List of RSSArticleData objects
"""
articles = []
try:
logger.info(f"🔍 Parsing RSS feed: {source_url}")
# Parse RSS feed with timeout
feed = feedparser.parse(source_url)
# Check for feed errors
if feed.get('bozo', False):
logger.warning(f"⚠️ RSS feed has malformed XML: {source_url}")
# Continue anyway as feedparser can handle some malformed feeds
# Extract feed info
feed_title = feed.feed.get('title', 'Unknown')
logger.info(f"📰 Feed: {feed_title}")
logger.info(f" Total entries: {len(feed.entries)}")
# Process entries
for entry in feed.entries[:self.max_articles_per_source]:
try:
# Extract article ID
article_id = entry.get('id') or entry.get('link', '')
if not article_id:
logger.warning(f"⚠️ Article missing ID, skipping")
continue
# Extract title
title = entry.get('title', '')
if not title:
logger.warning(f"⚠️ Article missing title, skipping")
continue
# Extract content
content = ''
if 'content' in entry:
content = entry.content[0].value if entry.content else ''
elif 'summary' in entry:
content = entry.summary
elif 'description' in entry:
content = entry.description
# Parse published date
published_str = entry.get('published') or entry.get('updated')
if not published_str:
logger.warning(f"⚠️ Article missing published date, using current time")
published_at = datetime.now(timezone.utc)
else:
published_at = self._parse_published_date(published_str)
# Filter relevant articles
if not self._is_article_relevant(title, content):
logger.debug(f"🚫 Article not relevant: {title}")
continue
# Create article data
article_data = RSSArticleData(
article_id=article_id,
title=title,
content=content,
published_at=published_at,
source_url=source_url,
match_id=None, # Will be matched later if needed
source=feed_title
)
articles.append(article_data)
except Exception as e:
logger.error(f"❌ Error processing article: {e}")
continue
logger.info(f"✅ Collected {len(articles)} relevant articles from {source_url}")
except Exception as e:
logger.error(f"❌ Failed to parse RSS feed {source_url}: {e}")
return articles
def scrape_all_sources(
self,
match_id: Optional[int] = None
) -> List[RSSArticleData]:
"""
Scrape all configured RSS sources.
Args:
match_id: Optional match ID to associate with articles
Returns:
List of RSSArticleData objects from all sources
"""
all_articles = []
for source_url in self.rss_sources:
try:
# Parse feed
articles = self._parse_feed(source_url)
# Set match_id if provided
if match_id:
for article in articles:
article.match_id = match_id
all_articles.extend(articles)
except Exception as e:
logger.error(
f"❌ Failed to scrape source {source_url}: {e}. "
f"Continuing with other sources..."
)
continue
logger.info(
f"✅ Total articles collected from all sources: {len(all_articles)}"
)
return all_articles
def scrape_single_source(
self,
source_url: str,
match_id: Optional[int] = None
) -> List[RSSArticleData]:
"""
Scrape a single RSS source.
Args:
source_url: URL of the RSS feed to scrape
match_id: Optional match ID to associate with articles
Returns:
List of RSSArticleData objects
"""
articles = self._parse_feed(source_url)
# Set match_id if provided
if match_id:
for article in articles:
article.match_id = match_id
return articles
def save_articles_to_db(self, articles: List[RSSArticleData], db: Session) -> None:
"""
Save RSS articles to database.
Args:
articles: List of RSSArticleData objects
db: SQLAlchemy database session
"""
from app.models.rss_article import RSSArticle
saved_count = 0
for article_data in articles:
# Check if article already exists
existing = db.query(RSSArticle).filter(
RSSArticle.article_id == article_data.article_id
).first()
if existing:
logger.debug(f"Article {article_data.article_id} already exists, skipping")
continue
# Create new article
article = RSSArticle(
article_id=article_data.article_id,
title=article_data.title,
content=article_data.content,
published_at=article_data.published_at,
source_url=article_data.source_url,
match_id=article_data.match_id,
source=article_data.source
)
db.add(article)
saved_count += 1
# Commit changes
try:
db.commit()
logger.info(f"✅ Saved {saved_count} new RSS articles to database")
except Exception as e:
db.rollback()
logger.error(f"❌ Failed to save RSS articles to database: {e}")
raise
def scrape_and_save(
self,
db: Session,
match_id: Optional[int] = None
) -> List[RSSArticleData]:
"""
Scrape all RSS sources and save to database.
Args:
db: SQLAlchemy database session
match_id: Optional match ID to associate with articles
Returns:
List of RSSArticleData objects
"""
try:
# Scrape articles
articles = self.scrape_all_sources(match_id)
# Save to database
if articles:
self.save_articles_to_db(articles, db)
return articles
except Exception as e:
logger.error(f"❌ Failed to scrape and save RSS articles: {e}")
raise
def create_rss_scraper(
rss_sources: Optional[List[str]] = None,
keywords: Optional[List[str]] = None
) -> RSSScraper:
"""
Factory function to create an RSS scraper instance.
Args:
rss_sources: Optional list of RSS feed URLs
keywords: Optional list of keywords to filter articles
Returns:
Configured RSSScraper instance
"""
scraper = RSSScraper(
rss_sources=rss_sources,
timeout=30,
max_articles_per_source=100,
keywords=keywords
)
return scraper

View File

@@ -0,0 +1,351 @@
"""
Twitter scraper module with rate limiting and degraded mode support.
This module provides functionality to scrape tweets for football matches,
with built-in rate limiting (1000 req/hour) and degraded mode for VIP matches.
"""
import logging
import time
from datetime import datetime, timezone
from typing import List, Dict, Optional
from dataclasses import dataclass
import tweepy
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@dataclass
class RateLimitInfo:
"""Information about rate limit usage."""
remaining: int
limit: int
reset_time: Optional[datetime]
@property
def usage_percentage(self) -> float:
"""Calculate usage percentage."""
return (self.limit - self.remaining) / self.limit if self.limit > 0 else 0
@dataclass
class TweetData:
"""Structured data for a tweet."""
tweet_id: str
text: str
created_at: datetime
retweet_count: int
like_count: int
match_id: int
source: str = "twitter"
class TwitterScraper:
"""
Twitter scraper with rate limiting and degraded mode.
Features:
- Rate limiting (1000 req/hour)
- Predictive alerts when approaching limits (>90%)
- Exponential backoff for retries
- Degraded mode for VIP matches only
- Structured logging
"""
def __init__(
self,
bearer_token: str,
max_tweets_per_hour: int = 1000,
rate_limit_alert_threshold: float = 0.9,
vip_match_ids: Optional[List[int]] = None
):
"""
Initialize Twitter scraper.
Args:
bearer_token: Twitter API bearer token
max_tweets_per_hour: Maximum API calls per hour (default: 1000)
rate_limit_alert_threshold: Alert threshold (0.0-1.0, default: 0.9)
vip_match_ids: List of VIP match IDs for degraded mode
"""
self.bearer_token = bearer_token
self.max_tweets_per_hour = max_tweets_per_hour
self.rate_limit_alert_threshold = rate_limit_alert_threshold
self.vip_match_ids = vip_match_ids or []
self.vip_mode_only = False
# Initialize Twitter API client
self.client = tweepy.Client(bearer_token=bearer_token)
# Rate limit tracking
self.api_calls_made = 0
self.rate_limit_info: Optional[RateLimitInfo] = None
# Verify authentication
self._verify_authentication()
def _verify_authentication(self) -> None:
"""Verify Twitter API authentication."""
try:
# Try to get user info to verify authentication
response = self.client.get_me()
if response.data:
logger.info(f"✅ Twitter API authenticated successfully as @{response.data.username}")
else:
logger.warning("⚠️ Twitter API authentication returned no user data")
except Exception as e:
logger.error(f"❌ Twitter API authentication failed: {e}")
raise
def _check_rate_limit(self) -> bool:
"""
Check rate limit status and handle alerts.
Returns:
True if API calls can be made, False otherwise
"""
# Calculate usage
if self.rate_limit_info:
usage = self.rate_limit_info.usage_percentage
else:
usage = self.api_calls_made / self.max_tweets_per_hour
# Predictive alert at threshold
if usage >= self.rate_limit_alert_threshold:
logger.warning(
f"⚠️ Rate limit approaching {usage * 100:.1f}% "
f"({self.api_calls_made}/{self.max_tweets_per_hour} calls)"
)
# Check if limit reached
if usage >= 1.0:
logger.error(
f"❌ Rate limit reached ({self.api_calls_made}/{self.max_tweets_per_hour})"
)
return False
return True
def _wait_for_rate_limit_reset(self) -> None:
"""
Wait for rate limit to reset with exponential backoff.
"""
if self.rate_limit_info and self.rate_limit_info.reset_time:
now = datetime.now(timezone.utc)
wait_seconds = (self.rate_limit_info.reset_time - now).total_seconds()
wait_seconds = max(60, wait_seconds) # Minimum 1 minute wait
else:
# Default to waiting 1 hour if no reset time available
wait_seconds = 3600
logger.info(f"⏳ Waiting {wait_seconds/60:.1f} minutes for rate limit reset...")
time.sleep(wait_seconds)
# Reset counters after waiting
self.api_calls_made = 0
self.vip_mode_only = False
def _enable_vip_mode_only(self) -> None:
"""Enable VIP mode (degraded mode)."""
if not self.vip_mode_only:
self.vip_mode_only = True
logger.warning(
"⚠️ ENTERING DEGRADED MODE - VIP MATCHES ONLY\n"
f"VIP match IDs: {self.vip_match_ids}"
)
def scrape_twitter_match(
self,
match_id: int,
keywords: List[str],
max_results: int = 100
) -> List[TweetData]:
"""
Scrape tweets for a specific match using keywords.
Args:
match_id: Match identifier
keywords: List of keywords to search (e.g., ["#MatchName", "team1 vs team2"])
max_results: Maximum number of tweets to retrieve (default: 100)
Returns:
List of TweetData objects
Raises:
ValueError: If match is not VIP and VIP mode is active
tweepy.TweepyException: For API errors
"""
# Check VIP mode
if self.vip_mode_only and match_id not in self.vip_match_ids:
logger.warning(
f"⚠️ Skipping match {match_id} - Not in VIP list "
f"(degraded mode active)"
)
raise ValueError(f"Match {match_id} is not VIP and degraded mode is active")
# Check rate limit before scraping
if not self._check_rate_limit():
self._enable_vip_mode_only()
self._wait_for_rate_limit_reset()
# Build search query
query = " OR ".join(keywords)
logger.info(f"🔍 Searching tweets for match {match_id}: '{query}'")
try:
# Increment API call counter
self.api_calls_made += 1
# Search for tweets
response = self.client.search_recent_tweets(
query=query,
max_results=max_results,
tweet_fields=[
'created_at',
'public_metrics',
'text',
'author_id'
]
)
if not response.data:
logger.info(f" No tweets found for match {match_id}")
return []
# Parse tweets
tweets = []
for tweet in response.data:
tweet_data = TweetData(
tweet_id=tweet.id,
text=tweet.text,
created_at=tweet.created_at,
retweet_count=tweet.public_metrics['retweet_count'],
like_count=tweet.public_metrics['like_count'],
match_id=match_id,
source="twitter"
)
tweets.append(tweet_data)
logger.info(
f"✅ Collected {len(tweets)} tweets for match {match_id} "
f"({self.api_calls_made}/{self.max_tweets_per_hour} API calls)"
)
return tweets
except tweepy.TooManyRequests:
logger.error("❌ Rate limit exceeded during scraping")
self._enable_vip_mode_only()
self._wait_for_rate_limit_reset()
return []
except tweepy.TweepyException as e:
logger.error(f"❌ Twitter API error: {e}")
raise
def save_tweets_to_db(self, tweets: List[TweetData], db: Session) -> None:
"""
Save tweets to database.
Args:
tweets: List of TweetData objects
db: SQLAlchemy database session
"""
from app.models.tweet import Tweet
saved_count = 0
for tweet_data in tweets:
# Check if tweet already exists
existing = db.query(Tweet).filter(
Tweet.tweet_id == tweet_data.tweet_id
).first()
if existing:
logger.debug(f"Tweet {tweet_data.tweet_id} already exists, skipping")
continue
# Create new tweet
tweet = Tweet(
tweet_id=tweet_data.tweet_id,
text=tweet_data.text,
created_at=tweet_data.created_at,
retweet_count=tweet_data.retweet_count,
like_count=tweet_data.like_count,
match_id=tweet_data.match_id,
source=tweet_data.source
)
db.add(tweet)
saved_count += 1
# Commit changes
try:
db.commit()
logger.info(f"✅ Saved {saved_count} new tweets to database")
except Exception as e:
db.rollback()
logger.error(f"❌ Failed to save tweets to database: {e}")
raise
def scrape_and_save(
self,
match_id: int,
keywords: List[str],
db: Session,
max_results: int = 100
) -> List[TweetData]:
"""
Scrape tweets for a match and save to database.
Args:
match_id: Match identifier
keywords: List of keywords to search
db: SQLAlchemy database session
max_results: Maximum number of tweets to retrieve
Returns:
List of TweetData objects
"""
try:
# Scrape tweets
tweets = self.scrape_twitter_match(match_id, keywords, max_results)
# Save to database
if tweets:
self.save_tweets_to_db(tweets, db)
return tweets
except Exception as e:
logger.error(f"❌ Failed to scrape and save tweets for match {match_id}: {e}")
raise
def create_twitter_scraper(
bearer_token: str,
vip_match_ids: Optional[List[int]] = None
) -> TwitterScraper:
"""
Factory function to create a Twitter scraper instance.
Args:
bearer_token: Twitter API bearer token
vip_match_ids: Optional list of VIP match IDs
Returns:
Configured TwitterScraper instance
"""
# TODO: Load from environment variables or config file
max_tweets_per_hour = 1000
rate_limit_alert_threshold = 0.9
scraper = TwitterScraper(
bearer_token=bearer_token,
max_tweets_per_hour=max_tweets_per_hour,
rate_limit_alert_threshold=rate_limit_alert_threshold,
vip_match_ids=vip_match_ids or []
)
return scraper

View File

@@ -0,0 +1,166 @@
"""
API Key Service.
This module handles API key generation, validation, and management.
"""
import secrets
import hashlib
from typing import Optional
from datetime import datetime
from sqlalchemy.orm import Session
from app.models.api_key import ApiKey
from app.models.user import User
class ApiKeyService:
"""Service for managing API keys."""
def __init__(self, db: Session):
self.db = db
def generate_api_key(self, user_id: int, rate_limit: int = 100) -> ApiKey:
"""
Generate a new API key for a user.
Args:
user_id: ID of the user
rate_limit: Rate limit per minute (default: 100)
Returns:
Created ApiKey object
Raises:
ValueError: If user doesn't exist
"""
# Verify user exists
user = self.db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError(f"User with id {user_id} not found")
# Generate API key (32 bytes = 256 bits)
api_key = secrets.token_urlsafe(32)
# Hash the API key for storage (SHA-256)
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
# Store prefix for identification (first 8 characters)
key_prefix = api_key[:8]
# Create API key record
api_key_record = ApiKey(
user_id=user_id,
key_hash=key_hash,
key_prefix=key_prefix,
rate_limit=rate_limit,
is_active=True,
created_at=datetime.utcnow()
)
self.db.add(api_key_record)
self.db.commit()
self.db.refresh(api_key_record)
# Return the plain API key (only time it's shown)
# We'll add it to the dict temporarily for the response
api_key_dict = api_key_record.to_dict()
api_key_dict['api_key'] = api_key
return api_key_dict
def validate_api_key(self, api_key: str) -> Optional[ApiKey]:
"""
Validate an API key.
Args:
api_key: The API key to validate
Returns:
ApiKey object if valid, None otherwise
"""
# Hash the provided API key
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
# Query for matching hash
api_key_record = self.db.query(ApiKey).filter(
ApiKey.key_hash == key_hash,
ApiKey.is_active == True
).first()
if api_key_record:
# Update last used timestamp
api_key_record.last_used_at = datetime.utcnow()
self.db.commit()
self.db.refresh(api_key_record)
return api_key_record
return None
def get_user_api_keys(self, user_id: int) -> list[dict]:
"""
Get all API keys for a user.
Args:
user_id: ID of the user
Returns:
List of API key dictionaries (without actual keys)
"""
api_keys = self.db.query(ApiKey).filter(
ApiKey.user_id == user_id
).all()
return [api_key.to_dict() for api_key in api_keys]
def revoke_api_key(self, api_key_id: int, user_id: int) -> bool:
"""
Revoke (deactivate) an API key.
Args:
api_key_id: ID of the API key to revoke
user_id: ID of the user (for authorization)
Returns:
True if revoked, False otherwise
"""
api_key = self.db.query(ApiKey).filter(
ApiKey.id == api_key_id,
ApiKey.user_id == user_id
).first()
if api_key:
api_key.is_active = False
self.db.commit()
return True
return False
def regenerate_api_key(self, api_key_id: int, user_id: int) -> Optional[dict]:
"""
Regenerate an API key (create new, deactivate old).
Args:
api_key_id: ID of the API key to regenerate
user_id: ID of the user (for authorization)
Returns:
New API key dict with plain key, or None if not found
"""
old_api_key = self.db.query(ApiKey).filter(
ApiKey.id == api_key_id,
ApiKey.user_id == user_id
).first()
if not old_api_key:
return None
# Get rate limit from old key
rate_limit = old_api_key.rate_limit
# Deactivate old key
old_api_key.is_active = False
self.db.commit()
# Generate new key
return self.generate_api_key(user_id, rate_limit)

View File

@@ -0,0 +1,200 @@
"""
Backtesting Service.
This module provides the service layer for backtesting operations,
integrating with the database to run backtesting on historical matches.
"""
import logging
from datetime import datetime
from typing import Dict, List, Any, Optional
from sqlalchemy.orm import Session
from app.models.match import Match
from app.models.energy_score import EnergyScore
from app.ml.backtesting import (
run_backtesting_batch,
export_to_json,
export_to_csv,
export_to_html,
filter_matches_by_league,
filter_matches_by_period
)
logger = logging.getLogger(__name__)
class BacktestingService:
"""Service for running backtesting on historical match data."""
def __init__(self, db: Session):
"""
Initialize backtesting service.
Args:
db: SQLAlchemy database session
"""
self.db = db
def get_historical_matches(
self,
leagues: Optional[List[str]] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""
Retrieve historical matches with energy scores and actual results.
Args:
leagues: Optional list of leagues to filter by
start_date: Optional start date for filtering
end_date: Optional end date for filtering
Returns:
List of match dictionaries with energy scores and actual winners
Raises:
ValueError: If no historical matches found
"""
logger.info("Fetching historical matches from database")
# Query matches that have actual_winner set (completed matches)
query = self.db.query(Match).filter(
Match.actual_winner.isnot(None)
)
# Apply filters
if leagues:
query = query.filter(Match.league.in_(leagues))
if start_date:
query = query.filter(Match.date >= start_date)
if end_date:
query = query.filter(Match.date <= end_date)
matches = query.all()
if not matches:
raise ValueError(
"No historical matches found. Please populate database with "
"historical match data and actual winners before running backtesting."
)
logger.info(f"Found {len(matches)} historical matches")
# Convert to list of dictionaries with energy scores
match_data = []
for match in matches:
# Get energy scores for this match
home_energy_score = self.db.query(EnergyScore).filter(
EnergyScore.match_id == match.id
).first()
if not home_energy_score:
logger.warning(f"No energy score found for match {match.id}, skipping")
continue
match_dict = {
'match_id': match.id,
'home_team': match.home_team,
'away_team': match.away_team,
'date': match.date,
'league': match.league,
'home_energy': home_energy_score.home_energy,
'away_energy': home_energy_score.away_energy,
'actual_winner': match.actual_winner
}
match_data.append(match_dict)
logger.info(f"Processed {len(match_data)} matches with energy scores")
return match_data
def run_backtesting(
self,
leagues: Optional[List[str]] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Run backtesting on historical matches with optional filters.
Args:
leagues: Optional list of leagues to filter by
start_date: Optional start date for filtering
end_date: Optional end date for filtering
Returns:
Dictionary containing backtesting results
Raises:
ValueError: If no matches found or matches lack required data
"""
logger.info("Starting backtesting process")
# Get historical matches
matches = self.get_historical_matches(
leagues=leagues,
start_date=start_date,
end_date=end_date
)
# Apply filters
if leagues:
matches = filter_matches_by_league(matches, leagues)
if start_date or end_date:
matches = filter_matches_by_period(matches, start_date, end_date)
if not matches:
raise ValueError("No matches match the specified filters")
logger.info(f"Running backtesting on {len(matches)} matches")
# Run backtesting
result = run_backtesting_batch(matches)
# Log results
logger.info(
f"Backtesting complete: {result['total_matches']} matches, "
f"{result['correct_predictions']} correct, "
f"{result['accuracy']:.2f}% accuracy, "
f"status: {result['status']}"
)
return result
def export_results(
self,
backtesting_result: Dict[str, Any],
format: str = 'json'
) -> str:
"""
Export backtesting results in specified format.
Args:
backtesting_result: Result from run_backtesting
format: Export format ('json', 'csv', or 'html')
Returns:
Formatted string in specified format
Raises:
ValueError: If format is not supported
"""
logger.info(f"Exporting backtesting results as {format}")
if format == 'json':
return export_to_json(backtesting_result)
elif format == 'csv':
return export_to_csv(backtesting_result)
elif format == 'html':
return export_to_html(backtesting_result)
else:
raise ValueError(
f"Unsupported export format: {format}. "
"Supported formats are: json, csv, html"
)

View File

@@ -0,0 +1,190 @@
"""
Service de gestion des badges
"""
from sqlalchemy.orm import Session
from typing import List, Dict, Any
from datetime import datetime
from app.models.badge import Badge, UserBadge
from app.models.user import User
from app.models.user_prediction import UserPrediction
from app.lib.badges import (
BADGES,
isBadgeUnlocked,
getBadgeById,
)
class BadgeService:
"""Service pour la gestion des badges"""
def __init__(self, db: Session):
self.db = db
def get_user_criteria(self, user_id: int) -> Dict[str, int]:
"""
Récupère les critères actuels de l'utilisateur
"""
# Nombre de prédictions consultées
predictions_count = self.db.query(UserPrediction).filter(
UserPrediction.user_id == user_id
).count()
# Nombre de prédictions correctes (pour l'instant on utilise une valeur par défaut)
# TODO: Implémenter la logique de calcul des prédictions correctes
correct_predictions = 0
# Nombre de jours consécutifs (streak)
# TODO: Implémenter la logique de calcul de streak
streak_days = 0
# Nombre de partages
# TODO: Implémenter la logique de suivi des partages
share_count = 0
# Nombre de parrainages
# TODO: Implémenter la logique de suivi des parrainages
referral_count = 0
return {
"predictions_count": predictions_count,
"correct_predictions": correct_predictions,
"streak_days": streak_days,
"share_count": share_count,
"referral_count": referral_count,
}
def check_and_unlock_badges(self, user_id: int) -> Dict[str, Any]:
"""
Vérifie et débloque les nouveaux badges pour un utilisateur
"""
# Récupérer les critères actuels de l'utilisateur
user_criteria = self.get_user_criteria(user_id)
# Récupérer les badges déjà débloqués par l'utilisateur
unlocked_badges = self.db.query(UserBadge).filter(
UserBadge.user_id == user_id
).all()
unlocked_badge_ids = set()
for ub in unlocked_badges:
badge = self.db.query(Badge).filter(Badge.id == ub.badge_id).first()
if badge:
unlocked_badge_ids.add(badge.badge_id)
# Vérifier tous les badges potentiels
newly_unlocked_badges = []
for badge_def in BADGES:
if badge_def["id"] not in unlocked_badge_ids:
# Vérifier si le badge peut être débloqué
criteria_type = badge_def["criteria"]["type"]
criteria_value = badge_def["criteria"]["value"]
if user_criteria[criteria_type] >= criteria_value:
# Débloquer le badge
new_badge = self._unlock_badge(user_id, badge_def)
if new_badge:
newly_unlocked_badges.append(new_badge)
total_badges = len(unlocked_badges) + len(newly_unlocked_badges)
# Générer un message de notification
message = ""
if len(newly_unlocked_badges) > 0:
if len(newly_unlocked_badges) == 1:
message = f'🎉 Félicitations ! Vous avez débloqué le badge "{newly_unlocked_badges[0]["name"]}" !'
else:
badge_names = ', '.join(b["name"] for b in newly_unlocked_badges)
message = f'🎉 Félicitations ! Vous avez débloqué {len(newly_unlocked_badges)} nouveaux badges : {badge_names} !'
return {
"new_badges": newly_unlocked_badges,
"total_badges": total_badges,
"message": message,
}
def _unlock_badge(self, user_id: int, badge_def: Dict[str, Any]) -> Dict[str, Any] | None:
"""
Débloque un badge pour un utilisateur
"""
try:
# Récupérer ou créer le badge dans la base de données
db_badge = self.db.query(Badge).filter(
Badge.badge_id == badge_def["id"]
).first()
if not db_badge:
# Créer le badge dans la base de données
db_badge = Badge(
badge_id=badge_def["id"],
name=badge_def["name"],
description=badge_def["description"],
icon=badge_def["icon"],
category=badge_def["category"],
criteria_type=badge_def["criteria"]["type"],
criteria_value=badge_def["criteria"]["value"],
criteria_description=badge_def["criteria"]["description"],
rarity=badge_def["rarity"],
points=badge_def["points"],
created_at=datetime.utcnow(),
)
self.db.add(db_badge)
self.db.flush()
# Créer le badge utilisateur
user_badge = UserBadge(
user_id=user_id,
badge_id=db_badge.id,
unlocked_at=datetime.utcnow(),
)
self.db.add(user_badge)
self.db.commit()
return {
"id": db_badge.id,
"badgeId": db_badge.badge_id,
"name": db_badge.name,
"description": db_badge.description,
"icon": db_badge.icon,
"category": db_badge.category,
"rarity": db_badge.rarity,
"points": db_badge.points,
}
except Exception as e:
self.db.rollback()
print(f"Erreur lors du déblocage du badge {badge_def['id']}: {e}")
return None
def get_user_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""
Récupère tous les badges débloqués par un utilisateur
"""
user_badges = self.db.query(UserBadge).filter(
UserBadge.user_id == user_id
).all()
result = []
for ub in user_badges:
badge = self.db.query(Badge).filter(Badge.id == ub.badge_id).first()
if badge:
result.append({
"id": ub.id,
"userId": ub.user_id,
"badgeId": ub.badge_id,
"unlockedAt": ub.unlocked_at.isoformat(),
"badge": {
"id": badge.id,
"badgeId": badge.badge_id,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"category": badge.category,
"rarity": badge.rarity,
"points": badge.points,
"createdAt": badge.created_at.isoformat(),
}
})
return result

View File

@@ -0,0 +1,278 @@
"""
Energy Score Service.
This module provides business logic for energy score calculation and storage.
"""
from datetime import datetime
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.ml.energy_calculator import (
calculate_energy_score,
adjust_weights_for_degraded_mode,
get_source_weights
)
from app.models.energy_score import EnergyScore
from app.schemas.energy_score import (
EnergyScoreCreate,
EnergyScoreUpdate,
EnergyScoreCalculationRequest,
EnergyScoreCalculationResponse
)
from app.database import get_db
def calculate_and_store_energy_score(
db: Session,
request: EnergyScoreCalculationRequest
) -> EnergyScore:
"""
Calculate energy score and store it in the database.
Args:
db: Database session
request: Energy score calculation request
Returns:
Created EnergyScore object
"""
# Calculate energy score using the ML module
result = calculate_energy_score(
match_id=request.match_id,
team_id=request.team_id,
twitter_sentiments=request.twitter_sentiments or [],
reddit_sentiments=request.reddit_sentiments or [],
rss_sentiments=request.rss_sentiments or [],
tweets_with_timestamps=request.tweets_with_timestamps or []
)
# Get adjusted weights for degraded mode tracking
available_sources = result['sources_used']
original_weights = get_source_weights()
adjusted_weights = adjust_weights_for_degraded_mode(
original_weights=original_weights,
available_sources=available_sources
)
# Create energy score record
energy_score = EnergyScore(
match_id=request.match_id,
team_id=request.team_id,
score=result['score'],
confidence=result['confidence'],
sources_used=result['sources_used'],
twitter_score=_calculate_component_score(request.twitter_sentiments),
reddit_score=_calculate_component_score(request.reddit_sentiments),
rss_score=_calculate_component_score(request.rss_sentiments),
temporal_factor=result.get('temporal_factor'),
twitter_weight=adjusted_weights.get('twitter'),
reddit_weight=adjusted_weights.get('reddit'),
rss_weight=adjusted_weights.get('rss'),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Save to database
db.add(energy_score)
db.commit()
db.refresh(energy_score)
return energy_score
def _calculate_component_score(sentiments: Optional[List[Dict]]) -> Optional[float]:
"""
Calculate component score for a single source.
Args:
sentiments: List of sentiment scores
Returns:
Component score or None if no sentiments
"""
if not sentiments:
return None
# Simple average of compound scores
total = sum(s.get('compound', 0) for s in sentiments)
return total / len(sentiments) if sentiments else None
def get_energy_score(
db: Session,
energy_score_id: int
) -> Optional[EnergyScore]:
"""
Get an energy score by ID.
Args:
db: Database session
energy_score_id: ID of the energy score
Returns:
EnergyScore object or None
"""
return db.query(EnergyScore).filter(EnergyScore.id == energy_score_id).first()
def get_energy_scores_by_match(
db: Session,
match_id: int
) -> List[EnergyScore]:
"""
Get all energy scores for a specific match.
Args:
db: Database session
match_id: ID of the match
Returns:
List of EnergyScore objects
"""
return db.query(EnergyScore).filter(EnergyScore.match_id == match_id).all()
def get_energy_scores_by_team(
db: Session,
team_id: int
) -> List[EnergyScore]:
"""
Get all energy scores for a specific team.
Args:
db: Database session
team_id: ID of the team
Returns:
List of EnergyScore objects
"""
return db.query(EnergyScore).filter(EnergyScore.team_id == team_id).all()
def get_energy_score_by_match_and_team(
db: Session,
match_id: int,
team_id: int
) -> Optional[EnergyScore]:
"""
Get the most recent energy score for a specific match and team.
Args:
db: Database session
match_id: ID of the match
team_id: ID of the team
Returns:
EnergyScore object or None
"""
return (db.query(EnergyScore)
.filter(and_(EnergyScore.match_id == match_id, EnergyScore.team_id == team_id))
.order_by(EnergyScore.created_at.desc())
.first())
def update_energy_score(
db: Session,
energy_score_id: int,
update: EnergyScoreUpdate
) -> Optional[EnergyScore]:
"""
Update an existing energy score.
Args:
db: Database session
energy_score_id: ID of the energy score
update: Updated energy score data
Returns:
Updated EnergyScore object or None
"""
energy_score = get_energy_score(db, energy_score_id)
if not energy_score:
return None
# Update fields
update_data = update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(energy_score, key, value)
energy_score.updated_at = datetime.utcnow()
# Save to database
db.commit()
db.refresh(energy_score)
return energy_score
def delete_energy_score(
db: Session,
energy_score_id: int
) -> bool:
"""
Delete an energy score.
Args:
db: Database session
energy_score_id: ID of the energy score
Returns:
True if deleted, False if not found
"""
energy_score = get_energy_score(db, energy_score_id)
if not energy_score:
return False
db.delete(energy_score)
db.commit()
return True
def list_energy_scores(
db: Session,
match_id: Optional[int] = None,
team_id: Optional[int] = None,
min_score: Optional[float] = None,
max_score: Optional[float] = None,
min_confidence: Optional[float] = None,
limit: int = 10,
offset: int = 0
) -> List[EnergyScore]:
"""
List energy scores with optional filters.
Args:
db: Database session
match_id: Optional filter by match ID
team_id: Optional filter by team ID
min_score: Optional filter by minimum score
max_score: Optional filter by maximum score
min_confidence: Optional filter by minimum confidence
limit: Maximum number of results
offset: Offset for pagination
Returns:
List of EnergyScore objects
"""
query = db.query(EnergyScore)
# Apply filters
if match_id is not None:
query = query.filter(EnergyScore.match_id == match_id)
if team_id is not None:
query = query.filter(EnergyScore.team_id == team_id)
if min_score is not None:
query = query.filter(EnergyScore.score >= min_score)
if max_score is not None:
query = query.filter(EnergyScore.score <= max_score)
if min_confidence is not None:
query = query.filter(EnergyScore.confidence >= min_confidence)
# Apply pagination and ordering
query = query.order_by(EnergyScore.created_at.desc())
query = query.offset(offset).limit(limit)
return query.all()

View File

@@ -0,0 +1,161 @@
"""
Leaderboard Service.
This module handles the calculation and retrieval of user rankings
based on prediction accuracy.
"""
from typing import Optional
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import func, case, desc
from app.models.user import User
from app.models.user_prediction import UserPrediction
class LeaderboardService:
"""Service for managing leaderboard calculations and queries."""
def __init__(self, db: Session):
"""Initialize leaderboard service with database session."""
self.db = db
def get_leaderboard(self, limit: int = 100) -> list[dict]:
"""
Get leaderboard sorted by accuracy (descending) with predictions count as tie-breaker.
Args:
limit: Maximum number of users to return (default: 100)
Returns:
List of dictionaries containing user_id, username, accuracy, predictions_count
"""
# Calculate accuracy for each user who has viewed predictions
# Accuracy = (number of correct predictions / total predictions where was_correct is not NULL) * 100
query = (
self.db.query(
User.id.label('user_id'),
User.name.label('username'),
func.count(UserPrediction.id).label('predictions_count'),
func.sum(
case(
(UserPrediction.was_correct == True, 1),
else_=0
)
).label('correct_count'),
func.sum(
case(
(UserPrediction.was_correct.isnot(None), 1),
else_=0
)
).label('completed_predictions_count')
)
.join(UserPrediction, User.id == UserPrediction.user_id)
.filter(
UserPrediction.was_correct.isnot(None) # Only include predictions where match is completed
)
.group_by(User.id, User.name)
.order_by(
desc(
# Calculate accuracy: correct / completed * 100
func.coalesce(
func.sum(
case(
(UserPrediction.was_correct == True, 1),
else_=0
)
) * 100.0 /
func.sum(
case(
(UserPrediction.was_correct.isnot(None), 1),
else_=0
)
),
0.0
)
),
desc('predictions_count') # Tie-breaker: more predictions = higher rank
)
.limit(limit)
)
results = query.all()
# Format results
leaderboard = []
for row in results:
completed_count = row.completed_predictions_count or 0
correct_count = row.correct_count or 0
accuracy = (correct_count / completed_count * 100) if completed_count > 0 else 0.0
leaderboard.append({
'user_id': row.user_id,
'username': row.username,
'accuracy': round(accuracy, 1),
'predictions_count': row.predictions_count
})
return leaderboard
def get_personal_rank(self, user_id: int) -> Optional[dict]:
"""
Get personal rank data for a specific user.
Args:
user_id: ID of the user
Returns:
Dictionary containing rank, accuracy, predictions_count, or None if user not found
"""
# Get user's stats
user_stats = (
self.db.query(
User.id,
func.count(UserPrediction.id).label('predictions_count'),
func.sum(
case(
(UserPrediction.was_correct == True, 1),
else_=0
)
).label('correct_count'),
func.sum(
case(
(UserPrediction.was_correct.isnot(None), 1),
else_=0
)
).label('completed_predictions_count')
)
.join(UserPrediction, User.id == UserPrediction.user_id)
.filter(User.id == user_id)
.filter(UserPrediction.was_correct.isnot(None))
.group_by(User.id)
.first()
)
if not user_stats:
return None
completed_count = user_stats.completed_predictions_count or 0
correct_count = user_stats.correct_count or 0
accuracy = (correct_count / completed_count * 100) if completed_count > 0 else 0.0
predictions_count = user_stats.predictions_count or 0
# Get full leaderboard to calculate rank
full_leaderboard = self.get_leaderboard(limit=1000) # Get more to find rank accurately
# Find user's rank
rank = None
for idx, entry in enumerate(full_leaderboard, start=1):
if entry['user_id'] == user_id:
rank = idx
break
if rank is None:
return None
return {
'rank': rank,
'accuracy': round(accuracy, 1),
'predictions_count': predictions_count
}

View File

@@ -0,0 +1,304 @@
"""
Prediction Service Module.
This module provides business logic for creating and managing match predictions.
"""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from app.ml.prediction_calculator import calculate_prediction, validate_prediction_result
from app.models.match import Match
from app.models.prediction import Prediction
from app.schemas.prediction import PredictionCreate, MatchInfo
class PredictionService:
"""Service for handling prediction business logic."""
def __init__(self, db: Session):
"""
Initialize the prediction service.
Args:
db: SQLAlchemy database session
"""
self.db = db
def create_prediction_for_match(
self,
match_id: int,
home_energy: float,
away_energy: float,
energy_score_label: Optional[str] = None
) -> Prediction:
"""
Create a prediction for a specific match based on energy scores.
This method:
1. Validates that the match exists
2. Calculates the prediction using energy scores
3. Stores the prediction in the database
4. Returns the created prediction
Args:
match_id: ID of the match to predict
home_energy: Energy score of the home team
away_energy: Energy score of the away team
energy_score_label: Optional label for energy score (e.g., "high", "medium", "low")
Returns:
Created Prediction object
Raises:
ValueError: If match doesn't exist or energy scores are invalid
"""
# Validate match exists
match = self.db.query(Match).filter(Match.id == match_id).first()
if not match:
raise ValueError(f"Match with id {match_id} not found")
# Validate energy scores
if not isinstance(home_energy, (int, float)) or not isinstance(away_energy, (int, float)):
raise ValueError("Energy scores must be numeric values")
if home_energy < 0 or away_energy < 0:
raise ValueError("Energy scores cannot be negative")
# Calculate prediction
prediction_result = calculate_prediction(home_energy, away_energy)
# Validate prediction result
if not validate_prediction_result(prediction_result):
raise ValueError("Invalid prediction calculation result")
# Determine energy score label if not provided
if energy_score_label is None:
avg_energy = (home_energy + away_energy) / 2
if avg_energy >= 70:
energy_score_label = "very_high"
elif avg_energy >= 50:
energy_score_label = "high"
elif avg_energy >= 30:
energy_score_label = "medium"
else:
energy_score_label = "low"
# Determine predicted winner team name
if prediction_result['predicted_winner'] == 'home':
predicted_winner_name = match.home_team
elif prediction_result['predicted_winner'] == 'away':
predicted_winner_name = match.away_team
else:
predicted_winner_name = "Draw"
# Create prediction object
prediction = Prediction(
match_id=match_id,
energy_score=energy_score_label,
confidence=f"{prediction_result['confidence']:.1f}%",
predicted_winner=predicted_winner_name,
created_at=datetime.now(timezone.utc)
)
# Save to database
self.db.add(prediction)
self.db.commit()
self.db.refresh(prediction)
return prediction
def get_prediction_by_id(self, prediction_id: int) -> Optional[Prediction]:
"""
Get a prediction by its ID.
Args:
prediction_id: ID of the prediction to retrieve
Returns:
Prediction object or None if not found
"""
return self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
def get_predictions_for_match(self, match_id: int) -> list[Prediction]:
"""
Get all predictions for a specific match.
Args:
match_id: ID of the match
Returns:
List of Prediction objects
"""
return self.db.query(Prediction).filter(Prediction.match_id == match_id).all()
def get_latest_prediction_for_match(self, match_id: int) -> Optional[Prediction]:
"""
Get the most recent prediction for a match.
Args:
match_id: ID of the match
Returns:
Latest Prediction object or None if no predictions exist
"""
return (
self.db.query(Prediction)
.filter(Prediction.match_id == match_id)
.order_by(Prediction.created_at.desc())
.first()
)
def delete_prediction(self, prediction_id: int) -> bool:
"""
Delete a prediction by its ID.
Args:
prediction_id: ID of the prediction to delete
Returns:
True if deleted, False if not found
"""
prediction = self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
if prediction:
self.db.delete(prediction)
self.db.commit()
return True
return False
def get_predictions_with_pagination(
self,
limit: int = 20,
offset: int = 0,
team_id: Optional[int] = None,
league: Optional[str] = None,
date_min: Optional[datetime] = None,
date_max: Optional[datetime] = None
) -> tuple[list[Prediction], int]:
"""
Get predictions with pagination and filters.
This method retrieves predictions joined with match data, applies filters,
and returns paginated results.
Args:
limit: Maximum number of predictions to return (max 100)
offset: Number of predictions to skip
team_id: Optional filter by team ID (home or away)
league: Optional filter by league name
date_min: Optional filter for matches after this date
date_max: Optional filter for matches before this date
Returns:
Tuple of (list of predictions, total count)
"""
# Start with a query that includes match data
query = (
self.db.query(Prediction)
.join(Match)
)
# Apply filters
if team_id:
# Get the match for the team_id to get team names
team_match = self.db.query(Match).filter(Match.id == team_id).first()
if team_match:
query = query.filter(
(Match.home_team == team_match.home_team) |
(Match.away_team == team_match.away_team)
)
if league:
query = query.filter(Match.league.ilike(f"%{league}%"))
if date_min:
query = query.filter(Match.date >= date_min)
if date_max:
query = query.filter(Match.date <= date_max)
# Get total count before pagination
total = query.count()
# Apply pagination and ordering by match date (upcoming matches first)
predictions = (
query
.order_by(Match.date.asc())
.limit(min(limit, 100))
.offset(offset)
.all()
)
return predictions, total
def get_prediction_with_details(self, match_id: int) -> Optional[dict]:
"""
Get a prediction for a specific match with full details.
This method retrieves latest prediction for a match and includes
match details, energy score information, and historical data.
Args:
match_id: ID of match
Returns:
Dictionary with prediction details or None if not found
"""
prediction = self.get_latest_prediction_for_match(match_id)
if not prediction:
return None
# Get match details
match = prediction.match
# Build response with all details
result = {
"id": prediction.id,
"match_id": prediction.match_id,
"match": {
"id": match.id,
"home_team": match.home_team,
"away_team": match.away_team,
"date": match.date.isoformat() if match.date else None,
"league": match.league,
"status": match.status,
"actual_winner": match.actual_winner
},
"energy_score": prediction.energy_score,
"confidence": prediction.confidence,
"predicted_winner": prediction.predicted_winner,
"created_at": prediction.created_at.isoformat() if prediction.created_at else None,
"history": self._get_prediction_history(match_id)
}
return result
def _get_prediction_history(self, match_id: int) -> list[dict]:
"""
Get historical predictions for a match.
Args:
match_id: ID of match
Returns:
List of historical predictions (all predictions for match)
"""
predictions = (
self.db.query(Prediction)
.filter(Prediction.match_id == match_id)
.order_by(Prediction.created_at.desc())
.all()
)
return [
{
"id": pred.id,
"energy_score": pred.energy_score,
"confidence": pred.confidence,
"predicted_winner": pred.predicted_winner,
"created_at": pred.created_at.isoformat() if pred.created_at else None
}
for pred in predictions
]

View File

@@ -0,0 +1,326 @@
"""
Sentiment Analysis Service
This module provides services for batch processing of tweets and posts,
storing sentiment scores in the database, and calculating aggregated metrics.
"""
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
from app.ml.sentiment_analyzer import (
analyze_sentiment,
analyze_sentiment_batch,
calculate_aggregated_metrics
)
from app.models.sentiment_score import SentimentScore
from app.models.tweet import Tweet
from app.models.reddit_post import RedditPost
def process_tweet_sentiment(
db: Session,
tweet_id: str,
text: str
) -> SentimentScore:
"""
Analyze sentiment for a single tweet and store in database.
Args:
db: Database session
tweet_id: Tweet identifier
text: Tweet text to analyze
Returns:
Created SentimentScore record
"""
# Analyze sentiment
sentiment_result = analyze_sentiment(text)
# Create database record
sentiment_score = SentimentScore(
entity_id=tweet_id,
entity_type='tweet',
score=sentiment_result['compound'],
sentiment_type=sentiment_result['sentiment'],
positive=sentiment_result['positive'],
negative=sentiment_result['negative'],
neutral=sentiment_result['neutral']
)
db.add(sentiment_score)
db.commit()
db.refresh(sentiment_score)
return sentiment_score
def process_tweet_batch(
db: Session,
tweets: List[Tweet]
) -> List[SentimentScore]:
"""
Analyze sentiment for a batch of tweets and store in database.
Args:
db: Database session
tweets: List of Tweet models to analyze
Returns:
List of created SentimentScore records
"""
if not tweets:
return []
# Extract texts
texts = [tweet.text for tweet in tweets]
tweet_ids = [tweet.tweet_id for tweet in tweets]
# Analyze in batch
sentiment_results = analyze_sentiment_batch(texts)
# Create database records
sentiment_scores = []
for tweet_id, result in zip(tweet_ids, sentiment_results):
sentiment_score = SentimentScore(
entity_id=tweet_id,
entity_type='tweet',
score=result['compound'],
sentiment_type=result['sentiment'],
positive=result['positive'],
negative=result['negative'],
neutral=result['neutral']
)
sentiment_scores.append(sentiment_score)
# Batch insert
db.add_all(sentiment_scores)
db.commit()
# Refresh to get IDs
for score in sentiment_scores:
db.refresh(score)
return sentiment_scores
def process_reddit_post_sentiment(
db: Session,
post_id: str,
text: str
) -> SentimentScore:
"""
Analyze sentiment for a single Reddit post and store in database.
Args:
db: Database session
post_id: Reddit post identifier
text: Post text to analyze
Returns:
Created SentimentScore record
"""
# Analyze sentiment
sentiment_result = analyze_sentiment(text)
# Create database record
sentiment_score = SentimentScore(
entity_id=post_id,
entity_type='reddit_post',
score=sentiment_result['compound'],
sentiment_type=sentiment_result['sentiment'],
positive=sentiment_result['positive'],
negative=sentiment_result['negative'],
neutral=sentiment_result['neutral']
)
db.add(sentiment_score)
db.commit()
db.refresh(sentiment_score)
return sentiment_score
def process_reddit_post_batch(
db: Session,
posts: List[RedditPost]
) -> List[SentimentScore]:
"""
Analyze sentiment for a batch of Reddit posts and store in database.
Args:
db: Database session
posts: List of RedditPost models to analyze
Returns:
List of created SentimentScore records
"""
if not posts:
return []
# Extract texts (combine title and text if available)
texts = []
post_ids = []
for post in posts:
text = post.text if post.text else ""
full_text = f"{post.title} {text}"
texts.append(full_text)
post_ids.append(post.post_id)
# Analyze in batch
sentiment_results = analyze_sentiment_batch(texts)
# Create database records
sentiment_scores = []
for post_id, result in zip(post_ids, sentiment_results):
sentiment_score = SentimentScore(
entity_id=post_id,
entity_type='reddit_post',
score=result['compound'],
sentiment_type=result['sentiment'],
positive=result['positive'],
negative=result['negative'],
neutral=result['neutral']
)
sentiment_scores.append(sentiment_score)
# Batch insert
db.add_all(sentiment_scores)
db.commit()
# Refresh to get IDs
for score in sentiment_scores:
db.refresh(score)
return sentiment_scores
def get_sentiment_by_entity(
db: Session,
entity_id: str,
entity_type: str
) -> Optional[SentimentScore]:
"""
Retrieve sentiment score for a specific entity.
Args:
db: Database session
entity_id: Entity identifier
entity_type: Entity type ('tweet' or 'reddit_post')
Returns:
SentimentScore if found, None otherwise
"""
return db.query(SentimentScore).filter(
SentimentScore.entity_id == entity_id,
SentimentScore.entity_type == entity_type
).first()
def get_sentiments_by_match(
db: Session,
match_id: int
) -> List[SentimentScore]:
"""
Retrieve all sentiment scores for a specific match.
Args:
db: Database session
match_id: Match identifier
Returns:
List of SentimentScore records for the match
"""
# Join with tweets table to filter by match_id
return db.query(SentimentScore).join(
Tweet, Tweet.tweet_id == SentimentScore.entity_id
).filter(
Tweet.match_id == match_id,
SentimentScore.entity_type == 'tweet'
).all()
def calculate_match_sentiment_metrics(
db: Session,
match_id: int
) -> Dict:
"""
Calculate aggregated sentiment metrics for a match.
Args:
db: Database session
match_id: Match identifier
Returns:
Dictionary with aggregated metrics
"""
# Get all sentiments for the match
sentiments = get_sentiments_by_match(db, match_id)
if not sentiments:
return {
'match_id': match_id,
'total_count': 0,
'positive_count': 0,
'negative_count': 0,
'neutral_count': 0,
'positive_ratio': 0.0,
'negative_ratio': 0.0,
'neutral_ratio': 0.0,
'average_compound': 0.0
}
# Convert to list of dicts for calculate_aggregated_metrics
sentiment_dicts = [
{
'compound': s.score,
'sentiment': s.sentiment_type
}
for s in sentiments
]
# Calculate metrics
metrics = calculate_aggregated_metrics(sentiment_dicts)
metrics['match_id'] = match_id
return metrics
def get_global_sentiment_metrics(
db: Session
) -> Dict:
"""
Calculate global sentiment metrics across all entities.
Args:
db: Database session
Returns:
Dictionary with global aggregated metrics
"""
# Get all sentiment scores
all_sentiments = db.query(SentimentScore).all()
if not all_sentiments:
return {
'total_count': 0,
'positive_count': 0,
'negative_count': 0,
'neutral_count': 0,
'positive_ratio': 0.0,
'negative_ratio': 0.0,
'neutral_ratio': 0.0,
'average_compound': 0.0
}
# Convert to list of dicts
sentiment_dicts = [
{
'compound': s.score,
'sentiment': s.sentiment_type
}
for s in all_sentiments
]
# Calculate metrics
return calculate_aggregated_metrics(sentiment_dicts)

View File

@@ -0,0 +1,216 @@
"""
User Prediction Service.
This module provides business logic for tracking user predictions,
calculating ROI and accuracy rates.
"""
from datetime import datetime
from typing import List, Tuple, Optional
from sqlalchemy.orm import Session
from app.models import User, UserPrediction, Prediction, Match
class UserPredictionService:
"""
Service for managing user predictions and statistics.
This service handles:
- Tracking which predictions users have viewed
- Updating prediction results when matches complete
- Calculating user accuracy rates
- Calculating user ROI (Return on Investment)
"""
def __init__(self, db: Session):
"""Initialize service with database session."""
self.db = db
def record_prediction_view(self, user_id: int, prediction_id: int) -> UserPrediction:
"""
Record that a user viewed a prediction.
Args:
user_id: ID of the user
prediction_id: ID of the prediction viewed
Returns:
Created or existing UserPrediction record
Raises:
ValueError: If user or prediction doesn't exist
"""
# Check if user exists
user = self.db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError(f"User with id {user_id} not found")
# Check if prediction exists
prediction = self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
if not prediction:
raise ValueError(f"Prediction with id {prediction_id} not found")
# Check if already viewed (unique constraint)
existing = self.db.query(UserPrediction).filter(
UserPrediction.user_id == user_id,
UserPrediction.prediction_id == prediction_id
).first()
if existing:
return existing
# Create new record
user_prediction = UserPrediction(
user_id=user_id,
prediction_id=prediction_id,
viewed_at=datetime.utcnow(),
was_correct=None # Will be set when match completes
)
self.db.add(user_prediction)
self.db.commit()
self.db.refresh(user_prediction)
return user_prediction
def get_user_prediction_history(
self,
user_id: int,
limit: int = 50,
offset: int = 0
) -> Tuple[List[dict], int]:
"""
Get a user's prediction viewing history.
Args:
user_id: ID of the user
limit: Maximum number of records to return
offset: Number of records to skip
Returns:
Tuple of (list of user predictions with details, total count)
"""
query = self.db.query(UserPrediction).filter(
UserPrediction.user_id == user_id
).order_by(UserPrediction.viewed_at.desc())
total = query.count()
user_predictions = query.limit(limit).offset(offset).all()
# Build response with full details
result = []
for up in user_predictions:
pred = up.prediction
match = pred.match if pred else None
result.append({
'id': up.id,
'user_id': up.user_id,
'prediction_id': up.prediction_id,
'viewed_at': up.viewed_at.isoformat() if up.viewed_at else None,
'was_correct': up.was_correct,
'prediction': {
'id': pred.id if pred else None,
'match_id': pred.match_id if pred else None,
'energy_score': pred.energy_score if pred else None,
'confidence': pred.confidence if pred else None,
'predicted_winner': pred.predicted_winner if pred else None,
'created_at': pred.created_at.isoformat() if pred and pred.created_at else None
} if pred else None,
'match': {
'id': match.id if match else None,
'home_team': match.home_team if match else None,
'away_team': match.away_team if match else None,
'date': match.date.isoformat() if match and match.date else None,
'league': match.league if match else None,
'status': match.status if match else None,
'actual_winner': match.actual_winner if match else None
} if match else None
})
return result, total
def update_prediction_result(self, prediction_id: int, actual_winner: Optional[str]) -> None:
"""
Update all user predictions for a given prediction with match result.
This is called when a match completes to mark which predictions were correct.
Args:
prediction_id: ID of the prediction
actual_winner: Actual winner of the match ("home", "away", "draw", or None)
"""
# Get prediction
prediction = self.db.query(Prediction).filter(Prediction.id == prediction_id).first()
if not prediction:
return
# Update match result
match = self.db.query(Match).filter(Match.id == prediction.match_id).first()
if match:
match.actual_winner = actual_winner
# Determine if prediction was correct
was_correct = False
if actual_winner:
# Normalize winner comparison
predicted = prediction.predicted_winner.lower()
actual = actual_winner.lower()
if predicted == actual:
was_correct = True
elif (predicted == 'home' and actual == 'home_team') or \
(predicted == 'away' and actual == 'away_team'):
was_correct = True
# Update all user predictions for this prediction
user_predictions = self.db.query(UserPrediction).filter(
UserPrediction.prediction_id == prediction_id
).all()
for up in user_predictions:
up.was_correct = was_correct
self.db.commit()
def get_user_stats(self, user_id: int) -> dict:
"""
Calculate user statistics including accuracy and ROI.
Args:
user_id: ID of the user
Returns:
Dictionary with statistics:
- total_predictions_viewed: Total predictions viewed
- correct_predictions: Number of correct predictions
- incorrect_predictions: Number of incorrect predictions
- accuracy_rate: Accuracy as percentage
- roi: Return on Investment in EUR
"""
user_predictions = self.db.query(UserPrediction).filter(
UserPrediction.user_id == user_id
).all()
total = len(user_predictions)
correct = sum(1 for up in user_predictions if up.was_correct is True)
incorrect = sum(1 for up in user_predictions if up.was_correct is False)
# Calculate accuracy
accuracy_rate = 0.0
if correct + incorrect > 0:
accuracy_rate = (correct / (correct + incorrect)) * 100
# Calculate ROI
# Assumptions: Each correct prediction = +100€, Each incorrect = -50€
# This is a simplified model - can be adjusted based on actual betting rules
roi = (correct * 100) - (incorrect * 50)
return {
'total_predictions_viewed': total,
'correct_predictions': correct,
'incorrect_predictions': incorrect,
'accuracy_rate': round(accuracy_rate, 1),
'roi': roi
}

View File

@@ -0,0 +1,16 @@
"""
Workers module.
This module provides background workers for processing
asynchronous tasks from RabbitMQ queues.
"""
from app.workers.scraping_worker import ScrapingWorker
from app.workers.sentiment_worker import SentimentWorker
from app.workers.energy_worker import EnergyWorker
__all__ = [
'ScrapingWorker',
'SentimentWorker',
'EnergyWorker'
]

View File

@@ -0,0 +1,191 @@
"""
Energy calculation worker module.
This module provides a worker that consumes energy calculation tasks
from RabbitMQ and executes energy calculation operations.
"""
import logging
from typing import Dict, List, Optional
from sqlalchemy.orm import Session
from app.services.energy_service import (
calculate_and_store_energy_score,
get_energy_score_by_match_and_team
)
from app.schemas.energy_score import EnergyScoreCalculationRequest
from app.ml.energy_calculator import (
calculate_energy_score,
get_source_weights
)
logger = logging.getLogger(__name__)
class EnergyWorker:
"""
Worker for processing energy calculation tasks.
Features:
- Consumes tasks from energy_calculation_tasks queue
- Executes energy score calculations
- Publishes results to results queue
- Handles errors with retries
- Structured logging
"""
def __init__(self):
"""Initialize energy calculation worker."""
# No initialization needed for energy worker
# Energy calculator is initialized in ml/energy_calculator
pass
def execute_energy_calculation_task(
self,
task: Dict,
db: Session
) -> Dict:
"""
Execute an energy calculation task.
Args:
task: Energy calculation task data
db: Database session
Returns:
Dictionary with energy calculation results
"""
match_id = task.get('match_id')
team_id = task.get('team_id')
twitter_sentiments = task.get('twitter_sentiments', [])
reddit_sentiments = task.get('reddit_sentiments', [])
rss_sentiments = task.get('rss_sentiments', [])
tweets_with_timestamps = task.get('tweets_with_timestamps', [])
logger.info(
f"🔧 Executing energy calculation task: "
f"match_id={match_id}, team_id={team_id}"
)
try:
# Check if energy score already exists
existing_score = get_energy_score_by_match_and_team(
db, match_id, team_id
)
if existing_score:
logger.info(
f" Energy score already exists for "
f"match {match_id}, team {team_id}"
)
return {
'energy_score': existing_score.score,
'confidence': existing_score.confidence,
'sources_used': existing_score.sources_used,
'status': 'success',
'metadata': {
'match_id': match_id,
'team_id': team_id,
'updated_existing': True
}
}
# Create calculation request
request = EnergyScoreCalculationRequest(
match_id=match_id,
team_id=team_id,
twitter_sentiments=twitter_sentiments,
reddit_sentiments=reddit_sentiments,
rss_sentiments=rss_sentiments,
tweets_with_timestamps=tweets_with_timestamps
)
# Calculate and store energy score
energy_score = calculate_and_store_energy_score(db, request)
logger.info(
f"✅ Energy calculation completed: "
f"score={energy_score.score:.2f}, "
f"confidence={energy_score.confidence:.2f}"
)
return {
'energy_score': energy_score.score,
'confidence': energy_score.confidence,
'sources_used': energy_score.sources_used,
'status': 'success',
'metadata': {
'match_id': match_id,
'team_id': team_id,
'twitter_score': energy_score.twitter_score,
'reddit_score': energy_score.reddit_score,
'rss_score': energy_score.rss_score,
'temporal_factor': energy_score.temporal_factor
}
}
except Exception as e:
logger.error(f"❌ Energy calculation task failed: {e}")
return {
'energy_score': 0.0,
'confidence': 0.0,
'sources_used': [],
'status': 'error',
'error': str(e)
}
def calculate_mock_energy(
self,
twitter_sentiments: List[Dict],
reddit_sentiments: List[Dict],
rss_sentiments: List[Dict] = None,
tweets_with_timestamps: List[Dict] = None
) -> Dict:
"""
Calculate energy score without storing to database (for testing).
Args:
twitter_sentiments: List of Twitter sentiment scores
reddit_sentiments: List of Reddit sentiment scores
rss_sentiments: Optional list of RSS sentiment scores
tweets_with_timestamps: Optional list of tweets with timestamps
Returns:
Dictionary with energy calculation results
"""
try:
result = calculate_energy_score(
match_id=0,
team_id=0,
twitter_sentiments=twitter_sentiments,
reddit_sentiments=reddit_sentiments,
rss_sentiments=rss_sentiments or [],
tweets_with_timestamps=tweets_with_timestamps or []
)
return {
'energy_score': result['score'],
'confidence': result['confidence'],
'sources_used': result['sources_used'],
'status': 'success'
}
except Exception as e:
logger.error(f"❌ Mock energy calculation failed: {e}")
return {
'energy_score': 0.0,
'confidence': 0.0,
'sources_used': [],
'status': 'error',
'error': str(e)
}
def create_energy_worker() -> EnergyWorker:
"""
Factory function to create an energy calculation worker.
Returns:
Configured EnergyWorker instance
"""
return EnergyWorker()

View File

@@ -0,0 +1,243 @@
"""
Scraping worker module.
This module provides a worker that consumes scraping tasks
from RabbitMQ and executes scraping operations.
"""
import logging
from typing import Dict, List
from sqlalchemy.orm import Session
from app.scrapers.twitter_scraper import TwitterScraper, create_twitter_scraper
from app.scrapers.reddit_scraper import RedditScraper, create_reddit_scraper
logger = logging.getLogger(__name__)
class ScrapingWorker:
"""
Worker for processing scraping tasks.
Features:
- Consumes tasks from scraping_tasks queue
- Executes Twitter and Reddit scraping
- Publishes results to results queue
- Handles errors with retries
- Structured logging
"""
def __init__(
self,
twitter_bearer_token: str,
reddit_client_id: str,
reddit_client_secret: str
):
"""
Initialize scraping worker.
Args:
twitter_bearer_token: Twitter API bearer token
reddit_client_id: Reddit API client ID
reddit_client_secret: Reddit API client secret
"""
self.twitter_bearer_token = twitter_bearer_token
self.reddit_client_id = reddit_client_id
self.reddit_client_secret = reddit_client_secret
# Initialize scrapers (lazy initialization)
self.twitter_scraper: TwitterScraper = None
self.reddit_scraper: RedditScraper = None
def _get_twitter_scraper(self) -> TwitterScraper:
"""Get or create Twitter scraper instance."""
if self.twitter_scraper is None:
self.twitter_scraper = create_twitter_scraper(
bearer_token=self.twitter_bearer_token,
vip_match_ids=[]
)
return self.twitter_scraper
def _get_reddit_scraper(self) -> RedditScraper:
"""Get or create Reddit scraper instance."""
if self.reddit_scraper is None:
self.reddit_scraper = create_reddit_scraper(
client_id=self.reddit_client_id,
client_secret=self.reddit_client_secret
)
return self.reddit_scraper
def execute_scraping_task(
self,
task: Dict,
db: Session
) -> Dict:
"""
Execute a scraping task.
Args:
task: Scraping task data
db: Database session
Returns:
Dictionary with scraping results
"""
source = task.get('source')
match_id = task.get('match_id')
keywords = task.get('keywords', [])
priority = task.get('priority', 'normal')
logger.info(
f"🔧 Executing scraping task: match_id={match_id}, "
f"source={source}, priority={priority}"
)
try:
if source == 'twitter':
return self._execute_twitter_scraping(match_id, keywords, db)
elif source == 'reddit':
return self._execute_reddit_scraping(match_id, keywords, db)
else:
logger.error(f"❌ Unknown scraping source: {source}")
return {
'collected_count': 0,
'status': 'error',
'error': f'Unknown source: {source}'
}
except Exception as e:
logger.error(f"❌ Scraping task failed: {e}")
return {
'collected_count': 0,
'status': 'error',
'error': str(e)
}
def _execute_twitter_scraping(
self,
match_id: int,
keywords: List[str],
db: Session
) -> Dict:
"""
Execute Twitter scraping.
Args:
match_id: Match identifier
keywords: Search keywords
db: Database session
Returns:
Dictionary with scraping results
"""
try:
scraper = self._get_twitter_scraper()
# Scrape and save tweets
tweets = scraper.scrape_and_save(
match_id=match_id,
keywords=keywords,
db=db,
max_results=100
)
logger.info(
f"✅ Twitter scraping completed: {len(tweets)} tweets collected"
)
return {
'collected_count': len(tweets),
'status': 'success',
'metadata': {
'source': 'twitter',
'match_id': match_id,
'keywords': keywords
}
}
except Exception as e:
logger.error(f"❌ Twitter scraping failed: {e}")
return {
'collected_count': 0,
'status': 'error',
'error': str(e)
}
def _execute_reddit_scraping(
self,
match_id: int,
keywords: List[str],
db: Session
) -> Dict:
"""
Execute Reddit scraping.
Args:
match_id: Match identifier
keywords: Search keywords
db: Database session
Returns:
Dictionary with scraping results
"""
try:
scraper = self._get_reddit_scraper()
# Scrape and save Reddit posts
result = scraper.scrape_and_save(
match_id=match_id,
db=db,
keywords=keywords,
scrape_comments=True
)
posts = result.get('posts', [])
comments = result.get('comments', [])
logger.info(
f"✅ Reddit scraping completed: "
f"{len(posts)} posts, {len(comments)} comments collected"
)
return {
'collected_count': len(posts) + len(comments),
'status': 'success',
'metadata': {
'source': 'reddit',
'match_id': match_id,
'keywords': keywords,
'posts_count': len(posts),
'comments_count': len(comments)
}
}
except Exception as e:
logger.error(f"❌ Reddit scraping failed: {e}")
return {
'collected_count': 0,
'status': 'error',
'error': str(e)
}
def create_scraping_worker(
twitter_bearer_token: str,
reddit_client_id: str,
reddit_client_secret: str
) -> ScrapingWorker:
"""
Factory function to create a scraping worker.
Args:
twitter_bearer_token: Twitter API bearer token
reddit_client_id: Reddit API client ID
reddit_client_secret: Reddit API client secret
Returns:
Configured ScrapingWorker instance
"""
return ScrapingWorker(
twitter_bearer_token=twitter_bearer_token,
reddit_client_id=reddit_client_id,
reddit_client_secret=reddit_client_secret
)

View File

@@ -0,0 +1,302 @@
"""
Sentiment analysis worker module.
This module provides a worker that consumes sentiment analysis tasks
from RabbitMQ and executes sentiment analysis operations.
"""
import logging
from typing import Dict, List
from sqlalchemy.orm import Session
from app.services.sentiment_service import (
process_tweet_batch,
process_reddit_post_batch,
get_sentiment_by_entity
)
from app.models.tweet import Tweet
from app.models.reddit_post import RedditPost
logger = logging.getLogger(__name__)
class SentimentWorker:
"""
Worker for processing sentiment analysis tasks.
Features:
- Consumes tasks from sentiment_analysis_tasks queue
- Executes VADER sentiment analysis
- Processes batches of tweets and Reddit posts
- Publishes results to results queue
- Handles errors with retries
- Structured logging
"""
def __init__(self):
"""Initialize sentiment analysis worker."""
# No initialization needed for sentiment worker
# VADER analyzer is initialized in sentiment_service
pass
def execute_sentiment_analysis_task(
self,
task: Dict,
db: Session
) -> Dict:
"""
Execute a sentiment analysis task.
Args:
task: Sentiment analysis task data
db: Database session
Returns:
Dictionary with sentiment analysis results
"""
source = task.get('source')
match_id = task.get('match_id')
entity_ids = task.get('entity_ids', [])
logger.info(
f"🔧 Executing sentiment analysis task: "
f"match_id={match_id}, source={source}, "
f"entities={len(entity_ids)}"
)
try:
if source == 'twitter':
return self._execute_twitter_sentiment_analysis(
match_id, entity_ids, db
)
elif source == 'reddit':
return self._execute_reddit_sentiment_analysis(
match_id, entity_ids, db
)
else:
logger.error(f"❌ Unknown sentiment source: {source}")
return {
'analyzed_count': 0,
'status': 'error',
'error': f'Unknown source: {source}'
}
except Exception as e:
logger.error(f"❌ Sentiment analysis task failed: {e}")
return {
'analyzed_count': 0,
'status': 'error',
'error': str(e)
}
def _execute_twitter_sentiment_analysis(
self,
match_id: int,
entity_ids: List[str],
db: Session
) -> Dict:
"""
Execute sentiment analysis for Twitter data.
Args:
match_id: Match identifier
entity_ids: List of tweet IDs
db: Database session
Returns:
Dictionary with sentiment analysis results
"""
try:
# Fetch tweets from database
tweets = db.query(Tweet).filter(
Tweet.tweet_id.in_(entity_ids)
).all()
if not tweets:
logger.warning(f"⚠️ No tweets found for entities: {entity_ids}")
return {
'analyzed_count': 0,
'status': 'success',
'metrics': {
'total_count': 0,
'positive_count': 0,
'negative_count': 0,
'neutral_count': 0,
'average_compound': 0.0
}
}
# Check if already analyzed
unanalyzed_tweets = []
for tweet in tweets:
existing_sentiment = get_sentiment_by_entity(
db, tweet.tweet_id, 'tweet'
)
if not existing_sentiment:
unanalyzed_tweets.append(tweet)
if not unanalyzed_tweets:
logger.info(
f" All {len(tweets)} tweets already analyzed"
)
# Get metrics from existing sentiments
metrics = self._calculate_metrics_from_existing(db, match_id)
return {
'analyzed_count': 0,
'status': 'success',
'metrics': metrics
}
# Analyze batch
sentiment_scores = process_tweet_batch(db, unanalyzed_tweets)
# Calculate metrics
metrics = self._calculate_sentiment_metrics(db, match_id)
logger.info(
f"✅ Twitter sentiment analysis completed: "
f"{len(sentiment_scores)} tweets analyzed"
)
return {
'analyzed_count': len(sentiment_scores),
'status': 'success',
'metrics': metrics
}
except Exception as e:
logger.error(f"❌ Twitter sentiment analysis failed: {e}")
return {
'analyzed_count': 0,
'status': 'error',
'error': str(e)
}
def _execute_reddit_sentiment_analysis(
self,
match_id: int,
entity_ids: List[str],
db: Session
) -> Dict:
"""
Execute sentiment analysis for Reddit data.
Args:
match_id: Match identifier
entity_ids: List of Reddit post IDs
db: Database session
Returns:
Dictionary with sentiment analysis results
"""
try:
# Fetch Reddit posts from database
posts = db.query(RedditPost).filter(
RedditPost.post_id.in_(entity_ids)
).all()
if not posts:
logger.warning(f"⚠️ No Reddit posts found for entities: {entity_ids}")
return {
'analyzed_count': 0,
'status': 'success',
'metrics': {
'total_count': 0,
'positive_count': 0,
'negative_count': 0,
'neutral_count': 0,
'average_compound': 0.0
}
}
# Check if already analyzed
unanalyzed_posts = []
for post in posts:
existing_sentiment = get_sentiment_by_entity(
db, post.post_id, 'reddit_post'
)
if not existing_sentiment:
unanalyzed_posts.append(post)
if not unanalyzed_posts:
logger.info(
f" All {len(posts)} Reddit posts already analyzed"
)
# Get metrics from existing sentiments
metrics = self._calculate_metrics_from_existing(db, match_id)
return {
'analyzed_count': 0,
'status': 'success',
'metrics': metrics
}
# Analyze batch
sentiment_scores = process_reddit_post_batch(db, unanalyzed_posts)
# Calculate metrics
metrics = self._calculate_sentiment_metrics(db, match_id)
logger.info(
f"✅ Reddit sentiment analysis completed: "
f"{len(sentiment_scores)} posts analyzed"
)
return {
'analyzed_count': len(sentiment_scores),
'status': 'success',
'metrics': metrics
}
except Exception as e:
logger.error(f"❌ Reddit sentiment analysis failed: {e}")
return {
'analyzed_count': 0,
'status': 'error',
'error': str(e)
}
def _calculate_sentiment_metrics(
self,
db: Session,
match_id: int
) -> Dict:
"""
Calculate aggregated sentiment metrics for a match.
Args:
db: Database session
match_id: Match identifier
Returns:
Dictionary with aggregated metrics
"""
from app.services.sentiment_service import calculate_match_sentiment_metrics
return calculate_match_sentiment_metrics(db, match_id)
def _calculate_metrics_from_existing(
self,
db: Session,
match_id: int
) -> Dict:
"""
Calculate metrics from existing sentiment scores.
Args:
db: Database session
match_id: Match identifier
Returns:
Dictionary with aggregated metrics
"""
return self._calculate_sentiment_metrics(db, match_id)
def create_sentiment_worker() -> SentimentWorker:
"""
Factory function to create a sentiment analysis worker.
Returns:
Configured SentimentWorker instance
"""
return SentimentWorker()

13
backend/clear_cache.bat Normal file
View File

@@ -0,0 +1,13 @@
@echo off
echo Nettoyage du cache Python...
echo Suppression des fichiers .pyc dans app...
del /s /q "backend\app\**\*.pyc"
echo Suppression des fichiers .pyc dans app/api...
del /s /q "backend\app\api\**\*.pyc"
echo Suppression des fichiers .pyc dans app/schemas...
del /s /q "backend\app\schemas\**\*.pyc"
echo Suppression du dossier __pycache__...
for /d /r . %%d in (__pycache__) do @if exist "%%d" rd /s /q "%%d"
echo Cache nettoye !
echo.
echo Le serveur va maintenant se recharger correctement.

View File

@@ -0,0 +1,111 @@
"""Script corrigé pour créer l'utilisateur de test."""
import sys
import os
import sqlite3
from datetime import datetime
# Chemins corrects Windows
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BACKEND_DIR = os.path.join(BASE_DIR, 'backend')
DB_PATH = os.path.join(BASE_DIR, 'chartbastan.db')
# Importations correctes depuis backend
sys.path.insert(0, BACKEND_DIR)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.ext.declarative import declarative_base
from passlib.context import CryptContext
# Définition du modèle User pour éviter les problèmes de cache
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, nullable=False, index=True)
name = Column(String, nullable=True)
password_hash = Column(String, nullable=True)
is_premium = Column(Boolean, default=False, nullable=False)
referral_code = Column(String, unique=True, nullable=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
daily_predictions_count = Column(Integer, default=0)
last_prediction_date = Column(DateTime, nullable=True)
def main():
"""Créer l'utilisateur de test."""
print("=== Création de l'utilisateur de test ===")
print(f"Chemin de la base de données : {DB_PATH}")
print(f"Dossier backend : {BACKEND_DIR}")
# Créer l'engine et créer les tables
print("Création de la base de données...")
engine = create_engine(f'sqlite:///{DB_PATH}', echo=False)
# Créer les tables
Base.metadata.create_all(engine)
print("Tables créées avec succès !")
# Créer une session
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
session = SessionLocal()
# Hashage du mot de passe
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password_hash = pwd_context.hash("password123")
print(f"Hash du mot de passe : {password_hash[:20]}...")
try:
# Vérifier si l'utilisateur existe déjà
existing_user = session.query(User).filter(User.email == "test@example.com").first()
if existing_user:
print(f"⚠️ Utilisateur existe déjà : ID={existing_user.id}")
print(f" Email : {existing_user.email}")
print(f" Nom : {existing_user.name}")
# Mettre à jour le mot de passe
existing_user.password_hash = password_hash
session.commit()
print(f"✅ Mot de passe mis à jour pour l'utilisateur ID={existing_user.id}")
print(f" Email : {existing_user.email}")
print(f" Nom : {existing_user.name}")
sys.exit(0)
# Créer l'utilisateur de test
print("\nCréation de l'utilisateur de test...")
new_user = User(
email="test@example.com",
name="Utilisateur Test",
password_hash=password_hash,
is_premium=False,
referral_code="TESTCODE",
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
session.add(new_user)
session.commit()
session.refresh(new_user)
print(f"✅ Utilisateur créé avec succès !")
print(f" ID : {new_user.id}")
print(f" Email : {new_user.email}")
print(f" Nom : {new_user.name}")
print(f" Mot de passe hash : {new_user.password_hash[:20]}...")
print(f" Premium : {'Oui' if new_user.is_premium else 'Non'}")
print(f" Code de parrainage : {new_user.referral_code}")
print(f" Créé le : {new_user.created_at.isoformat()}")
finally:
session.close()
print("\n=== Identifiants de connexion ===")
print(f" Email : test@example.com")
print(f" Mot de passe : password123")
print(f" URL du backend : http://127.0.0.1:8000")
print("\nVous pouvez maintenant vous connecter via le frontend !")

6
backend/main.py Normal file
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from backend!")
if __name__ == "__main__":
main()

1
backend/openapi.json Normal file

File diff suppressed because one or more lines are too long

27
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,27 @@
[project]
name = "backend"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"alembic==1.13.0",
"bcrypt==4.2.1",
"black==24.8.0",
"email-validator==2.1.0",
"fastapi==0.128.0",
"feedparser==6.0.11",
"flake8==7.1.0",
"pika==1.3.2",
"passlib[bcrypt]==1.7.4",
"praw==7.8.1",
"pydantic==2.7.0",
"pydantic-settings==2.3.0",
"pytest==8.3.3",
"python-multipart==0.0.9",
"sqlalchemy==2.0.45",
"textblob==0.17.1",
"tweepy==4.14.0",
"uvicorn[standard]==0.30.0",
"vadersentiment==3.3.2",
]

Some files were not shown because too many files have changed in this diff Show More