diff --git a/backend/app/routers/applications.py b/backend/app/routers/applications.py index 35a3f09..b3f030f 100644 --- a/backend/app/routers/applications.py +++ b/backend/app/routers/applications.py @@ -15,7 +15,7 @@ from app.services import application_repository as repo from app.services import email_service from app.services.application_service import ApplicationService -from app.services.contract_service import ContractService +from app.services.contract_service import SIGNATURE_DATE_POS, ContractService from app.services.pdf_service import pdf_service from app.services.storage_service import storage_service from app.services.token_service import create_download_token @@ -144,6 +144,10 @@ def sign_application(index: int, request: SignApplicationRequest): signed_path = source_pdf.parent / "signed.pdf" + # Stamp the date the coordinator actually signs, not the date the contract + # was generated (which can be days earlier). + signed_on = datetime.now(timezone.utc).date().isoformat() + pdf_service.embed_signature( source_pdf=source_pdf, output_pdf=signed_path, @@ -153,6 +157,7 @@ def sign_application(index: int, request: SignApplicationRequest): y=request.y, w=request.w, h=request.h, + text_stamps=[(signed_on, *SIGNATURE_DATE_POS)], ) token = create_download_token(application.contract_task_id) diff --git a/backend/app/services/contract_service.py b/backend/app/services/contract_service.py index 5a917a7..d447dfa 100644 --- a/backend/app/services/contract_service.py +++ b/backend/app/services/contract_service.py @@ -23,6 +23,11 @@ _BEAR_BROWN = HexColor("#7A5A2E") _PAGE_W, _PAGE_H = A4 +# Where the signing date is stamped, in PDF points with a TOP-LEFT origin (the +# convention pdf_service uses). Mirrors the reportlab position of the date line +# below, which is drawn at 61 mm from the bottom. +SIGNATURE_DATE_POS = (_PAGE_W - 73 * mm, _PAGE_H - 63 * mm) + class ContractService: @staticmethod @@ -109,9 +114,12 @@ def field(label: str, value: str): c.line(left, 61 * mm, left + 70 * mm, 61 * mm) # signature line c.drawString(left, 55 * mm, "Signature of the Dean's Internship Supervisor") + # The date beside the signature is intentionally left blank here: it is + # the date the coordinator signs, which happens later (often days after + # the contract is generated). It is stamped during signing, at + # SIGNATURE_DATE_POS below. c.drawString(_PAGE_W - 85 * mm, 61 * mm, "Date:") c.line(_PAGE_W - 73 * mm, 61 * mm, _PAGE_W - 20 * mm, 61 * mm) - c.drawString(_PAGE_W - 73 * mm, 63 * mm, date.today().isoformat()) c.setFont("Helvetica-Oblique", 7) c.drawString(left, 15 * mm, diff --git a/backend/app/services/pdf_service.py b/backend/app/services/pdf_service.py index bf9ccb6..9a3170a 100644 --- a/backend/app/services/pdf_service.py +++ b/backend/app/services/pdf_service.py @@ -196,6 +196,7 @@ def embed_signature( y: float, w: float, h: float, + text_stamps: list[tuple[str, float, float]] | None = None, ) -> None: """Embed *image_bytes* on *page_index* of *source_pdf*, save to *output_pdf*. @@ -204,6 +205,10 @@ def embed_signature( using page top-left as origin with y growing downward. w, h — box width and height in PDF points (both must be > 0). + text_stamps — optional (text, x, y) items drawn on the same page in the + same save, for values that are only known at signing time (the + signing date). Same top-left origin as above. + Save options (garbage=3, deflate=True, clean=True): - Drop orphan objects and duplicate streams (garbage=3). - zlib-compress all streams (deflate=True). @@ -250,6 +255,13 @@ def embed_signature( f"PyMuPDF rejected the signature image: {exc}" ) from exc + for text, tx, ty in (text_stamps or []): + try: + page.insert_text((tx, ty), text, fontsize=10, fontname="helv") + except (ValueError, RuntimeError) as exc: + # A stamp is supplementary — never lose the signature over it. + logger.warning("could not stamp %r: %s", text, exc) + try: doc.save( str(part_path), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0908d71..3e02aef 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -188,3 +188,42 @@ def test_sign_pdf_download_url_is_reachable(): download = client.get(download_url) assert download.status_code == 200 assert download.headers["content-type"] == "application/pdf" + + +def test_signature_date_is_stamped_at_signing_not_generation(tmp_path): + """The date beside the signature must be the date the coordinator signed. + + It used to be printed by create_contract_pdf, i.e. when the contract was + generated — often days before signing — so a contract signed on the 21st + still showed the 16th. Generation must leave it blank; signing stamps it. + """ + from datetime import datetime, timezone + + from app.services.contract_service import SIGNATURE_DATE_POS, ContractService + from app.services.pdf_service import pdf_service + + unsigned = tmp_path / "contract.pdf" + ContractService.create_contract_pdf( + name="Ada Lovelace", email="ada@example.com", + recommended_role="Backend Developer Internship", + candidate_score=80, output_path=unsigned, + ) + + today = datetime.now(timezone.utc).date().isoformat() + unsigned_text = fitz.open(str(unsigned))[0].get_text() + # Only the header "Date:" field carries a date before signing. + assert unsigned_text.count(today) == 1 + + img = Image.new("RGBA", (60, 25), (0, 0, 255, 255)) + buf = io.BytesIO() + img.save(buf, format="PNG") + + signed = tmp_path / "signed.pdf" + pdf_service.embed_signature( + source_pdf=unsigned, output_pdf=signed, image_bytes=buf.getvalue(), + page_index=0, x=70, y=600, w=220, h=70, + text_stamps=[(today, *SIGNATURE_DATE_POS)], + ) + + signed_text = fitz.open(str(signed))[0].get_text() + assert signed_text.count(today) == 2 # header + signing date