GitHub Actions commited on
Commit
84b0fa3
·
1 Parent(s): b47f0e8

Deploy backend from GitHub Actions

Browse files

🚀 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

.env.example CHANGED
@@ -1,6 +1,9 @@
1
  # Google OAuth Configuration
2
  GOOGLE_CLIENT_ID=your-google-client-id
3
  GOOGLE_CLIENT_SECRET=your-google-client-secret
 
 
 
4
  AUTH_REDIRECT_URI=http://localhost:3000/auth/google/callback
5
  FRONTEND_URL=http://localhost:3000
6
 
 
1
  # Google OAuth Configuration
2
  GOOGLE_CLIENT_ID=your-google-client-id
3
  GOOGLE_CLIENT_SECRET=your-google-client-secret
4
+ # For production:
5
+ # AUTH_REDIRECT_URI=https://your-hf-space.hf.space/backend/auth/google/callback
6
+ # FRONTEND_URL=https://your-username.github.io/your-repo
7
  AUTH_REDIRECT_URI=http://localhost:3000/auth/google/callback
8
  FRONTEND_URL=http://localhost:3000
9
 
DATABASE_DEPLOYMENT_FIX.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces Database Deployment Fix
2
+
3
+ ## Problem
4
+ The Hugging Face deployment was failing due to a binary database file (`database/auth.db`) being present in the repository. Hugging Face Spaces doesn't allow binary files in the repository without using xet storage.
5
+
6
+ ## Solution Implemented
7
+
8
+ ### 1. Removed Database File from Git Tracking
9
+ - Executed: `git rm --cached backend/database/auth.db`
10
+ - This removes the file from git tracking while keeping it locally
11
+
12
+ ### 2. Updated .gitignore
13
+ Added the following patterns to exclude database files:
14
+ ```
15
+ # Database files
16
+ *.db
17
+ *.sqlite
18
+ *.sqlite3
19
+ backend/database/*.db
20
+ backend/database/*.sqlite
21
+ backend/database/*.sqlite3
22
+ ```
23
+
24
+ ### 3. Created Database Initialization Script
25
+ - File: `backend/init_database.py`
26
+ - Automatically creates the database directory and tables on startup
27
+ - Safe to run multiple times (uses SQLAlchemy's `create_all`)
28
+
29
+ ### 4. Created Server Startup Script
30
+ - File: `backend/start_server.py`
31
+ - Initializes the database before starting the FastAPI server
32
+ - Provides clear logging of the initialization process
33
+
34
+ ### 5. Updated Dockerfile
35
+ - Changed CMD to use `start_server.py` instead of direct uvicorn
36
+ - Ensures database is ready before the server starts
37
+
38
+ ### 6. Enhanced FastAPI Main Application
39
+ - Updated `main.py` to ensure database directory exists
40
+ - Added database directory creation in the lifespan function
41
+
42
+ ## How It Works
43
+
44
+ 1. **On Deployment Start**:
45
+ - The container starts and runs `start_server.py`
46
+ - This script first runs `init_database.py`
47
+ - The database directory and tables are created if they don't exist
48
+ - Then the FastAPI server starts normally
49
+
50
+ 2. **Database Location**:
51
+ - SQLite database: `database/auth.db`
52
+ - Automatically created on first run
53
+ - Stored in the container's filesystem (persistent for the container's lifetime)
54
+
55
+ 3. **Safety Features**:
56
+ - Database initialization is idempotent (safe to run multiple times)
57
+ - Uses SQLAlchemy's `create_all()` which only creates missing tables
58
+ - The FastAPI app also ensures the database directory exists on startup
59
+
60
+ ## Verification Steps
61
+
62
+ 1. Check that `database/auth.db` is not tracked by git:
63
+ ```bash
64
+ git status # Should not show database/auth.db
65
+ ```
66
+
67
+ 2. Verify .gitignore includes database patterns
68
+
69
+ 3. Test locally:
70
+ ```bash
71
+ cd backend
72
+ rm -f database/auth.db # Remove existing database
73
+ python start_server.py # Should create database and start server
74
+ ```
75
+
76
+ ## Deployment Notes
77
+
78
+ - The database will be created automatically when the Space starts
79
+ - The database persists as long as the Space container is not restarted
80
+ - For production use, consider migrating to a persistent database solution
81
+ - The SQLite file is stored in the container's filesystem at `/app/database/auth.db`
82
+
83
+ ## Future Improvements
84
+
85
+ 1. **Persistent Storage**: Consider using Hugging Face Spaces persistent storage if available
86
+ 2. **Database Migration**: Add Alembic migrations for schema changes
87
+ 3. **Backup Strategy**: Implement regular database backups
88
+ 4. **Cloud Database**: Migrate to PostgreSQL or MySQL for better scalability
89
+
90
+ ## Files Modified
91
+
92
+ - `.gitignore` - Added database file patterns
93
+ - `backend/database/config.py` - Fixed import path for models
94
+ - `backend/main.py` - Added database directory creation
95
+ - `backend/Dockerfile` - Updated startup command
96
+ - `backend/init_database.py` - New file (database initialization)
97
+ - `backend/start_server.py` - New file (server startup script)
Dockerfile CHANGED
@@ -35,5 +35,5 @@ EXPOSE 7860
35
  HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
36
  CMD curl -f http://localhost:7860/health || exit 1
37
 
38
- # Run the application
39
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
 
35
  HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
36
  CMD curl -f http://localhost:7860/health || exit 1
37
 
38
+ # Run the application with database initialization
39
+ CMD ["python", "start_server.py"]
alembic.ini ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts
5
+ script_location = alembic
6
+
7
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8
+ # Uncomment the line below if you want the files to be prepended with date and time
9
+ # file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
10
+
11
+ # sys.path path, will be prepended to sys.path if present.
12
+ # defaults to the current working directory.
13
+ prepend_sys_path = .
14
+
15
+ # timezone to use when rendering the date within the migration file
16
+ # as well as the filename.
17
+ # If specified, requires the python-dateutil library that can be
18
+ # installed by adding `alembic[tz]` to the pip requirements
19
+ # string value is passed to dateutil.tz.gettz()
20
+ # leave blank for localtime
21
+ # timezone =
22
+
23
+ # max length of characters to apply to the
24
+ # "slug" field
25
+ # truncate_slug_length = 40
26
+
27
+ # set to 'true' to run the environment during
28
+ # the 'revision' command, regardless of autogenerate
29
+ # revision_environment = false
30
+
31
+ # set to 'true' to allow .pyc and .pyo files without
32
+ # a source .py file to be detected as revisions in the
33
+ # versions/ directory
34
+ # sourceless = false
35
+
36
+ # version number format
37
+ version_num_format = %04d
38
+
39
+ # version path separator; As mentioned above, this is the character used to split
40
+ # version_locations. The default within new alembic.ini files is "os", which uses
41
+ # os.pathsep. If this key is omitted entirely, it falls back to the legacy
42
+ # behavior of splitting on spaces and/or commas.
43
+ # Valid values for version_path_separator are:
44
+ #
45
+ # version_path_separator = :
46
+ # version_path_separator = ;
47
+ # version_path_separator = space
48
+ version_path_separator = os
49
+
50
+ # set to 'true' to search source files recursively
51
+ # in each "version_locations" directory
52
+ # new in Alembic version 1.10
53
+ # recursive_version_locations = false
54
+
55
+ # the output encoding used when revision files
56
+ # are written from script.py.mako
57
+ # output_encoding = utf-8
58
+
59
+ sqlalchemy.url = sqlite:///./app.db
60
+
61
+
62
+ [post_write_hooks]
63
+ # post_write_hooks defines scripts or Python functions that are run
64
+ # on newly generated revision scripts. See the documentation for further
65
+ # detail and examples
66
+
67
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
68
+ # hooks = black
69
+ # black.type = console_scripts
70
+ # black.entrypoint = black
71
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
72
+
73
+ # lint with attempts to fix using "ruff" - use the exec runner, execute a binary
74
+ # hooks = ruff
75
+ # ruff.type = exec
76
+ # ruff.executable = %(here)s/.venv/bin/ruff
77
+ # ruff.options = --fix REVISION_SCRIPT_FILENAME
78
+
79
+ # Logging configuration
80
+ [loggers]
81
+ keys = root,sqlalchemy,alembic
82
+
83
+ [handlers]
84
+ keys = console
85
+
86
+ [formatters]
87
+ keys = generic
88
+
89
+ [logger_root]
90
+ level = WARN
91
+ handlers = console
92
+ qualname =
93
+
94
+ [logger_sqlalchemy]
95
+ level = WARN
96
+ handlers =
97
+ qualname = sqlalchemy.engine
98
+
99
+ [logger_alembic]
100
+ level = INFO
101
+ handlers =
102
+ qualname = alembic
103
+
104
+ [handler_console]
105
+ class = StreamHandler
106
+ args = (sys.stderr,)
107
+ level = NOTSET
108
+ formatter = generic
109
+
110
+ [formatter_generic]
111
+ format = %(levelname)-5.5s [%(name)s] %(message)s
112
+ datefmt = %H:%M:%S
alembic/env.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from logging.config import fileConfig
2
+ from sqlalchemy import engine_from_config
3
+ from sqlalchemy import pool
4
+ from alembic import context
5
+ import os
6
+ import sys
7
+
8
+ # Add the backend directory to Python path
9
+ sys.path.append(os.path.dirname(os.path.dirname(__file__)))
10
+
11
+ # Import models
12
+ from src.models.auth import Base
13
+ from src.models.chat import Base
14
+
15
+ # this is the Alembic Config object, which provides
16
+ # access to the values within the .ini file in use.
17
+ config = context.config
18
+
19
+ # Interpret the config file for Python logging.
20
+ # This line sets up loggers basically.
21
+ if config.config_file_name is not None:
22
+ fileConfig(config.config_file_name)
23
+
24
+ # add your model's MetaData object here
25
+ # for 'autogenerate' support
26
+ target_metadata = Base.metadata
27
+
28
+ # other values from the config, defined by the needs of env.py,
29
+ # can be acquired:
30
+ # my_important_option = config.get_main_option("my_important_option")
31
+ # ... etc.
32
+
33
+
34
+ def get_url():
35
+ """Get database URL from environment or config."""
36
+ url = os.getenv("DATABASE_URL")
37
+ if url:
38
+ return url
39
+ return config.get_main_option("sqlalchemy.url")
40
+
41
+
42
+ def run_migrations_offline() -> None:
43
+ """Run migrations in 'offline' mode.
44
+
45
+ This configures the context with just a URL
46
+ and not an Engine, though an Engine is acceptable
47
+ here as well. By skipping the Engine creation
48
+ we don't even need a DBAPI to be available.
49
+
50
+ Calls to context.execute() here emit the given string to the
51
+ script output.
52
+
53
+ """
54
+ url = get_url()
55
+ context.configure(
56
+ url=url,
57
+ target_metadata=target_metadata,
58
+ literal_binds=True,
59
+ dialect_opts={"paramstyle": "named"},
60
+ )
61
+
62
+ with context.begin_transaction():
63
+ context.run_migrations()
64
+
65
+
66
+ def run_migrations_online() -> None:
67
+ """Run migrations in 'online' mode.
68
+
69
+ In this scenario we need to create an Engine
70
+ and associate a connection with the context.
71
+
72
+ """
73
+ configuration = config.get_section(config.config_ini_section)
74
+ configuration["sqlalchemy.url"] = get_url()
75
+
76
+ connectable = engine_from_config(
77
+ configuration,
78
+ prefix="sqlalchemy.",
79
+ poolclass=pool.NullPool,
80
+ )
81
+
82
+ with connectable.connect() as connection:
83
+ context.configure(
84
+ connection=connection,
85
+ target_metadata=target_metadata
86
+ )
87
+
88
+ with context.begin_transaction():
89
+ context.run_migrations()
90
+
91
+
92
+ if context.is_offline_mode():
93
+ run_migrations_offline()
94
+ else:
95
+ run_migrations_online()
alembic/script.py.mako ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+ ${imports if imports else ""}
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = ${repr(up_revision)}
14
+ down_revision = ${repr(down_revision)}
15
+ branch_labels = ${repr(branch_labels)}
16
+ depends_on = ${repr(depends_on)}
17
+
18
+
19
+ def upgrade() -> None:
20
+ ${upgrades if upgrades else "pass"}
21
+
22
+
23
+ def downgrade() -> None:
24
+ ${downgrades if downgrades else "pass"}
alembic/versions/001_initial_authentication_tables.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Initial authentication tables
2
+
3
+ Revision ID: 0001
4
+ Revises:
5
+ Create Date: 2025-01-08 14:30:00.000000
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '0001'
16
+ down_revision: Union[str, None] = None
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+
24
+ # Create users table
25
+ op.create_table('users',
26
+ sa.Column('id', sa.String(length=36), nullable=False),
27
+ sa.Column('email', sa.String(length=255), nullable=False),
28
+ sa.Column('password_hash', sa.String(length=255), nullable=False),
29
+ sa.Column('name', sa.String(length=100), nullable=True),
30
+ sa.Column('email_verified', sa.Boolean(), nullable=False),
31
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
32
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
33
+ sa.PrimaryKeyConstraint('id')
34
+ )
35
+ op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
36
+ op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False)
37
+
38
+ # Create user_backgrounds table
39
+ op.create_table('user_backgrounds',
40
+ sa.Column('id', sa.String(length=36), nullable=False),
41
+ sa.Column('user_id', sa.String(length=36), nullable=False),
42
+ sa.Column('experience_level', sa.Enum('beginner', 'intermediate', 'advanced', name='experiencelevel'), nullable=False),
43
+ sa.Column('years_experience', sa.Integer(), nullable=False),
44
+ sa.Column('preferred_languages', sa.JSON(), nullable=False),
45
+ sa.Column('hardware_expertise', sa.JSON(), nullable=False),
46
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
47
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
48
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
49
+ sa.PrimaryKeyConstraint('id')
50
+ )
51
+ op.create_index(op.f('ix_user_background_user_id'), 'user_backgrounds', ['user_id'], unique=True)
52
+
53
+ # Create onboarding_responses table
54
+ op.create_table('onboarding_responses',
55
+ sa.Column('id', sa.String(length=36), nullable=False),
56
+ sa.Column('user_id', sa.String(length=36), nullable=False),
57
+ sa.Column('question_key', sa.String(length=100), nullable=False),
58
+ sa.Column('response_value', sa.JSON(), nullable=False),
59
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
60
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
61
+ sa.PrimaryKeyConstraint('id')
62
+ )
63
+
64
+ # Create sessions table
65
+ op.create_table('sessions',
66
+ sa.Column('id', sa.String(length=36), nullable=False),
67
+ sa.Column('user_id', sa.String(length=36), nullable=False),
68
+ sa.Column('token_hash', sa.String(length=255), nullable=False),
69
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
70
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
71
+ sa.Column('ip_address', sa.String(length=45), nullable=True),
72
+ sa.Column('user_agent', sa.Text(), nullable=True),
73
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
74
+ sa.PrimaryKeyConstraint('id')
75
+ )
76
+ op.create_index(op.f('ix_sessions_token_hash'), 'sessions', ['token_hash'], unique=True)
77
+ op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
78
+ op.create_index(op.f('ix_sessions_expires_at'), 'sessions', ['expires_at'], unique=False)
79
+
80
+ # Create password_reset_tokens table
81
+ op.create_table('password_reset_tokens',
82
+ sa.Column('id', sa.String(length=36), nullable=False),
83
+ sa.Column('user_id', sa.String(length=36), nullable=False),
84
+ sa.Column('token', sa.String(length=255), nullable=False),
85
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
86
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
87
+ sa.Column('used', sa.Boolean(), nullable=False),
88
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
89
+ sa.PrimaryKeyConstraint('id')
90
+ )
91
+ op.create_index(op.f('ix_password_reset_expires'), 'password_reset_tokens', ['expires_at'], unique=False)
92
+
93
+ # Create anonymous_sessions table
94
+ op.create_table('anonymous_sessions',
95
+ sa.Column('id', sa.String(length=36), nullable=False),
96
+ sa.Column('message_count', sa.Integer(), nullable=False),
97
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
98
+ sa.Column('last_activity', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
99
+ sa.PrimaryKeyConstraint('id')
100
+ )
101
+ op.create_index(op.f('ix_anonymous_sessions_activity'), 'anonymous_sessions', ['last_activity'], unique=False)
102
+
103
+ # Create chat_sessions table
104
+ op.create_table('chat_sessions',
105
+ sa.Column('id', sa.String(length=36), nullable=False),
106
+ sa.Column('user_id', sa.String(length=36), nullable=True),
107
+ sa.Column('anonymous_session_id', sa.String(length=36), nullable=True),
108
+ sa.Column('title', sa.String(length=255), nullable=False),
109
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
110
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
111
+ sa.ForeignKeyConstraint(['anonymous_session_id'], ['anonymous_sessions.id'], ),
112
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
113
+ sa.PrimaryKeyConstraint('id')
114
+ )
115
+ op.create_index(op.f('ix_chat_sessions_anonymous_id'), 'chat_sessions', ['anonymous_session_id'], unique=False)
116
+ op.create_index(op.f('ix_chat_sessions_updated_at'), 'chat_sessions', ['updated_at'], unique=False)
117
+ op.create_index(op.f('ix_chat_sessions_user_id'), 'chat_sessions', ['user_id'], unique=False)
118
+
119
+ # Create chat_messages table
120
+ op.create_table('chat_messages',
121
+ sa.Column('id', sa.String(length=36), nullable=False),
122
+ sa.Column('chat_session_id', sa.String(length=36), nullable=False),
123
+ sa.Column('role', sa.Enum('user', 'assistant', 'system', name='chatmessage_role'), nullable=False),
124
+ sa.Column('content', sa.Text(), nullable=False),
125
+ sa.Column('metadata', sa.JSON(), nullable=True),
126
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
127
+ sa.ForeignKeyConstraint(['chat_session_id'], ['chat_sessions.id'], ),
128
+ sa.PrimaryKeyConstraint('id')
129
+ )
130
+ op.create_index(op.f('ix_chat_messages_created_at'), 'chat_messages', ['created_at'], unique=False)
131
+ op.create_index(op.f('ix_chat_messages_session_id'), 'chat_messages', ['chat_session_id'], unique=False)
132
+
133
+ # Create user_preferences table
134
+ op.create_table('user_preferences',
135
+ sa.Column('id', sa.String(length=36), nullable=False),
136
+ sa.Column('user_id', sa.String(length=36), nullable=False),
137
+ sa.Column('theme', sa.Enum('light', 'dark', 'auto', name='theme'), nullable=False),
138
+ sa.Column('language', sa.String(length=10), nullable=False),
139
+ sa.Column('notification_settings', sa.JSON(), nullable=False),
140
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
141
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
142
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
143
+ sa.PrimaryKeyConstraint('id')
144
+ )
145
+ op.create_constraint('uk_user_background_user_id', 'user_backgrounds', sa.UniqueConstraint('user_id'))
146
+
147
+ # ### end Alembic commands ###
148
+
149
+
150
+ def downgrade() -> None:
151
+ # ### commands auto generated by Alembic - please adjust! ###
152
+ op.drop_constraint('uk_user_background_user_id', 'user_backgrounds', type_='unique')
153
+ op.drop_table('user_preferences')
154
+ op.drop_index(op.f('ix_chat_messages_session_id'), table_name='chat_messages')
155
+ op.drop_index(op.f('ix_chat_messages_created_at'), table_name='chat_messages')
156
+ op.drop_table('chat_messages')
157
+ op.drop_index(op.f('ix_chat_sessions_user_id'), table_name='chat_sessions')
158
+ op.drop_index(op.f('ix_chat_sessions_updated_at'), table_name='chat_sessions')
159
+ op.drop_index(op.f('ix_chat_sessions_anonymous_id'), table_name='chat_sessions')
160
+ op.drop_table('chat_sessions')
161
+ op.drop_index(op.f('ix_anonymous_sessions_activity'), table_name='anonymous_sessions')
162
+ op.drop_table('anonymous_sessions')
163
+ op.drop_index(op.f('ix_password_reset_expires'), table_name='password_reset_tokens')
164
+ op.drop_table('password_reset_tokens')
165
+ op.drop_index(op.f('ix_sessions_expires_at'), table_name='sessions')
166
+ op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
167
+ op.drop_index(op.f('ix_sessions_token_hash'), table_name='sessions')
168
+ op.drop_table('sessions')
169
+ op.drop_table('onboarding_responses')
170
+ op.drop_index(op.f('ix_user_background_user_id'), table_name='user_backgrounds')
171
+ op.drop_table('user_backgrounds')
172
+ op.drop_index(op.f('ix_users_created_at'), table_name='users')
173
+ op.drop_index(op.f('ix_users_email'), table_name='users')
174
+ op.drop_table('users')
175
+ # ### end Alembic commands ###
alembic/versions/002_add_onboarding_tables.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add onboarding response and background tables
2
+
3
+ Revision ID: 002
4
+ Revises: 001_initial_authentication_tables
5
+ Create Date: 2025-01-08 10:30:00.000000
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ from sqlalchemy.dialects import sqlite
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '002'
16
+ down_revision: Union[str, None] = '001_initial_authentication_tables'
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+
24
+ # Create onboarding_responses table
25
+ op.create_table('onboarding_responses',
26
+ sa.Column('id', sa.String(length=36), nullable=False),
27
+ sa.Column('user_id', sa.String(length=36), nullable=False),
28
+ sa.Column('question_key', sa.String(length=100), nullable=False),
29
+ sa.Column('response_value', sa.JSON(), nullable=False),
30
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
31
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
32
+ sa.PrimaryKeyConstraint('id')
33
+ )
34
+ op.create_index(op.f('ix_onboarding_responses_user_id'), 'onboarding_responses', ['user_id'], unique=False)
35
+
36
+ # Update user_backgrounds table if it doesn't have all required fields
37
+ # Check if hardware_expertise column exists
38
+ conn = op.get_bind()
39
+ inspector = sa.inspect(conn)
40
+ columns = [col['name'] for col in inspector.get_columns('user_backgrounds')]
41
+
42
+ if 'hardware_expertise' not in columns:
43
+ op.add_column('user_backgrounds', sa.Column('hardware_expertise', sa.JSON(), nullable=False, server_default='{}'))
44
+
45
+ # ### end Alembic commands ###
46
+
47
+
48
+ def downgrade() -> None:
49
+ # ### commands auto generated by Alembic - please adjust! ###
50
+ op.drop_index(op.f('ix_onboarding_responses_user_id'), table_name='onboarding_responses')
51
+ op.drop_table('onboarding_responses')
52
+ # ### end Alembic commands ###
auth/auth.py CHANGED
@@ -13,7 +13,7 @@ from authlib.integrations.starlette_client import OAuth
13
  from starlette.config import Config
14
 
15
  from database.config import get_db
16
- from models.auth import User, Session, Account, UserPreferences
17
 
18
  # JWT Settings
19
  SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32))
