diff --git a/AGENTS.md b/AGENTS.md index a3cdd37..59691e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,11 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role ## Ticket System (Sprint 2) +- `reported_at` (nullable DateTime, alembic `d5e0f2a1c3b4`) records a ticket's original reported date; + `TicketCreate.reported_at` lets Admin/Wahab enter backdated tickets that stay active. It defaults to + now when omitted (migration backfilled existing rows from `created_at`). SLA deadlines run from + `created_at`, not `reported_at` — backfilling history never instantly breaches a ticket. + ### Status Lifecycle (16 statuses) New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened → Cancelled diff --git a/alembic/versions/d5e0f2a1c3b4_tickets_reported_at.py b/alembic/versions/d5e0f2a1c3b4_tickets_reported_at.py new file mode 100644 index 0000000..fbaa617 --- /dev/null +++ b/alembic/versions/d5e0f2a1c3b4_tickets_reported_at.py @@ -0,0 +1,47 @@ +"""tickets.reported_at column — original reported date for backdated tickets + +Revision ID: d5e0f2a1c3b4 +Revises: c4e8f1a2d3b4 +Create Date: 2026-08-03 00:00:00.000000 + +Backdated-ticket support (Wahab demo): one schema change. + +* Add ``tickets.reported_at`` (nullable DateTime) so historical/backfilled + tickets keep their true report date instead of inheriting today's + ``created_at``. +* Backfill existing rows with their ``created_at`` value so no ticket shows + an empty reported date after the upgrade. The service layer also defaults + new tickets without a ``reported_at`` to now, so the column is effectively + always populated from here on. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd5e0f2a1c3b4' +down_revision: Union[str, Sequence[str], None] = 'c4e8f1a2d3b4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add tickets.reported_at (nullable) and backfill from created_at.""" + op.add_column( + 'tickets', + sa.Column( + 'reported_at', + sa.DateTime(), + nullable=True, + comment='Original reported date; backdated/backfilled tickets keep their true report date', + ), + ) + # Backfill: every existing ticket was reported when it was created. + op.execute('UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL') + + +def downgrade() -> None: + """Drop tickets.reported_at.""" + op.drop_column('tickets', 'reported_at') diff --git a/app/main.py b/app/main.py index e10f27e..d45289d 100644 --- a/app/main.py +++ b/app/main.py @@ -38,6 +38,14 @@ async def ensure_legacy_schema(conn) -> None: text("ALTER TABLE tickets ADD COLUMN phone VARCHAR(50)") ) logger.info("Added missing tickets.phone column (legacy database)") + if "reported_at" not in ticket_columns: + await conn.execute( + text("ALTER TABLE tickets ADD COLUMN reported_at DATETIME") + ) + await conn.execute( + text("UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL") + ) + logger.info("Added missing tickets.reported_at column (legacy database)") result = await conn.execute( text( "UPDATE categories SET name = 'Missing Item' " diff --git a/app/models/ticket.py b/app/models/ticket.py index 919bdc5..17882aa 100644 --- a/app/models/ticket.py +++ b/app/models/ticket.py @@ -55,6 +55,11 @@ class Ticket(Base): customer_rating: Mapped[int | None] = mapped_column(Integer, nullable=True) reopen_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + reported_at: Mapped[datetime | None] = mapped_column( + DateTime, + nullable=True, + comment="Original reported date. Backdated/backfilled tickets keep their true report date; NULL falls back to created_at.", + ) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime, diff --git a/app/schemas/ticket.py b/app/schemas/ticket.py index e3d9ae0..86172ba 100644 --- a/app/schemas/ticket.py +++ b/app/schemas/ticket.py @@ -46,6 +46,7 @@ class TicketCreate(BaseModel): assigned_to: int | None = None customer_name: str | None = None phone: str | None = None + reported_at: datetime | None = None # original report date for backdated/backfilled tickets; defaults to now when omitted class TicketUpdate(BaseModel): @@ -100,6 +101,7 @@ class TicketBrief(BaseModel): description: str | None = None sla_deadline: datetime | None = None reopen_count: int = 0 + reported_at: datetime | None = None created_at: datetime updated_at: datetime diff --git a/app/services/ticket.py b/app/services/ticket.py index a8cf8da..00fd6d1 100644 --- a/app/services/ticket.py +++ b/app/services/ticket.py @@ -114,6 +114,10 @@ async def create_ticket( ticket_number = await _generate_ticket_number(db) priority = data.get("priority") sla_deadline = compute_sla_deadline(priority) if priority else None + # Original report date: backdated/backfilled tickets keep their true date; + # when omitted the ticket is considered reported right now. The SLA clock + # is unchanged — deadlines run from creation time, not the reported date. + reported_at = data.get("reported_at") or datetime.now(timezone.utc) ticket = Ticket( ticket_number=ticket_number, @@ -126,6 +130,7 @@ async def create_ticket( reported_via=data.get("reported_via"), description=data.get("description"), assigned_to=data.get("assigned_to"), + reported_at=reported_at, sla_deadline=sla_deadline, ) db.add(ticket) diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index ffdce9d..872a844 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -23,7 +23,11 @@ -

Created

+

Created + +

