From b5dbc6a39b281e424a49b0cbd91869a0271d3ce1 Mon Sep 17 00:00:00 2001 From: Murat Can Nergiz Date: Thu, 2 Jul 2026 15:06:28 +0200 Subject: [PATCH 1/2] feat: add .dockerignore to reduce build context size --- .dockerignore | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3e9a7e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +*.md +frontend/ +n8n/ +docs/ +*.db +*.pdf +__pycache__/ +*.pyc +.env From 8f330ba518e15a813b3d1bb7804927dabdc92808 Mon Sep 17 00:00:00 2001 From: Murat Can Nergiz Date: Tue, 21 Jul 2026 13:23:55 +0200 Subject: [PATCH 2/2] fix: stamp the real signing date next to the signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The date beside the signature was printed by create_contract_pdf, i.e. when the contract was generated. Signing usually happens days later, so a contract signed on the 21st still showed the 16th — and the date never changed no matter when it was signed. Generation now leaves that field blank and the date is stamped during signing, at the position the contract layout owns (SIGNATURE_DATE_POS). embed_signature gained an optional text_stamps argument so it lands in the same save as the signature. The header 'Date:' still records when the contract was drawn up. Adds a regression test covering both halves. --- backend/app/routers/applications.py | 7 ++++- backend/app/services/contract_service.py | 10 +++++- backend/app/services/pdf_service.py | 12 ++++++++ backend/tests/test_api.py | 39 ++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) 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