Skip to content
This repository was archived by the owner on Jan 14, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/api-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,23 @@ jobs:
- name: Install dependencies
run: uv sync --extra dev

- name: Run tests
run: uv run pytest tests/ -v
- name: Run tests with coverage
run: uv run pytest tests/ -v --cov=src/finquest_api --cov-report=term --cov-report=term-missing --cov-report=html --cov-report=json
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
SUPABASE_JWT_SECRET: ${{ secrets.SUPABASE_JWT_SECRET }}

- name: Upload coverage reports
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
app/services/api/htmlcov/
app/services/api/coverage.json
retention-days: 30

- name: Test API startup
run: |
uv run python -c "from finquest_api.main import app; print('API imports successfully')"
Expand Down
17 changes: 15 additions & 2 deletions app/services/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,26 @@ uv add <package-name>
## Testing

```bash
# Install test dependencies
uv add --dev pytest pytest-asyncio httpx
# Install test dependencies (includes pytest-cov for coverage)
uv sync --group dev

# Run tests
uv run pytest

# Run tests with coverage report (includes all files, even 100% covered)
# Note: Files with 100% coverage ARE included in the total percentage calculation,
# but may be hidden from the detailed report. To see all files, use:
uv run pytest --cov=src/finquest_api --cov-report=term --cov-report=term-missing --cov-report=html

# Run tests with coverage summary only (skips 100% covered files for brevity)
uv run pytest --cov=src/finquest_api --cov-report=term-missing:skip-covered
```

Coverage reports:

- Terminal output: Shows coverage percentage and missing lines
- HTML report: Generated in `htmlcov/index.html` (open in browser for detailed view)

## Next Steps

- [ ] Add database integration (PostgreSQL/SQLite)
Expand Down
63 changes: 63 additions & 0 deletions app/services/api/migrations/001_add_gamification_tables.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
-- Migration: Add gamification tables
-- Created: 2025-01-XX
-- Description: Adds user_gamification_stats, badge_definitions, and user_badges tables

-- Create user_gamification_stats table
CREATE TABLE IF NOT EXISTS user_gamification_stats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES users (id) ON DELETE CASCADE,
total_xp INTEGER NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 1,
current_streak INTEGER NOT NULL DEFAULT 0,
last_streak_date DATE,
total_modules_completed INTEGER NOT NULL DEFAULT 0,
total_quizzes_completed INTEGER NOT NULL DEFAULT 0,
total_portfolio_positions INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX IF NOT EXISTS uq_gamification_stats_user ON user_gamification_stats (user_id);
CREATE INDEX IF NOT EXISTS ix_gamification_stats_user ON user_gamification_stats (user_id);

-- Create badge_definitions table
CREATE TABLE IF NOT EXISTS badge_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX IF NOT EXISTS uq_badge_code ON badge_definitions (code);
CREATE INDEX IF NOT EXISTS ix_badge_code ON badge_definitions (code);

-- Create user_badges table
CREATE TABLE IF NOT EXISTS user_badges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
badge_id UUID NOT NULL REFERENCES badge_definitions (id) ON DELETE CASCADE,
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, badge_id)
);

CREATE UNIQUE INDEX IF NOT EXISTS uq_user_badge ON user_badges (user_id, badge_id);
CREATE INDEX IF NOT EXISTS ix_user_badges_user ON user_badges (user_id);
CREATE INDEX IF NOT EXISTS ix_user_badges_badge ON user_badges (badge_id);

-- Add trigger to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER update_user_gamification_stats_updated_at
BEFORE UPDATE ON user_gamification_stats
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

27 changes: 27 additions & 0 deletions app/services/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,31 @@ build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=8.4.2",
"pytest-cov>=6.0.0",
]

[tool.coverage.run]
source = ["src"]
omit = [
"*/tests/*",
"*/test_*.py",
"*/__pycache__/*",
"*/migrations/*",
"*/scripts/*",
"*/examples/*",
]