@@ -129,6 +133,10 @@
Reported Via
+
+
Reported
+
+
Priority
diff --git a/app/templates/tickets/list.html b/app/templates/tickets/list.html index d906478..c8bde85 100644 --- a/app/templates/tickets/list.html +++ b/app/templates/tickets/list.html @@ -130,6 +130,7 @@ Created + Reported SLA @@ -142,6 +143,7 @@ + @@ -151,7 +153,7 @@ - + No tickets match your filters @@ -178,6 +180,7 @@ Description Assigned To Created + Reported SLA @@ -190,6 +193,7 @@ + diff --git a/app/templates/tickets/new.html b/app/templates/tickets/new.html index d29b945..764b758 100644 --- a/app/templates/tickets/new.html +++ b/app/templates/tickets/new.html @@ -154,6 +154,15 @@
+ +
+
+ + +

Defaults to today. Use a past date when entering an old/backlogged issue — it stays active in the normal workflow.

+
+
+
@@ -205,8 +214,10 @@ priority: '', description: '', reporter: '', - reported_via: '' + reported_via: '', + reported_date: '' }, + todayStr: '', priorityAuto: false, categories: [], subCategories: [], @@ -223,6 +234,14 @@ async init() { await this.loadCategories(); await this.loadUnits(); + // Default reported date to today (local), allow backdating via the date picker + this.todayStr = this.localDateStr(new Date()); + if (!this.form.reported_date) this.form.reported_date = this.todayStr; + }, + + localDateStr(d) { + const offset = d.getTimezoneOffset(); + return new Date(d.getTime() - offset * 60000).toISOString().slice(0, 10); }, // ── Report mode ────────────────────────────────────────── @@ -384,6 +403,7 @@ unit_id: this.form.unit.id, customer_name: this.form.customer_name || null, phone: this.form.phone || null, + reported_at: this.form.reported_date || null, }; const ticket = await app().apiPost('/api/tickets', payload); diff --git a/tests/test_backdated_reported_date.py b/tests/test_backdated_reported_date.py new file mode 100644 index 0000000..5e04661 --- /dev/null +++ b/tests/test_backdated_reported_date.py @@ -0,0 +1,118 @@ +"""Tests for backdated reported-date support (Wahab demo). + +Anchors: +* A ticket created with a past ``reported_at`` persists that date and it is + exposed on list + detail responses — this is how Wahab enters old tickets + that stay active in the normal workflow. +* A ticket created without ``reported_at`` defaults to "now", so existing + create behavior is unchanged. +* The reported date is metadata only: SLA deadlines still run from creation + time and no age/backdate restriction kicks in. +""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +pytestmark = pytest.mark.asyncio + + +def _naive(iso: str) -> datetime: + """Parse an ISO datetime and strip any tz offset for safe comparison.""" + dt = datetime.fromisoformat(iso) + return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt + + +async def _login(client, email="wahab@denya.com", password="denya123") -> str: + resp = await client.post( + "/api/auth/login", + json={"email": email, "password": password}, + ) + assert resp.status_code == 200, resp.text + return resp.json()["access_token"] + + +async def _create_ticket(client, token: str, **overrides) -> dict: + payload = { + "unit_id": 2, + "category_id": 3, + "priority": "medium", + "reporter": "Backdate Test", + "reported_via": "walk-in", + "description": "backdate test ticket", + **overrides, + } + resp = await client.post( + "/api/tickets", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def test_create_with_backdated_reported_at_persists(client): + """Wahab (Admin/Wahab) can enter an old ticket and its date sticks.""" + token = await _login(client) + ticket = await _create_ticket( + client, + token, + reported_at="2026-07-20", + description="Old plumbing issue reported weeks ago", + ) + assert ticket["reported_at"] is not None + assert ticket["reported_at"].startswith("2026-07-20") + + # Still an active ticket in the normal workflow — no age restriction. + assert ticket["status"] in {"New", "Logged"} + + # Detail endpoint exposes the reported date. + detail = await client.get(f"/api/tickets/{ticket['id']}") + assert detail.status_code == 200 + assert detail.json()["reported_at"].startswith("2026-07-20") + + # List endpoint exposes it too. + listing = await client.get("/api/tickets") + assert listing.status_code == 200 + listed = next(t for t in listing.json()["items"] if t["id"] == ticket["id"]) + assert listed["reported_at"].startswith("2026-07-20") + + +async def test_create_without_reported_at_defaults_to_now(client): + """Omitting reported_at behaves exactly as before: reported == created.""" + token = await _login(client) + ticket = await _create_ticket(client, token, description="normal today ticket") + assert ticket["reported_at"] is not None + reported = _naive(ticket["reported_at"]) + created = _naive(ticket["created_at"]) + assert abs((reported - created).total_seconds()) < 60 + + +async def test_reported_at_does_not_shift_sla_deadline(client): + """SLA computation is unchanged: deadlines run from creation time.""" + token = await _login(client) + ticket = await _create_ticket( + client, + token, + priority="urgent", + reported_at="2026-01-01", + description="old urgent ticket", + ) + assert ticket["sla_deadline"] is not None + created = _naive(ticket["created_at"]) + deadline = _naive(ticket["sla_deadline"]) + hours = (deadline - created).total_seconds() / 3600 + assert 3.5 <= hours <= 4.5 # urgent → 4 h resolution window from creation + + +async def test_reported_at_round_trips_full_datetime(client): + """A precise datetime (not just a date) survives the round trip.""" + token = await _login(client) + reported = "2026-07-20T14:30:00" + ticket = await _create_ticket(client, token, reported_at=reported, description="datetime round trip") + assert ticket["reported_at"] is not None + parsed = _naive(ticket["reported_at"]) + assert parsed.date().isoformat() == "2026-07-20" + assert parsed.hour == 14 and parsed.minute == 30