loreal-utilisation-dept/backend/app/auth/session.py
DJP e1db93ad4a backend + frontend: leave hours, server-side bookings filter, dynamic meta, defensive charts
Backend (33/33 tests, +5 new):

- Split Zoho parser's canonical "billable" into "billable" (bool column)
  and "billingType" (string column with values like "Client Related" /
  "Leave Hours" / "Idle Time"). Each parsed row now carries both, and
  billable is cross-filled from billingType when only the latter is
  present.
- Merge service computes leaveHours separately from non_billable_h: any
  row with billingType "leave hours"/"leave" lands in the leave bucket
  and is no longer double-counted as non-billable.
- UtilisationSummaryRow gains leaveHours: float; TimelogRow gains
  billingType: str | None.
- /api/airtable/bookings accepts ?department=&name= (comma-separated
  multi-value), folded into the filterByFormula alongside the date
  overlap. Apostrophes in names are escaped. Cache key now includes
  the filter values so different selections don't collide.
- /api/airtable/meta computes departments + employmentTypes from a
  live fetch_resources call (sorted distinct), falls back to the
  hardcoded lists on any exception. billingTypes/bookingStatuses
  stay static.
- Logout cookie now mirrors the login cookie's HttpOnly / Secure /
  SameSite / Path attributes with max_age=0 and empty value, for
  consistency.

Frontend (typecheck/lint/build clean):

- types.ts: UtilisationSummaryRow.leaveHours: number.
- BillabilityBreakdown uses r.leaveHours directly; idle becomes
  max(0, available - billable - nonBillable - leave). Capped to top
  20 employees by (available + billable) with "Other (N)" rollup;
  Legend replaced with compact inline swatches.
- BookingVsActual and FTEvsFreelancer: same top-20 + Other treatment
  to prevent the ProjectLoad-style x-axis explosion at scale.
- Defensive sweep on WeeklyUtilisation, MonthlyUtilisation,
  BookingVsActual, FTEvsFreelancer: null-coerce sort keys, Number()-
  guard arithmetic, skip rows with no usable period/employee.
- getBookings signature gains department + name; Resourcing passes
  them through. Client-side visibleBookings filter retained as
  belt-and-braces since linked-lookup filterByFormula on Airtable
  can be flaky.
- Tutorial steps.ts restructured to cover the new chart and CSV
  export tags; existing TutorialOverlay defensive selector check
  preserved.
- ErrorBoundary: removed dead eslint-disable directive flagged by
  --report-unused-disable-directives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:48:12 -04:00

67 lines
2 KiB
Python

"""Session cookie signing helpers.
Decisions:
- Uses itsdangerous.TimestampSigner so we can enforce max-age server-side
even if the cookie's own Max-Age is tampered with (defence in depth).
- The cookie value is "<username>" signed; no JWT, no JSON. Sessions don't
need to carry data here — the user is single-tenant admin.
"""
from __future__ import annotations
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
from fastapi import Response
from app.config import settings
def _signer() -> TimestampSigner:
return TimestampSigner(settings.SESSION_SECRET, salt="ud-session-v1")
def sign_username(username: str) -> str:
return _signer().sign(username.encode("utf-8")).decode("utf-8")
def verify_signed(value: str, max_age: int | None = None) -> str | None:
"""Return username if signature valid and not expired; else None."""
try:
max_age = max_age if max_age is not None else settings.SESSION_MAX_AGE
unsigned = _signer().unsign(value, max_age=max_age)
return unsigned.decode("utf-8")
except SignatureExpired:
return None
except BadSignature:
return None
COOKIE_NAME = "ud_session"
def set_session_cookie(response: Response, username: str) -> None:
signed = sign_username(username)
response.set_cookie(
key=COOKIE_NAME,
value=signed,
max_age=settings.SESSION_MAX_AGE,
path=settings.cookie_path,
httponly=True,
secure=True,
samesite="lax",
)
def clear_session_cookie(response: Response) -> None:
# Match the login cookie's attributes exactly so browsers reliably
# overwrite/expire the original. Anything that differs (path,
# SameSite, Secure, HttpOnly) can cause the browser to treat this
# as a separate cookie and leave the original in place.
response.set_cookie(
key=COOKIE_NAME,
value="",
max_age=0,
path=settings.cookie_path,
httponly=True,
secure=True,
samesite="lax",
)