@@ -44,11 +44,17 @@ oauth.register(
44
 
45
  def verify_password(plain_password: str, hashed_password: str) -> bool:
46
  """Verify a password against its hash"""
 
 
 
47
  return pwd_context.verify(plain_password, hashed_password)
48
 
49
 
50
  def get_password_hash(password: str) -> str:
51
  """Generate password hash"""
 
 
 
52
  return pwd_context.hash(password)
53
 
54
 
@@ -89,7 +95,7 @@ async def get_current_user(
89
  headers={"WWW-Authenticate": "Bearer"},
90
  )
91
 
92
- user_id: int = payload.get("sub")
93
  if user_id is None:
94
  raise HTTPException(
95
  status_code=status.HTTP_401_UNAUTHORIZED,
@@ -124,7 +130,7 @@ def create_user_session(db: Session, user: User) -> str:
124
 
125
  db_session = Session(
126
  user_id=user.id,
127
- token=session_token,
128
  expires_at=expires_at
129
  )
130
  db.add(db_session)
 
13
  from starlette.config import Config
14
 
15
  from database.config import get_db
16
+ from src.models.auth import User, Session, Account, UserPreferences
17
 
18
  # JWT Settings
19
  SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32))
 
44
 
45
  def verify_password(plain_password: str, hashed_password: str) -> bool:
46
  """Verify a password against its hash"""
47
+ # Truncate password to 72 bytes maximum for bcrypt
48
+ if len(plain_password.encode('utf-8')) > 72:
49
+ plain_password = plain_password.encode('utf-8')[:72].decode('utf-8', errors='ignore')
50
  return pwd_context.verify(plain_password, hashed_password)
51
 
52
 
53
  def get_password_hash(password: str) -> str:
54
  """Generate password hash"""
55
+ # Truncate password to 72 bytes maximum for bcrypt
56
+ if len(password.encode('utf-8')) > 72:
57
+ password = password.encode('utf-8')[:72].decode('utf-8', errors='ignore')
58
  return pwd_context.hash(password)
59
 
60
 
 
95
  headers={"WWW-Authenticate": "Bearer"},
96
  )
97
 
98
+ user_id: str = payload.get("sub")
99
  if user_id is None:
