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 +3 -0
- DATABASE_DEPLOYMENT_FIX.md +97 -0
- Dockerfile +2 -2
- alembic.ini +112 -0
- alembic/env.py +95 -0
- alembic/script.py.mako +24 -0
- alembic/versions/001_initial_authentication_tables.py +175 -0
- alembic/versions/002_add_onboarding_tables.py +52 -0
- auth/auth.py +9 -3
- init_database.py +58 -0
- main.py +94 -9
- middleware/auth.py +1 -1
- models/auth.py +3 -1
- pyproject.toml +3 -1
- rag/chat.py +15 -1
- requirements.txt +2 -0
- routes/auth.py +133 -5
- src/api/routes/auth.py +451 -0
- src/api/routes/chat.py +549 -0
- src/api/routes/users.py +381 -0
- src/database/base.py +31 -0
- src/database/config.py +77 -0
- src/models/auth.py +286 -0
- src/models/chat.py +38 -0
- src/schemas/auth.py +344 -0
- src/security/dependencies.py +289 -0
- src/services/anonymous.py +183 -0
- src/services/auth.py +126 -0
- src/services/email.py +301 -0
- src/services/message_editor.py +261 -0
- src/services/session_migration.py +265 -0
- start_server.py +30 -0
- tests/test_auth.py +1 -1
- uv.lock +52 -66
.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 ["
|
|
|
|
| 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:
|
| 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 |
-
|
| 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 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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@
|
| 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": [
|
| 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 |
-
|
|
|
|
| 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:
|
| 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 = "
|
| 244 |
-
source = { registry = "https://pypi.org/simple" }
|
| 245 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 246 |
-
wheels = [
|
| 247 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 248 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 249 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 250 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 251 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 252 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 253 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 254 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 255 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 256 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 257 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 258 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 259 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 260 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 261 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 262 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 263 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 264 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 265 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 266 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 267 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 268 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 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" },
|