dodil/erp-brick-time-off

DODIL ERP brick: time off

src/brick_time_off/domain/evidence.py

"""A document that backs an absence (a fit note, a MATB1): what may be stored, its key, how long it is kept. Plain Python, no I/O."""
import base64
import binascii
import hashlib
from datetime import date

from brick_time_off.domain.errors import RuleBroken

KINDS = {"image/jpeg": ("jpg", b"\xff\xd8\xff"), "image/png": ("png", b"\x89PNG\r\n\x1a\n"), "application/pdf": ("pdf", b"%PDF-")}
MAX_BYTES = 3 * 1024 * 1024
RETAIN_YEARS = 3          # GAP: the spec gives retain_until no rule; three years after the days covered is HMRC's SSP record rule - to be confirmed


def decode(content_type: str, content_base64: str) -> bytes:
    """The document's bytes, or the rule it breaks: a supported type whose first bytes agree, valid base64, not empty, not over 3 MiB."""
    if content_type not in KINDS:
        raise RuleBroken(f"a fit note is {', '.join(KINDS)}, not {content_type}")
    try:
        body = base64.b64decode(content_base64, validate=True)
    except (binascii.Error, ValueError):
        raise RuleBroken("the document is not valid base64") from None
    if not body:
        raise RuleBroken("the document is empty")
    if len(body) > MAX_BYTES:
        raise RuleBroken(f"a document is at most {MAX_BYTES // (1024 * 1024)} MiB ({len(body)} bytes given)")
    if not body.startswith(KINDS[content_type][1]):
        raise RuleBroken(f"the bytes are not a {content_type} (they do not start like one)")
    return body


def sha256(body: bytes) -> str:
    return hashlib.sha256(body).hexdigest()


def object_key(absence_ref: str, digest: str, content_type: str) -> str:
    return f"raw/time-off/{absence_ref}/{digest}.{KINDS[content_type][0]}"


def retain_until(covers_to: date) -> date:
    try:
        return covers_to.replace(year=covers_to.year + RETAIN_YEARS)
    except ValueError:                                     # 29 February
        return covers_to.replace(year=covers_to.year + RETAIN_YEARS, day=28)