[tool.coverage.report]
precision = 2
show_missing = true
skip_covered = false
skip_empty = false
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
"@abstractmethod",
]
2 changes: 1 addition & 1 deletion app/services/api/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
addopts = -v --tb=short --cov-report=term-missing --cov-report=html
176 changes: 176 additions & 0 deletions app/services/api/scripts/seed_badges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""
Seed badge definitions into the database.
"""
import sys
from pathlib import Path

# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))

# Load environment variables before importing database modules
# Note: pydantic-settings will also load .env automatically, but we try dotenv first
try:
from dotenv import load_dotenv
env_path = Path(__file__).parent.parent / '.env'
if env_path.exists():
load_dotenv(env_path)
print(f"Loaded environment from {env_path}")
else:
print(f"Note: .env file not found at {env_path}")
print("Using environment variables or pydantic-settings will load .env")
except ImportError:
print("Note: python-dotenv not available, relying on pydantic-settings for .env loading")

print("Importing database modules...")
try:
from sqlalchemy.orm import Session
from finquest_api.db.session import get_engine, SessionLocal
from finquest_api.db.models import BadgeDefinition
print("Database modules imported successfully")
except Exception as e:
print(f"❌ Error importing database modules: {e}")
print("This might be due to missing SUPABASE_DB_URL environment variable")
import traceback
traceback.print_exc()
sys.exit(1)


BADGE_DEFINITIONS = [
# Learning badges
{
"code": "MODULE_5",
"name": "Module Apprentice",
"description": "Completed 5 modules",
"category": "learning",
"is_active": True,
},
{
"code": "MODULE_10",
"name": "Module Scholar",
"description": "Completed 10 modules",
"category": "learning",
"is_active": True,
},
{
"code": "MODULE_20",
"name": "Module Master",
"description": "Completed 20 modules",
"category": "learning",
"is_active": True,
},
{
"code": "QUIZ_CHAMP",
"name": "Quiz Champ",
"description": "Achieved ≥90% on any quiz",
"category": "learning",
"is_active": True,
},
# Note: Category Finisher badges would need category tracking - skipping for MVP

# Portfolio badges
{
"code": "PORTFOLIO_CREATOR",
"name": "Portfolio Creator",
"description": "Added first portfolio position",
"category": "portfolio",
"is_active": True,
},
{
"code": "DIVERSIFIER",
"name": "Diversifier",
"description": "Have 3 or more distinct positions",
"category": "portfolio",
"is_active": True,
},
{
"code": "RISK_MANAGER",
"name": "Risk Manager",
"description": "Performed a rebalance action",
"category": "portfolio",
"is_active": False, # Not implemented yet
},
{
"code": "LONG_TERM_THINKER",
"name": "Long-Term Thinker",
"description": "Kept a position for at least 30 days",
"category": "portfolio",
"is_active": False, # Not implemented yet
},
{
"code": "ANALYST",
"name": "Analyst",
"description": "Completed 5 stock analysis actions",
"category": "portfolio",
"is_active": False, # Not implemented yet
},

# Streak badges
{
"code": "STREAK_7",
"name": "7-Day Streak",
"description": "Reached a 7-day streak",
"category": "streak",
"is_active": True,
},
{
"code": "STREAK_30",
"name": "30-Day Streak",
"description": "Reached a 30-day streak",
"category": "streak",
"is_active": True,
},
]


def seed_badges():
"""Seed badge definitions into the database."""
try:
print("Getting database engine...")
engine = get_engine()
print("Creating database session...")
db: Session = SessionLocal(bind=engine)

print(f"Seeding {len(BADGE_DEFINITIONS)} badge definitions...")
added_count = 0
skipped_count = 0

for badge_data in BADGE_DEFINITIONS:
existing = db.query(BadgeDefinition).filter(
BadgeDefinition.code == badge_data["code"]
).first()

if existing:
print(f" ⏭️ Badge {badge_data['code']} already exists, skipping...")
skipped_count += 1
continue

badge = BadgeDefinition(**badge_data)
db.add(badge)
print(f" ✅ Added badge: {badge_data['code']} - {badge_data['name']}")
added_count += 1

db.commit()
print("\n✅ Successfully seeded badge definitions!")
print(f" Added: {added_count}, Skipped: {skipped_count}")

except RuntimeError as e:
if "SUPABASE_DB_URL" in str(e):
print(f"\n❌ Database configuration error: {e}")
print("\nPlease set SUPABASE_DB_URL in your environment or .env file")
sys.exit(1)
raise
except Exception as e:
if db:
db.rollback()
print(f"\n❌ Error seeding badges: {e}")
import traceback
traceback.print_exc()
raise
finally:
if 'db' in locals():
db.close()


if __name__ == "__main__":
seed_badges()

53 changes: 53 additions & 0 deletions app/services/api/src/finquest_api/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,56 @@ class DailyLearningLog(Base):
created_at: Mapped[datetime] = ts_created()
updated_at: Mapped[datetime] = ts_updated()


class UserGamificationStats(Base):
__tablename__ = "user_gamification_stats"
__table_args__ = (UniqueConstraint("user_id", name="uq_gamification_stats_user"),)

id: Mapped[UUID] = uuid_pk()
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True
)
total_xp: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
level: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
current_streak: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_streak_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
total_modules_completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_quizzes_completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_portfolio_positions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = ts_created()
updated_at: Mapped[datetime] = ts_updated()


class BadgeDefinition(Base):
__tablename__ = "badge_definitions"
__table_args__ = (UniqueConstraint("code", name="uq_badge_code"),)

id: Mapped[UUID] = uuid_pk()
code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
category: Mapped[str] = mapped_column(String(32), nullable=False) # 'learning', 'streak', 'portfolio'
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = ts_created()


class UserBadge(Base):
__tablename__ = "user_badges"
__table_args__ = (UniqueConstraint("user_id", "badge_id", name="uq_user_badge"),)

id: Mapped[UUID] = uuid_pk()
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
badge_id: Mapped[UUID] = mapped_column(
ForeignKey("badge_definitions.id", ondelete="CASCADE"),
nullable=False,
index=True
)
earned_at: Mapped[datetime] = ts_created()

Loading
Loading