- 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>
129 lines
4.2 KiB
Python
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}"},
|
|
)
|