Fix CI failures: add backend/conftest.py for module resolution and run black formatting

- Add backend/conftest.py that inserts the backend directory into sys.path,
  fixing ModuleNotFoundError when pytest runs from the backend/ directory
  (as CI does with `cd backend && pytest tests/`)
- Run black formatter on all 28 backend files that needed reformatting
- All 53 tests pass with both `pytest tests/` and `python -m pytest tests/`

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/beb47db2-da25-416a-8fb0-c1452a3b22a7
This commit is contained in:
copilot-swe-agent[bot]
2026-03-23 10:24:28 +00:00
parent 681e0582f6
commit bcbef88803
29 changed files with 863 additions and 693 deletions
+22 -5
View File
@@ -1,17 +1,34 @@
"""
API v1 router aggregation.
"""
from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, mail_accounts, notifications, subscriptions, admin, providers
from app.api.v1.endpoints import (
auth,
users,
mail_accounts,
notifications,
subscriptions,
admin,
providers,
)
api_router = APIRouter()
# Include all endpoint routers
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(mail_accounts.router, prefix="/mail-accounts", tags=["Mail Accounts"])
api_router.include_router(providers.router, prefix="/providers", tags=["Providers & Gmail"])
api_router.include_router(notifications.router, prefix="/notifications", tags=["Notifications"])
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
api_router.include_router(
mail_accounts.router, prefix="/mail-accounts", tags=["Mail Accounts"]
)
api_router.include_router(
providers.router, prefix="/providers", tags=["Providers & Gmail"]
)
api_router.include_router(
notifications.router, prefix="/notifications", tags=["Notifications"]
)
api_router.include_router(
subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"]
)
api_router.include_router(admin.router, prefix="/admin", tags=["Admin"])
+7 -6
View File
@@ -1,4 +1,5 @@
"""Admin endpoints"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
@@ -13,24 +14,24 @@ router = APIRouter()
@router.get("/stats")
async def get_admin_stats(
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Get overall system statistics (admin only)"""
# Count users
user_count = await db.execute(select(func.count(User.id)))
total_users = user_count.scalar()
# Count accounts
account_count = await db.execute(select(func.count(MailAccount.id)))
total_accounts = account_count.scalar()
# Count processing runs
run_count = await db.execute(select(func.count(ProcessingRun.id)))
total_runs = run_count.scalar()
return {
"total_users": total_users,
"total_mail_accounts": total_accounts,
"total_processing_runs": total_runs
"total_processing_runs": total_runs,
}
+51 -62
View File
@@ -1,6 +1,7 @@
"""
Authentication endpoints (login, register, OAuth).
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,72 +12,65 @@ import logging
from app.core.database import get_db
from app.core.security import verify_password, get_password_hash
from app.models.database_models import User, SubscriptionTier
from app.models.schemas import (
Token, UserCreate, UserResponse, GoogleAuthRequest
)
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
from app.services.auth_service import oauth_service
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_in: UserCreate,
db: AsyncSession = Depends(get_db)
):
@router.post(
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
)
async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
"""Register a new user with email and password"""
# Check if user exists
result = await db.execute(
select(User).where(User.email == user_in.email)
)
result = await db.execute(select(User).where(User.email == user_in.email))
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
)
# Create new user
user = User(
email=user_in.email,
full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password) if user_in.password else None,
hashed_password=(
get_password_hash(user_in.password) if user_in.password else None
),
subscription_tier=SubscriptionTier.FREE,
is_active=True
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
logger.info(f"New user registered: {user.email}")
return user
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db)
form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)
):
"""Login with email and password"""
# Get user
result = await db.execute(
select(User).where(User.email == form_data.username)
)
result = await db.execute(select(User).where(User.email == form_data.username))
user = result.scalar_one_or_none()
if not user or not user.hashed_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify password
if not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
@@ -84,90 +78,85 @@ async def login(
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive"
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
)
# Update last login
user.last_login_at = datetime.utcnow()
await db.commit()
# Create tokens
tokens = oauth_service.create_tokens_for_user(user)
logger.info(f"User logged in: {user.email}")
return tokens
@router.post("/google", response_model=Token)
async def google_oauth(
auth_request: GoogleAuthRequest,
db: AsyncSession = Depends(get_db)
auth_request: GoogleAuthRequest, db: AsyncSession = Depends(get_db)
):
"""
Authenticate with Google OAuth2.
Exchange authorization code for access token and user info.
"""
# Get user info from Google
user_info = await oauth_service.get_google_user_info(
code=auth_request.code,
redirect_uri=auth_request.redirect_uri
code=auth_request.code, redirect_uri=auth_request.redirect_uri
)
if not user_info.get('verified_email'):
if not user_info.get("verified_email"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email not verified with Google"
detail="Email not verified with Google",
)
email = user_info['email']
google_id = user_info['google_id']
email = user_info["email"]
google_id = user_info["google_id"]
# Check if user exists
result = await db.execute(
select(User).where(
(User.email == email) | (User.google_id == google_id)
)
select(User).where((User.email == email) | (User.google_id == google_id))
)
user = result.scalar_one_or_none()
if user:
# Update Google ID if not set
if not user.google_id:
user.google_id = google_id
user.oauth_provider = "google"
# Update last login
user.last_login_at = datetime.utcnow()
logger.info(f"Existing user logged in with Google: {user.email}")
else:
# Create new user
user = User(
email=email,
full_name=user_info.get('full_name'),
full_name=user_info.get("full_name"),
google_id=google_id,
oauth_provider="google",
subscription_tier=SubscriptionTier.FREE,
is_active=True,
last_login_at=datetime.utcnow()
last_login_at=datetime.utcnow(),
)
db.add(user)
logger.info(f"New user registered with Google: {user.email}")
await db.commit()
await db.refresh(user)
# Create tokens
tokens = oauth_service.create_tokens_for_user(user)
return tokens
@@ -175,7 +164,7 @@ async def google_oauth(
async def get_google_authorize_url(redirect_uri: str):
"""Get Google OAuth2 authorization URL"""
from app.core.config import settings
auth_url = (
f"https://accounts.google.com/o/oauth2/v2/auth?"
f"client_id={settings.GOOGLE_CLIENT_ID}&"
@@ -184,5 +173,5 @@ async def get_google_authorize_url(redirect_uri: str):
f"redirect_uri={redirect_uri}&"
f"access_type=offline"
)
return {"authorization_url": auth_url}
+53 -54
View File
@@ -1,4 +1,5 @@
"""Mail account management endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,9 +10,13 @@ from app.core.deps import get_current_active_user
from app.core.security import encrypt_credential, decrypt_credential
from app.models.database_models import User, MailAccount
from app.models.schemas import (
MailAccountCreate, MailAccountResponse, MailAccountUpdate,
MailAccountTestRequest, MailAccountTestResponse,
MailAccountAutoDetectRequest, MailAccountAutoDetectResponse
MailAccountCreate,
MailAccountResponse,
MailAccountUpdate,
MailAccountTestRequest,
MailAccountTestResponse,
MailAccountAutoDetectRequest,
MailAccountAutoDetectResponse,
)
from app.services.mail_processor import MailProcessor, MailServerAutoDetect
from app.core.config import settings
@@ -19,38 +24,40 @@ from app.core.config import settings
router = APIRouter()
@router.post("", response_model=MailAccountResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"", response_model=MailAccountResponse, status_code=status.HTTP_201_CREATED
)
async def create_mail_account(
account_in: MailAccountCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Create a new mail account"""
# Check subscription limits
result = await db.execute(
select(MailAccount).where(MailAccount.user_id == current_user.id)
)
existing_accounts = result.scalars().all()
tier_limits = {
"free": settings.TIER_FREE_MAX_ACCOUNTS,
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
}
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1)
if len(existing_accounts) >= max_accounts:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f"Account limit reached. Upgrade your subscription to add more accounts."
detail=f"Account limit reached. Upgrade your subscription to add more accounts.",
)
# Encrypt password
encrypted_password = encrypt_credential(account_in.password)
# Create account
account = MailAccount(
user_id=current_user.id,
@@ -68,20 +75,20 @@ async def create_mail_account(
is_enabled=account_in.is_enabled,
check_interval_minutes=account_in.check_interval_minutes,
max_emails_per_check=account_in.max_emails_per_check,
delete_after_forward=account_in.delete_after_forward
delete_after_forward=account_in.delete_after_forward,
)
db.add(account)
await db.commit()
await db.refresh(account)
return account
@router.get("", response_model=List[MailAccountResponse])
async def list_mail_accounts(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""List all mail accounts for current user"""
result = await db.execute(
@@ -97,23 +104,21 @@ async def list_mail_accounts(
async def get_mail_account(
account_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Get a specific mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mail account not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
return account
@@ -122,35 +127,35 @@ async def update_mail_account(
account_id: int,
account_update: MailAccountUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Update a mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mail account not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
# Update fields
update_data = account_update.dict(exclude_unset=True)
if "password" in update_data:
update_data["encrypted_password"] = encrypt_credential(update_data.pop("password"))
update_data["encrypted_password"] = encrypt_credential(
update_data.pop("password")
)
for field, value in update_data.items():
setattr(account, field, value)
await db.commit()
await db.refresh(account)
return account
@@ -158,23 +163,21 @@ async def update_mail_account(
async def delete_mail_account(
account_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Delete a mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mail account not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
await db.delete(account)
await db.commit()
@@ -185,7 +188,7 @@ async def test_mail_connection(
current_user: User = Depends(get_current_active_user),
):
"""Test connection to mail server"""
# Create temporary account for testing
temp_account = MailAccount(
user_id=current_user.id,
@@ -198,16 +201,13 @@ async def test_mail_connection(
use_tls=test_request.use_tls,
username=test_request.username,
encrypted_password="", # Not used for test
forward_to="test@test.com"
forward_to="test@test.com",
)
processor = MailProcessor(temp_account, test_request.password)
success, message = await processor.test_connection()
return MailAccountTestResponse(
success=success,
message=message
)
return MailAccountTestResponse(success=success, message=message)
@router.post("/auto-detect", response_model=MailAccountAutoDetectResponse)
@@ -216,10 +216,9 @@ async def auto_detect_mail_settings(
current_user: User = Depends(get_current_active_user),
):
"""Auto-detect mail server settings for an email address"""
suggestions = MailServerAutoDetect.detect(detect_request.email_address)
return MailAccountAutoDetectResponse(
success=len(suggestions) > 0,
suggestions=suggestions
success=len(suggestions) > 0, suggestions=suggestions
)
+10 -8
View File
@@ -1,4 +1,5 @@
"""Notification configuration endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,23 +9,24 @@ from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import User, NotificationConfig
from app.models.schemas import (
NotificationConfigCreate, NotificationConfigResponse, NotificationConfigUpdate
NotificationConfigCreate,
NotificationConfigResponse,
NotificationConfigUpdate,
)
router = APIRouter()
@router.post("", response_model=NotificationConfigResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"", response_model=NotificationConfigResponse, status_code=status.HTTP_201_CREATED
)
async def create_notification_config(
config_in: NotificationConfigCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Create notification configuration"""
config = NotificationConfig(
user_id=current_user.id,
**config_in.dict()
)
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
db.add(config)
await db.commit()
await db.refresh(config)
@@ -34,7 +36,7 @@ async def create_notification_config(
@router.get("", response_model=List[NotificationConfigResponse])
async def list_notification_configs(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""List all notification configurations"""
result = await db.execute(
+9 -2
View File
@@ -1,4 +1,5 @@
"""Provider presets and Gmail credential management endpoints"""
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
@@ -156,7 +157,11 @@ async def get_provider_preset(
)
@router.post("/gmail-credential", response_model=GmailCredentialResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/gmail-credential",
response_model=GmailCredentialResponse,
status_code=status.HTTP_201_CREATED,
)
async def save_gmail_credential(
credential_in: GmailCredentialCreate,
current_user: User = Depends(get_current_active_user),
@@ -189,7 +194,9 @@ async def save_gmail_credential(
encrypted_access = encrypt_credential(credential_in.access_token)
encrypted_refresh = (
encrypt_credential(credential_in.refresh_token) if credential_in.refresh_token else None
encrypt_credential(credential_in.refresh_token)
if credential_in.refresh_token
else None
)
if existing:
@@ -1,4 +1,5 @@
"""Subscription and payment endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,9 +14,7 @@ router = APIRouter()
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
async def list_subscription_plans(
db: AsyncSession = Depends(get_db)
):
async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
"""List all available subscription plans"""
result = await db.execute(
select(SubscriptionPlan).where(SubscriptionPlan.is_active == True)
@@ -25,11 +24,11 @@ async def list_subscription_plans(
@router.get("/current")
async def get_current_subscription(
current_user: User = Depends(get_current_active_user)
current_user: User = Depends(get_current_active_user),
):
"""Get current user's subscription details"""
return {
"tier": current_user.subscription_tier,
"status": current_user.subscription_status,
"expires_at": current_user.subscription_expires_at
"expires_at": current_user.subscription_expires_at,
}
+4 -3
View File
@@ -1,4 +1,5 @@
"""User management endpoints"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,7 +13,7 @@ router = APIRouter()
@router.get("/me", response_model=UserDetailResponse)
async def get_current_user_profile(
current_user: User = Depends(get_current_active_user)
current_user: User = Depends(get_current_active_user),
):
"""Get current user profile"""
return current_user
@@ -22,14 +23,14 @@ async def get_current_user_profile(
async def update_current_user_profile(
user_update: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Update current user profile"""
if user_update.email:
current_user.email = user_update.email
if user_update.full_name:
current_user.full_name = user_update.full_name
await db.commit()
await db.refresh(current_user)
return current_user