MyMidas/backend/app/api/v1/users.py
megaproxy 9897d03d91 Add public demo mode with auto-seeding, hourly reset, and Portainer deploy guide
- DEMO_MODE=true env flag: disables password changes and backup endpoints (403),
  exposes GET /demo/status for frontend detection
- Auto-seed on first startup: creates demo user (demo@mymidas.app / demo123)
  with 6 months of transactions, investments, budgets, subscriptions, and tax
  payslips; takes a pg_dump snapshot immediately after for hourly restore
- Hourly reset: resetter Alpine container with cron restores DB from snapshot
  and purges uploaded attachments every hour on the hour
- Frontend: amber demo banner on all pages, login page shows credentials,
  password change disabled with notice, backups section replaced with notice
- demo/ directory: self-contained docker-compose.yml (ports 4001/8091),
  .env.example, reset.sh, and step-by-step Portainer DEPLOY.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:08:24 +00:00

129 lines
4.2 KiB
Python

import csv
import io
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.audit import write_audit
from app.core.security import hash_password, verify_password
from app.dependencies import get_current_user, get_db
router = APIRouter()
@router.get("/me")
async def get_me(user=Depends(get_current_user)):
return {
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"base_currency": user.base_currency,
"theme": user.theme,
"locale": user.locale,
"totp_enabled": user.totp_enabled,
"last_login_at": user.last_login_at,
"created_at": user.created_at,
}
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str = Field(..., min_length=10)
@router.post("/me/password", status_code=200)
async def change_password(
body: PasswordChangeRequest,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
if get_settings().is_demo:
raise HTTPException(status_code=403, detail="Password changes are disabled in demo mode")
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
user.password_hash = hash_password(body.new_password)
user.updated_at = datetime.now(timezone.utc)
await write_audit(db, user_id=user.id, action="password_change")
await db.commit()
return {"message": "Password updated successfully"}
class ProfileUpdateRequest(BaseModel):
display_name: str | None = Field(default=None, max_length=100)
base_currency: str | None = Field(default=None, min_length=3, max_length=10)
@router.put("/me", status_code=200)
async def update_profile(
body: ProfileUpdateRequest,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
if body.display_name is not None:
user.display_name = body.display_name
if body.base_currency is not None:
user.base_currency = body.base_currency.upper()
user.updated_at = datetime.now(timezone.utc)
await db.commit()
return {"message": "Profile updated"}
@router.get("/me/export")
async def export_data(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
from app.db.models.transaction import Transaction
from app.db.models.account import Account
from app.db.models.category import Category
from app.core.security import decrypt_field
result = await db.execute(
select(Transaction, Account, Category)
.join(Account, Account.id == Transaction.account_id)
.outerjoin(Category, Category.id == Transaction.category_id)
.where(
Transaction.user_id == user.id,
Transaction.deleted_at.is_(None),
)
.order_by(Transaction.date.desc())
)
rows = result.all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"date", "description", "merchant", "amount", "currency",
"type", "status", "category", "account", "notes", "tags",
])
for txn, account, category in rows:
writer.writerow([
txn.date.isoformat(),
decrypt_field(txn.description_enc) or "",
decrypt_field(txn.merchant_enc) if txn.merchant_enc else "",
str(txn.amount),
txn.currency,
txn.type,
txn.status,
category.name if category else "",
decrypt_field(account.name_enc) or "",
decrypt_field(txn.notes_enc) if txn.notes_enc else "",
",".join(txn.tags or []),
])
output.seek(0)
filename = f"transactions_{datetime.now(timezone.utc).strftime('%Y%m%d')}.csv"
await write_audit(db, user_id=user.id, action="data_export")
await db.commit()
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)