diff --git a/README.md b/README.md index 5b6011e..0c4b5fa 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,14 @@ Modern async finance backend with secure auth, role-based access control, analyt ![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?logo=docker&logoColor=white) ![Tests](https://img.shields.io/badge/Tests-47%20Passing-success) +## 🔥 Start Here: App Workflow + +If you want to understand how this backend actually behaves from login to role-based usage, read: + +**[docs/BASIC_FLOW.md](docs/BASIC_FLOW.md)** + +It explains the real journey for `viewer`, `analyst`, and `admin`, including auth lifecycle, record lifecycle, dashboard usage order, and websocket presence flow. + ## 🌟 What You Get - Secure JWT auth with refresh rotation and revocation. @@ -43,4 +51,4 @@ I thought to build this in Spring Boot because it is widely used in enterprise f ## 🙏 Acknowledgement -"I am genuinely thankful for this problem statement. Building it end-to-end gave me practical experience with RBAC. While I have previously explored caching, rate-limiting, and other system design concepts in my past projects, I had never worked on an RBAC project before, and this gave me great experience truly thankful. \ No newline at end of file +I am genuinely thankful for this problem statement. Building it end-to-end gave me practical experience with RBAC. While I have previously explored caching, rate-limiting, and other system design concepts in my past projects, I had never worked on an RBAC project before, and this gave me great experience truly thankful. \ No newline at end of file diff --git a/app/routes/financial_records.py b/app/routes/financial_records.py index 4145c8e..9aa24dd 100644 --- a/app/routes/financial_records.py +++ b/app/routes/financial_records.py @@ -1,8 +1,8 @@ from datetime import date, datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from redis.asyncio import Redis -from sqlalchemy import delete, func, select +from sqlalchemy import delete, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings @@ -12,12 +12,19 @@ from app.schemas import ( DeletedFinancialRecordListResponse, DeletedFinancialRecordOut, + CSVImportResponse, FinancialRecordCreate, FinancialRecordListResponse, FinancialRecordOut, FinancialRecordUpdate, MessageResponse, ) +from app.services.csv_service import ( + CSVParseError, + parse_financial_records_csv, + serialize_financial_records_to_csv, + validate_financial_record_csv_rows, +) from app.services.dashboard_service import invalidate_dashboard_cache router = APIRouter(prefix="/financial-records", tags=["Financial Records"]) @@ -84,6 +91,7 @@ async def list_financial_records( start_date: date | None = Query(default=None), end_date: date | None = Query(default=None), category: str | None = Query(default=None, min_length=2, max_length=100), + search: str | None = Query(default=None, min_length=1, max_length=100), record_type: RecordType | None = Query(default=None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), @@ -101,6 +109,14 @@ async def list_financial_records( filters.append(FinancialRecord.entry_date <= end_date) if category: filters.append(FinancialRecord.category == category) + if search: + search_pattern = f"%{search}%" + filters.append( + or_( + FinancialRecord.category.ilike(search_pattern), + FinancialRecord.notes.ilike(search_pattern), + ) + ) if record_type: filters.append(FinancialRecord.record_type == RecordType(record_type.value)) @@ -124,6 +140,153 @@ async def list_financial_records( ) +@router.get("/export") +async def export_financial_records_csv( + start_date: date | None = Query(default=None), + end_date: date | None = Query(default=None), + category: str | None = Query(default=None, min_length=2, max_length=100), + search: str | None = Query(default=None, min_length=1, max_length=100), + record_type: RecordType | None = Query(default=None), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Response: + _assert_can_read_records(current_user) + await _purge_expired_deleted_records(db) + + if start_date and end_date and start_date > end_date: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date cannot be after end_date") + + filters = [FinancialRecord.is_deleted.is_(False)] + if start_date: + filters.append(FinancialRecord.entry_date >= start_date) + if end_date: + filters.append(FinancialRecord.entry_date <= end_date) + if category: + filters.append(FinancialRecord.category == category) + if search: + search_pattern = f"%{search}%" + filters.append( + or_( + FinancialRecord.category.ilike(search_pattern), + FinancialRecord.notes.ilike(search_pattern), + ) + ) + if record_type: + filters.append(FinancialRecord.record_type == RecordType(record_type.value)) + + stmt = ( + select(FinancialRecord) + .where(*filters) + .order_by(FinancialRecord.entry_date.desc(), FinancialRecord.id.desc()) + ) + rows = (await db.execute(stmt)).scalars().all() + + csv_content = serialize_financial_records_to_csv(rows) + return Response( + content=csv_content, + media_type="text/csv", + headers={"Content-Disposition": 'attachment; filename="financial_records_export.csv"'}, + ) + + +@router.post("/import", response_model=CSVImportResponse) +async def import_financial_records_csv( + request: Request, + response: Response, + db: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), + current_user: User = Depends(get_current_user), + _: None = Depends(sensitive_route_limiter), +) -> CSVImportResponse: + _assert_admin(current_user) + await _purge_expired_deleted_records(db) + + content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type != "text/csv": + raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="Content-Type must be text/csv") + + raw_body = await request.body() + if not raw_body: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="CSV body is empty") + + try: + csv_text = raw_body.decode("utf-8") + except UnicodeDecodeError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="CSV must be UTF-8 encoded") from exc + + try: + parsed_rows = parse_financial_records_csv(csv_text) + except CSVParseError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + valid_rows, errors = validate_financial_record_csv_rows(parsed_rows) + + user_ids = {payload.user_id for _, payload, _ in valid_rows if payload.user_id is not None} + if user_ids: + existing_user_ids = set((await db.execute(select(User.id).where(User.id.in_(user_ids)))).scalars().all()) + missing_user_ids = user_ids - existing_user_ids + if missing_user_ids: + retained_rows: list[tuple[int, FinancialRecordCreate, dict[str, str]]] = [] + for row_index, payload, row_data in valid_rows: + if payload.user_id in missing_user_ids: + errors.append( + { + "row_index": row_index, + "row_data": row_data, + "errors": ["Target user not found"], + } + ) + else: + retained_rows.append((row_index, payload, row_data)) + valid_rows = retained_rows + + imported_count = 0 + if valid_rows: + created_rows = [ + FinancialRecord( + amount=payload.amount, + record_type=RecordType(payload.record_type.value), + category=payload.category, + entry_date=payload.entry_date, + notes=payload.notes, + user_id=payload.user_id or current_user.id, + ) + for _, payload, _ in valid_rows + ] + db.add_all(created_rows) + await db.commit() + imported_count = len(created_rows) + await invalidate_dashboard_cache(redis) + + failed_count = len(errors) + if failed_count > 0 and imported_count > 0: + response.status_code = status.HTTP_207_MULTI_STATUS + status_value = "partial_success" + elif failed_count > 0: + response.status_code = status.HTTP_400_BAD_REQUEST + status_value = "failed" + else: + response.status_code = status.HTTP_201_CREATED + status_value = "success" + + normalized_errors = [ + { + "row_index": int(error["row_index"]), + "row_data": dict(error["row_data"]), + "errors": [str(item) for item in error["errors"]], + } + for error in errors + ] + + return CSVImportResponse( + status=status_value, + total_rows=len(parsed_rows), + imported_count=imported_count, + failed_count=failed_count, + errors=normalized_errors, + ) + + @router.get("/{record_id}", response_model=FinancialRecordOut) async def get_financial_record( record_id: int, diff --git a/app/schemas.py b/app/schemas.py index aa24634..4a7e202 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -118,6 +118,20 @@ class DeletedFinancialRecordListResponse(BaseModel): items: list[DeletedFinancialRecordOut] +class CSVImportRowError(BaseModel): + row_index: int + row_data: dict[str, str] + errors: list[str] + + +class CSVImportResponse(BaseModel): + status: str + total_rows: int + imported_count: int + failed_count: int + errors: list[CSVImportRowError] + + class PaginationParams(BaseModel): offset: int = Field(default=0, ge=0) limit: int = Field(default=20, ge=1, le=100) diff --git a/app/services/csv_service.py b/app/services/csv_service.py new file mode 100644 index 0000000..8be2c40 --- /dev/null +++ b/app/services/csv_service.py @@ -0,0 +1,122 @@ +import csv +import io +from decimal import Decimal, InvalidOperation + +from pydantic import ValidationError + +from app.models import FinancialRecord, RecordType +from app.schemas import FinancialRecordCreate + +CSV_EXPORT_HEADERS = [ + "id", + "amount", + "record_type", + "category", + "entry_date", + "notes", + "created_at", + "updated_at", + "user_id", +] + + +class CSVParseError(ValueError): + """Raised when CSV payload cannot be parsed safely.""" + + +def serialize_financial_records_to_csv(records: list[FinancialRecord]) -> str: + output = io.StringIO(newline="") + writer = csv.writer(output) + writer.writerow(CSV_EXPORT_HEADERS) + + for row in records: + writer.writerow( + [ + row.id, + str(row.amount), + row.record_type.value, + row.category, + row.entry_date.isoformat(), + row.notes or "", + row.created_at.isoformat(), + row.updated_at.isoformat(), + row.user_id, + ] + ) + + return output.getvalue() + + +def parse_financial_records_csv(text: str) -> list[dict[str, str]]: + normalized = text.lstrip("\ufeff").strip() + if not normalized: + raise CSVParseError("CSV body is empty") + + reader = csv.DictReader(io.StringIO(normalized)) + if reader.fieldnames is None: + raise CSVParseError("CSV header row is required") + + headers = {name.strip() for name in reader.fieldnames if name is not None} + required_headers = {"amount", "record_type", "category", "entry_date"} + allowed_headers = required_headers | {"notes", "user_id"} + + missing_headers = required_headers - headers + if missing_headers: + raise CSVParseError(f"Missing required CSV headers: {', '.join(sorted(missing_headers))}") + + unknown_headers = headers - allowed_headers + if unknown_headers: + raise CSVParseError(f"Unsupported CSV headers: {', '.join(sorted(unknown_headers))}") + + rows: list[dict[str, str]] = [] + for raw_row in reader: + row = { + key.strip(): (value.strip() if isinstance(value, str) else "") + for key, value in raw_row.items() + if key is not None + } + if not any(row.values()): + continue + rows.append(row) + + if not rows: + raise CSVParseError("CSV contains no data rows") + + return rows + + +def validate_financial_record_csv_rows( + rows: list[dict[str, str]], +) -> tuple[list[tuple[int, FinancialRecordCreate, dict[str, str]]], list[dict[str, object]]]: + valid_rows: list[tuple[int, FinancialRecordCreate, dict[str, str]]] = [] + errors: list[dict[str, object]] = [] + + for row_index, row in enumerate(rows, start=2): + row_errors: list[str] = [] + + user_id: int | None = None + raw_user_id = row.get("user_id") + if raw_user_id: + try: + user_id = int(raw_user_id) + except ValueError: + row_errors.append("user_id must be an integer when provided") + + try: + payload = FinancialRecordCreate( + amount=Decimal(row.get("amount", "")), + record_type=RecordType(row.get("record_type", "")), + category=row.get("category", ""), + entry_date=row.get("entry_date", ""), + notes=row.get("notes") or None, + user_id=user_id, + ) + if row_errors: + errors.append({"row_index": row_index, "row_data": row, "errors": row_errors}) + continue + valid_rows.append((row_index, payload, row)) + except (InvalidOperation, ValidationError, ValueError) as exc: + row_errors.append(str(exc)) + errors.append({"row_index": row_index, "row_data": row, "errors": row_errors}) + + return valid_rows, errors diff --git a/docs/BASIC_FLOW.md b/docs/BASIC_FLOW.md new file mode 100644 index 0000000..18bd7f6 --- /dev/null +++ b/docs/BASIC_FLOW.md @@ -0,0 +1,185 @@ +# Basic App Flow 🧭 + +This is the practical workflow guide for this backend. + +If you already checked `FEATURES.md` and `API_GUIDE.md`, this file is the missing piece: it explains how a real user journey moves from login to daily usage, and how the flow changes for viewer, analyst, and admin. + +--- + +## Why This File Exists + +- `FEATURES.md` tells you what exists. +- `API_GUIDE.md` tells you how to call each endpoint. +- This file tells you the real usage flow from start to finish. + +--- + +## 1. First-Time System Bootstrap Flow + +Before normal usage starts, first admin setup should happen once. + +1. Configure `ADMIN_BOOTSTRAP_KEY` in `.env`. +2. Call `POST /api/v1/users/bootstrap-admin` with header `X-Bootstrap-Key`. +3. First admin account gets created. +4. This endpoint is blocked after an admin already exists. + +Why this matters: + +- Public users cannot self-register as admin. +- Privileged setup is controlled and one-time. + +--- + +## 2. Normal User Registration Flow + +For public signup: + +1. User calls `POST /api/v1/users/register`. +2. Backend allows only `viewer` role in self-registration. +3. If user sends `analyst` or `admin`, backend returns `403`. +4. User logs in via `POST /api/v1/users/login` and receives: +- `access_token` +- `refresh_token` + +--- + +## 3. Session Flow (All Roles) + +After login, the common token lifecycle is: + +1. Use access token in `Authorization: Bearer `. +2. Access protected endpoints based on role. +3. When access token expires, call `POST /api/v1/users/refresh`. +4. Logout via `POST /api/v1/users/logout` to revoke active token usage. + +Security behavior: + +- Refresh tokens are rotated. +- Revocation checks are enforced. +- Invalid or revoked access fails with `401`. + +--- + +## 4. Role-by-Role Workflow + +## Viewer Flow + +Viewer is read-only for dashboard analytics. + +Typical steps: + +1. Register as viewer. +2. Login. +3. Open dashboard: +- `GET /api/v1/dashboard/summary` +- `GET /api/v1/dashboard/categories` +- `GET /api/v1/dashboard/recent-activity` +- `GET /api/v1/dashboard/monthly-trends` +4. Viewer cannot access records CRUD or admin endpoints. + +--- + +## Analyst Flow + +Analyst accounts are created by admin. + +Typical steps: + +1. Admin creates analyst account using `POST /api/v1/users/admin/users`. +2. Analyst logs in with provided credentials. +3. Analyst can use all viewer dashboard reads. +4. Analyst can read financial records: +- `GET /api/v1/financial-records` +- `GET /api/v1/financial-records/{record_id}` +5. Analyst cannot create/update/delete records. + +--- + +## Admin Flow + +Admin handles operational and governance actions. + +Typical steps: + +1. Login as admin. +2. Create analyst/viewer accounts (`POST /api/v1/users/admin/users`). +3. Promote/demote roles and activate/deactivate users (`PATCH /api/v1/users/admin/users/{user_id}`). +4. Create and manage financial records: +- `POST /api/v1/financial-records` +- `PATCH /api/v1/financial-records/{record_id}` +- `DELETE /api/v1/financial-records/{record_id}` +5. Manage recycle bin: +- `GET /api/v1/financial-records/bin/records` +- `POST /api/v1/financial-records/bin/records/{record_id}/restore` +6. Monitor presence: +- `GET /api/v1/users/admin/online-users` + +--- + +## 5. Financial Record Lifecycle (Real Path) + +1. Admin creates income/expense entries. +2. Analyst/admin use filters and pagination while reading records. +3. Dashboard endpoints aggregate active (non-deleted) records. +4. When admin deletes a record, it goes to recycle bin. +5. Admin can restore record within retention window. +6. Expired deleted records are auto-purged based on retention config. + +--- + +## 6. Dashboard Usage Pattern + +Recommended usage sequence for frontend or API consumers: + +1. Load `summary` for top cards. +2. Load `monthly-trends` for chart. +3. Load `categories` for distribution. +4. Load `recent-activity` for latest entries. + +Behavior note: + +- Aggregate dashboard endpoints use Redis caching. +- Cache invalidates on record changes and restores. + +--- + +## 7. WebSocket Presence Flow + +Presence endpoint: `WS /ws/presence?token=` + +Flow: + +1. User connects with access token. +2. Backend validates token and user activity status. +3. User is marked online in Redis with TTL. +4. Heartbeat loop keeps status active. +5. On disconnect, user is marked offline. + +--- + +## 8. Rate-Limit Behavior in Real Usage + +Sensitive endpoints are protected with fixed-window limiting. + +Limiter identity strategy: + +- Anonymous traffic: IP-based limiting. +- Authenticated traffic (valid bearer token): user-based limiting. + +This helps reduce spam on public endpoints and abuse on authenticated flows. + +--- + +## 9. Quick End-to-End Example + +A practical full journey: + +1. Bootstrap first admin (one-time). +2. Admin logs in. +3. Admin creates analyst account. +4. Analyst logs in and reviews records + dashboard. +5. Admin creates/updates/deletes a few records. +6. Admin reviews recycle bin and restores one record. +7. All roles continue using dashboard with role-appropriate permissions. + +--- \ No newline at end of file diff --git a/tests/test_financial_records_routes.py b/tests/test_financial_records_routes.py index cbf2dd4..2ffe3b7 100644 --- a/tests/test_financial_records_routes.py +++ b/tests/test_financial_records_routes.py @@ -1,3 +1,5 @@ +import csv +import io from decimal import Decimal from httpx import AsyncClient @@ -146,6 +148,52 @@ async def test_filter_by_category(client: AsyncClient, user_factory): assert all(item["category"] == "food" for item in items) +async def test_list_records_search_matches_notes_and_category(client: AsyncClient, user_factory): + admin = await user_factory(role="admin") + token = admin["tokens"]["access_token"] + + await client.post( + "/api/v1/financial-records", + headers=_auth(token), + json={ + "amount": "210.00", + "record_type": "expense", + "category": "groceries", + "entry_date": "2026-04-04", + "notes": "weekly supermarket", + }, + ) + await client.post( + "/api/v1/financial-records", + headers=_auth(token), + json={ + "amount": "20.00", + "record_type": "expense", + "category": "travel", + "entry_date": "2026-04-04", + "notes": "metro commute", + }, + ) + + notes_search = await client.get( + "/api/v1/financial-records?search=supermarket", + headers=_auth(token), + ) + assert notes_search.status_code == 200 + notes_items = notes_search.json()["items"] + assert notes_items + assert all("supermarket" in (item["notes"] or "") for item in notes_items) + + category_search = await client.get( + "/api/v1/financial-records?search=travel", + headers=_auth(token), + ) + assert category_search.status_code == 200 + category_items = category_search.json()["items"] + assert category_items + assert all(item["category"] == "travel" for item in category_items) + + async def test_invalid_date_range_returns_400(client: AsyncClient, user_factory): admin = await user_factory(role="admin") token = admin["tokens"]["access_token"] @@ -313,3 +361,178 @@ async def test_recycle_bin_auto_purges_after_retention(client: AsyncClient, user monkeypatch.delenv("RECYCLE_BIN_RETENTION_DAYS", raising=False) get_settings.cache_clear() + + +async def test_export_csv_analyst_allowed(client: AsyncClient, user_factory): + admin = await user_factory(role="admin") + analyst = await user_factory(role="analyst") + + created = await client.post( + "/api/v1/financial-records", + headers=_auth(admin["tokens"]["access_token"]), + json={ + "amount": "1000.00", + "record_type": "income", + "category": "salary", + "entry_date": "2026-04-08", + "notes": "paycheck", + }, + ) + assert created.status_code == 201 + + exported = await client.get( + "/api/v1/financial-records/export", + headers=_auth(analyst["tokens"]["access_token"]), + ) + assert exported.status_code == 200 + assert exported.headers["content-type"].startswith("text/csv") + assert "attachment;" in exported.headers.get("content-disposition", "") + + rows = list(csv.DictReader(io.StringIO(exported.text))) + assert any(row["category"] == "salary" for row in rows) + + +async def test_export_csv_viewer_forbidden(client: AsyncClient, user_factory): + viewer = await user_factory(role="viewer") + + exported = await client.get( + "/api/v1/financial-records/export", + headers=_auth(viewer["tokens"]["access_token"]), + ) + assert exported.status_code == 403 + + +async def test_export_csv_filters_by_category(client: AsyncClient, user_factory): + admin = await user_factory(role="admin") + token = admin["tokens"]["access_token"] + + await client.post( + "/api/v1/financial-records", + headers=_auth(token), + json={ + "amount": "40.00", + "record_type": "expense", + "category": "food", + "entry_date": "2026-04-08", + "notes": "meal", + }, + ) + await client.post( + "/api/v1/financial-records", + headers=_auth(token), + json={ + "amount": "55.00", + "record_type": "expense", + "category": "transport", + "entry_date": "2026-04-08", + "notes": "taxi", + }, + ) + + exported = await client.get( + "/api/v1/financial-records/export?category=food", + headers=_auth(token), + ) + assert exported.status_code == 200 + + rows = list(csv.DictReader(io.StringIO(exported.text))) + assert rows + assert all(row["category"] == "food" for row in rows) + + +async def test_export_csv_supports_search(client: AsyncClient, user_factory): + admin = await user_factory(role="admin") + token = admin["tokens"]["access_token"] + + await client.post( + "/api/v1/financial-records", + headers=_auth(token), + json={ + "amount": "120.00", + "record_type": "expense", + "category": "office", + "entry_date": "2026-04-08", + "notes": "printer supplies", + }, + ) + await client.post( + "/api/v1/financial-records", + headers=_auth(token), + json={ + "amount": "75.00", + "record_type": "expense", + "category": "food", + "entry_date": "2026-04-08", + "notes": "team lunch", + }, + ) + + exported = await client.get( + "/api/v1/financial-records/export?search=printer", + headers=_auth(token), + ) + assert exported.status_code == 200 + + rows = list(csv.DictReader(io.StringIO(exported.text))) + assert rows + assert all("printer" in (row["notes"] or "") or "printer" in row["category"] for row in rows) + + +async def test_import_csv_admin_success(client: AsyncClient, user_factory): + admin = await user_factory(role="admin") + token = admin["tokens"]["access_token"] + + csv_body = """amount,record_type,category,entry_date,notes +100.50,income,salary,2026-04-08,monthly +45.00,expense,food,2026-04-09,lunch +""" + + imported = await client.post( + "/api/v1/financial-records/import", + headers={**_auth(token), "Content-Type": "text/csv"}, + content=csv_body, + ) + assert imported.status_code == 201 + payload = imported.json() + assert payload["status"] == "success" + assert payload["imported_count"] == 2 + assert payload["failed_count"] == 0 + + listed = await client.get("/api/v1/financial-records", headers=_auth(token)) + assert listed.status_code == 200 + categories = [item["category"] for item in listed.json()["items"]] + assert "salary" in categories + assert "food" in categories + + +async def test_import_csv_partial_success(client: AsyncClient, user_factory): + admin = await user_factory(role="admin") + token = admin["tokens"]["access_token"] + + csv_body = """amount,record_type,category,entry_date,notes +120.00,income,salary,2026-04-08,ok row +-30.00,expense,food,2026-04-09,bad amount +""" + + imported = await client.post( + "/api/v1/financial-records/import", + headers={**_auth(token), "Content-Type": "text/csv"}, + content=csv_body, + ) + assert imported.status_code == 207 + payload = imported.json() + assert payload["status"] == "partial_success" + assert payload["imported_count"] == 1 + assert payload["failed_count"] == 1 + assert payload["errors"][0]["row_index"] == 3 + + +async def test_import_csv_requires_admin(client: AsyncClient, user_factory): + analyst = await user_factory(role="analyst") + + imported = await client.post( + "/api/v1/financial-records/import", + headers={**_auth(analyst["tokens"]["access_token"]), "Content-Type": "text/csv"}, + content="amount,record_type,category,entry_date\n100,income,salary,2026-04-08\n", + ) + assert imported.status_code == 403