Initial commit
This commit is contained in:
24
backend/.env
Normal file
24
backend/.env
Normal 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
10
backend/.gitignore
vendored
Normal 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
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
0
backend/README.md
Normal file
0
backend/README.md
Normal file
18
backend/RESET_DATABASE.txt
Normal file
18
backend/RESET_DATABASE.txt
Normal 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
110
backend/alembic.ini
Normal 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
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Ce répertoire contient les scripts de migration Alembic.
|
||||
82
backend/alembic/env.py
Normal file
82
backend/alembic/env.py
Normal 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()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal 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"}
|
||||
1
backend/alembic/versions/.gitkeep
Normal file
1
backend/alembic/versions/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Ce fichier permet de garder le répertoire vide dans git
|
||||
45
backend/alembic/versions/20260117_0000_initial_migration.py
Normal file
45
backend/alembic/versions/20260117_0000_initial_migration.py
Normal 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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
50
backend/alembic/versions/add_user_predictions_tracking.py
Normal file
50
backend/alembic/versions/add_user_predictions_tracking.py
Normal 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
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application FastAPI Chartbastan."""
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Routes API de l'application."""
|
||||
95
backend/app/api/dependencies.py
Normal file
95
backend/app/api/dependencies.py
Normal 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
|
||||
5
backend/app/api/public/__init__.py
Normal file
5
backend/app/api/public/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Public API package.
|
||||
|
||||
This module provides public API endpoints with OpenAPI documentation.
|
||||
"""
|
||||
5
backend/app/api/public/v1/__init__.py
Normal file
5
backend/app/api/public/v1/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Public API v1 package.
|
||||
|
||||
This module provides v1 public API endpoints.
|
||||
"""
|
||||
162
backend/app/api/public/v1/matches.py
Normal file
162
backend/app/api/public/v1/matches.py
Normal 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
|
||||
}
|
||||
189
backend/app/api/public/v1/predictions.py
Normal file
189
backend/app/api/public/v1/predictions.py
Normal 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
|
||||
}
|
||||
30
backend/app/api/v1/__init__.py
Normal file
30
backend/app/api/v1/__init__.py
Normal 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
261
backend/app/api/v1/auth.py
Normal 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"
|
||||
}
|
||||
}
|
||||
177
backend/app/api/v1/backtesting.py
Normal file
177
backend/app/api/v1/backtesting.py
Normal 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'
|
||||
}
|
||||
}
|
||||
166
backend/app/api/v1/badges.py
Normal file
166
backend/app/api/v1/badges.py
Normal 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"
|
||||
}
|
||||
)
|
||||
142
backend/app/api/v1/leaderboard.py
Normal file
142
backend/app/api/v1/leaderboard.py
Normal 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)
|
||||
425
backend/app/api/v1/predictions.py
Normal file
425
backend/app/api/v1/predictions.py
Normal 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
|
||||
241
backend/app/api/v1/user_predictions.py
Normal file
241
backend/app/api/v1/user_predictions.py
Normal 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
153
backend/app/api/v1/users.py
Normal 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
185
backend/app/app_new.py
Normal 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
32
backend/app/config_db.py
Normal 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
83
backend/app/database.py
Normal 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
300
backend/app/lib/badges.py
Normal 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
263
backend/app/main.py
Normal 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
|
||||
3
backend/app/middleware/__init__.py
Normal file
3
backend/app/middleware/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Middleware package for the application.
|
||||
"""
|
||||
154
backend/app/middleware/rate_limiter.py
Normal file
154
backend/app/middleware/rate_limiter.py
Normal 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
|
||||
2
backend/app/ml/__init__.py
Normal file
2
backend/app/ml/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# ML Module
|
||||
# This module contains machine learning components for sentiment analysis and energy calculations
|
||||
619
backend/app/ml/backtesting.py
Normal file
619
backend/app/ml/backtesting.py
Normal 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
|
||||
356
backend/app/ml/energy_calculator.py
Normal file
356
backend/app/ml/energy_calculator.py
Normal 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
|
||||
}
|
||||
146
backend/app/ml/prediction_calculator.py
Normal file
146
backend/app/ml/prediction_calculator.py
Normal 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
|
||||
178
backend/app/ml/sentiment_analyzer.py
Normal file
178
backend/app/ml/sentiment_analyzer.py
Normal 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
|
||||
15
backend/app/models/__init__.py
Normal file
15
backend/app/models/__init__.py
Normal 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"]
|
||||
55
backend/app/models/api_key.py
Normal file
55
backend/app/models/api_key.py
Normal 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
|
||||
}
|
||||
45
backend/app/models/badge.py
Normal file
45
backend/app/models/badge.py
Normal 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}>"
|
||||
101
backend/app/models/energy_score.py
Normal file
101
backend/app/models/energy_score.py
Normal 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
|
||||
}
|
||||
62
backend/app/models/match.py
Normal file
62
backend/app/models/match.py
Normal 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
|
||||
}
|
||||
57
backend/app/models/prediction.py
Normal file
57
backend/app/models/prediction.py
Normal 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
|
||||
}
|
||||
118
backend/app/models/reddit_post.py
Normal file
118
backend/app/models/reddit_post.py
Normal 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
|
||||
}
|
||||
64
backend/app/models/rss_article.py
Normal file
64
backend/app/models/rss_article.py
Normal 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
|
||||
}
|
||||
65
backend/app/models/sentiment_score.py
Normal file
65
backend/app/models/sentiment_score.py
Normal 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
|
||||
}
|
||||
63
backend/app/models/tweet.py
Normal file
63
backend/app/models/tweet.py
Normal 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
|
||||
}
|
||||
27
backend/app/models/user.py
Normal file
27
backend/app/models/user.py
Normal 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")
|
||||
62
backend/app/models/user_prediction.py
Normal file
62
backend/app/models/user_prediction.py
Normal 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
|
||||
}
|
||||
36
backend/app/queues/__init__.py
Normal file
36
backend/app/queues/__init__.py
Normal 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'
|
||||
]
|
||||
246
backend/app/queues/consumers.py
Normal file
246
backend/app/queues/consumers.py
Normal 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)
|
||||
268
backend/app/queues/producers.py
Normal file
268
backend/app/queues/producers.py
Normal 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
|
||||
)
|
||||
238
backend/app/queues/rabbitmq_client.py
Normal file
238
backend/app/queues/rabbitmq_client.py
Normal 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
|
||||
119
backend/app/schemas/__init__.py
Normal file
119
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
37
backend/app/schemas/auth.py
Normal file
37
backend/app/schemas/auth.py
Normal 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
|
||||
191
backend/app/schemas/backtesting.py
Normal file
191
backend/app/schemas/backtesting.py
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
60
backend/app/schemas/badge.py
Normal file
60
backend/app/schemas/badge.py
Normal 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]
|
||||
91
backend/app/schemas/energy_score.py
Normal file
91
backend/app/schemas/energy_score.py
Normal 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")
|
||||
81
backend/app/schemas/leaderboard.py
Normal file
81
backend/app/schemas/leaderboard.py
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
55
backend/app/schemas/match.py
Normal file
55
backend/app/schemas/match.py
Normal 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")
|
||||
77
backend/app/schemas/prediction.py
Normal file
77
backend/app/schemas/prediction.py
Normal 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")
|
||||
89
backend/app/schemas/public.py
Normal file
89
backend/app/schemas/public.py
Normal 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")
|
||||
79
backend/app/schemas/reddit_post.py
Normal file
79
backend/app/schemas/reddit_post.py
Normal 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")
|
||||
46
backend/app/schemas/rss_article.py
Normal file
46
backend/app/schemas/rss_article.py
Normal 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")
|
||||
77
backend/app/schemas/sentiment_score.py
Normal file
77
backend/app/schemas/sentiment_score.py
Normal 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")
|
||||
48
backend/app/schemas/tweet.py
Normal file
48
backend/app/schemas/tweet.py
Normal 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")
|
||||
42
backend/app/schemas/user.py
Normal file
42
backend/app/schemas/user.py
Normal 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
|
||||
75
backend/app/schemas/user_prediction.py
Normal file
75
backend/app/schemas/user_prediction.py
Normal 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")
|
||||
144
backend/app/scrapers/README.md
Normal file
144
backend/app/scrapers/README.md
Normal 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
|
||||
12
backend/app/scrapers/__init__.py
Normal file
12
backend/app/scrapers/__init__.py
Normal 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"]
|
||||
441
backend/app/scrapers/reddit_scraper.py
Normal file
441
backend/app/scrapers/reddit_scraper.py
Normal 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
|
||||
380
backend/app/scrapers/rss_scraper.py
Normal file
380
backend/app/scrapers/rss_scraper.py
Normal 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
|
||||
351
backend/app/scrapers/twitter_scraper.py
Normal file
351
backend/app/scrapers/twitter_scraper.py
Normal 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
|
||||
166
backend/app/services/apiKeyService.py
Normal file
166
backend/app/services/apiKeyService.py
Normal 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)
|
||||
200
backend/app/services/backtesting_service.py
Normal file
200
backend/app/services/backtesting_service.py
Normal 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"
|
||||
)
|
||||
190
backend/app/services/badge_service.py
Normal file
190
backend/app/services/badge_service.py
Normal 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
|
||||
278
backend/app/services/energy_service.py
Normal file
278
backend/app/services/energy_service.py
Normal 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()
|
||||
161
backend/app/services/leaderboard_service.py
Normal file
161
backend/app/services/leaderboard_service.py
Normal 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
|
||||
}
|
||||
304
backend/app/services/prediction_service.py
Normal file
304
backend/app/services/prediction_service.py
Normal 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
|
||||
]
|
||||
326
backend/app/services/sentiment_service.py
Normal file
326
backend/app/services/sentiment_service.py
Normal 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)
|
||||
216
backend/app/services/user_prediction_service.py
Normal file
216
backend/app/services/user_prediction_service.py
Normal 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
|
||||
}
|
||||
16
backend/app/workers/__init__.py
Normal file
16
backend/app/workers/__init__.py
Normal 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'
|
||||
]
|
||||
191
backend/app/workers/energy_worker.py
Normal file
191
backend/app/workers/energy_worker.py
Normal 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()
|
||||
243
backend/app/workers/scraping_worker.py
Normal file
243
backend/app/workers/scraping_worker.py
Normal 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
|
||||
)
|
||||
302
backend/app/workers/sentiment_worker.py
Normal file
302
backend/app/workers/sentiment_worker.py
Normal 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
13
backend/clear_cache.bat
Normal 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.
|
||||
111
backend/fix_and_create_user.py
Normal file
111
backend/fix_and_create_user.py
Normal 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
6
backend/main.py
Normal file
@@ -0,0 +1,6 @@
|
||||
def main():
|
||||
print("Hello from backend!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
backend/openapi.json
Normal file
1
backend/openapi.json
Normal file
File diff suppressed because one or more lines are too long
27
backend/pyproject.toml
Normal file
27
backend/pyproject.toml
Normal 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
Reference in New Issue
Block a user