100
  raise HTTPException(
101
  status_code=status.HTTP_401_UNAUTHORIZED,
 
130
 
131
  db_session = Session(
132
  user_id=user.id,
133
+ token_hash=get_password_hash(session_token),
134
  expires_at=expires_at
135
  )
136
  db.add(db_session)
init_database.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database initialization script for Hugging Face Spaces deployment.
3
+ This script ensures the database directory exists and creates necessary tables.
4
+ """
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ # Add backend to Python path
10
+ backend_path = Path(__file__).parent
11
+ sys.path.insert(0, str(backend_path))
12
+
13
+ from database.config import create_tables, engine, DATABASE_URL
14
+
15
+
16
+ def ensure_database_directory():
17
+ """Ensure the database directory exists."""
18
+ db_path = Path(DATABASE_URL.replace("sqlite:///", ""))
19
+
20
+ if not db_path.exists():
21
+ db_dir = db_path.parent
22
+ db_dir.mkdir(parents=True, exist_ok=True)
23
+ print(f"Created database directory: {db_dir}")
24
+
25
+ return db_path
26
+
27
+
28
+ def initialize_database():
29
+ """Initialize the database with all required tables."""
30
+ print(f"Initializing database at: {DATABASE_URL}")
31
+
32
+ # Ensure database directory exists
33
+ db_path = ensure_database_directory()
34
+ print(f"Database path: {db_path}")
35
+
36
+ # Create tables
37
+ try:
38
+ create_tables()
39
+ print("✅ Database initialized successfully!")
40
+ return True
41
+ except Exception as e:
42
+ print(f"❌ Failed to initialize database: {e}")
43
+ return False
44
+
45
+
46
+ if __name__ == "__main__":
47
+ print("🔧 Database initialization script running...")
48
+
49
+ # Set environment variables for Hugging Face Spaces
50
+ os.environ.setdefault("DATABASE_URL", "sqlite:///./database/auth.db")
51
+
52
+ success = initialize_database()
53
+
54
+ if success:
55
+ print("✅ Database is ready for use!")
56
+ else:
57
+ print("❌ Database initialization failed!")
58
+ sys.exit(1)
main.py CHANGED
@@ -32,6 +32,7 @@ from middleware.auth import AuthMiddleware
32
 
33
  # Import auth routes
34
  from routes import auth
 
35
 
36
  # Import ChatKit server
37
  # from chatkit_server import get_chatkit_server
@@ -131,7 +132,18 @@ async def lifespan(app: FastAPI):
131
  global chat_handler, qdrant_manager, document_ingestor, task_manager
132
 
133
  # Create database tables on startup
134
- from database.config import create_tables
 
 
 
 
 
 
 
 
 
 
 
135
  create_tables()
136
 
137
  logger.info("Starting up RAG backend...",
@@ -225,7 +237,7 @@ app.add_middleware(
225
  httponly=False,
226
  samesite="lax",
227
  max_age=3600,
228
- exempt_paths=["/health", "/docs", "/openapi.json", "/ingest/status", "/collections"],
229
  )
230
 
231
  app.add_middleware(
@@ -238,6 +250,9 @@ app.add_middleware(
238
  # Include auth routes
239
  app.include_router(auth.router)
240
 
 
 
 
241
 
242
  # Optional API key security for higher rate limits
243
  security = HTTPBearer(auto_error=False)
@@ -424,13 +439,83 @@ async def chat_endpoint(
424
  )
425
  else:
426
  # Return complete response (non-streaming)
427
- response = await chat_handler.chat(
428
- query=query,
429
- session_id=session_id,
430
- k=k,
431
- context_window=context_window
432
- )
433
- return response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  except ContentNotFoundError as e:
435
  # Specific error for when no relevant content is found
436
  logger.warning(f"No content found for query: {query[:100]}...")
 
32
 
33
  # Import auth routes
34
  from routes import auth
35
+ from src.api.routes import chat
36
 
37
  # Import ChatKit server
38
  # from chatkit_server import get_chatkit_server
 
132
  global chat_handler, qdrant_manager, document_ingestor, task_manager
133
 
134
  # Create database tables on startup
135
+ from database.config import create_tables, engine, DATABASE_URL
136
+ import os
137
+ from pathlib import Path
138
+
139
+ # Ensure database directory exists
140
+ if "sqlite" in DATABASE_URL:
141
+ db_path = Path(DATABASE_URL.replace("sqlite:///", ""))
142
+ db_dir = db_path.parent
143
+ db_dir.mkdir(parents=True, exist_ok=True)
144
+ print(f"Database directory ensured: {db_dir}")
145
+
146
+ # Create tables
147
  create_tables()
148
 
149
  logger.info("Starting up RAG backend...",
 
237
  httponly=False,
238
  samesite="lax",
239
  max_age=3600,
240
+ exempt_paths=["/health", "/docs", "/openapi.json", "/ingest/status", "/collections", "/auth/login", "/auth/register", "/api/chat", "/auth/logout", "/auth/me", "/auth/preferences", "/auth/refresh"],
241
  )
242
 
243
  app.add_middleware(
 
250
  # Include auth routes
251
  app.include_router(auth.router)
252
 
253
+ # Include new chat routes
254
+ app.include_router(chat.router)
255
+
256
 
257
  # Optional API key security for higher rate limits
258
  security = HTTPBearer(auto_error=False)
 
439
  )
440
  else:
441
  # Return complete response (non-streaming)
442
+ # Create a helper to collect streaming response
443
+ async def collect_streaming_response():
444
+ collected_response = {
445
+ "answer": "",
446
+ "sources": [],
447
+ "session_id": session_id,
448
+ "query": query,
449
+ "response_time": 0,
450
+ "tokens_used": 0,
451
+ "context_used": False
452
+ }
453
+
454
+ response_chunks = []
455
+ try:
456
+ async for chunk in chat_handler.chat(
457
+ query=query,
458
+ session_id=session_id,
459
+ k=k,
460
+ context_window=context_window
461
+ ):
462
+ response_chunks.append(chunk)
463
+
464
+ # Parse SSE chunk
465
+ if chunk.startswith("data: "):
466
+ try:
467
+ import json
468
+ chunk_data = json.loads(chunk[6:]) # Remove "data: " prefix
469
+
470
+ # Handle different chunk types
471
+ if chunk_data.get("type") == "final":
472
+ # Handle sources serialization issue
473
+ if "sources" in chunk_data:
474
+ try:
475
+ # Convert Citation objects to serializable format
476
+ sources = []
477
+ for source in chunk_data["sources"]:
478
+ if hasattr(source, '__dict__'):
479
+ # Convert object to dict
480
+ source_dict = {
481
+ "id": getattr(source, 'id', ''),
482
+ "chunk_id": getattr(source, 'chunk_id', ''),
483
+ "document_id": getattr(source, 'document_id', ''),
484
+ "text_snippet": getattr(source, 'text_snippet', ''),
485
+ "relevance_score": getattr(source, 'relevance_score', 0),
486
+ "chapter": getattr(source, 'chapter', ''),
487
+ "section": getattr(source, 'section', ''),
488
+ "confidence": getattr(source, 'confidence', 0)
489
+ }
490
+ sources.append(source_dict)
491
+ else:
492
+ sources.append(source)
493
+ chunk_data["sources"] = sources
494
+ except Exception as serialize_error:
495
+ logger.warning(f"Failed to serialize sources: {serialize_error}")
496
+ chunk_data["sources"] = []
497
+
498
+ collected_response.update(chunk_data)
499
+ break
500
+ elif chunk_data.get("type") == "chunk":
501
+ collected_response["answer"] += chunk_data.get("content", "")
502
+ elif chunk_data.get("type") == "error":
503
+ collected_response["answer"] = chunk_data.get("message", "Error occurred")
504
+ break
505
+ except json.JSONDecodeError:
506
+ # If not JSON, treat as plain text
507
+ if chunk.startswith("data: "):
508
+ text_part = chunk[6:].strip()
509
+ collected_response["answer"] += text_part
510
+
511
+ except Exception as e:
512
+ logger.error(f"Error collecting streaming response: {e}")
513
+ collected_response["answer"] = "I encountered an error while processing your request."
514
+ collected_response["error"] = str(e)
515
+
516
+ return collected_response
517
+
518
+ return await collect_streaming_response()
519
  except ContentNotFoundError as e:
520
  # Specific error for when no relevant content is found
521
  logger.warning(f"No content found for query: {query[:100]}...")
middleware/auth.py CHANGED
@@ -13,7 +13,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
13
  from sqlalchemy.orm import Session
14
 
15
  from database.config import get_db
16
- from models.auth import Session as AuthSession
17
  from auth.auth import verify_token
18
 
19
 
 
13
  from sqlalchemy.orm import Session
14
 
15
  from database.config import get_db
16
+ from src.models.auth import Session as AuthSession
17
  from auth.auth import verify_token
18
 
19
 
models/auth.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from datetime import datetime
2
  from typing import Optional, Dict, Any
3
  from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON
@@ -13,6 +14,7 @@ class User(Base):
13
 
14
  id = Column(Integer, primary_key=True, index=True)
15
  email = Column(String, unique=True, index=True, nullable=False)
 
16
  name = Column(String, nullable=False)
17
  image_url = Column(String, nullable=True)
18
  email_verified = Column(Boolean, default=False, nullable=False)
@@ -100,4 +102,4 @@ class UserPreferences(Base):
100
  updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
101
 
102
  # Relationships
103
- user = relationship("User", back_populates="preferences")
 
1
+
2
  from datetime import datetime
3
  from typing import Optional, Dict, Any
4
  from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON
 
14
 
15
  id = Column(Integer, primary_key=True, index=True)
16
  email = Column(String, unique=True, index=True, nullable=False)
17
+ hashed_password = Column(String, nullable=False)
18
  name = Column(String, nullable=False)
19
  image_url = Column(String, nullable=True)
20
  email_verified = Column(Boolean, default=False, nullable=False)
 
102
  updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
103
 
104
  # Relationships
105
+ user = relationship("User", back_populates="preferences")
pyproject.toml CHANGED
@@ -10,7 +10,7 @@ readme = "README.md"
10
  requires-python = ">=3.11"
11
  license = {text = "MIT"}
12
  authors = [
13
- {name = "M. Owais Abdullah", email = "mrowaisabdullah@example.com"},
14
  ]
15
  keywords = ["rag", "retrieval", "openai", "qdrant", "fastapi", "robotics"]
16
  classifiers = [
@@ -62,6 +62,8 @@ dependencies = [
62
  # Monitoring and Performance
63
  "psutil>=5.9.6",
64
  "openai-chatkit>=1.4.0",
 
 
65
  ]
66
 
67
  [project.optional-dependencies]
 
10
  requires-python = ">=3.11"
11
  license = {text = "MIT"}
12
  authors = [
13
+ {name = "M. Owais Abdullah", email = "mrowaisabdullah@gmail.com"},
14
  ]
15
  keywords = ["rag", "retrieval", "openai", "qdrant", "fastapi", "robotics"]
16
  classifiers = [
 
62
  # Monitoring and Performance
63
  "psutil>=5.9.6",
64
  "openai-chatkit>=1.4.0",
65
+ "email-validator>=2.3.0",
66
+ "bcrypt==4.2.0",
67
  ]
68
 
69
  [project.optional-dependencies]
rag/chat.py CHANGED
@@ -540,11 +540,25 @@ class ChatHandler:
540
  # Calculate response time
541
  response_time = (datetime.utcnow() - start_time).total_seconds()
542
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  # Final response
544
  final_response = {
545
  "type": "final",
546
  "answer": answer,
547
- "sources": [citation.to_dict() if hasattr(citation, 'to_dict') else citation for citation in citations],
548
  "session_id": session_id,
549
  "query": query,
550
  "response_time": response_time,
 
540
  # Calculate response time
541
  response_time = (datetime.utcnow() - start_time).total_seconds()
542
 
543
+ # Helper function to serialize citations
544
+ def serialize_citation(citation):
545
+ """Convert Citation object to JSON-serializable dict."""
546
+ return {
547
+ "id": getattr(citation, 'id', ''),
548
+ "chunk_id": getattr(citation, 'chunk_id', ''),
549
+ "document_id": getattr(citation, 'document_id', ''),
550
+ "text_snippet": getattr(citation, 'text_snippet', ''),
551
+ "relevance_score": getattr(citation, 'relevance_score', 0),
552
+ "chapter": getattr(citation, 'chapter', ''),
553
+ "section": getattr(citation, 'section', ''),
554
+ "confidence": getattr(citation, 'confidence', 0)
555
+ }
556
+
557
  # Final response
558
  final_response = {
559
  "type": "final",
560
  "answer": answer,
561
+ "sources": [serialize_citation(citation) for citation in citations],
562
  "session_id": session_id,
563
  "query": query,
564
  "response_time": response_time,
requirements.txt CHANGED
@@ -12,6 +12,8 @@ aiofiles>=23.2.1
12
  slowapi>=0.1.9
13
  limits>=3.13.1
14
  python-multipart>=0.0.6
 
 
15
  python-dotenv>=1.0.0
16
  structlog>=23.2.0
17
  backoff>=2.2.1
 
12
  slowapi>=0.1.9
13
  limits>=3.13.1
14
  python-multipart>=0.0.6
15
+ aiosmtplib>=3.0.0
16
+ jinja2>=3.1.0
17
  python-dotenv>=1.0.0
18
  structlog>=23.2.0
19
  backoff>=2.2.1
routes/auth.py CHANGED
@@ -1,4 +1,5 @@
1
- from typing import Optional
 
2
  from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
3
  from fastapi.responses import RedirectResponse
4
  from sqlalchemy.orm import Session
@@ -9,9 +10,11 @@ from database.config import get_db
9
  from auth.auth import (
10
  oauth, create_access_token, get_or_create_user,
11
  create_or_update_account, create_user_session,
12
- invalidate_user_sessions, get_current_active_user
 
13
  )
14
- from models.auth import User, UserPreferences
 
15
  from slowapi import Limiter
16
  from slowapi.util import get_remote_address
17
 
@@ -23,7 +26,7 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
23
 
24
  # Pydantic models
25
  class UserResponse(BaseModel):
26
- id: int
27
  email: str
28
  name: str
29
  image_url: Optional[str] = None
@@ -37,6 +40,8 @@ class LoginResponse(BaseModel):
37
  user: UserResponse
38
  access_token: str
39
  token_type: str = "bearer"
 
 
40
 
41
 
42
  class PreferencesResponse(BaseModel):
@@ -48,6 +53,129 @@ class PreferencesResponse(BaseModel):
48
  class Config:
49
  from_attributes = True
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
  @router.get("/login/google")
53
  @limiter.limit("10/minute") # Limit OAuth initiation attempts
@@ -231,4 +359,4 @@ async def refresh_token(
231
  return {
232
  "access_token": access_token,
233
  "token_type": "bearer"
234
- }
 
1
+
2
+ from typing import Optional, Dict, Any
3
  from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
4
  from fastapi.responses import RedirectResponse
5
  from sqlalchemy.orm import Session
 
10
  from auth.auth import (
11
  oauth, create_access_token, get_or_create_user,
12
  create_or_update_account, create_user_session,
13
+ invalidate_user_sessions, get_current_active_user,
14
+ verify_password, get_password_hash
15
  )
16
+ from src.models.auth import User, UserPreferences
17
+ from src.services.session_migration import SessionMigrationService, migrate_anonymous_session_on_auth
18
  from slowapi import Limiter
19
  from slowapi.util import get_remote_address
20
 
 
26
 
27
  # Pydantic models
28
  class UserResponse(BaseModel):
29
+ id: str
30
  email: str
31
  name: str
32
  image_url: Optional[str] = None
 
40
  user: UserResponse
41
  access_token: str
42
  token_type: str = "bearer"
43
+ migrated_sessions: Optional[int] = 0
44
+ migrated_messages: Optional[int] = 0
45
 
46
 
47
  class PreferencesResponse(BaseModel):
 
53
  class Config:
54
  from_attributes = True
55
 
56
+ class RegisterRequest(BaseModel):
57
+ email: str
58
+ password: str
59
+ name: str
60
+
61
+ class LoginRequest(BaseModel):
62
+ email: str
63
+ password: str
64
+
65
+ @router.post("/register")
66
+ @limiter.limit("5/minute")
67
+ async def register(request: Request, register_data: RegisterRequest, db: Session = Depends(get_db)):
68
+ """Register a new user"""
69
+ # Check if user already exists
70
+ existing_user = db.query(User).filter(User.email == register_data.email).first()
71
+ if existing_user:
72
+ raise HTTPException(
73
+ status_code=status.HTTP_400_BAD_REQUEST,
74
+ detail="Email already registered"
75
+ )
76
+
77
+ # Hash password
78
+ hashed_password = get_password_hash(register_data.password)
79
+
80
+ # Create user
81
+ new_user = User(
82
+ email=register_data.email,
83
+ password_hash=hashed_password,
84
+ name=register_data.name,
85
+ email_verified=False
86
+ )
87
+ db.add(new_user)
88
+ db.commit()
89
+ db.refresh(new_user)
90
+
91
+ # Create default preferences
92
+ preferences = UserPreferences(user_id=new_user.id)
93
+ db.add(preferences)
94
+ db.commit()
95
+
96
+ # Check for anonymous session to migrate
97
+ anonymous_session_id = request.headers.get("X-Anonymous-Session-ID")
98
+ migrated_sessions = 0
99
+ migrated_messages = 0
100
+
101
+ if anonymous_session_id:
102
+ try:
103
+ migration_service = SessionMigrationService(db)
104
+ migration_result = migration_service.migrate_anonymous_session(
105
+ anonymous_session_id=anonymous_session_id,
106
+ authenticated_user_id=str(new_user.id)
107
+ )
108
+ if migration_result["success"]:
109
+ migrated_sessions = migration_result["migrated_sessions_count"]
110
+ migrated_messages = migration_result["migrated_messages_count"]
111
+ except Exception as e:
112
+ # Log error but don't fail registration
113
+ print(f"Session migration failed during registration: {e}")
114
+
115
+ # Create session
116
+ session_token = create_user_session(db, new_user)
117
+ access_token = create_access_token(data={"sub": str(new_user.id), "email": new_user.email})
118
+
119
+ return LoginResponse(
120
+ user=UserResponse(
121
+ id=new_user.id,
122
+ email=new_user.email,
123
+ name=new_user.name,
124
+ image_url=new_user.image_url,
125
+ email_verified=new_user.email_verified
126
+ ),
127
+ access_token=access_token,
128
+ migrated_sessions=migrated_sessions,
129
+ migrated_messages=migrated_messages
130
+ )
131
+
132
+ @router.post("/login")
133
+ @limiter.limit("10/minute")
134
+ async def login(request: Request, login_data: LoginRequest, db: Session = Depends(get_db)):
135
+ """Login with email and password"""
136
+ user = db.query(User).filter(User.email == login_data.email).first()
137
+ if not user or not verify_password(login_data.password, user.password_hash):
138
+ raise HTTPException(
139
+ status_code=status.HTTP_401_UNAUTHORIZED,
140
+ detail="Incorrect email or password",
141
+ headers={"WWW-Authenticate": "Bearer"},
142
+ )
143
+
144
+ # Check for anonymous session to migrate
145
+ anonymous_session_id = request.headers.get("X-Anonymous-Session-ID")
146
+ migrated_sessions = 0
147
+ migrated_messages = 0
148
+
149
+ if anonymous_session_id:
150
+ try:
151
+ migration_service = SessionMigrationService(db)
152
+ migration_result = migration_service.migrate_anonymous_session(
153
+ anonymous_session_id=anonymous_session_id,
154
+ authenticated_user_id=str(user.id)
155
+ )
156
+ if migration_result["success"]:
157
+ migrated_sessions = migration_result["migrated_sessions_count"]
158
+ migrated_messages = migration_result["migrated_messages_count"]
159
+ except Exception as e:
160
+ # Log error but don't fail login
161
+ print(f"Session migration failed during login: {e}")
162
+
163
+ # Create session
164
+ session_token = create_user_session(db, user)
165
+ access_token = create_access_token(data={"sub": str(user.id), "email": user.email})
166
+
167
+ return LoginResponse(
168
+ user=UserResponse(
169
+ id=user.id,
170
+ email=user.email,
171
+ name=user.name,
172
+ image_url=user.image_url,
173
+ email_verified=user.email_verified
174
+ ),
175
+ access_token=access_token,
176
+ migrated_sessions=migrated_sessions,
177
+ migrated_messages=migrated_messages
178
+ )
179
 
180
  @router.get("/login/google")
181
  @limiter.limit("10/minute") # Limit OAuth initiation attempts
 
359
  return {
360
  "access_token": access_token,
361
  "token_type": "bearer"
362
+ }
src/api/routes/auth.py ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication API routes.
3
+
4
+ This module provides endpoints for user registration, login, logout,
5
+ and password management.
6
+ """
7
+
8
+ from datetime import datetime, timedelta
9
+ from typing import Any
10
+ from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
11
+ from fastapi.security import OAuth2PasswordRequestForm
12
+ from sqlalchemy.orm import Session
13
+
14
+ from src.database.config import get_db
15
+ from src.models.auth import User, Session as UserSession, AnonymousSession
16
+ from src.schemas.auth import (
17
+ User as UserSchema,
18
+ UserCreate,
19
+ UserResponse,
20
+ Token,
21
+ LoginRequest,
22
+ LoginResponse,
23
+ PasswordResetRequest,
24
+ PasswordResetConfirm,
25
+ SuccessResponse,
26
+ ErrorResponse
27
+ )
28
+ from src.services.auth import (
29
+ verify_password,
30
+ get_password_hash,
31
+ create_access_token,
32
+ create_token_hash,
33
+ generate_password_reset_token,
34
+ verify_password_reset_token,
35
+ validate_password_strength
36
+ )
37
+ from src.services.email import email_service
38
+ from src.security.dependencies import get_current_user, get_current_active_user, require_auth
39
+
40
+ router = APIRouter(prefix="/auth", tags=["authentication"])
41
+
42
+
43
+ @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
44
+ async def register(
45
+ user_data: UserCreate,
46
+ response: Response,
47
+ db: Session = Depends(get_db)
48
+ ) -> Any:
49
+ """
50
+ Register a new user with email and password.
51
+
52
+ Args:
53
+ user_data: User registration data
54
+ response: FastAPI response object for setting cookies
55
+ db: Database session
56
+
57
+ Returns:
58
+ Created user data
59
+
60
+ Raises:
61
+ HTTPException: If user already exists or validation fails
62
+ """
63
+ # Check if user already exists
64
+ existing_user = db.query(User).filter(User.email == user_data.email).first()
65
+ if existing_user:
66
+ raise HTTPException(
67
+ status_code=status.HTTP_400_BAD_REQUEST,
68
+ detail="Email already registered"
69
+ )
70
+
71
+ # Validate password strength
72
+ if not validate_password_strength(user_data.password):
73
+ raise HTTPException(
74
+ status_code=status.HTTP_400_BAD_REQUEST,
75
+ detail="Password must be at least 8 characters long and contain letters and numbers"
76
+ )
77
+
78
+ # Create new user
79
+ hashed_password = get_password_hash(user_data.password)
80
+ db_user = User(
81
+ email=user_data.email,
82
+ password_hash=hashed_password,
83
+ name=user_data.name,
84
+ email_verified=False # Will be verified via email
85
+ )
86
+ db.add(db_user)
87
+ db.commit()
88
+ db.refresh(db_user)
89
+
90
+ # Create user background if provided
91
+ if user_data.software_experience or user_data.hardware_expertise or user_data.years_of_experience is not None:
92
+ from src.models.auth import UserBackground
93
+ background = UserBackground(
94
+ user_id=db_user.id,
95
+ experience_level=user_data.software_experience or "Beginner",
96
+ hardware_expertise=user_data.hardware_expertise or "None",
97
+ years_of_experience=user_data.years_of_experience or 0
98
+ )
99
+ db.add(background)
100
+ db.commit()
101
+
102
+ # Create access token
103
+ access_token_expires = timedelta(minutes=10080) # 7 days
104
+ access_token = create_access_token(
105
+ data={"sub": str(db_user.id)},
106
+ expires_delta=access_token_expires
107
+ )
108
+
109
+ # Create session record
110
+ user_session = UserSession(
111
+ user_id=db_user.id,
112
+ token_hash=create_token_hash(access_token),
113
+ expires_at=datetime.utcnow() + access_token_expires
114
+ )
115
+ db.add(user_session)
116
+ db.commit()
117
+
118
+ # Set HTTP-only cookie
119
+ response.set_cookie(
120
+ key="access_token",
121
+ value=access_token,
122
+ max_age=access_token_expires.total_seconds(),
123
+ httponly=True,
124
+ samesite="lax",
125
+ secure=False # Set to True in production with HTTPS
126
+ )
127
+
128
+ # TODO: Send email verification
129
+ # if email_service.is_configured():
130
+ # verification_token = generate_password_reset_token(db_user.email)
131
+ # await email_service.send_verification_email(
132
+ # db_user.email,
133
+ # verification_token,
134
+ # os.getenv("FRONTEND_URL", "http://localhost:3000")
135
+ # )
136
+
137
+ return db_user
138
+
139
+
140
+ @router.post("/login", response_model=LoginResponse)
141
+ async def login(
142
+ response: Response,
143
+ db: Session = Depends(get_db),
144
+ form_data: OAuth2PasswordRequestForm = Depends()
145
+ ) -> Any:
146
+ """
147
+ Authenticate user with email and password.
148
+
149
+ Args:
150
+ response: FastAPI response object for setting cookies
151
+ db: Database session
152
+ form_data: OAuth2PasswordRequestForm with username (email) and password
153
+
154
+ Returns:
155
+ Login response with token and user data
156
+
157
+ Raises:
158
+ HTTPException: If authentication fails
159
+ """
160
+ # Find user by email
161
+ user = db.query(User).filter(User.email == form_data.username).first()
162
+ if not user or not verify_password(form_data.password, user.password_hash):
163
+ raise HTTPException(
164
+ status_code=status.HTTP_401_UNAUTHORIZED,
165
+ detail="Incorrect email or password",
166
+ headers={"WWW-Authenticate": "Bearer"},
167
+ )
168
+
169
+ # Create access token
170
+ access_token_expires = timedelta(minutes=10080) # 7 days
171
+ access_token = create_access_token(
172
+ data={"sub": str(user.id)},
173
+ expires_delta=access_token_expires
174
+ )
175
+
176
+ # Invalidate previous sessions (single session enforcement)
177
+ db.query(UserSession).filter(
178
+ UserSession.user_id == user.id,
179
+ UserSession.expires_at > datetime.utcnow()
180
+ ).update({"expires_at": datetime.utcnow()})
181
+ db.commit()
182
+
183
+ # Create new session
184
+ user_session = UserSession(
185
+ user_id=user.id,
186
+ token_hash=create_token_hash(access_token),
187
+ expires_at=datetime.utcnow() + access_token_expires
188
+ )
189
+ db.add(user_session)
190
+ db.commit()
191
+
192
+ # Set HTTP-only cookie
193
+ response.set_cookie(
194
+ key="access_token",
195
+ value=access_token,
196
+ max_age=access_token_expires.total_seconds(),
197
+ httponly=True,
198
+ samesite="lax",
199
+ secure=False # Set to True in production with HTTPS
200
+ )
201
+
202
+ return {
203
+ "access_token": access_token,
204
+ "token_type": "bearer",
205
+ "expires_in": int(access_token_expires.total_seconds()),
206
+ "user": user
207
+ }
208
+
209
+
210
+ @router.post("/logout", response_model=SuccessResponse)
211
+ async def logout(
212
+ response: Response,
213
+ current_user: UserSchema = Depends(get_current_active_user),
214
+ db: Session = Depends(get_db)
215
+ ) -> Any:
216
+ """
217
+ Logout user and invalidate session.
218
+
219
+ Args:
220
+ response: FastAPI response object for clearing cookies
221
+ current_user: Currently authenticated user
222
+ db: Database session
223
+
224
+ Returns:
225
+ Success response
226
+ """
227
+ # Invalidate all user sessions
228
+ db.query(UserSession).filter(
229
+ UserSession.user_id == current_user.id
230
+ ).update({"expires_at": datetime.utcnow()})
231
+ db.commit()
232
+
233
+ # Clear cookie
234
+ response.delete_cookie(key="access_token")
235
+
236
+ return {"success": True, "message": "Successfully logged out"}
237
+
238
+
239
+ @router.get("/me", response_model=UserResponse)
240
+ async def get_current_user_info(
241
+ current_user: UserSchema = Depends(get_current_active_user)
242
+ ) -> Any:
243
+ """
244
+ Get current user information.
245
+
246
+ Args:
247
+ current_user: Currently authenticated user
248
+
249
+ Returns:
250
+ Current user data
251
+ """
252
+ return current_user
253
+
254
+
255
+ @router.post("/refresh")
256
+ async def refresh_token(
257
+ current_user: UserSchema = Depends(get_current_active_user),
258
+ response: Response = None
259
+ ) -> Any:
260
+ """
261
+ Refresh authentication token.
262
+
263
+ Args:
264
+ current_user: Currently authenticated user
265
+ response: FastAPI response object for setting cookies
266
+
267
+ Returns:
268
+ New access token
269
+
270
+ Raises:
271
+ HTTPException: If refresh fails
272
+ """
273
+ # Create new access token
274
+ access_token_expires = timedelta(minutes=10080) # 7 days
275
+ access_token = create_access_token(
276
+ data={"sub": str(current_user.id)},
277
+ expires_delta=access_token_expires
278
+ )
279
+
280
+ # Set HTTP-only cookie if response is provided
281
+ if response:
282
+ response.set_cookie(
283
+ key="access_token",
284
+ value=access_token,
285
+ max_age=access_token_expires.total_seconds(),
286
+ httponly=True,
287
+ samesite="lax",
288
+ secure=False # Set to True in production with HTTPS
289
+ )
290
+
291
+ return {"token": access_token}
292
+
293
+
294
+ @router.get("/anonymous-session/{session_id}")
295
+ async def get_anonymous_session(
296
+ session_id: str,
297
+ db: Session = Depends(get_db)
298
+ ) -> Any:
299
+ """
300
+ Get anonymous session data including message count.
301
+
302
+ Args:
303
+ session_id: Anonymous session ID from localStorage
304
+ db: Database session
305
+
306
+ Returns:
307
+ Anonymous session data with message count and existence flag
308
+ """
309
+ # Query for existing session
310
+ session = db.query(AnonymousSession).filter(
311
+ AnonymousSession.id == session_id
312
+ ).first()
313
+
314
+ if not session:
315
+ # Return new session data if not found
316
+ return {
317
+ "id": session_id,
318
+ "message_count": 0,
319
+ "exists": False
320
+ }
321
+
322
+ # Return existing session data
323
+ return {
324
+ "id": session.id,
325
+ "message_count": session.message_count,
326
+ "exists": True
327
+ }
328
+
329
+
330
+ @router.post("/password-reset/request", response_model=SuccessResponse)
331
+ async def request_password_reset(
332
+ request_data: PasswordResetRequest,
333
+ db: Session = Depends(get_db)
334
+ ) -> Any:
335
+ """
336
+ Request password reset email.
337
+
338
+ Args:
339
+ request_data: Email for password reset
340
+ db: Database session
341
+
342
+ Returns:
343
+ Success response
344
+ """
345
+ user = db.query(User).filter(User.email == request_data.email).first()
346
+ if not user:
347
+ # Don't reveal if user exists
348
+ return {"success": True, "message": "If the email exists, a reset link has been sent"}
349
+
350
+ # Generate reset token
351
+ reset_token = generate_password_reset_token(user.email)
352
+
353
+ # Save reset token
354
+ from src.models.auth import PasswordResetToken
355
+ password_reset_token = PasswordResetToken(
356
+ user_id=user.id,
357
+ token=reset_token,
358
+ expires_at=datetime.utcnow() + timedelta(hours=24)
359
+ )
360
+ db.add(password_reset_token)
361
+
362
+ # Invalidate previous tokens
363
+ db.query(PasswordResetToken).filter(
364
+ PasswordResetToken.user_id == user.id,
365
+ PasswordResetToken.used == False,
366
+ PasswordResetToken.expires_at > datetime.utcnow()
367
+ ).update({"used": True})
368
+ db.commit()
369
+
370
+ # Send email
371
+ if email_service.is_configured():
372
+ frontend_url = "http://localhost:3000" # TODO: Get from environment
373
+ await email_service.send_password_reset_email_async(
374
+ user.email,
375
+ reset_token,
376
+ frontend_url
377
+ )
378
+
379
+ return {"success": True, "message": "If the email exists, a reset link has been sent"}
380
+
381
+
382
+ @router.post("/password-reset/confirm", response_model=SuccessResponse)
383
+ async def confirm_password_reset(
384
+ request_data: PasswordResetConfirm,
385
+ db: Session = Depends(get_db)
386
+ ) -> Any:
387
+ """
388
+ Confirm password reset with token.
389
+
390
+ Args:
391
+ request_data: Token and new password
392
+ db: Database session
393
+
394
+ Returns:
395
+ Success response
396
+
397
+ Raises:
398
+ HTTPException: If token is invalid or expired
399
+ """
400
+ # Verify token
401
+ email = verify_password_reset_token(request_data.token)
402
+ if not email:
403
+ raise HTTPException(
404
+ status_code=status.HTTP_400_BAD_REQUEST,
405
+ detail="Invalid or expired reset token"
406
+ )
407
+
408
+ # Get user
409
+ user = db.query(User).filter(User.email == email).first()
410
+ if not user:
411
+ raise HTTPException(
412
+ status_code=status.HTTP_400_BAD_REQUEST,
413
+ detail="Invalid or expired reset token"
414
+ )
415
+
416
+ # Get and validate token record
417
+ from src.models.auth import PasswordResetToken
418
+ token_record = db.query(PasswordResetToken).filter(
419
+ PasswordResetToken.token == request_data.token,
420
+ PasswordResetToken.user_id == user.id,
421
+ PasswordResetToken.used == False
422
+ ).first()
423
+
424
+ if not token_record or token_record.expires_at < datetime.utcnow():
425
+ raise HTTPException(
426
+ status_code=status.HTTP_400_BAD_REQUEST,
427
+ detail="Invalid or expired reset token"
428
+ )
429
+
430
+ # Validate new password
431
+ if not validate_password_strength(request_data.new_password):
432
+ raise HTTPException(
433
+ status_code=status.HTTP_400_BAD_REQUEST,
434
+ detail="Password must be at least 8 characters long and contain letters and numbers"
435
+ )
436
+
437
+ # Update password
438
+ user.password_hash = get_password_hash(request_data.new_password)
439
+ user.updated_at = datetime.utcnow()
440
+
441
+ # Mark token as used
442
+ token_record.used = True
443
+
444
+ # Invalidate all user sessions (force re-login)
445
+ db.query(UserSession).filter(UserSession.user_id == user.id).update(
446
+ {"expires_at": datetime.utcnow()}
447
+ )
448
+
449
+ db.commit()
450
+
451
+ return {"success": True, "message": "Password has been reset successfully"}
src/api/routes/chat.py ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat API routes with authentication support.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Any, Dict, Optional
7
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
8
+ from sqlalchemy.orm import Session
9
+ from pydantic import BaseModel
10
+ import json
11
+ import uuid
12
+ import logging
13
+ from slowapi import Limiter
14
+ from slowapi.util import get_remote_address
15
+
16
+ from src.database.config import get_db
17
+ from src.models.auth import User, ChatSession, ChatMessage, AnonymousSession
18
+ from src.security.dependencies import get_current_user, get_current_user_or_anonymous
19
+ from src.services.message_editor import MessageEditorService
20
+ from rag.chat import ChatHandler
21
+
22
+ router = APIRouter(prefix="/api/chat", tags=["chat"])
23
+
24
+ # Rate limiter
25
+ limiter = Limiter(key_func=get_remote_address)
26
+
27
+ # Logger
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class ChatRequest(BaseModel):
32
+ """Chat request model."""
33
+ message: str
34
+ session_id: Optional[str] = None
35
+ context_window: Optional[int] = None
36
+ k: Optional[int] = 5
37
+ stream: bool = True
38
+
39
+
40
+ class ChatSessionResponse(BaseModel):
41
+ """Chat session response model."""
42
+ id: str
43
+ title: str
44
+ created_at: datetime
45
+ updated_at: datetime
46
+ messages: list = []
47
+
48
+
49
+ @router.post("/send")
50
+ @limiter.limit("20/minute")
51
+ async def send_message(
52
+ request: Request,
53
+ chat_request: ChatRequest,
54
+ db: Session = Depends(get_db),
55
+ current_user: User = Depends(get_current_user_or_anonymous)
56
+ ):
57
+ """Send a chat message with authentication support."""
58
+
59
+ # Get the global chat_handler from main module
60
+ import sys
61
+ import os
62
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))))
63
+ from main import chat_handler
64
+
65
+ if not chat_handler:
66
+ raise HTTPException(
67
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
68
+ detail="Chat service not initialized"
69
+ )
70
+
71
+ # Check if user is anonymous and has exceeded limit
72
+ if not current_user.is_authenticated:
73
+ # Get or create anonymous session
74
+ anon_session_id = request.headers.get("X-Anonymous-Session-ID")
75
+ if anon_session_id:
76
+ anon_session = db.query(AnonymousSession).filter(
77
+ AnonymousSession.id == anon_session_id
78
+ ).first()
79
+ else:
80
+ anon_session = None
81
+
82
+ if not anon_session:
83
+ # Create new anonymous session
84
+ anon_session = AnonymousSession(
85
+ id=str(uuid.uuid4()),
86
+ message_count=0
87
+ )
88
+ db.add(anon_session)
89
+ db.commit()
90
+
91
+ # Check message limit
92
+ if anon_session.message_count >= 3:
93
+ raise HTTPException(
94
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
95
+ detail="Anonymous users are limited to 3 messages. Please sign in to continue."
96
+ )
97
+
98
+ # Increment message count
99
+ anon_session.message_count += 1
100
+ anon_session.last_activity = datetime.utcnow()
101
+ db.commit()
102
+
103
+ # Get or create chat session
104
+ session_id = chat_request.session_id
105
+ chat_session = None
106
+
107
+ if session_id:
108
+ # Try to find existing session
109
+ chat_session = db.query(ChatSession).filter(
110
+ ChatSession.id == session_id
111
+ ).first()
112
+
113
+ if not chat_session:
114
+ # Create new chat session
115
+ chat_session = ChatSession(
116
+ id=str(uuid.uuid4()),
117
+ user_id=current_user.id if current_user.is_authenticated else None,
118
+ anonymous_session_id=anon_session.id if not current_user.is_authenticated and anon_session else None,
119
+ title="New Chat"
120
+ )
121
+ db.add(chat_session)
122
+ db.commit()
123
+ db.refresh(chat_session)
124
+
125
+ # Save user message
126
+ user_message = ChatMessage(
127
+ chat_session_id=chat_session.id,
128
+ role="user",
129
+ content=chat_request.message,
130
+ created_at=datetime.utcnow()
131
+ )
132
+ db.add(user_message)
133
+ db.commit()
134
+
135
+ # Process the message through RAG system
136
+ try:
137
+ # Get session ID for context
138
+ session_id_for_context = chat_request.session_id if chat_request.session_id else str(chat_session.id)
139
+
140
+ # Get response from chat handler
141
+ response = await chat_handler.chat(
142
+ query=chat_request.message,
143
+ session_id=session_id_for_context,
144
+ k=chat_request.k,
145
+ context_window=chat_request.context_window
146
+ )
147
+
148
+ # Extract the response content
149
+ ai_content = response.get("answer", "I'm sorry, I couldn't process your request.")
150
+
151
+ # Create AI response
152
+ ai_response = ChatMessage(
153
+ chat_session_id=chat_session.id,
154
+ role="assistant",
155
+ content=ai_content,
156
+ created_at=datetime.utcnow()
157
+ )
158
+ db.add(ai_response)
159
+ db.commit()
160
+
161
+ except Exception as e:
162
+ # If RAG fails, provide a fallback response
163
+ logger.error(f"RAG processing failed: {str(e)}")
164
+ ai_response = ChatMessage(
165
+ chat_session_id=chat_session.id,
166
+ role="assistant",
167
+ content="I'm having trouble accessing the book content right now. Please try again later.",
168
+ created_at=datetime.utcnow()
169
+ )
170
+ db.add(ai_response)
171
+ db.commit()
172
+
173
+ # Update session timestamp
174
+ chat_session.updated_at = datetime.utcnow()
175
+ db.commit()
176
+
177
+ # Get session messages
178
+ messages = db.query(ChatMessage).filter(
179
+ ChatMessage.chat_session_id == chat_session.id
180
+ ).order_by(ChatMessage.created_at).all()
181
+
182
+ return {
183
+ "session_id": chat_session.id,
184
+ "messages": [
185
+ {
186
+ "id": msg.id,
187
+ "role": msg.role,
188
+ "content": msg.content,
189
+ "created_at": msg.created_at.isoformat()
190
+ }
191
+ for msg in messages
192
+ ],
193
+ "title": chat_session.title
194
+ }
195
+
196
+
197
+ @router.get("/sessions", response_model=list[ChatSessionResponse])
198
+ async def get_chat_sessions(
199
+ db: Session = Depends(get_db),
200
+ current_user: User = Depends(get_current_user)
201
+ ):
202
+ """Get user's chat sessions."""
203
+ if not current_user.is_authenticated:
204
+ raise HTTPException(
205
+ status_code=status.HTTP_401_UNAUTHORIZED,
206
+ detail="Authentication required"
207
+ )
208
+
209
+ sessions = db.query(ChatSession).filter(
210
+ ChatSession.user_id == current_user.id
211
+ ).order_by(ChatSession.updated_at.desc()).all()
212
+
213
+ return [
214
+ {
215
+ "id": session.id,
216
+ "title": session.title,
217
+ "created_at": session.created_at,
218
+ "updated_at": session.updated_at,
219
+ "messages": [] # Messages loaded separately
220
+ }
221
+ for session in sessions
222
+ ]
223
+
224
+
225
+ @router.get("/sessions/{session_id}", response_model=ChatSessionResponse)
226
+ async def get_chat_session(
227
+ session_id: str,
228
+ db: Session = Depends(get_db),
229
+ current_user: User = Depends(get_current_user)
230
+ ):
231
+ """Get a specific chat session with messages."""
232
+ if not current_user.is_authenticated:
233
+ raise HTTPException(
234
+ status_code=status.HTTP_401_UNAUTHORIZED,
235
+ detail="Authentication required"
236
+ )
237
+
238
+ # Check session ownership
239
+ session = db.query(ChatSession).filter(
240
+ ChatSession.id == session_id,
241
+ ChatSession.user_id == current_user.id
242
+ ).first()
243
+
244
+ if not session:
245
+ raise HTTPException(
246
+ status_code=status.HTTP_404_NOT_FOUND,
247
+ detail="Chat session not found"
248
+ )
249
+
250
+ # Get messages
251
+ messages = db.query(ChatMessage).filter(
252
+ ChatMessage.chat_session_id == session_id
253
+ ).order_by(ChatMessage.created_at).all()
254
+
255
+ return {
256
+ "id": session.id,
257
+ "title": session.title,
258
+ "created_at": session.created_at,
259
+ "updated_at": session.updated_at,
260
+ "messages": [
261
+ {
262
+ "id": msg.id,
263
+ "role": msg.role,
264
+ "content": msg.content,
265
+ "created_at": msg.created_at.isoformat()
266
+ }
267
+ for msg in messages
268
+ ]
269
+ }
270
+
271
+
272
+ @router.put("/sessions/{session_id}")
273
+ async def update_chat_session(
274
+ session_id: str,
275
+ session_data: Dict[str, Any],
276
+ db: Session = Depends(get_db),
277
+ current_user: User = Depends(get_current_user)
278
+ ):
279
+ """Update a chat session (e.g., title)."""
280
+ if not current_user.is_authenticated:
281
+ raise HTTPException(
282
+ status_code=status.HTTP_401_UNAUTHORIZED,
283
+ detail="Authentication required"
284
+ )
285
+
286
+ # Check session ownership
287
+ session = db.query(ChatSession).filter(
288
+ ChatSession.id == session_id,
289
+ ChatSession.user_id == current_user.id
290
+ ).first()
291
+
292
+ if not session:
293
+ raise HTTPException(
294
+ status_code=status.HTTP_404_NOT_FOUND,
295
+ detail="Chat session not found"
296
+ )
297
+
298
+ # Update allowed fields
299
+ if "title" in session_data:
300
+ session.title = session_data["title"]
301
+
302
+ session.updated_at = datetime.utcnow()
303
+ db.commit()
304
+ db.refresh(session)
305
+
306
+ return {
307
+ "id": session.id,
308
+ "title": session.title,
309
+ "created_at": session.created_at,
310
+ "updated_at": session.updated_at,
311
+ "messages": []
312
+ }
313
+
314
+
315
+ @router.delete("/sessions/{session_id}")
316
+ async def delete_chat_session(
317
+ session_id: str,
318
+ db: Session = Depends(get_db),
319
+ current_user: User = Depends(get_current_user)
320
+ ):
321
+ """Delete a chat session."""
322
+ if not current_user.is_authenticated:
323
+ raise HTTPException(
324
+ status_code=status.HTTP_401_UNAUTHORIZED,
325
+ detail="Authentication required"
326
+ )
327
+
328
+ # Check session ownership
329
+ session = db.query(ChatSession).filter(
330
+ ChatSession.id == session_id,
331
+ ChatSession.user_id == current_user.id
332
+ ).first()
333
+
334
+ if not session:
335
+ raise HTTPException(
336
+ status_code=status.HTTP_404_NOT_FOUND,
337
+ detail="Chat session not found"
338
+ )
339
+
340
+ # Delete session (messages will be deleted via cascade)
341
+ db.delete(session)
342
+ db.commit()
343
+
344
+ return {"message": "Chat session deleted successfully"}
345
+
346
+
347
+ @router.get("/anonymous/session-info")
348
+ async def get_anonymous_session_info(
349
+ request: Request,
350
+ db: Session = Depends(get_db)
351
+ ):
352
+ """Get information about an anonymous session for migration preview."""
353
+ anonymous_session_id = request.headers.get("X-Anonymous-Session-ID")
354
+
355
+ if not anonymous_session_id:
356
+ raise HTTPException(
357
+ status_code=status.HTTP_400_BAD_REQUEST,
358
+ detail="Anonymous session ID required"
359
+ )
360
+
361
+ from src.services.session_migration import SessionMigrationService
362
+ migration_service = SessionMigrationService(db)
363
+
364
+ session_info = migration_service.get_anonymous_session_info(anonymous_session_id)
365
+
366
+ if not session_info:
367
+ raise HTTPException(
368
+ status_code=status.HTTP_404_NOT_FOUND,
369
+ detail="Anonymous session not found"
370
+ )
371
+
372
+ return session_info
373
+
374
+
375
+ @router.put("/messages/{message_id}/edit")
376
+ @limiter.limit("30/minute")
377
+ async def edit_message(
378
+ request: Request,
379
+ message_id: str,
380
+ edit_data: Dict[str, Any],
381
+ db: Session = Depends(get_db),
382
+ current_user: User = Depends(get_current_user)
383
+ ):
384
+ """Edit a chat message (only user messages within 15 minutes)."""
385
+
386
+ # Validate edit data
387
+ new_content = edit_data.get("content")
388
+ if not new_content or not new_content.strip():
389
+ raise HTTPException(
390
+ status_code=status.HTTP_400_BAD_REQUEST,
391
+ detail="Content is required and cannot be empty"
392
+ )
393
+
394
+ # Initialize the message editor service
395
+ editor_service = MessageEditorService(db)
396
+
397
+ # Check if message can be edited
398
+ can_edit = editor_service.can_edit_message(message_id, str(current_user.id))
399
+ if not can_edit["can_edit"]:
400
+ raise HTTPException(
401
+ status_code=status.HTTP_403_FORBIDDEN,
402
+ detail=can_edit["reason"]
403
+ )
404
+
405
+ # Edit the message
406
+ result = editor_service.edit_message(
407
+ message_id=message_id,
408
+ new_content=new_content.strip(),
409
+ user_id=str(current_user.id),
410
+ edit_reason=edit_data.get("reason")
411
+ )
412
+
413
+ if not result["success"]:
414
+ raise HTTPException(
415
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
416
+ detail=result.get("error", "Failed to edit message")
417
+ )
418
+
419
+ return result
420
+
421
+
422
+ @router.get("/messages/{message_id}/versions")
423
+ @limiter.limit("60/minute")
424
+ async def get_message_versions(
425
+ request: Request,
426
+ message_id: str,
427
+ db: Session = Depends(get_db),
428
+ current_user: User = Depends(get_current_user)
429
+ ):
430
+ """Get version history of a message."""
431
+
432
+ editor_service = MessageEditorService(db)
433
+ result = editor_service.get_message_versions(message_id, str(current_user.id))
434
+
435
+ if not result["success"]:
436
+ raise HTTPException(
437
+ status_code=status.HTTP_404_NOT_FOUND,
438
+ detail=result.get("error", "Message not found")
439
+ )
440
+
441
+ return result
442
+
443
+
444
+ class ChatSearchRequest(BaseModel):
445
+ """Chat search request model."""
446
+ query: str
447
+ session_id: Optional[str] = None
448
+ limit: Optional[int] = 50
449
+ offset: Optional[int] = 0
450
+ date_from: Optional[str] = None
451
+ date_to: Optional[str] = None
452
+ message_type: Optional[str] = None # "user", "assistant", or None for all
453
+
454
+
455
+ @router.post("/search")
456
+ @limiter.limit("100/minute")
457
+ async def search_chat_messages(
458
+ request: Request,
459
+ search_request: ChatSearchRequest,
460
+ db: Session = Depends(get_db),
461
+ current_user: User = Depends(get_current_user)
462
+ ):
463
+ """Search chat messages for authenticated users."""
464
+
465
+ if not current_user.is_authenticated:
466
+ raise HTTPException(
467
+ status_code=status.HTTP_401_UNAUTHORIZED,
468
+ detail="Authentication required for searching messages"
469
+ )
470
+
471
+ try:
472
+ # Build base query
473
+ query = db.query(ChatMessage).join(ChatSession).filter(
474
+ ChatSession.user_id == str(current_user.id)
475
+ )
476
+
477
+ # Filter by session if specified
478
+ if search_request.session_id:
479
+ query = query.filter(ChatMessage.chat_session_id == search_request.session_id)
480
+
481
+ # Filter by message type if specified
482
+ if search_request.message_type:
483
+ query = query.filter(ChatMessage.role == search_request.message_type)
484
+
485
+ # Filter by date range if provided
486
+ if search_request.date_from:
487
+ try:
488
+ date_from = datetime.fromisoformat(search_request.date_from.replace('Z', '+00:00'))
489
+ query = query.filter(ChatMessage.created_at >= date_from)
490
+ except ValueError:
491
+ pass # Invalid date format, ignore filter
492
+
493
+ if search_request.date_to:
494
+ try:
495
+ date_to = datetime.fromisoformat(search_request.date_to.replace('Z', '+00:00'))
496
+ query = query.filter(ChatMessage.created_at <= date_to)
497
+ except ValueError:
498
+ pass # Invalid date format, ignore filter
499
+
500
+ # Search in message content (case-insensitive)
501
+ if search_request.query:
502
+ search_term = f"%{search_request.query}%"
503
+ query = query.filter(ChatMessage.content.ilike(search_term))
504
+
505
+ # Get total count
506
+ total_count = query.count()
507
+
508
+ # Apply pagination and ordering
509
+ messages = query.order_by(ChatMessage.created_at.desc()).offset(
510
+ search_request.offset or 0
511
+ ).limit(search_request.limit or 50).all()
512
+
513
+ # Format results
514
+ results = []
515
+ for message in messages:
516
+ # Highlight matching text
517
+ content = message.content
518
+ if search_request.query:
519
+ # Simple highlighting (in production, you might want more sophisticated highlighting)
520
+ content = content.replace(
521
+ search_request.query,
522
+ f"**{search_request.query}**"
523
+ )
524
+
525
+ results.append({
526
+ "id": message.id,
527
+ "session_id": message.chat_session_id,
528
+ "role": message.role,
529
+ "content": content[:500] + ("..." if len(content) > 500 else ""), # Preview
530
+ "full_content": content,
531
+ "created_at": message.created_at.isoformat(),
532
+ "edited_at": message.edited_at.isoformat() if message.edited_at else None,
533
+ "edit_count": message.edit_count or 0
534
+ })
535
+
536
+ return {
537
+ "results": results,
538
+ "total_count": total_count,
539
+ "query": search_request.query,
540
+ "limit": search_request.limit or 50,
541
+ "offset": search_request.offset or 0
542
+ }
543
+
544
+ except Exception as e:
545
+ logger.error(f"Search failed: {str(e)}")
546
+ raise HTTPException(
547
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
548
+ detail="Search failed. Please try again."
549
+ )
src/api/routes/users.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User management API routes.
3
+
4
+ This module provides endpoints for user profile management,
5
+ onboarding, and preferences.
6
+ """
7
+
8
+ from datetime import datetime
9
+ from typing import Any, List
10
+ from fastapi import APIRouter, Depends, HTTPException, status
11
+ from sqlalchemy.orm import Session
12
+ from pydantic import BaseModel
13
+
14
+ from src.database.config import get_db
15
+ from src.models.auth import User, UserBackground, OnboardingResponse, UserPreferences
16
+ from src.schemas.auth import (
17
+ User as UserSchema,
18
+ UserBackground as UserBackgroundSchema,
19
+ UserBackgroundCreate,
20
+ UserBackgroundUpdate,
21
+ UserBackgroundResponse,
22
+ OnboardingResponse as OnboardingResponseSchema,
23
+ OnboardingResponseCreate,
24
+ OnboardingBatch,
25
+ UserPreferences as UserPreferencesSchema,
26
+ UserPreferencesUpdate,
27
+ UserPreferencesResponse,
28
+ SuccessResponse
29
+ )
30
+ from src.security.dependencies import get_current_active_user
31
+
32
+ router = APIRouter(prefix="/users", tags=["users"])
33
+
34
+
35
+ @router.get("/me", response_model=UserSchema)
36
+ async def get_user_profile(
37
+ current_user: UserSchema = Depends(get_current_active_user)
38
+ ) -> Any:
39
+ """
40
+ Get current user's profile information.
41
+
42
+ Args:
43
+ current_user: Currently authenticated user
44
+
45
+ Returns:
46
+ User profile data
47
+ """
48
+ return current_user
49
+
50
+
51
+ @router.put("/me", response_model=UserSchema)
52
+ async def update_user_profile(
53
+ user_update: dict,
54
+ current_user: UserSchema = Depends(get_current_active_user),
55
+ db: Session = Depends(get_db)
56
+ ) -> Any:
57
+ """
58
+ Update current user's profile information.
59
+
60
+ Args:
61
+ user_update: User data to update
62
+ current_user: Currently authenticated user
63
+ db: Database session
64
+
65
+ Returns:
66
+ Updated user data
67
+ """
68
+ # Get user from database
69
+ db_user = db.query(User).filter(User.id == current_user.id).first()
70
+ if not db_user:
71
+ raise HTTPException(
72
+ status_code=status.HTTP_404_NOT_FOUND,
73
+ detail="User not found"
74
+ )
75
+
76
+ # Update allowed fields
77
+ if "name" in user_update and user_update["name"] is not None:
78
+ db_user.name = user_update["name"]
79
+
80
+ if "email" in user_update and user_update["email"] is not None:
81
+ # Check if email is already taken by another user
82
+ existing_user = db.query(User).filter(
83
+ User.email == user_update["email"],
84
+ User.id != current_user.id
85
+ ).first()
86
+ if existing_user:
87
+ raise HTTPException(
88
+ status_code=status.HTTP_400_BAD_REQUEST,
89
+ detail="Email already taken by another user"
90
+ )
91
+ db_user.email = user_update["email"]
92
+ db_user.email_verified = False # Require re-verification
93
+
94
+ db_user.updated_at = datetime.utcnow()
95
+ db.commit()
96
+ db.refresh(db_user)
97
+
98
+ return db_user
99
+
100
+
101
+ @router.get("/background", response_model=UserBackgroundResponse)
102
+ async def get_user_background(
103
+ current_user: UserSchema = Depends(get_current_active_user),
104
+ db: Session = Depends(get_db)
105
+ ) -> Any:
106
+ """
107
+ Get user's background information.
108
+
109
+ Args:
110
+ current_user: Currently authenticated user
111
+ db: Database session
112
+
113
+ Returns:
114
+ User background data
115
+ """
116
+ background = db.query(UserBackground).filter(
117
+ UserBackground.user_id == current_user.id
118
+ ).first()
119
+
120
+ if not background:
121
+ # Return default background
122
+ return UserBackgroundResponse(
123
+ id="",
124
+ user_id=current_user.id,
125
+ experience_level="beginner",
126
+ years_experience=0,
127
+ preferred_languages=[],
128
+ hardware_expertise={"cpu": "none", "gpu": "none", "networking": "none"},
129
+ created_at=datetime.utcnow(),
130
+ updated_at=datetime.utcnow()
131
+ )
132
+
133
+ return background
134
+
135
+
136
+ @router.post("/background", response_model=UserBackgroundResponse)
137
+ async def create_or_update_user_background(
138
+ background_data: UserBackgroundCreate,
139
+ current_user: UserSchema = Depends(get_current_active_user),
140
+ db: Session = Depends(get_db)
141
+ ) -> Any:
142
+ """
143
+ Create or update user's background information.
144
+
145
+ Args:
146
+ background_data: User background data
147
+ current_user: Currently authenticated user
148
+ db: Database session
149
+
150
+ Returns:
151
+ Created/updated user background data
152
+ """
153
+ # Check if background exists
154
+ background = db.query(UserBackground).filter(
155
+ UserBackground.user_id == current_user.id
156
+ ).first()
157
+
158
+ if background:
159
+ # Update existing background
160
+ background.experience_level = background_data.experience_level
161
+ background.years_experience = background_data.years_experience
162
+ background.preferred_languages = background_data.preferred_languages
163
+ background.hardware_expertise = background_data.hardware_expertise
164
+ background.updated_at = datetime.utcnow()
165
+ else:
166
+ # Create new background
167
+ background = UserBackground(
168
+ user_id=current_user.id,
169
+ experience_level=background_data.experience_level,
170
+ years_experience=background_data.years_experience,
171
+ preferred_languages=background_data.preferred_languages,
172
+ hardware_expertise=background_data.hardware_expertise
173
+ )
174
+ db.add(background)
175
+
176
+ db.commit()
177
+ db.refresh(background)
178
+
179
+ return background
180
+
181
+
182
+ @router.post("/onboarding", response_model=SuccessResponse)
183
+ async def submit_onboarding(
184
+ onboarding_data: OnboardingBatch,
185
+ current_user: UserSchema = Depends(get_current_active_user),
186
+ db: Session = Depends(get_db)
187
+ ) -> Any:
188
+ """
189
+ Submit onboarding responses.
190
+
191
+ Args:
192
+ onboarding_data: Batch of onboarding responses
193
+ current_user: Currently authenticated user
194
+ db: Database session
195
+
196
+ Returns:
197
+ Success response
198
+ """
199
+ # Clear existing onboarding responses for this user
200
+ db.query(OnboardingResponse).filter(
201
+ OnboardingResponse.user_id == current_user.id
202
+ ).delete()
203
+
204
+ # Add new responses
205
+ for response_data in onboarding_data.responses:
206
+ response = OnboardingResponse(
207
+ user_id=current_user.id,
208
+ question_key=response_data.question_key,
209
+ response_value=response_data.response_value
210
+ )
211
+ db.add(response)
212
+
213
+ db.commit()
214
+
215
+ # Optionally update user background based on responses
216
+ background_responses = {
217
+ resp.question_key: resp.response_value
218
+ for resp in onboarding_data.responses
219
+ }
220
+
221
+ # Map onboarding responses to background fields
222
+ background_update = {}
223
+ if "experience_level_selection" in background_responses:
224
+ background_update["experience_level"] = background_responses["experience_level_selection"]
225
+ if "years_of_experience" in background_responses:
226
+ background_update["years_experience"] = int(background_responses["years_of_experience"])
227
+ if "preferred_languages" in background_responses:
228
+ background_update["preferred_languages"] = background_responses["preferred_languages"]
229
+ if "cpu_expertise" in background_responses:
230
+ if "hardware_expertise" not in background_update:
231
+ background_update["hardware_expertise"] = {}
232
+ background_update["hardware_expertise"]["cpu"] = background_responses["cpu_expertise"]
233
+ if "gpu_expertise" in background_responses:
234
+ if "hardware_expertise" not in background_update:
235
+ background_update["hardware_expertise"] = {}
236
+ background_update["hardware_expertise"]["gpu"] = background_responses["gpu_expertise"]
237
+ if "networking_expertise" in background_responses:
238
+ if "hardware_expertise" not in background_update:
239
+ background_update["hardware_expertise"] = {}
240
+ background_update["hardware_expertise"]["networking"] = background_responses["networking_expertise"]
241
+
242
+ # Create or update user background
243
+ if background_update:
244
+ background = db.query(UserBackground).filter(
245
+ UserBackground.user_id == current_user.id
246
+ ).first()
247
+
248
+ if background:
249
+ # Update existing background
250
+ for key, value in background_update.items():
251
+ if hasattr(background, key):
252
+ setattr(background, key, value)
253
+ background.updated_at = datetime.utcnow()
254
+ else:
255
+ # Create new background
256
+ # Set default values for missing fields
257
+ full_background = {
258
+ "user_id": current_user.id,
259
+ "experience_level": "beginner",
260
+ "years_experience": 0,
261
+ "preferred_languages": [],
262
+ "hardware_expertise": {"cpu": "none", "gpu": "none", "networking": "none"},
263
+ **background_update
264
+ }
265
+ background = UserBackground(**full_background)
266
+ db.add(background)
267
+
268
+ db.commit()
269
+
270
+ return {"success": True, "message": "Onboarding responses saved successfully"}
271
+
272
+
273
+ @router.get("/onboarding", response_model=List[OnboardingResponseSchema])
274
+ async def get_onboarding_responses(
275
+ current_user: UserSchema = Depends(get_current_active_user),
276
+ db: Session = Depends(get_db)
277
+ ) -> Any:
278
+ """
279
+ Get user's onboarding responses.
280
+
281
+ Args:
282
+ current_user: Currently authenticated user
283
+ db: Database session
284
+
285
+ Returns:
286
+ List of onboarding responses
287
+ """
288
+ responses = db.query(OnboardingResponse).filter(
289
+ OnboardingResponse.user_id == current_user.id
290
+ ).all()
291
+
292
+ return responses
293
+
294
+
295
+ @router.get("/preferences", response_model=UserPreferencesResponse)
296
+ async def get_user_preferences(
297
+ current_user: UserSchema = Depends(get_current_active_user),
298
+ db: Session = Depends(get_db)
299
+ ) -> Any:
300
+ """
301
+ Get user's preferences.
302
+
303
+ Args:
304
+ current_user: Currently authenticated user
305
+ db: Database session
306
+
307
+ Returns:
308
+ User preferences data
309
+ """
310
+ preferences = db.query(UserPreferences).filter(
311
+ UserPreferences.user_id == current_user.id
312
+ ).first()
313
+
314
+ if not preferences:
315
+ # Return default preferences
316
+ return UserPreferencesResponse(
317
+ id="",
318
+ user_id=current_user.id,
319
+ theme="auto",
320
+ language="en",
321
+ notification_settings={
322
+ "email_responses": False,
323
+ "browser_notifications": True,
324
+ "marketing_emails": False
325
+ },
326
+ created_at=datetime.utcnow(),
327
+ updated_at=datetime.utcnow()
328
+ )
329
+
330
+ return preferences
331
+
332
+
333
+ @router.put("/preferences", response_model=UserPreferencesResponse)
334
+ async def update_user_preferences(
335
+ preferences_data: UserPreferencesUpdate,
336
+ current_user: UserSchema = Depends(get_current_active_user),
337
+ db: Session = Depends(get_db)
338
+ ) -> Any:
339
+ """
340
+ Update user's preferences.
341
+
342
+ Args:
343
+ preferences_data: Preferences data to update
344
+ current_user: Currently authenticated user
345
+ db: Database session
346
+
347
+ Returns:
348
+ Updated preferences data
349
+ """
350
+ # Get or create preferences
351
+ preferences = db.query(UserPreferences).filter(
352
+ UserPreferences.user_id == current_user.id
353
+ ).first()
354
+
355
+ if preferences:
356
+ # Update existing preferences
357
+ if preferences_data.theme is not None:
358
+ preferences.theme = preferences_data.theme
359
+ if preferences_data.language is not None:
360
+ preferences.language = preferences_data.language
361
+ if preferences_data.notification_settings is not None:
362
+ preferences.notification_settings.update(preferences_data.notification_settings)
363
+ preferences.updated_at = datetime.utcnow()
364
+ else:
365
+ # Create new preferences with defaults
366
+ preferences = UserPreferences(
367
+ user_id=current_user.id,
368
+ theme=preferences_data.theme or "auto",
369
+ language=preferences_data.language or "en",
370
+ notification_settings=preferences_data.notification_settings or {
371
+ "email_responses": False,
372
+ "browser_notifications": True,
373
+ "marketing_emails": False
374
+ }
375
+ )
376
+ db.add(preferences)
377
+
378
+ db.commit()
379
+ db.refresh(preferences)
380
+
381
+ return preferences
src/database/base.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base model and database configuration.
3
+ """
4
+
5
+ from sqlalchemy import create_engine
6
+ from sqlalchemy.ext.declarative import declarative_base
7
+ from sqlalchemy.orm import sessionmaker
8
+ import os
9
+
10
+ # Create the declarative base
11
+ Base = declarative_base()
12
+
13
+ # Database URL from environment
14
+ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
15
+
16
+ # Create engine
17
+ engine = create_engine(
18
+ DATABASE_URL,
19
+ connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
20
+ )
21
+
22
+ # Create session factory
23
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
24
+
25
+ # Dependency to get DB session
26
+ def get_db():
27
+ db = SessionLocal()
28
+ try:
29
+ yield db
30
+ finally:
31
+ db.close()
src/database/config.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database configuration for the AI Book application.
3
+ """
4
+
5
+ import os
6
+ from sqlalchemy import create_engine, MetaData
7
+ from sqlalchemy.orm import sessionmaker
8
+ from sqlalchemy.pool import StaticPool
9
+
10
+ # Database URL from environment
11
+ DATABASE_URL = os.getenv(
12
+ "DATABASE_URL",
13
+ "sqlite:///./app.db"
14
+ )
15
+
16
+ # Create engine
17
+ if "sqlite" in DATABASE_URL:
18
+ engine = create_engine(
19
+ DATABASE_URL,
20
+ connect_args={
21
+ "check_same_thread": False,
22
+ "timeout": 30
23
+ },
24
+ poolclass=StaticPool,
25
+ echo=os.getenv("DEBUG", "false").lower() == "true"
26
+ )
27
+ else:
28
+ engine = create_engine(
29
+ DATABASE_URL,
30
+ pool_pre_ping=True,
31
+ echo=os.getenv("DEBUG", "false").lower() == "true"
32
+ )
33
+
34
+ # Create session factory
35
+ SessionLocal = sessionmaker(
36
+ autocommit=False,
37
+ autoflush=False,
38
+ bind=engine
39
+ )
40
+
41
+ # Metadata for migrations
42
+ metadata = MetaData()
43
+
44
+ # Dependency to get DB session
45
+ def get_db():
46
+ """
47
+ Dependency to get database session.
48
+ """
49
+ db = SessionLocal()
50
+ try:
51
+ yield db
52
+ finally:
53
+ db.close()
54
+
55
+ # Initialize database
56
+ def init_db():
57
+ """
58
+ Initialize database with all tables.
59
+ """
60
+ from src.models.auth import Base
61
+ from src.models.chat import Base
62
+
63
+ # Import all models to ensure they are registered
64
+ Base.metadata.create_all(bind=engine)
65
+
66
+ # Test database connection
67
+ def test_connection():
68
+ """
69
+ Test database connection.
70
+ """
71
+ try:
72
+ with engine.connect() as connection:
73
+ connection.execute("SELECT 1")
74
+ return True
75
+ except Exception as e:
76
+ print(f"Database connection failed: {e}")
77
+ return False
src/models/auth.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication models for the AI Book application.
3
+
4
+ This module contains SQLAlchemy models for user authentication,
5
+ sessions, and related entities.
6
+ """
7
+
8
+ from typing import Optional
9
+ from enum import Enum
10
+
11
+ from sqlalchemy import (
12
+ Column, String, Boolean, Integer, DateTime,
13
+ Text, JSON, ForeignKey, Enum as SQLEnum, func
14
+ )
15
+ from sqlalchemy.orm import relationship
16
+ import uuid
17
+
18
+ from src.database.base import Base
19
+
20
+
21
+ class User(Base):
22
+ """Represents a registered user with email/password authentication."""
23
+
24
+ __tablename__ = "users"
25
+
26
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
27
+ email = Column(String(255), unique=True, nullable=False, index=True)
28
+ password_hash = Column(String(255), nullable=False)
29
+ name = Column(String(100), nullable=True)
30
+ image_url = Column(String(500), nullable=True)
31
+ email_verified = Column(Boolean, default=False, nullable=False)
32
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
33
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
34
+
35
+ # Relationships
36
+ accounts = relationship("Account", back_populates="user", cascade="all, delete-orphan")
37
+ sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
38
+ background = relationship("UserBackground", back_populates="user", uselist=False, cascade="all, delete-orphan")
39
+ preferences = relationship("UserPreferences", back_populates="user", uselist=False, cascade="all, delete-orphan")
40
+ password_reset_tokens = relationship("PasswordResetToken", back_populates="user", cascade="all, delete-orphan")
41
+ onboarding_responses = relationship("OnboardingResponse", back_populates="user", cascade="all, delete-orphan")
42
+ chat_sessions = relationship("ChatSession", back_populates="user", cascade="all, delete-orphan")
43
+ folders = relationship("ChatFolder", back_populates="user", cascade="all, delete-orphan")
44
+ tags = relationship("ChatTag", back_populates="user", cascade="all, delete-orphan")
45
+
46
+
47
+ class Account(Base):
48
+ """OAuth provider accounts (Google, etc.) linked to users."""
49
+
50
+ __tablename__ = "accounts"
51
+
52
+ id = Column(Integer, primary_key=True, index=True)
53
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
54
+ provider = Column(String, nullable=False) # 'google', 'github', etc.
55
+ provider_account_id = Column(String, nullable=False)
56
+ access_token = Column(Text, nullable=True)
57
+ refresh_token = Column(Text, nullable=True)
58
+ expires_at = Column(DateTime(timezone=True), nullable=True)
59
+ token_type = Column(String, nullable=True)
60
+ scope = Column(String, nullable=True)
61
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
62
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
63
+
64
+ # Relationships
65
+ user = relationship("User", back_populates="accounts")
66
+
67
+
68
+ class UserBackground(Base):
69
+ """Stores user's technical background for personalization."""
70
+
71
+ __tablename__ = "user_backgrounds"
72
+
73
+ class ExperienceLevel(str, Enum):
74
+ BEGINNER = "Beginner"
75
+ INTERMEDIATE = "Intermediate"
76
+ ADVANCED = "Advanced"
77
+
78
+ class HardwareExpertise(str, Enum):
79
+ NONE = "None"
80
+ ARDUINO = "Arduino"
81
+ ROS_PRO = "ROS-Pro"
82
+
83
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
84
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=False, unique=True)
85
+ experience_level = Column(SQLEnum(ExperienceLevel), nullable=False)
86
+ years_of_experience = Column(Integer, nullable=False, default=0)
87
+ preferred_languages = Column(JSON, nullable=False, default=list)
88
+ hardware_expertise = Column(SQLEnum(HardwareExpertise), nullable=False, default=HardwareExpertise.NONE)
89
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
90
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
91
+
92
+ # Relationships
93
+ user = relationship("User", back_populates="background")
94
+
95
+
96
+ class OnboardingResponse(Base):
97
+ """Captures individual onboarding question responses."""
98
+
99
+ __tablename__ = "onboarding_responses"
100
+
101
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
102
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
103
+ question_key = Column(String(100), nullable=False)
104
+ response_value = Column(JSON, nullable=False)
105
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
106
+
107
+ # Relationships
108
+ user = relationship("User", back_populates="onboarding_responses")
109
+
110
+
111
+ class Session(Base):
112
+ """Active user authentication sessions with sliding expiration."""
113
+
114
+ __tablename__ = "sessions"
115
+
116
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
117
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
118
+ token_hash = Column(String(255), nullable=False, unique=True)
119
+ expires_at = Column(DateTime(timezone=True), nullable=False)
120
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
121
+ ip_address = Column(String(45), nullable=True) # IPv6 compatible
122
+ user_agent = Column(Text, nullable=True)
123
+
124
+ # Relationships
125
+ user = relationship("User", back_populates="sessions")
126
+
127
+
128
+ class PasswordResetToken(Base):
129
+ """Temporary tokens for secure password reset."""
130
+
131
+ __tablename__ = "password_reset_tokens"
132
+
133
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
134
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
135
+ token = Column(String(255), nullable=False, unique=True)
136
+ expires_at = Column(DateTime(timezone=True), nullable=False)
137
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
138
+ used = Column(Boolean, default=False, nullable=False)
139
+
140
+ # Relationships
141
+ user = relationship("User", back_populates="password_reset_tokens")
142
+
143
+
144
+ class AnonymousSession(Base):
145
+ """Temporary session for anonymous chat users."""
146
+
147
+ __tablename__ = "anonymous_sessions"
148
+
149
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
150
+ message_count = Column(Integer, default=0, nullable=False)
151
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
152
+ last_activity = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
153
+
154
+ # Relationships - ChatSession links to AnonymousSession, not ChatMessage directly
155
+ chat_sessions = relationship("ChatSession", back_populates="anonymous_session")
156
+
157
+
158
+ class ChatSession(Base):
159
+ """Conversation threads associated with users or anonymous sessions."""
160
+
161
+ __tablename__ = "chat_sessions"
162
+
163
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
164
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
165
+ anonymous_session_id = Column(String(36), ForeignKey("anonymous_sessions.id"), nullable=True)
166
+ folder_id = Column(String(36), ForeignKey("chat_folders.id"), nullable=True)
167
+ title = Column(String(255), nullable=False, default="New Chat")
168
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
169
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
170
+
171
+ # Relationships
172
+ user = relationship("User", back_populates="chat_sessions")
173
+ anonymous_session = relationship("AnonymousSession")
174
+ folder = relationship("ChatFolder", back_populates="chat_sessions")
175
+ chat_messages = relationship("ChatMessage", back_populates="chat_session", cascade="all, delete-orphan")
176
+
177
+
178
+ class ChatMessage(Base):
179
+ """Individual messages within chat sessions."""
180
+
181
+ __tablename__ = "chat_messages"
182
+
183
+ class Role(str, Enum):
184
+ USER = "user"
185
+ ASSISTANT = "assistant"
186
+ SYSTEM = "system"
187
+
188
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
189
+ chat_session_id = Column(String(36), ForeignKey("chat_sessions.id"), nullable=False)
190
+ role = Column(SQLEnum(Role), nullable=False)
191
+ content = Column(Text, nullable=False)
192
+ message_metadata = Column(JSON, nullable=True)
193
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
194
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
195
+ edited_at = Column(DateTime(timezone=True), nullable=True)
196
+ edit_count = Column(Integer, default=0, nullable=False)
197
+
198
+ # Relationships
199
+ chat_session = relationship("ChatSession", back_populates="chat_messages")
200
+ versions = relationship("MessageVersion", back_populates="message", cascade="all, delete-orphan")
201
+
202
+
203
+ class UserPreferences(Base):
204
+ """User-specific settings and preferences."""
205
+ __tablename__ = "user_preferences"
206
+
207
+ class Theme(str, Enum):
208
+ LIGHT = "light"
209
+ DARK = "dark"
210
+ AUTO = "auto"
211
+
212
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
213
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=False, unique=True)
214
+ theme = Column(SQLEnum(Theme), default=Theme.AUTO, nullable=False)
215
+ language = Column(String(10), default="en", nullable=False)
216
+ notification_settings = Column(JSON, nullable=False, default=dict)
217
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
218
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
219
+
220
+ # Relationships
221
+ user = relationship("User", back_populates="preferences")
222
+
223
+
224
+ class MessageVersion(Base):
225
+ """Track version history of edited messages."""
226
+
227
+ __tablename__ = "message_versions"
228
+
229
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
230
+ message_id = Column(String(36), ForeignKey("chat_messages.id"), nullable=False)
231
+ version_number = Column(Integer, nullable=False)
232
+ content = Column(Text, nullable=False)
233
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
234
+ edit_reason = Column(String(500), nullable=True) # Optional reason for editing
235
+
236
+ # Relationships
237
+ message = relationship("ChatMessage", back_populates="versions")
238
+
239
+
240
+ class ChatFolder(Base):
241
+ """User-defined folders for organizing chat sessions."""
242
+
243
+ __tablename__ = "chat_folders"
244
+
245
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
246
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
247
+ name = Column(String(100), nullable=False)
248
+ description = Column(Text, nullable=True)
249
+ color = Column(String(7), nullable=True) # Hex color code
250
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
251
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
252
+
253
+ # Relationships
254
+ user = relationship("User", back_populates="folders")
255
+ chat_sessions = relationship("ChatSession", back_populates="folder")
256
+
257
+
258
+ class ChatTag(Base):
259
+ """Tags for categorizing chat sessions."""
260
+
261
+ __tablename__ = "chat_tags"
262
+
263
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
264
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
265
+ name = Column(String(50), nullable=False)
266
+ color = Column(String(7), nullable=True) # Hex color code
267
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
268
+
269
+ # Relationships
270
+ user = relationship("User", back_populates="tags")
271
+
272
+
273
+ class MessageReaction(Base):
274
+ """Reactions/em responses to chat messages."""
275
+
276
+ __tablename__ = "message_reactions"
277
+
278
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
279
+ message_id = Column(String(36), ForeignKey("chat_messages.id"), nullable=False)
280
+ user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
281
+ emoji = Column(String(50), nullable=False) # Emoji or reaction name
282
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
283
+
284
+ # Relationships
285
+ message = relationship("ChatMessage")
286
+ user = relationship("User")
src/models/chat.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat models for the AI Book application.
3
+
4
+ This module contains SQLAlchemy models for chat functionality.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ import uuid
10
+
11
+ from sqlalchemy import (
12
+ Column, String, DateTime, Text, JSON, ForeignKey, Enum as SQLEnum, func
13
+ )
14
+ from sqlalchemy.orm import relationship
15
+
16
+ from src.database.base import Base
17
+
18
+
19
+ class ChatMessage(Base):
20
+ """Individual messages within chat sessions."""
21
+
22
+ class Role(str, Enum):
23
+ USER = "user"
24
+ ASSISTANT = "assistant"
25
+ SYSTEM = "system"
26
+
27
+ __tablename__ = "chat_messages"
28
+
29
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
30
+ chat_session_id = Column(String(36), ForeignKey("chat_sessions.id"), nullable=False)
31
+ role = Column(SQLEnum(Role), nullable=False)
32
+ content = Column(Text, nullable=False)
33
+ metadata = Column(JSON, nullable=True)
34
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
35
+
36
+ # Relationships
37
+ chat_session = relationship("ChatSession", back_populates="chat_messages")
38
+ anonymous_session = relationship("AnonymousSession", back_populates="chat_messages")
src/schemas/auth.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic schemas for authentication-related data structures.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Optional, List, Dict, Any
7
+ from enum import Enum
8
+ from pydantic import BaseModel, EmailStr, Field, validator
9
+
10
+
11
+ # Base schemas
12
+ class BaseSchema(BaseModel):
13
+ """Base schema with common configuration."""
14
+ class Config:
15
+ from_attributes = True
16
+ json_encoders = {
17
+ datetime: lambda v: v.isoformat()
18
+ }
19
+
20
+
21
+ # User schemas
22
+ class UserBase(BaseSchema):
23
+ """Base user schema."""
24
+ email: EmailStr
25
+ name: Optional[str] = None
26
+ email_verified: bool = False
27
+
28
+
29
+ class UserCreate(BaseSchema):
30
+ """Schema for creating a user."""
31
+ email: EmailStr
32
+ password: str
33
+ name: Optional[str] = None
34
+
35
+ # Optional background fields for knowledge collection
36
+ software_experience: Optional[str] = None
37
+ hardware_expertise: Optional[str] = None
38
+ years_of_experience: Optional[int] = None
39
+ primary_interest: Optional[str] = None
40
+
41
+ @validator('password')
42
+ def validate_password(cls, v):
43
+ if len(v) < 8:
44
+ raise ValueError('Password must be at least 8 characters long')
45
+ if not any(c.isalpha() for c in v):
46
+ raise ValueError('Password must contain at least one letter')
47
+ if not any(c.isdigit() for c in v):
48
+ raise ValueError('Password must contain at least one number')
49
+ return v
50
+
51
+ @validator('software_experience')
52
+ def validate_software_experience(cls, v):
53
+ if v is not None and v not in ['Beginner', 'Intermediate', 'Advanced']:
54
+ raise ValueError('Software experience must be one of: Beginner, Intermediate, Advanced')
55
+ return v
56
+
57
+ @validator('hardware_expertise')
58
+ def validate_hardware_expertise(cls, v):
59
+ if v is not None and v not in ['None', 'Arduino', 'ROS-Pro']:
60
+ raise ValueError('Hardware expertise must be one of: None, Arduino, ROS-Pro')
61
+ return v
62
+
63
+ @validator('years_of_experience')
64
+ def validate_years_of_experience(cls, v):
65
+ if v is not None and (v < 0 or v > 50):
66
+ raise ValueError('Years of experience must be between 0 and 50')
67
+ return v
68
+
69
+ @validator('primary_interest')
70
+ def validate_primary_interest(cls, v):
71
+ if v is not None and v not in [
72
+ 'Computer Vision', 'Machine Learning', 'Control Systems',
73
+ 'Path Planning', 'State Estimation', 'Sensors & Perception',
74
+ 'Hardware Integration', 'Human-Robot Interaction', 'All of the Above'
75
+ ]:
76
+ raise ValueError('Primary interest must be one of: Computer Vision, Machine Learning, Control Systems, Path Planning, State Estimation, Sensors & Perception, Hardware Integration, Human-Robot Interaction, or All of the Above')
77
+ return v
78
+
79
+
80
+ class UserUpdate(BaseSchema):
81
+ """Schema for updating a user."""
82
+ name: Optional[str] = None
83
+ email: Optional[EmailStr] = None
84
+
85
+
86
+ class UserResponse(BaseSchema):
87
+ """Schema for user response."""
88
+ id: str
89
+ email: str
90
+ name: Optional[str]
91
+ email_verified: bool
92
+ created_at: datetime
93
+ updated_at: datetime
94
+
95
+
96
+ # Authentication schemas
97
+ class Token(BaseSchema):
98
+ """Token schema."""
99
+ access_token: str
100
+ token_type: str = "bearer"
101
+ expires_in: int
102
+
103
+
104
+ class TokenData(BaseSchema):
105
+ """Token data schema."""
106
+ user_id: Optional[str] = None
107
+
108
+
109
+ class LoginRequest(BaseSchema):
110
+ """Schema for login request."""
111
+ username: str = Field(..., description="Email address (OAuth2PasswordRequestForm uses 'username' field)")
112
+ password: str
113
+
114
+
115
+ class LoginResponse(BaseSchema):
116
+ """Schema for login response."""
117
+ access_token: str
118
+ token_type: str
119
+ expires_in: int
120
+ user: UserResponse
121
+
122
+
123
+ # User background schemas
124
+ class ExperienceLevel(str, Enum):
125
+ """Experience level enum."""
126
+ BEGINNER = "beginner"
127
+ INTERMEDIATE = "intermediate"
128
+ ADVANCED = "advanced"
129
+
130
+
131
+ class HardwareExpertise(BaseSchema):
132
+ """Hardware expertise schema."""
133
+ cpu: str
134
+ gpu: str
135
+ networking: str
136
+
137
+
138
+ class UserBackgroundBase(BaseSchema):
139
+ """Base user background schema."""
140
+ experience_level: ExperienceLevel
141
+ years_experience: int = Field(..., ge=0, le=50)
142
+ preferred_languages: List[str] = Field(default_factory=list)
143
+ hardware_expertise: HardwareExpertise
144
+
145
+
146
+ class UserBackgroundCreate(UserBackgroundBase):
147
+ """Schema for creating user background."""
148
+ pass
149
+
150
+
151
+ class UserBackgroundUpdate(BaseSchema):
152
+ """Schema for updating user background."""
153
+ experience_level: Optional[ExperienceLevel] = None
154
+ years_experience: Optional[int] = Field(None, ge=0, le=50)
155
+ preferred_languages: Optional[List[str]] = None
156
+ hardware_expertise: Optional[HardwareExpertise] = None
157
+
158
+
159
+ class UserBackgroundResponse(UserBackgroundBase):
160
+ """Schema for user background response."""
161
+ id: str
162
+ user_id: str
163
+ created_at: datetime
164
+ updated_at: datetime
165
+
166
+
167
+ # Onboarding schemas
168
+ class OnboardingQuestion(str, Enum):
169
+ """Onboarding question keys."""
170
+ EXPERIENCE_LEVEL = "experience_level_selection"
171
+ YEARS_OF_EXPERIENCE = "years_of_experience"
172
+ PREFERRED_LANGUAGES = "preferred_languages"
173
+ CPU_EXPERTISE = "cpu_expertise"
174
+ GPU_EXPERTISE = "gpu_expertise"
175
+ NETWORKING_EXPERTISE = "networking_expertise"
176
+
177
+
178
+ class OnboardingResponseBase(BaseSchema):
179
+ """Base onboarding response schema."""
180
+ question_key: OnboardingQuestion
181
+ response_value: Any
182
+
183
+
184
+ class OnboardingResponseCreate(OnboardingResponseBase):
185
+ """Schema for creating onboarding response."""
186
+ pass
187
+
188
+
189
+ class OnboardingResponseResponse(OnboardingResponseBase):
190
+ """Schema for onboarding response."""
191
+ id: str
192
+ user_id: str
193
+ created_at: datetime
194
+
195
+
196
+ class OnboardingBatch(BaseSchema):
197
+ """Schema for batch onboarding submission."""
198
+ responses: List[OnboardingResponseCreate]
199
+
200
+
201
+ # Password reset schemas
202
+ class PasswordResetRequest(BaseSchema):
203
+ """Schema for password reset request."""
204
+ email: EmailStr
205
+
206
+
207
+ class PasswordResetConfirm(BaseSchema):
208
+ """Schema for password reset confirmation."""
209
+ token: str
210
+ new_password: str
211
+
212
+ @validator('new_password')
213
+ def validate_new_password(cls, v):
214
+ if len(v) < 8:
215
+ raise ValueError('Password must be at least 8 characters long')
216
+ if not any(c.isalpha() for c in v):
217
+ raise ValueError('Password must contain at least one letter')
218
+ if not any(c.isdigit() for c in v):
219
+ raise ValueError('Password must contain at least one number')
220
+ return v
221
+
222
+
223
+ # User preferences schemas
224
+ class Theme(str, Enum):
225
+ """Theme options."""
226
+ LIGHT = "light"
227
+ DARK = "dark"
228
+ AUTO = "auto"
229
+
230
+
231
+ class NotificationSettings(BaseSchema):
232
+ """Notification settings schema."""
233
+ email_responses: bool = False
234
+ browser_notifications: bool = True
235
+ marketing_emails: bool = False
236
+
237
+
238
+ class UserPreferencesBase(BaseSchema):
239
+ """Base user preferences schema."""
240
+ theme: Theme = Theme.AUTO
241
+ language: str = "en"
242
+ notification_settings: NotificationSettings
243
+
244
+
245
+ class UserPreferencesUpdate(BaseSchema):
246
+ """Schema for updating user preferences."""
247
+ theme: Optional[Theme] = None
248
+ language: Optional[str] = None
249
+ notification_settings: Optional[NotificationSettings] = None
250
+
251
+
252
+ class UserPreferencesResponse(UserPreferencesBase):
253
+ """Schema for user preferences response."""
254
+ id: str
255
+ user_id: str
256
+ created_at: datetime
257
+ updated_at: datetime
258
+
259
+
260
+ # Chat schemas
261
+ class ChatMessageRole(str, Enum):
262
+ """Chat message role enum."""
263
+ USER = "user"
264
+ ASSISTANT = "assistant"
265
+ SYSTEM = "system"
266
+
267
+
268
+ class ChatMessageBase(BaseSchema):
269
+ """Base chat message schema."""
270
+ role: ChatMessageRole
271
+ content: str
272
+ metadata: Optional[Dict[str, Any]] = None
273
+
274
+
275
+ class ChatMessageCreate(ChatMessageBase):
276
+ """Schema for creating chat message."""
277
+ chat_session_id: str
278
+
279
+
280
+ class ChatMessageResponse(ChatMessageBase):
281
+ """Schema for chat message response."""
282
+ id: str
283
+ chat_session_id: str
284
+ created_at: datetime
285
+
286
+
287
+ class ChatSessionBase(BaseSchema):
288
+ """Base chat session schema."""
289
+ title: str = "New Chat"
290
+
291
+
292
+ class ChatSessionCreate(ChatSessionBase):
293
+ """Schema for creating chat session."""
294
+ user_id: Optional[str] = None
295
+ anonymous_session_id: Optional[str] = None
296
+
297
+
298
+ class ChatSessionResponse(ChatSessionBase):
299
+ """Schema for chat session response."""
300
+ id: str
301
+ user_id: Optional[str]
302
+ anonymous_session_id: Optional[str]
303
+ created_at: datetime
304
+ updated_at: datetime
305
+ messages: List[ChatMessageResponse] = []
306
+
307
+
308
+ # Anonymous session schemas
309
+ class AnonymousSessionResponse(BaseSchema):
310
+ """Schema for anonymous session response."""
311
+ id: str
312
+ message_count: int
313
+ remaining_messages: int
314
+ created_at: datetime
315
+ last_activity: datetime
316
+ is_expired: bool
317
+ can_send_message: bool
318
+
319
+
320
+ # API response schemas
321
+ class SuccessResponse(BaseSchema):
322
+ """Success response schema."""
323
+ success: bool = True
324
+ message: str
325
+ data: Optional[Any] = None
326
+
327
+
328
+ class ErrorResponse(BaseSchema):
329
+ """Error response schema."""
330
+ success: bool = False
331
+ error: str
332
+ detail: Optional[str] = None
333
+
334
+
335
+ # Health check schemas
336
+ class HealthResponse(BaseSchema):
337
+ """Health check response schema."""
338
+ status: str
339
+ timestamp: datetime
340
+ version: Optional[str] = None
341
+
342
+
343
+ # Alias for backward compatibility
344
+ User = UserResponse
src/security/dependencies.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI security dependencies for authentication.
3
+
4
+ This module provides dependency functions for protecting routes
5
+ with JWT authentication and authorization.
6
+ """
7
+
8
+ from typing import Optional
9
+ from fastapi import Depends, HTTPException, status, Request
10
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, OAuth2PasswordRequestForm
11
+ from sqlalchemy.orm import Session
12
+ from datetime import datetime, timedelta
13
+
14
+ from src.database.config import get_db
15
+ from src.models.auth import User, Session, AnonymousSession
16
+ from src.services.auth import verify_token, SECRET_KEY, ALGORITHM
17
+ from src.services.auth import verify_password, get_password_hash
18
+ from src.schemas.auth import TokenData
19
+
20
+ # HTTP Bearer scheme for token authentication
21
+ bearer_scheme = HTTPBearer(auto_error=False)
22
+
23
+ # OAuth2PasswordRequestForm for login
24
+ oauth2_scheme = OAuth2PasswordRequestForm
25
+
26
+
27
+ def get_current_user_token(
28
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme)
29
+ ) -> str:
30
+ """
31
+ Extract and verify JWT token from Authorization header.
32
+
33
+ Returns:
34
+ The token string if valid
35
+ """
36
+ if credentials is None:
37
+ raise HTTPException(
38
+ status_code=status.HTTP_401_UNAUTHORIZED,
39
+ detail="Not authenticated",
40
+ headers={"WWW-Authenticate": "Bearer"},
41
+ )
42
+
43
+ token = credentials.credentials
44
+ payload = verify_token(token)
45
+
46
+ if payload is None:
47
+ raise HTTPException(
48
+ status_code=status.HTTP_401_UNAUTHORIZED,
49
+ detail="Invalid authentication credentials",
50
+ headers={"WWW-Authenticate": "Bearer"},
51
+ )
52
+
53
+ # Check token expiration
54
+ exp = payload.get("exp")
55
+ if exp is None:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_401_UNAUTHORIZED,
58
+ detail="Token missing expiration",
59
+ headers={"WWW-Authenticate": "Bearer"},
60
+ )
61
+
62
+ if datetime.utcnow().timestamp() > exp:
63
+ raise HTTPException(
64
+ status_code=status.HTTP_401_UNAUTHORIZED,
65
+ detail="Token has expired",
66
+ headers={"WWW-Authenticate": "Bearer"},
67
+ )
68
+
69
+ return token
70
+
71
+
72
+ def get_current_user(
73
+ token: str = Depends(get_current_user_token),
74
+ db: Session = Depends(get_db)
75
+ ) -> User:
76
+ """
77
+ Get the current authenticated user from JWT token.
78
+
79
+ Returns:
80
+ The authenticated user object
81
+ """
82
+ # Verify token and get user_id
83
+ payload = verify_token(token)
84
+ if payload is None:
85
+ raise HTTPException(
86
+ status_code=status.HTTP_401_UNAUTHORIZED,
87
+ detail="Invalid authentication credentials"
88
+ )
89
+
90
+ user_id = payload.get("sub")
91
+ if user_id is None:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_401_UNAUTHORIZED,
94
+ detail="Invalid authentication credentials"
95
+ )
96
+
97
+ # Get user from database
98
+ user = db.query(User).filter(User.id == user_id).first()
99
+ if user is None:
100
+ raise HTTPException(
101
+ status_code=status.HTTP_401_UNAUTHORIZED,
102
+ detail="User not found"
103
+ )
104
+
105
+ # Check if user is verified (optional)
106
+ if not user.email_verified:
107
+ raise HTTPException(
108
+ status_code=status.HTTP_403_FORBIDDEN,
109
+ detail="Email not verified. Please verify your email first."
110
+ )
111
+
112
+ return user
113
+
114
+
115
+ def get_current_active_user(
116
+ current_user: User = Depends(get_current_user)
117
+ ) -> User:
118
+ """
119
+ Get the current active user.
120
+
121
+ This is a placeholder for future user status checks.
122
+ """
123
+ # In the future, you might check if user is active, suspended, etc.
124
+ return current_user
125
+
126
+
127
+ def get_optional_current_user(
128
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
129
+ db: Session = Depends(get_db)
130
+ ) -> Optional[User]:
131
+ """
132
+ Get the current user if authenticated, but don't raise an error if not.
133
+
134
+ Returns:
135
+ The authenticated user object or None
136
+ """
137
+ if credentials is None:
138
+ return None
139
+
140
+ try:
141
+ token = credentials.credentials
142
+ payload = verify_token(token)
143
+
144
+ if payload is None:
145
+ return None
146
+
147
+ user_id = payload.get("sub")
148
+ if user_id is None:
149
+ return None
150
+
151
+ user = db.query(User).filter(User.id == user_id).first()
152
+ return user if user and user.email_verified else None
153
+
154
+ except Exception:
155
+ return None
156
+
157
+
158
+ def get_current_user_with_session(
159
+ token: str = Depends(get_current_user_token),
160
+ db: Session = Depends(get_db)
161
+ ) -> tuple[User, Session]:
162
+ """
163
+ Get the current user and their active session.
164
+
165
+ Returns:
166
+ Tuple of (user, session) objects
167
+ """
168
+ # Get user
169
+ payload = verify_token(token)
170
+ if payload is None:
171
+ raise HTTPException(
172
+ status_code=status.HTTP_401_UNAUTHORIZED,
173
+ detail="Invalid authentication credentials"
174
+ )
175
+
176
+ user_id = payload.get("sub")
177
+ if user_id is None:
178
+ raise HTTPException(
179
+ status_code=status.HTTP_401_UNAUTHORIZED,
180
+ detail="Invalid authentication credentials"
181
+ )
182
+
183
+ # Get user and session from database
184
+ user = db.query(User).filter(User.id == user_id).first()
185
+ if user is None:
186
+ raise HTTPException(
187
+ status_code=status.HTTP_401_UNAUTHORIZED,
188
+ detail="User not found"
189
+ )
190
+
191
+ # Get active session
192
+ session = db.query(Session).filter(
193
+ Session.user_id == user_id,
194
+ Session.expires_at > datetime.utcnow()
195
+ ).first()
196
+
197
+ if session is None:
198
+ raise HTTPException(
199
+ status_code=status.HTTP_401_UNAUTHORIZED,
200
+ detail="No active session found. Please login again."
201
+ )
202
+
203
+ return user, session
204
+
205
+
206
+ def validate_anonymous_session(
207
+ request: Request,
208
+ db: Session = Depends(get_db)
209
+ ) -> Optional[str]:
210
+ """
211
+ Validate and extract anonymous session from request headers.
212
+
213
+ Returns:
214
+ Anonymous session ID if valid, None otherwise
215
+ """
216
+ # Get session ID from header or cookie
217
+ session_id = request.headers.get("X-Anonymous-Session-ID")
218
+
219
+ if not session_id:
220
+ # Check for session cookie
221
+ session_id = request.cookies.get("anonymous_session_id")
222
+
223
+ if not session_id:
224
+ return None
225
+
226
+ # Verify session exists and is not expired
227
+ from src.services.anonymous import AnonymousSessionService
228
+ anon_service = AnonymousSessionService(db)
229
+
230
+ session = anon_service.get_session(session_id)
231
+ if not session or anon_service.is_session_expired(session_id):
232
+ return None
233
+
234
+ return session_id
235
+
236
+
237
+ def require_auth(user: Optional[User] = Depends(get_optional_current_user)):
238
+ """
239
+ Dependency that requires authentication but provides a user object.
240
+ """
241
+ if user is None:
242
+ raise HTTPException(
243
+ status_code=status.HTTP_401_UNAUTHORIZED,
244
+ detail="Authentication required"
245
+ )
246
+ return user
247
+
248
+
249
+ def get_current_user_or_anonymous(
250
+ request: Request,
251
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
252
+ db: Session = Depends(get_db)
253
+ ) -> User:
254
+ """
255
+ Get the current authenticated user or create an anonymous user.
256
+
257
+ Returns:
258
+ User object (authenticated or anonymous)
259
+ """
260
+ # Try to get authenticated user
261
+ if credentials:
262
+ try:
263
+ token = credentials.credentials
264
+ payload = verify_token(token)
265
+
266
+ if payload:
267
+ user_id = payload.get("sub")
268
+ if user_id:
269
+ user = db.query(User).filter(User.id == user_id).first()
270
+ if user and user.email_verified:
271
+ user.is_authenticated = True
272
+ return user
273
+ except Exception:
274
+ pass
275
+
276
+ # Create anonymous user object
277
+ anon_user = User(
278
+ id="anonymous",
279
+ email="[email protected]",
280
+ email_verified=False
281
+ )
282
+ anon_user.is_authenticated = False
283
+ return anon_user
284
+
285
+
286
+ # Common dependency for protecting routes
287
+ CurrentUser = Depends(get_current_active_user)
288
+ OptionalCurrentUser = Depends(get_optional_current_user)
289
+ RequireAuth = Depends(require_auth)
src/services/anonymous.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anonymous session tracking service.
3
+
4
+ This module provides utilities for managing anonymous user sessions
5
+ with message limits and migration to authenticated accounts.
6
+ """
7
+
8
+ import uuid
9
+ from datetime import datetime, timedelta
10
+ from typing import Optional, Dict, List, Any
11
+ from sqlalchemy.orm import Session
12
+ from fastapi import HTTPException, status
13
+
14
+ from src.models.auth import AnonymousSession, ChatSession, ChatMessage, User
15
+
16
+
17
+ class AnonymousSessionService:
18
+ """Service for managing anonymous user sessions."""
19
+
20
+ MAX_MESSAGES = 3 # Maximum messages per anonymous session
21
+ SESSION_DURATION_HOURS = 24 # Session duration in hours
22
+
23
+ def __init__(self, db: Session):
24
+ self.db = db
25
+
26
+ def create_session(self) -> AnonymousSession:
27
+ """Create a new anonymous session."""
28
+ session = AnonymousSession(
29
+ id=str(uuid.uuid4()),
30
+ message_count=0,
31
+ created_at=datetime.utcnow(),
32
+ last_activity=datetime.utcnow()
33
+ )
34
+ self.db.add(session)
35
+ self.db.commit()
36
+ self.db.refresh(session)
37
+ return session
38
+
39
+ def get_session(self, session_id: str) -> Optional[AnonymousSession]:
40
+ """Get an anonymous session by ID."""
41
+ return self.db.query(AnonymousSession).filter(
42
+ AnonymousSession.id == session_id
43
+ ).first()
44
+
45
+ def update_activity(self, session_id: str) -> Optional[AnonymousSession]:
46
+ """Update the last activity timestamp for a session."""
47
+ session = self.get_session(session_id)
48
+ if session:
49
+ session.last_activity = datetime.utcnow()
50
+ self.db.commit()
51
+ self.db.refresh(session)
52
+ return session
53
+
54
+ def increment_message_count(self, session_id: str) -> Optional[AnonymousSession]:
55
+ """Increment message count for a session."""
56
+ session = self.get_session(session_id)
57
+ if session:
58
+ session.message_count += 1
59
+ session.last_activity = datetime.utcnow()
60
+ self.db.commit()
61
+ self.db.refresh(session)
62
+ return session
63
+
64
+ def can_send_message(self, session_id: str) -> bool:
65
+ """Check if a session can send more messages."""
66
+ session = self.get_session(session_id)
67
+ if not session:
68
+ return False
69
+ return session.message_count < self.MAX_MESSAGES
70
+
71
+ def get_remaining_messages(self, session_id: str) -> int:
72
+ """Get remaining messages for a session."""
73
+ session = self.get_session(session_id)
74
+ if not session:
75
+ return self.MAX_MESSAGES
76
+ remaining = self.MAX_MESSAGES - session.message_count
77
+ return max(0, remaining)
78
+
79
+ def is_session_expired(self, session_id: str) -> bool:
80
+ """Check if a session is expired."""
81
+ session = self.get_session(session_id)
82
+ if not session:
83
+ return True
84
+
85
+ expiry_time = session.last_activity + timedelta(hours=self.SESSION_DURATION_HOURS)
86
+ return datetime.utcnow() > expiry_time
87
+
88
+ def cleanup_expired_sessions(self) -> int:
89
+ """Clean up expired sessions and return count of deleted sessions."""
90
+ expiry_threshold = datetime.utcnow() - timedelta(hours=self.SESSION_DURATION_HOURS)
91
+
92
+ expired_sessions = self.db.query(AnonymousSession).filter(
93
+ AnonymousSession.last_activity < expiry_threshold
94
+ ).all()
95
+
96
+ count = len(expired_sessions)
97
+
98
+ for session in expired_sessions:
99
+ self.db.delete(session)
100
+
101
+ self.db.commit()
102
+ return count
103
+
104
+ def migrate_to_user(
105
+ self,
106
+ session_id: str,
107
+ user: User,
108
+ max_messages: int = 10
109
+ ) -> Optional[ChatSession]:
110
+ """
111
+ Migrate anonymous session to authenticated user.
112
+
113
+ Args:
114
+ session_id: The anonymous session ID
115
+ user: The user to migrate to
116
+ max_messages: Maximum number of messages to migrate
117
+
118
+ Returns:
119
+ The new ChatSession for the user, or None if migration fails
120
+ """
121
+ # Get anonymous session
122
+ anon_session = self.get_session(session_id)
123
+ if not anon_session:
124
+ return None
125
+
126
+ # Get recent chat messages from anonymous session
127
+ messages = self.db.query(ChatMessage).filter(
128
+ ChatMessage.anonymous_session_id == session_id
129
+ ).order_by(ChatMessage.created_at.desc()).limit(max_messages).all()
130
+
131
+ if not messages:
132
+ return None
133
+
134
+ # Create new chat session for user
135
+ user_chat_session = ChatSession(
136
+ user_id=user.id,
137
+ title="New Chat",
138
+ created_at=datetime.utcnow(),
139
+ updated_at=datetime.utcnow()
140
+ )
141
+ self.db.add(user_chat_session)
142
+ self.db.flush() # Get the ID without committing
143
+
144
+ # Migrate messages in chronological order
145
+ for message in reversed(messages):
146
+ user_message = ChatMessage(
147
+ chat_session_id=user_chat_session.id,
148
+ role=message.role,
149
+ content=message.content,
150
+ metadata=message.metadata,
151
+ created_at=message.created_at
152
+ )
153
+ self.db.add(user_message)
154
+
155
+ # Delete anonymous session and messages
156
+ self.db.delete(anon_session)
157
+ for message in messages:
158
+ self.db.delete(message)
159
+
160
+ self.db.commit()
161
+ self.db.refresh(user_chat_session)
162
+ return user_chat_session
163
+
164
+ def get_session_stats(self, session_id: str) -> Dict[str, Any]:
165
+ """Get statistics for an anonymous session."""
166
+ session = self.get_session(session_id)
167
+ if not session:
168
+ return {}
169
+
170
+ return {
171
+ "session_id": session.id,
172
+ "message_count": session.message_count,
173
+ "remaining_messages": self.get_remaining_messages(session_id),
174
+ "created_at": session.created_at.isoformat(),
175
+ "last_activity": session.last_activity.isoformat(),
176
+ "is_expired": self.is_session_expired(session_id),
177
+ "can_send_message": self.can_send_message(session_id)
178
+ }
179
+
180
+ @staticmethod
181
+ def generate_session_id() -> str:
182
+ """Generate a new session ID."""
183
+ return str(uuid.uuid4())
src/services/auth.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication service utilities.
3
+
4
+ This module contains JWT token utilities, password hashing,
5
+ and other authentication-related helper functions.
6
+ """
7
+
8
+ from datetime import datetime, timedelta
9
+ from typing import Optional, Dict, Any
10
+ import os
11
+ import bcrypt
12
+ import jwt
13
+ from passlib.context import CryptContext
14
+
15
+ # Password hashing context
16
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
17
+
18
+ # JWT settings
19
+ SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
20
+ ALGORITHM = os.getenv("ALGORITHM", "HS256")
21
+ ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7 days
22
+
23
+
24
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
25
+ """Verify a password against its hash."""
26
+ return pwd_context.verify(plain_password, hashed_password)
27
+
28
+
29
+ def get_password_hash(password: str) -> str:
30
+ """Generate a password hash."""
31
+ return pwd_context.hash(password)
32
+
33
+
34
+ def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
35
+ """Create a JWT access token."""
36
+ to_encode = data.copy()
37
+
38
+ if expires_delta:
39
+ expire = datetime.utcnow() + expires_delta
40
+ else:
41
+ expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
42
+
43
+ to_encode.update({"exp": expire})
44
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
45
+ return encoded_jwt
46
+
47
+
48
+ def verify_token(token: str) -> Optional[Dict[str, Any]]:
49
+ """Verify and decode a JWT token."""
50
+ try:
51
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
52
+ return payload
53
+ except jwt.PyJWTError:
54
+ return None
55
+
56
+
57
+ def create_token_hash(token: str) -> str:
58
+ """Create a hash of the token for database storage."""
59
+ return get_password_hash(token)
60
+
61
+
62
+ def generate_password_reset_token(email: str) -> str:
63
+ """Generate a password reset token."""
64
+ delta = timedelta(hours=24) # Token expires in 24 hours
65
+ now = datetime.utcnow()
66
+ expires = now + delta
67
+ exp = expires.timestamp()
68
+ encoded_jwt = jwt.encode(
69
+ {"exp": exp, "nbf": now, "sub": email},
70
+ SECRET_KEY,
71
+ algorithm=ALGORITHM,
72
+ )
73
+ return encoded_jwt
74
+
75
+
76
+ def verify_password_reset_token(token: str) -> Optional[str]:
77
+ """Verify a password reset token and return the email."""
78
+ try:
79
+ decoded_token = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
80
+ return decoded_token["sub"]
81
+ except jwt.PyJWTError:
82
+ return None
83
+
84
+
85
+ def hash_token_for_storage(token: str) -> str:
86
+ """Hash a token for secure storage in database."""
87
+ return get_password_hash(token)
88
+
89
+
90
+ def validate_password_strength(password: str) -> bool:
91
+ """
92
+ Validate password strength.
93
+
94
+ Requirements:
95
+ - At least 8 characters
96
+ - Contains at least one letter
97
+ - Contains at least one number
98
+ """
99
+ if len(password) < 8:
100
+ return False
101
+
102
+ has_letter = any(c.isalpha() for c in password)
103
+ has_number = any(c.isdigit() for c in password)
104
+
105
+ return has_letter and has_number
106
+
107
+
108
+ def get_user_id_from_token(token: str) -> Optional[str]:
109
+ """Extract user ID from JWT token."""
110
+ payload = verify_token(token)
111
+ if payload:
112
+ return payload.get("sub")
113
+ return None
114
+
115
+
116
+ def is_token_expired(token: str) -> bool:
117
+ """Check if a token is expired."""
118
+ payload = verify_token(token)
119
+ if not payload:
120
+ return True
121
+
122
+ exp = payload.get("exp")
123
+ if not exp:
124
+ return True
125
+
126
+ return datetime.utcnow().timestamp() > exp
src/services/email.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Email service for sending password reset emails and other notifications.
3
+
4
+ This module handles email configuration and sending using SMTP.
5
+ """
6
+
7
+ import os
8
+ import smtplib
9
+ from email.mime.text import MIMEText
10
+ from email.mime.multipart import MIMEMultipart
11
+ from typing import Optional
12
+ import logging
13
+ from jinja2 import Template
14
+ import aiosmtplib
15
+ from email.utils import formataddr
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class EmailService:
21
+ """Service for sending emails."""
22
+
23
+ def __init__(self):
24
+ self.smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
25
+ self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
26
+ self.smtp_user = os.getenv("SMTP_USER")
27
+ self.smtp_password = os.getenv("SMTP_PASSWORD")
28
+ self.email_from = os.getenv("EMAIL_FROM", self.smtp_user)
29
+ self.email_from_name = os.getenv("EMAIL_FROM_NAME", "AI Book App")
30
+ self.use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
31
+
32
+ def _get_smtp_connection(self):
33
+ """Create SMTP connection."""
34
+ if self.use_tls:
35
+ return smtplib.SMTP(self.smtp_host, self.smtp_port)
36
+ else:
37
+ return smtplib.SMTP_SSL(self.smtp_host, self.smtp_port)
38
+
39
+ def send_password_reset_email(self, to_email: str, reset_token: str, frontend_url: str) -> bool:
40
+ """
41
+ Send password reset email.
42
+
43
+ Args:
44
+ to_email: Recipient email address
45
+ reset_token: Password reset token
46
+ frontend_url: Frontend URL for reset link
47
+
48
+ Returns:
49
+ True if email was sent successfully, False otherwise
50
+ """
51
+ try:
52
+ # Create reset link
53
+ reset_link = f"{frontend_url}/reset-password?token={reset_token}"
54
+
55
+ # Render email template
56
+ subject = "Reset your AI Book password"
57
+
58
+ html_template = """
59
+ <!DOCTYPE html>
60
+ <html>
61
+ <head>
62
+ <meta charset="utf-8">
63
+ <title>Reset your password</title>
64
+ <style>
65
+ body {
66
+ font-family: Arial, sans-serif;
67
+ line-height: 1.6;
68
+ color: #333;
69
+ max-width: 600px;
70
+ margin: 0 auto;
71
+ padding: 20px;
72
+ }
73
+ .container {
74
+ background-color: #f9f9f9;
75
+ padding: 30px;
76
+ border-radius: 5px;
77
+ border: 1px solid #ddd;
78
+ }
79
+ .header {
80
+ text-align: center;
81
+ margin-bottom: 30px;
82
+ }
83
+ .button {
84
+ display: inline-block;
85
+ background-color: #007bff;
86
+ color: white;
87
+ padding: 12px 30px;
88
+ text-decoration: none;
89
+ border-radius: 5px;
90
+ margin: 20px 0;
91
+ }
92
+ .footer {
93
+ margin-top: 30px;
94
+ text-align: center;
95
+ font-size: 12px;
96
+ color: #666;
97
+ }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <div class="container">
102
+ <div class="header">
103
+ <h1>AI Book</h1>
104
+ <h2>Password Reset Request</h2>
105
+ </div>
106
+
107
+ <p>Hello,</p>
108
+
109
+ <p>We received a request to reset the password for your AI Book account.
110
+ Click the button below to reset your password:</p>
111
+
112
+ <p style="text-align: center;">
113
+ <a href="{{ reset_link }}" class="button">Reset Password</a>
114
+ </p>
115
+
116
+ <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
117
+ <p>{{ reset_link }}</p>
118
+
119
+ <p>This link will expire in 24 hours for security reasons.</p>
120
+
121
+ <p>If you didn't request this password reset, you can safely ignore this email.</p>
122
+
123
+ <div class="footer">
124
+ <p>Best regards,<br>The AI Book Team</p>
125
+ <p>This is an automated message. Please do not reply to this email.</p>
126
+ </div>
127
+ </div>
128
+ </body>
129
+ </html>
130
+ """
131
+
132
+ text_template = """
133
+ AI Book - Password Reset Request
134
+
135
+ Hello,
136
+
137
+ We received a request to reset the password for your AI Book account.
138
+ Please visit this link to reset your password:
139
+
140
+ {reset_link}
141
+
142
+ This link will expire in 24 hours for security reasons.
143
+
144
+ If you didn't request this password reset, you can safely ignore this email.
145
+
146
+ Best regards,
147
+ The AI Book Team
148
+ """
149
+
150
+ # Render templates
151
+ html_content = Template(html_template).render(reset_link=reset_link)
152
+ text_content = text_template.format(reset_link=reset_link)
153
+
154
+ # Create message
155
+ msg = MIMEMultipart("alternative")
156
+ msg["Subject"] = subject
157
+ msg["From"] = formataddr((self.email_from_name, self.email_from))
158
+ msg["To"] = to_email
159
+
160
+ # Attach HTML and text parts
161
+ msg.attach(MIMEText(text_content, "plain"))
162
+ msg.attach(MIMEText(html_content, "html"))
163
+
164
+ # Send email
165
+ with self._get_smtp_connection() as server:
166
+ if self.use_tls:
167
+ server.starttls()
168
+ if self.smtp_user and self.smtp_password:
169
+ server.login(self.smtp_user, self.smtp_password)
170
+ server.send_message(msg)
171
+
172
+ logger.info(f"Password reset email sent to {to_email}")
173
+ return True
174
+
175
+ except Exception as e:
176
+ logger.error(f"Failed to send password reset email to {to_email}: {e}")
177
+ return False
178
+
179
+ async def send_password_reset_email_async(self, to_email: str, reset_token: str, frontend_url: str) -> bool:
180
+ """
181
+ Send password reset email asynchronously.
182
+
183
+ Args:
184
+ to_email: Recipient email address
185
+ reset_token: Password reset token
186
+ frontend_url: Frontend URL for reset link
187
+
188
+ Returns:
189
+ True if email was sent successfully, False otherwise
190
+ """
191
+ try:
192
+ # Create reset link
193
+ reset_link = f"{frontend_url}/reset-password?token={reset_token}"
194
+
195
+ # Create message
196
+ message = MIMEMultipart("alternative")
197
+ message["From"] = formataddr((self.email_from_name, self.email_from))
198
+ message["To"] = to_email
199
+ message["Subject"] = "Reset your AI Book password"
200
+
201
+ # HTML content
202
+ html = f"""
203
+ <html>
204
+ <body>
205
+ <h2>Reset your AI Book password</h2>
206
+ <p>Hello,</p>
207
+ <p>We received a request to reset the password for your AI Book account.</p>
208
+ <p>Click <a href="{reset_link}">here</a> to reset your password.</p>
209
+ <p>This link will expire in 24 hours.</p>
210
+ <p>If you didn't request this reset, you can safely ignore this email.</p>
211
+ </body>
212
+ </html>
213
+ """
214
+
215
+ # Attach parts
216
+ message.attach(MIMEText("Hello,\n\nWe received a request to reset your password. Visit: " + reset_link, "plain"))
217
+ message.attach(MIMEText(html, "html"))
218
+
219
+ # Send email using aiosmtplib
220
+ await aiosmtplib.send_message(
221
+ message,
222
+ hostname=self.smtp_host,
223
+ port=self.smtp_port,
224
+ start_tls=self.use_tls,
225
+ username=self.smtp_user,
226
+ password=self.smtp_password,
227
+ )
228
+
229
+ logger.info(f"Password reset email sent asynchronously to {to_email}")
230
+ return True
231
+
232
+ except Exception as e:
233
+ logger.error(f"Failed to send password reset email to {to_email}: {e}")
234
+ return False
235
+
236
+ def send_verification_email(self, to_email: str, verification_token: str, frontend_url: str) -> bool:
237
+ """
238
+ Send email verification email.
239
+
240
+ Args:
241
+ to_email: Recipient email address
242
+ verification_token: Email verification token
243
+ frontend_url: Frontend URL for verification link
244
+
245
+ Returns:
246
+ True if email was sent successfully, False otherwise
247
+ """
248
+ try:
249
+ # Create verification link
250
+ verification_link = f"{frontend_url}/verify-email?token={verification_token}"
251
+
252
+ # Create message
253
+ msg = MIMEMultipart()
254
+ msg["Subject"] = "Verify your AI Book email address"
255
+ msg["From"] = formataddr((self.email_from_name, self.email_from))
256
+ msg["To"] = to_email
257
+
258
+ # HTML content
259
+ html = f"""
260
+ <html>
261
+ <body>
262
+ <h2>Welcome to AI Book!</h2>
263
+ <p>Thank you for signing up. Please click the link below to verify your email address:</p>
264
+ <p><a href="{verification_link}">Verify Email</a></p>
265
+ <p>This link will expire in 24 hours.</p>
266
+ <p>If you didn't create an account, you can safely ignore this email.</p>
267
+ </body>
268
+ </html>
269
+ """
270
+
271
+ # Attach parts
272
+ msg.attach(MIMEText(f"Welcome to AI Book! Please verify your email: {verification_link}", "plain"))
273
+ msg.attach(MIMEText(html, "html"))
274
+
275
+ # Send email
276
+ with self._get_smtp_connection() as server:
277
+ if self.use_tls:
278
+ server.starttls()
279
+ if self.smtp_user and self.smtp_password:
280
+ server.login(self.smtp_user, self.smtp_password)
281
+ server.send_message(msg)
282
+
283
+ logger.info(f"Verification email sent to {to_email}")
284
+ return True
285
+
286
+ except Exception as e:
287
+ logger.error(f"Failed to send verification email to {to_email}: {e}")
288
+ return False
289
+
290
+ def is_configured(self) -> bool:
291
+ """Check if email service is properly configured."""
292
+ return all([
293
+ self.smtp_host,
294
+ self.smtp_user,
295
+ self.smtp_password,
296
+ self.email_from
297
+ ])
298
+
299
+
300
+ # Singleton instance
301
+ email_service = EmailService()
src/services/message_editor.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Service for editing chat messages with version tracking.
3
+
4
+ This module provides functionality to edit messages while maintaining
5
+ a complete version history for audit and rollback purposes.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional, Dict, Any
10
+ from sqlalchemy.orm import Session
11
+ from datetime import datetime, timedelta
12
+
13
+ from src.models.auth import ChatMessage, MessageVersion
14
+ from src.database.config import get_db
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class MessageEditorService:
20
+ """Service for editing messages with version tracking."""
21
+
22
+ def __init__(self, db: Session):
23
+ self.db = db
24
+
25
+ def can_edit_message(
26
+ self,
27
+ message_id: str,
28
+ user_id: str
29
+ ) -> Dict[str, Any]:
30
+ """
31
+ Check if a message can be edited by the user.
32
+
33
+ Args:
34
+ message_id: ID of the message to check
35
+ user_id: ID of the user attempting to edit
36
+
37
+ Returns:
38
+ Dictionary with can_edit status and reason if not editable
39
+ """
40
+ message = self.db.query(ChatMessage).filter(
41
+ ChatMessage.id == message_id
42
+ ).first()
43
+
44
+ if not message:
45
+ return {
46
+ "can_edit": False,
47
+ "reason": "Message not found"
48
+ }
49
+
50
+ # Check if message belongs to the user
51
+ if message.chat_session.user_id != user_id:
52
+ return {
53
+ "can_edit": False,
54
+ "reason": "You can only edit your own messages"
55
+ }
56
+
57
+ # Only user messages can be edited
58
+ if message.role != "user":
59
+ return {
60
+ "can_edit": False,
61
+ "reason": "Only user messages can be edited"
62
+ }
63
+
64
+ # Check if message is within the editable time window (15 minutes)
65
+ edit_window = timedelta(minutes=15)
66
+ if datetime.utcnow() - message.created_at > edit_window:
67
+ return {
68
+ "can_edit": False,
69
+ "reason": "Messages can only be edited within 15 minutes of sending"
70
+ }
71
+
72
+ return {
73
+ "can_edit": True,
74
+ "reason": None
75
+ }
76
+
77
+ def edit_message(
78
+ self,
79
+ message_id: str,
80
+ new_content: str,
81
+ user_id: str,
82
+ edit_reason: Optional[str] = None
83
+ ) -> Dict[str, Any]:
84
+ """
85
+ Edit a message and create a version record.
86
+
87
+ Args:
88
+ message_id: ID of the message to edit
89
+ new_content: New content for the message
90
+ user_id: ID of the user editing the message
91
+ edit_reason: Optional reason for the edit
92
+
93
+ Returns:
94
+ Dictionary with edit result and updated message
95
+ """
96
+ # Check if message can be edited
97
+ can_edit_result = self.can_edit_message(message_id, user_id)
98
+ if not can_edit_result["can_edit"]:
99
+ return {
100
+ "success": False,
101
+ "error": can_edit_result["reason"]
102
+ }
103
+
104
+ try:
105
+ # Get the message
106
+ message = self.db.query(ChatMessage).filter(
107
+ ChatMessage.id == message_id
108
+ ).first()
109
+
110
+ # No changes needed
111
+ if message.content == new_content:
112
+ return {
113
+ "success": True,
114
+ "message": "No changes made",
115
+ "edited_message": self._message_to_dict(message)
116
+ }
117
+
118
+ # Create version record before editing
119
+ version = MessageVersion(
120
+ message_id=message.id,
121
+ version_number=message.edit_count + 1,
122
+ content=message.content,
123
+ edit_reason=edit_reason
124
+ )
125
+ self.db.add(version)
126
+
127
+ # Update the message
128
+ message.content = new_content
129
+ message.edited_at = datetime.utcnow()
130
+ message.edit_count += 1
131
+ message.updated_at = datetime.utcnow()
132
+
133
+ self.db.commit()
134
+
135
+ logger.info(f"Message {message_id} edited by user {user_id}")
136
+
137
+ return {
138
+ "success": True,
139
+ "message": "Message edited successfully",
140
+ "edited_message": self._message_to_dict(message)
141
+ }
142
+
143
+ except Exception as e:
144
+ logger.error(f"Failed to edit message {message_id}: {str(e)}")
145
+ self.db.rollback()
146
+ return {
147
+ "success": False,
148
+ "error": "Failed to edit message"
149
+ }
150
+
151
+ def get_message_versions(
152
+ self,
153
+ message_id: str,
154
+ user_id: str
155
+ ) -> Dict[str, Any]:
156
+ """
157
+ Get all versions of a message.
158
+
159
+ Args:
160
+ message_id: ID of the message
161
+ user_id: ID of the user requesting versions
162
+
163
+ Returns:
164
+ Dictionary with version history
165
+ """
166
+ # Verify ownership
167
+ message = self.db.query(ChatMessage).filter(
168
+ ChatMessage.id == message_id
169
+ ).first()
170
+
171
+ if not message or message.chat_session.user_id != user_id:
172
+ return {
173
+ "success": False,
174
+ "error": "Message not found or access denied"
175
+ }
176
+
177
+ # Get all versions including current
178
+ versions = self.db.query(MessageVersion).filter(
179
+ MessageVersion.message_id == message_id
180
+ ).order_by(MessageVersion.version_number.desc()).all()
181
+
182
+ version_history = []
183
+
184
+ # Add current version
185
+ version_history.append({
186
+ "version": message.edit_count + 1,
187
+ "content": message.content,
188
+ "created_at": message.edited_at.isoformat() if message.edited_at else message.created_at.isoformat(),
189
+ "is_current": True
190
+ })
191
+
192
+ # Add historical versions
193
+ for version in versions:
194
+ version_history.append({
195
+ "version": version.version_number,
196
+ "content": version.content,
197
+ "created_at": version.created_at.isoformat(),
198
+ "edit_reason": version.edit_reason,
199
+ "is_current": False
200
+ })
201
+
202
+ return {
203
+ "success": True,
204
+ "versions": version_history
205
+ }
206
+
207
+ def _message_to_dict(self, message: ChatMessage) -> Dict[str, Any]:
208
+ """Convert message model to dictionary."""
209
+ return {
210
+ "id": message.id,
211
+ "content": message.content,
212
+ "role": message.role,
213
+ "created_at": message.created_at.isoformat(),
214
+ "edited_at": message.edited_at.isoformat() if message.edited_at else None,
215
+ "edit_count": message.edit_count
216
+ }
217
+
218
+
219
+ def edit_message(
220
+ message_id: str,
221
+ new_content: str,
222
+ user_id: str,
223
+ edit_reason: Optional[str] = None
224
+ ) -> Dict[str, Any]:
225
+ """
226
+ Convenience function to edit a message.
227
+
228
+ Args:
229
+ message_id: ID of the message to edit
230
+ new_content: New content for the message
231
+ user_id: ID of the user editing the message
232
+ edit_reason: Optional reason for the edit
233
+
234
+ Returns:
235
+ Edit result dictionary
236
+ """
237
+ db = next(get_db())
238
+ try:
239
+ service = MessageEditorService(db)
240
+ return service.edit_message(message_id, new_content, user_id, edit_reason)
241
+ finally:
242
+ db.close()
243
+
244
+
245
+ def get_message_versions(message_id: str, user_id: str) -> Dict[str, Any]:
246
+ """
247
+ Convenience function to get message versions.
248
+
249
+ Args:
250
+ message_id: ID of the message
251
+ user_id: ID of the user requesting versions
252
+
253
+ Returns:
254
+ Version history dictionary
255
+ """
256
+ db = next(get_db())
257
+ try:
258
+ service = MessageEditorService(db)
259
+ return service.get_message_versions(message_id, user_id)
260
+ finally:
261
+ db.close()
src/services/session_migration.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Service for handling migration of anonymous chat sessions to authenticated users.
3
+
4
+ This module provides functionality to migrate chat sessions, messages,
5
+ and related data from anonymous users to authenticated users when they sign up or log in.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional, List, Dict, Any
10
+ from sqlalchemy.orm import Session
11
+ from sqlalchemy import and_
12
+ from datetime import datetime, timedelta
13
+
14
+ from src.models.auth import User, ChatSession, ChatMessage, AnonymousSession
15
+ from src.database.config import get_db
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SessionMigrationService:
21
+ """Service for migrating anonymous sessions to authenticated users."""
22
+
23
+ def __init__(self, db: Session):
24
+ self.db = db
25
+
26
+ def migrate_anonymous_session(
27
+ self,
28
+ anonymous_session_id: str,
29
+ authenticated_user_id: str
30
+ ) -> Dict[str, Any]:
31
+ """
32
+ Migrate all chat sessions and messages from an anonymous session to an authenticated user.
33
+
34
+ Args:
35
+ anonymous_session_id: ID of the anonymous session
36
+ authenticated_user_id: ID of the authenticated user
37
+
38
+ Returns:
39
+ Dictionary containing migration results including:
40
+ - migrated_sessions_count: Number of sessions migrated
41
+ - migrated_messages_count: Number of messages migrated
42
+ - session_ids: List of migrated session IDs
43
+ """
44
+ try:
45
+ # Get the anonymous session record
46
+ anon_session = self.db.query(AnonymousSession).filter(
47
+ AnonymousSession.id == anonymous_session_id
48
+ ).first()
49
+
50
+ if not anon_session:
51
+ logger.warning(f"Anonymous session not found: {anonymous_session_id}")
52
+ return {
53
+ "success": False,
54
+ "error": "Anonymous session not found",
55
+ "migrated_sessions_count": 0,
56
+ "migrated_messages_count": 0,
57
+ "session_ids": []
58
+ }
59
+
60
+ # Get all chat sessions associated with this anonymous session
61
+ chat_sessions = self.db.query(ChatSession).filter(
62
+ ChatSession.anonymous_session_id == anonymous_session_id
63
+ ).all()
64
+
65
+ if not chat_sessions:
66
+ logger.info(f"No chat sessions found for anonymous session: {anonymous_session_id}")
67
+ return {
68
+ "success": True,
69
+ "migrated_sessions_count": 0,
70
+ "migrated_messages_count": 0,
71
+ "session_ids": []
72
+ }
73
+
74
+ migrated_sessions = []
75
+ total_messages = 0
76
+
77
+ for session in chat_sessions:
78
+ # Update the session to associate with the authenticated user
79
+ session.user_id = authenticated_user_id
80
+ session.anonymous_session_id = None
81
+
82
+ # Update all messages in this session
83
+ messages = self.db.query(ChatMessage).filter(
84
+ ChatMessage.chat_session_id == session.id
85
+ ).all()
86
+
87
+ for message in messages:
88
+ # Ensure message is properly linked
89
+ message.chat_session_id = session.id
90
+
91
+ total_messages += len(messages)
92
+ migrated_sessions.append(session.id)
93
+
94
+ # Update session title if it's the default
95
+ if session.title == "New Chat":
96
+ session.title = f"Chat from {datetime.utcnow().strftime('%Y-%m-%d')}"
97
+
98
+ # Commit the changes
99
+ self.db.commit()
100
+
101
+ # Update the anonymous session to mark it as migrated
102
+ anon_session.migrated_at = datetime.utcnow()
103
+ anon_session.migrated_to_user_id = authenticated_user_id
104
+ self.db.commit()
105
+
106
+ logger.info(
107
+ f"Successfully migrated {len(chat_sessions)} sessions "
108
+ f"with {total_messages} messages from anonymous session "
109
+ f"{anonymous_session_id} to user {authenticated_user_id}"
110
+ )
111
+
112
+ return {
113
+ "success": True,
114
+ "migrated_sessions_count": len(chat_sessions),
115
+ "migrated_messages_count": total_messages,
116
+ "session_ids": migrated_sessions
117
+ }
118
+
119
+ except Exception as e:
120
+ logger.error(
121
+ f"Failed to migrate anonymous session {anonymous_session_id}: {str(e)}",
122
+ exc_info=True
123
+ )
124
+ self.db.rollback()
125
+ return {
126
+ "success": False,
127
+ "error": str(e),
128
+ "migrated_sessions_count": 0,
129
+ "migrated_messages_count": 0,
130
+ "session_ids": []
131
+ }
132
+
133
+ def get_anonymous_session_info(self, anonymous_session_id: str) -> Optional[Dict[str, Any]]:
134
+ """
135
+ Get information about an anonymous session including session count and message count.
136
+
137
+ Args:
138
+ anonymous_session_id: ID of the anonymous session
139
+
140
+ Returns:
141
+ Dictionary with session info or None if not found
142
+ """
143
+ try:
144
+ anon_session = self.db.query(AnonymousSession).filter(
145
+ AnonymousSession.id == anonymous_session_id
146
+ ).first()
147
+
148
+ if not anon_session:
149
+ return None
150
+
151
+ # Count chat sessions
152
+ session_count = self.db.query(ChatSession).filter(
153
+ ChatSession.anonymous_session_id == anonymous_session_id
154
+ ).count()
155
+
156
+ # Count total messages
157
+ message_count = self.db.query(ChatMessage).join(ChatSession).filter(
158
+ ChatSession.anonymous_session_id == anonymous_session_id
159
+ ).count()
160
+
161
+ return {
162
+ "id": anon_session.id,
163
+ "message_count": anon_session.message_count,
164
+ "chat_sessions_count": session_count,
165
+ "total_messages_count": message_count,
166
+ "last_activity": anon_session.last_activity,
167
+ "created_at": anon_session.created_at,
168
+ "migrated_at": anon_session.migrated_at,
169
+ "migrated_to_user_id": anon_session.migrated_to_user_id
170
+ }
171
+
172
+ except Exception as e:
173
+ logger.error(f"Failed to get anonymous session info: {str(e)}")
174
+ return None
175
+
176
+ def cleanup_expired_anonymous_sessions(self, days_old: int = 30) -> int:
177
+ """
178
+ Clean up expired anonymous sessions that haven't been migrated.
179
+
180
+ Args:
181
+ days_old: Delete sessions older than this many days
182
+
183
+ Returns:
184
+ Number of sessions cleaned up
185
+ """
186
+ try:
187
+ cutoff_date = datetime.utcnow() - timedelta(days=days_old)
188
+
189
+ # Get expired anonymous sessions that haven't been migrated
190
+ expired_sessions = self.db.query(AnonymousSession).filter(
191
+ and_(
192
+ AnonymousSession.last_activity < cutoff_date,
193
+ AnonymousSession.migrated_at.is_(None)
194
+ )
195
+ ).all()
196
+
197
+ # Delete associated chat sessions and messages
198
+ deleted_count = 0
199
+ for anon_session in expired_sessions:
200
+ # Get and delete chat sessions
201
+ chat_sessions = self.db.query(ChatSession).filter(
202
+ ChatSession.anonymous_session_id == anon_session.id
203
+ ).all()
204
+
205
+ for session in chat_sessions:
206
+ # Messages will be deleted via cascade
207
+ self.db.delete(session)
208
+ deleted_count += 1
209
+
210
+ # Delete the anonymous session
211
+ self.db.delete(anon_session)
212
+
213
+ self.db.commit()
214
+
215
+ logger.info(f"Cleaned up {len(expired_sessions)} expired anonymous sessions "
216
+ f"with {deleted_count} chat sessions")
217
+
218
+ return len(expired_sessions)
219
+
220
+ except Exception as e:
221
+ logger.error(f"Failed to cleanup expired anonymous sessions: {str(e)}")
222
+ self.db.rollback()
223
+ return 0
224
+
225
+
226
+ def migrate_anonymous_session_on_auth(
227
+ anonymous_session_id: str,
228
+ authenticated_user_id: str
229
+ ) -> Dict[str, Any]:
230
+ """
231
+ Convenience function to migrate an anonymous session when a user authenticates.
232
+
233
+ This is typically called during login or registration flow.
234
+
235
+ Args:
236
+ anonymous_session_id: ID from X-Anonymous-Session-ID header
237
+ authenticated_user_id: ID of the newly authenticated user
238
+
239
+ Returns:
240
+ Migration result dictionary
241
+ """
242
+ db = next(get_db())
243
+ try:
244
+ service = SessionMigrationService(db)
245
+ return service.migrate_anonymous_session(anonymous_session_id, authenticated_user_id)
246
+ finally:
247
+ db.close()
248
+
249
+
250
+ def get_anonymous_session_for_migration(anonymous_session_id: str) -> Optional[Dict[str, Any]]:
251
+ """
252
+ Convenience function to get anonymous session info for migration preview.
253
+
254
+ Args:
255
+ anonymous_session_id: ID from X-Anonymous-Session-ID header
256
+
257
+ Returns:
258
+ Session info dictionary or None
259
+ """
260
+ db = next(get_db())
261
+ try:
262
+ service = SessionMigrationService(db)
263
+ return service.get_anonymous_session_info(anonymous_session_id)
264
+ finally:
265
+ db.close()
start_server.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Startup script for Hugging Face Spaces deployment.
3
+ Initializes the database and starts the FastAPI server.
4
+ """
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ def main():
10
+ """Initialize database and start the server."""
11
+ print("🚀 Starting server initialization...")
12
+
13
+ # Initialize database first
14
+ print("📦 Initializing database...")
15
+ os.system("python init_database.py")
16
+
17
+ # Check if database initialization was successful
18
+ db_path = Path("database/auth.db")
19
+ if db_path.exists():
20
+ print("✅ Database initialized successfully!")
21
+ else:
22
+ print("⚠️ Database file not found after initialization. The server will create it on startup.")
23
+
24
+ # Start the FastAPI server
25
+ print("🌟 Starting FastAPI server...")
26
+ os.system("uvicorn main:app --host 0.0.0.0 --port 7860 --workers 1")
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
tests/test_auth.py CHANGED
@@ -24,7 +24,7 @@ from jose import jwt
24
  # Import modules to test
25
  from main import app
26
  from database.config import get_db, Base
27
- from models.auth import User, Session, UserPreferences
28
  from auth.auth import create_access_token, verify_token
29
  from middleware.csrf import CSRFMiddleware
30
  from middleware.auth import AuthMiddleware
 
24
  # Import modules to test
25
  from main import app
26
  from database.config import get_db, Base
27
+ from src.models.auth import User, Session, UserPreferences
28
  from auth.auth import create_access_token, verify_token
29
  from middleware.csrf import CSRFMiddleware
30
  from middleware.auth import AuthMiddleware
uv.lock CHANGED
@@ -240,72 +240,32 @@ wheels = [
240
 
241
  [[package]]
242
  name = "bcrypt"
243
- version = "5.0.0"
244
- source = { registry = "https://pypi.org/simple" }
245
- sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386 }
246
- wheels = [
247
- { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806 },
248
- { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626 },
249
- { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853 },
250
- { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793 },
251
- { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930 },
252
- { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194 },
253
- { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381 },
254
- { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750 },
255
- { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757 },
256
- { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740 },
257
- { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197 },
258
- { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974 },
259
- { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498 },
260
- { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853 },
261
- { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626 },
262
- { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862 },
263
- { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544 },
264
- { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787 },
265
- { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753 },
266
- { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587 },
267
- { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178 },
268
- { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295 },
269
- { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700 },
270
- { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034 },
271
- { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766 },
272
- { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449 },
273
- { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310 },
274
- { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761 },
275
- { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553 },
276
- { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009 },
277
- { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029 },
278
- { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907 },
279
- { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500 },
280
- { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412 },
281
- { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486 },
282
- { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940 },
283
- { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776 },
284
- { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922 },
285
- { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367 },
286
- { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187 },
287
- { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752 },
288
- { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881 },
289
- { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931 },
290
- { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313 },
291
- { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290 },
292
- { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253 },
293
- { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084 },
294
- { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185 },
295
- { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656 },
296
- { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662 },
297
- { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240 },
298
- { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152 },
299
- { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284 },
300
- { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643 },
301
- { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698 },
302
- { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725 },
303
- { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912 },
304
- { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953 },
305
- { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180 },
306
- { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791 },
307
- { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746 },
308
- { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375 },
309
  ]
310
 
311
  [[package]]
@@ -707,6 +667,15 @@ wheels = [
707
  { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 },
708
  ]
709
 
 
 
 
 
 
 
 
 
 
710
  [[package]]
711
  name = "ecdsa"
712
  version = "0.19.1"
@@ -719,6 +688,19 @@ wheels = [
719
  { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607 },
720
  ]
721
 
 
 
 
 
 
 
 
 
 
 
 
 
 
722
  [[package]]
723
  name = "faker"
724
  version = "38.2.0"
@@ -2461,6 +2443,8 @@ dependencies = [
2461
  { name = "alembic" },
2462
  { name = "authlib" },
2463
  { name = "backoff" },
 
 
2464
  { name = "fastapi" },
2465
  { name = "httpx" },
2466
  { name = "itsdangerous" },
@@ -2524,7 +2508,9 @@ requires-dist = [
2524
  { name = "alembic", specifier = ">=1.12.0" },
2525
  { name = "authlib", specifier = ">=1.2.1" },
2526
  { name = "backoff", specifier = ">=2.2.1" },
 
2527
  { name = "black", marker = "extra == 'dev'", specifier = ">=23.12.1" },
 
2528
  { name = "faker", marker = "extra == 'test'", specifier = ">=20.1.0" },
2529
  { name = "fastapi", specifier = ">=0.109.0" },
2530
  { name = "httpx", specifier = ">=0.26.0" },
 
240
 
241
  [[package]]
242
  name = "bcrypt"
243
+ version = "4.2.0"
244
+ source = { registry = "https://pypi.org/simple" }
245
+ sdist = { url = "https://files.pythonhosted.org/packages/e4/7e/d95e7d96d4828e965891af92e43b52a4cd3395dc1c1ef4ee62748d0471d0/bcrypt-4.2.0.tar.gz", hash = "sha256:cf69eaf5185fd58f268f805b505ce31f9b9fc2d64b376642164e9244540c1221", size = 24294 }
246
+ wheels = [
247
+ { url = "https://files.pythonhosted.org/packages/a9/81/4e8f5bc0cd947e91fb720e1737371922854da47a94bc9630454e7b2845f8/bcrypt-4.2.0-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:096a15d26ed6ce37a14c1ac1e48119660f21b24cba457f160a4b830f3fe6b5cb", size = 471568 },
248
+ { url = "https://files.pythonhosted.org/packages/05/d2/1be1e16aedec04bcf8d0156e01b987d16a2063d38e64c3f28030a3427d61/bcrypt-4.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c02d944ca89d9b1922ceb8a46460dd17df1ba37ab66feac4870f6862a1533c00", size = 277372 },
249
+ { url = "https://files.pythonhosted.org/packages/e3/96/7a654027638ad9b7589effb6db77eb63eba64319dfeaf9c0f4ca953e5f76/bcrypt-4.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d84cf6d877918620b687b8fd1bf7781d11e8a0998f576c7aa939776b512b98d", size = 273488 },
250
+ { url = "https://files.pythonhosted.org/packages/46/54/dc7b58abeb4a3d95bab653405935e27ba32f21b812d8ff38f271fb6f7f55/bcrypt-4.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1bb429fedbe0249465cdd85a58e8376f31bb315e484f16e68ca4c786dcc04291", size = 277759 },
251
+ { url = "https://files.pythonhosted.org/packages/ac/be/da233c5f11fce3f8adec05e8e532b299b64833cc962f49331cdd0e614fa9/bcrypt-4.2.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:655ea221910bcac76ea08aaa76df427ef8625f92e55a8ee44fbf7753dbabb328", size = 273796 },
252
+ { url = "https://files.pythonhosted.org/packages/b0/b8/8b4add88d55a263cf1c6b8cf66c735280954a04223fcd2880120cc767ac3/bcrypt-4.2.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:1ee38e858bf5d0287c39b7a1fc59eec64bbf880c7d504d3a06a96c16e14058e7", size = 311082 },
253
+ { url = "https://files.pythonhosted.org/packages/7b/76/2aa660679abbdc7f8ee961552e4bb6415a81b303e55e9374533f22770203/bcrypt-4.2.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0da52759f7f30e83f1e30a888d9163a81353ef224d82dc58eb5bb52efcabc399", size = 305912 },
254
+ { url = "https://files.pythonhosted.org/packages/00/03/2af7c45034aba6002d4f2b728c1a385676b4eab7d764410e34fd768009f2/bcrypt-4.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3698393a1b1f1fd5714524193849d0c6d524d33523acca37cd28f02899285060", size = 325185 },
255
+ { url = "https://files.pythonhosted.org/packages/dc/5d/6843443ce4ab3af40bddb6c7c085ed4a8418b3396f7a17e60e6d9888416c/bcrypt-4.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:762a2c5fb35f89606a9fde5e51392dad0cd1ab7ae64149a8b935fe8d79dd5ed7", size = 335188 },
256
+ { url = "https://files.pythonhosted.org/packages/cb/4c/ff8ca83d816052fba36def1d24e97d9a85739b9bbf428c0d0ecd296a07c8/bcrypt-4.2.0-cp37-abi3-win32.whl", hash = "sha256:5a1e8aa9b28ae28020a3ac4b053117fb51c57a010b9f969603ed885f23841458", size = 156481 },
257
+ { url = "https://files.pythonhosted.org/packages/65/f1/e09626c88a56cda488810fb29d5035f1662873777ed337880856b9d204ae/bcrypt-4.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:8f6ede91359e5df88d1f5c1ef47428a4420136f3ce97763e31b86dd8280fbdf5", size = 151336 },
258
+ { url = "https://files.pythonhosted.org/packages/96/86/8c6a84daed4dd878fbab094400c9174c43d9b838ace077a2f8ee8bc3ae12/bcrypt-4.2.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:c52aac18ea1f4a4f65963ea4f9530c306b56ccd0c6f8c8da0c06976e34a6e841", size = 472414 },
259
+ { url = "https://files.pythonhosted.org/packages/f6/05/e394515f4e23c17662e5aeb4d1859b11dc651be01a3bd03c2e919a155901/bcrypt-4.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bbbfb2734f0e4f37c5136130405332640a1e46e6b23e000eeff2ba8d005da68", size = 277599 },
260
+ { url = "https://files.pythonhosted.org/packages/4b/3b/ad784eac415937c53da48983756105d267b91e56aa53ba8a1b2014b8d930/bcrypt-4.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3413bd60460f76097ee2e0a493ccebe4a7601918219c02f503984f0a7ee0aebe", size = 273491 },
261
+ { url = "https://files.pythonhosted.org/packages/cc/14/b9ff8e0218bee95e517b70e91130effb4511e8827ac1ab00b4e30943a3f6/bcrypt-4.2.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8d7bb9c42801035e61c109c345a28ed7e84426ae4865511eb82e913df18f58c2", size = 277934 },
262
+ { url = "https://files.pythonhosted.org/packages/3e/d0/31938bb697600a04864246acde4918c4190a938f891fd11883eaaf41327a/bcrypt-4.2.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3d3a6d28cb2305b43feac298774b997e372e56c7c7afd90a12b3dc49b189151c", size = 273804 },
263
+ { url = "https://files.pythonhosted.org/packages/e7/c3/dae866739989e3f04ae304e1201932571708cb292a28b2f1b93283e2dcd8/bcrypt-4.2.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9c1c4ad86351339c5f320ca372dfba6cb6beb25e8efc659bedd918d921956bae", size = 311275 },
264
+ { url = "https://files.pythonhosted.org/packages/5d/2c/019bc2c63c6125ddf0483ee7d914a405860327767d437913942b476e9c9b/bcrypt-4.2.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:27fe0f57bb5573104b5a6de5e4153c60814c711b29364c10a75a54bb6d7ff48d", size = 306355 },
265
+ { url = "https://files.pythonhosted.org/packages/75/fe/9e137727f122bbe29771d56afbf4e0dbc85968caa8957806f86404a5bfe1/bcrypt-4.2.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8ac68872c82f1add6a20bd489870c71b00ebacd2e9134a8aa3f98a0052ab4b0e", size = 325381 },
266
+ { url = "https://files.pythonhosted.org/packages/1a/d4/586b9c18a327561ea4cd336ff4586cca1a7aa0f5ee04e23a8a8bb9ca64f1/bcrypt-4.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cb2a8ec2bc07d3553ccebf0746bbf3d19426d1c6d1adbd4fa48925f66af7b9e8", size = 335685 },
267
+ { url = "https://files.pythonhosted.org/packages/24/55/1a7127faf4576138bb278b91e9c75307490178979d69c8e6e273f74b974f/bcrypt-4.2.0-cp39-abi3-win32.whl", hash = "sha256:77800b7147c9dc905db1cba26abe31e504d8247ac73580b4aa179f98e6608f34", size = 155857 },
268
+ { url = "https://files.pythonhosted.org/packages/1c/2a/c74052e54162ec639266d91539cca7cbf3d1d3b8b36afbfeaee0ea6a1702/bcrypt-4.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:61ed14326ee023917ecd093ee6ef422a72f3aec6f07e21ea5f10622b735538a9", size = 151717 },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  ]
270
 
271
  [[package]]
 
667
  { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 },
668
  ]
669
 
670
+ [[package]]
671
+ name = "dnspython"
672
+ version = "2.8.0"
673
+ source = { registry = "https://pypi.org/simple" }
674
+ sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251 }
675
+ wheels = [
676
+ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094 },
677
+ ]
678
+
679
  [[package]]
680
  name = "ecdsa"
681
  version = "0.19.1"
 
688
  { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607 },
689
  ]
690
 
691
+ [[package]]
692
+ name = "email-validator"
693
+ version = "2.3.0"
694
+ source = { registry = "https://pypi.org/simple" }
695
+ dependencies = [
696
+ { name = "dnspython" },
697
+ { name = "idna" },
698
+ ]
699
+ sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238 }
700
+ wheels = [
701
+ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 },
702
+ ]
703
+
704
  [[package]]
705
  name = "faker"
706
  version = "38.2.0"
 
2443
  { name = "alembic" },
2444
  { name = "authlib" },
2445
  { name = "backoff" },
2446
+ { name = "bcrypt" },
2447
+ { name = "email-validator" },
2448
  { name = "fastapi" },
2449
  { name = "httpx" },
2450
  { name = "itsdangerous" },
 
2508
  { name = "alembic", specifier = ">=1.12.0" },
2509
  { name = "authlib", specifier = ">=1.2.1" },
2510
  { name = "backoff", specifier = ">=2.2.1" },
2511
+ { name = "bcrypt", specifier = "==4.2.0" },
2512
  { name = "black", marker = "extra == 'dev'", specifier = ">=23.12.1" },
2513
+ { name = "email-validator", specifier = ">=2.3.0" },
2514
  { name = "faker", marker = "extra == 'test'", specifier = ">=20.1.0" },
2515
  { name = "fastapi", specifier = ">=0.109.0" },
2516
  { name = "httpx", specifier = ">=0.26.0" },