Compare commits

...

17 Commits

Author SHA1 Message Date
5c65e4d2ee fix: add BER to airports.py so scans include Berlin Brandenburg
All checks were successful
Deploy / deploy (push) Successful in 21s
OpenFlights dataset predates BER's 2020 opening. The patch already
existed in api_server.py for the search UI, but scan_processor.py
uses airports.py directly, so Germany scans silently skipped BER.

Added _MISSING_AIRPORTS to airports.py, patched both
get_airports_for_country() and _all_airports_by_iata() to inject
the extras, making BER available to scans and lookups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:45:07 +01:00
cf40736f0e feat: add date range option to create scan dialog
All checks were successful
Deploy / deploy (push) Successful in 49s
Adds a Rolling Window / Date Range toggle to the Parameters section.
Rolling Window mode (default) keeps the existing window_months stepper.
Date Range mode shows From/To date pickers and sends start_date/end_date
to the API instead of window_months. Validation covers empty dates,
past start dates, end ≤ start, and ranges exceeding 12 months.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:38:02 +01:00
4f4f7e86d1 fix: make migrations resilient to orphaned scans_new tables
All checks were successful
Deploy / deploy (push) Successful in 12s
- Add _recover_orphaned_new_tables() called at DB init startup: if scans_new
  exists but scans doesn't (from a previously aborted migration), rename it back
- Drop VIEW recent_scans/active_scans before dropping the scans table in both
  _migrate_add_pause_cancel_status and _migrate_add_reverse_scan_support, so
  executescript can cleanly recreate them
- Add DROP TABLE IF EXISTS scans_new / scheduled_scans_new at the start of each
  table-recreation migration to prevent 'table scans_new already exists' errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 18:18:22 +01:00
77d2a46264 feat: implement reverse scan (country → specific airports)
All checks were successful
Deploy / deploy (push) Successful in 30s
- DB schema: relaxed origin CHECK to >=2 chars, added scan_mode column to
  scans and scheduled_scans, added origin_airport to routes and flights,
  updated unique index to (scan_id, COALESCE(origin_airport,''), destination)
- Migrations: init_db.py recreates tables and adds columns via guarded ALTERs
- API: scan_mode field on ScanRequest/Scan; Route/Flight expose origin_airport;
  GET /scans/{id}/flights accepts origin_airport filter; CreateScheduleRequest
  and Schedule carry scan_mode; scheduler and run-now pass scan_mode through
- scan_processor: _write_route_incremental accepts origin_airport; process_scan
  branches on scan_mode=reverse (country → airports × destinations × dates)
- Frontend: new CountrySelect component (populated from GET /api/v1/countries);
  Scans page adds Direction toggle + CountrySelect for both modes; ScanDetails
  shows Origin column for reverse scans and uses composite route keys; Re-run
  preserves scan_mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:58:55 +01:00
7ece1f9b45 fix: replace faint sort arrows with ChevronsUpDown indicator on inactive columns
All checks were successful
Deploy / deploy (push) Successful in 35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 15:39:36 +01:00
69c2ddae29 feat: sort flights by date and add sortable date/price columns
All checks were successful
Deploy / deploy (push) Successful in 36s
- Change API ORDER BY from price/date to date/price (chronological default)
- Add flightSortField/flightSortDir state to ScanDetails
- Make Date and Price sub-table headers clickable with sort icons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 15:27:21 +01:00
3cad8a8447 fix: commit missing ScanTimer component and useScanTimer hook
All checks were successful
Deploy / deploy (push) Successful in 31s
These files were referenced by ScanDetails.tsx but never committed,
breaking the Docker build (tsc could not resolve the imports).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 18:12:47 +01:00
9a76d7af82 feat: add cancel, pause, and resume flow control for scans
Some checks failed
Deploy / deploy (push) Failing after 18s
Users running large scans can now pause (keep partial results, resume
later), cancel (stop permanently, partial results preserved), or resume
a paused scan which races through cache hits before continuing.

Backend:
- Extend scans.status CHECK to include 'paused' and 'cancelled'
- Add _migrate_add_pause_cancel_status() table-recreation migration
- scan_processor: _running_tasks/_cancel_reasons registries,
  cancel_scan_task/pause_scan_task/stop_scan_task helpers,
  CancelledError handler in process_scan(), start_resume_processor()
- api_server: POST /scans/{id}/pause|cancel|resume endpoints with
  rate limits (30/min pause+cancel, 10/min resume); list_scans now
  accepts paused/cancelled as status filter values

Frontend:
- Scan.status type extended with 'paused' | 'cancelled'
- scanApi.pause/cancel/resume added
- StatusChip: amber PauseCircle chip for paused, grey Ban for cancelled
- ScanDetails: context-aware action row with inline-confirm for
  Pause and Cancel; Resume button for paused scans

Tests: 129 total (58 new) across test_scan_control.py,
test_scan_processor_control.py, and additions to existing suites

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 18:11:23 +01:00
d494e80ff7 fix: force Traefik to use domverse network for routing
All checks were successful
Deploy / deploy (push) Successful in 11s
When a container is attached to multiple networks, Traefik picks an IP
from any of them. The frontend is on both domverse and flight-radar_default;
Traefik isn't on the latter, so ~50% of requests 504 after 30s.

Adding traefik.docker.network=domverse pins routing to the shared network.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 16:05:17 +01:00
cde496ad48 infra: fix CORS, add monitoring and dashboard labels
All checks were successful
Deploy / deploy (push) Successful in 11s
- Set ALLOWED_ORIGINS to production domain (fixes CORS for the web app)
- Add LOG_LEVEL=INFO to backend
- Add AutoKuma monitoring labels for Uptime Kuma auto-discovery
- Add Homepage dashboard labels (Productivity group)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:59:28 +01:00
7b07775845 fix: replace wget with curl in frontend healthcheck
All checks were successful
Deploy / deploy (push) Successful in 12s
nginx:alpine is a minimal image that does not include wget.
Install curl explicitly and use curl -f for the healthcheck.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 14:17:56 +01:00
6c1cffbdd4 fix: add missing vite-env.d.ts for CSS import type declarations
All checks were successful
Deploy / deploy (push) Successful in 36s
Without this file TypeScript errors on plain CSS side-effect imports
(e.g. import './index.css') because it lacks Vite's built-in type shims.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 14:06:10 +01:00
442e300457 fix: add missing tsconfig files and restore npm run build
Some checks failed
Deploy / deploy (push) Failing after 19s
tsconfig.json, tsconfig.app.json, and tsconfig.node.json were never
committed because *.json was gitignored without exceptions for them.
Added whitelist entries and restored Dockerfile to use npm run build
(tsc -b + vite) now that the config files are present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 14:02:21 +01:00
8eeb774d4e fix: replace wget --spider with wget -qO /dev/null for healthcheck
All checks were successful
Deploy / deploy (push) Successful in 11s
--spider sends a HEAD request which newer BusyBox builds in nginx:alpine
may not handle consistently. A plain GET to /dev/null is more reliable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 12:40:31 +01:00
000391f7fc fix: guard migrations against fresh-database installs
All checks were successful
Deploy / deploy (push) Successful in 36s
On a fresh DB, migrations ran before the schema was applied and tried to
operate on tables that didn't exist yet (routes, scans), causing:
  "no such table: routes" on _migrate_add_routes_unique_index
  "no such table: scans" on _migrate_add_scheduled_scan_id_to_scans

Added table-existence checks so both migrations bail out when the table
isn't there yet. The schema's CREATE TABLE IF NOT EXISTS handles fresh
installs correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 12:35:45 +01:00
9b982ad9a5 fix: bypass tsc -b in Docker build, use vite directly
Some checks failed
Deploy / deploy (push) Failing after 54s
tsc -b with project references fails in the Alpine Docker environment
(TypeScript 5.9 + no composite:true on referenced configs). Vite uses
esbuild for TS compilation anyway, so tsc -b only served as a type-check
which is redundant in a production build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 12:32:40 +01:00
de491dbb1f Update .gitea/workflows/deploy.yml
Some checks failed
Deploy / deploy (push) Failing after 37s
2026-02-28 12:22:54 +01:00
25 changed files with 2713 additions and 380 deletions

View File

@@ -29,7 +29,7 @@ on:
push:
branches:
- main
workflow_dispatch:
env:
COMPOSE_PROJECT: flight-radar
COMPOSE_FILE: flight-comparator/docker-compose.yml

321
PRD-reverse-scan.md Normal file
View File

@@ -0,0 +1,321 @@
# PRD: Reverse Scan — Country → Specific Airport(s)
**Status:** Ready for implementation
**Date:** 2026-03-01
**Target:** Web App (api_server.py + scan_processor.py + database + frontend)
---
## 1. Problem
All scans today are fixed-origin, variable-destination:
> *"From BDS — which German airports can I fly to directly?"*
The opposite question is equally common but unsupported:
> *"I want to fly to BRI or BDS — which German airport should I depart from?"*
This is the natural perspective of a traveller based in Germany looking for the cheapest access point to Puglia, not a traveller already in Puglia wondering where to go.
---
## 2. Goal
Let users create a scan where:
- **Origin** = all airports in a country (e.g. DE)
- **Destination** = one or more specific airports (e.g. BRI, BDS)
The results table for a reverse scan shows one row per **origin airport × destination** pair, so the user can directly compare which German airport is cheapest for each destination.
---
## 3. User Stories
- **As a user**, I want to search DE → BRI,BDS so I can see which German airport has the cheapest direct flight to Puglia.
- **As a user**, I want to schedule a reverse scan to run weekly.
- **As a user**, I want to mix both directions in my scan history.
- **As a user**, I want the destination country in forward scans to be a dropdown, not free text.
---
## 4. Scope
### In scope
- `scan_mode` field on scans and scheduled scans: `"forward"` (default) or `"reverse"`
- Reverse scan: origin = ISO country (dropdown), destinations = specific airport(s)
- Forward scan: destination country changed from text input to dropdown
- New `GET /api/v1/countries` endpoint (powers both dropdowns)
- `routes` table: add `origin_airport` column (nullable; populated for reverse scans)
- `flights` table: add `origin_airport` column (nullable; populated for reverse scans)
- ScanDetails routes table: show Origin column for reverse scans
- Scheduled scans: support `scan_mode`
### Out of scope
- Multi-country origin (e.g. DE + IT → BRI)
- Round-trip reverse scans
---
## 5. Data Model Changes
### 5.1 `scans` table
```sql
ALTER TABLE scans ADD COLUMN scan_mode TEXT NOT NULL DEFAULT 'forward'
CHECK (scan_mode IN ('forward', 'reverse'));
```
Column semantics by mode:
| Column | Forward (existing) | Reverse (new) |
|--------|--------------------|---------------|
| `origin` | Single IATA (e.g. `BDS`) | ISO country code (e.g. `DE`) |
| `country` | ISO destination country OR comma-separated destination IATAs | Comma-separated destination IATAs (e.g. `BRI,BDS`) |
| `scan_mode` | `forward` | `reverse` |
All existing rows default to `forward`. No data migration needed.
### 5.2 `routes` table
```sql
ALTER TABLE routes ADD COLUMN origin_airport TEXT;
```
- **Forward scans**: `origin_airport` = NULL (origin is always the scan-level `origin` IATA, already known)
- **Reverse scans**: `origin_airport` = the specific origin IATA for this route (e.g. `BER`, `FRA`)
The unique constraint on routes changes from `(scan_id, destination)` to `(scan_id, COALESCE(origin_airport, ''), destination)`.
**Reverse scan routes example** for `DE → BRI, BDS`:
| origin_airport | destination | flight_count | min_price |
|----------------|-------------|--------------|-----------|
| BER | BRI | 14 | €15 |
| FMM | BRI | 8 | €17 |
| DUS | BRI | 6 | €22 |
| BER | BDS | 3 | €28 |
| MUC | BDS | 2 | €34 |
### 5.3 `flights` table
```sql
ALTER TABLE flights ADD COLUMN origin_airport TEXT;
```
- **Forward scans**: `origin_airport` = NULL (origin is the scan-level `origin` IATA)
- **Reverse scans**: `origin_airport` = the specific IATA the flight departs from
### 5.4 `scheduled_scans` table
```sql
ALTER TABLE scheduled_scans ADD COLUMN scan_mode TEXT NOT NULL DEFAULT 'forward'
CHECK (scan_mode IN ('forward', 'reverse'));
```
Same column semantics as `scans`. When the scheduler fires a reverse scheduled scan, it creates a reverse scan child.
---
## 6. New API Endpoint
### `GET /api/v1/countries`
Returns the list of countries that have at least one airport in the database. Powers both the forward-mode destination country dropdown and the reverse-mode origin country dropdown.
**Response:**
```json
{
"data": [
{ "code": "DE", "name": "Germany", "airport_count": 23 },
{ "code": "IT", "name": "Italy", "airport_count": 41 },
...
]
}
```
Sorted alphabetically by name. Derived from `data/airports_by_country.json` at startup (same source as airport resolution). Rate limit: 100/min.
Country names are resolved from a static ISO → name mapping in `api_server.py` (same `COUNTRY_NAME_TO_ISO` dict already in `airports.py`, inverted).
---
## 7. Backend Changes
### 7.1 `ScanRequest` model
Add `scan_mode: Literal['forward', 'reverse'] = 'forward'`.
Root validator enforces:
**Forward mode** (unchanged):
- `origin`: required, 3-char IATA
- `country` OR `destinations`: required
**Reverse mode**:
- `origin`: required, 2-char ISO country code
- `destinations`: required, 110 IATAs
- `country` must be absent or null
### 7.2 `scan_processor.py` — `process_scan()`
**Forward** (unchanged):
```
fixed origin IATA × [airports resolved from destination country / destination IATAs]
```
**Reverse** (new):
```
[airports resolved from origin country] × fixed destination IATA(s)
```
Steps for reverse:
1. Read `scan.origin` as ISO country code → resolve airport list via `airports.py`
2. Read `scan.country` as comma-separated destination IATAs
3. Build pairs: `[(orig, dest) for orig in origin_airports for dest in destination_iatas]`
4. For each pair: call `search_multiple_routes(orig, dest)` as usual
5. Save route with `origin_airport=orig`, `destination=dest`
6. Save each flight with `origin_airport=orig`
`total_routes` = `len(origin_airports) × len(destination_iatas)`.
### 7.3 `GET /scans/{id}/routes` response
Add `origin_airport: str | null` to the `Route` response model. Frontend uses this to show/hide the Origin column.
### 7.4 `GET /scans/{id}/flights` response
Add `origin_airport: str | null` to the `Flight` response model.
### 7.5 Scheduled scans
`ScheduleRequest` and `Schedule` models gain `scan_mode`. The scheduler's `_fire_due_scans()` function passes `scan_mode` when inserting child scans into the `scans` table.
---
## 8. Frontend Changes
### 8.1 New `countriesApi` client (`api.ts`)
```typescript
export interface Country { code: string; name: string; airport_count: number; }
export const countriesApi = {
list: () => api.get<{ data: Country[] }>('/countries'),
};
```
### 8.2 New `CountrySelect` component
A standard `<select>` dropdown, populated from `GET /api/v1/countries` on mount. Shows `"Germany (DE)"` style labels, emits the ISO code. Reused in two places:
- Forward scan form: destination country field (replaces current text input)
- Reverse scan form: origin country field
### 8.3 Scan creation form (`/scans` page)
Add a **Direction toggle** at the top of the form (same segmented button style as "By Country / By Airports"):
```
[ → Forward ] [ ← Reverse ]
```
**Forward mode** (current layout, destination country now uses `CountrySelect`):
```
Origin airport: [AirportSearch]
Destination: [By Country ▼] [By Airports]
→ Country: [CountrySelect dropdown]
→ Airports: [comma-sep IATA input]
```
**Reverse mode** (new):
```
Origin country: [CountrySelect dropdown]
Destination airports: [AirportSearch / comma-sep IATA input]
```
### 8.4 ScanDetails — routes table
For reverse scans (`scan_mode === 'reverse'`), prepend an **Origin** column to the routes table:
| Origin | Destination | Flights | Airlines | Min | Avg | Max |
|--------|-------------|---------|----------|-----|-----|-----|
| BER Berlin | BRI Bari | 14 | Ryanair | €15 | €22 | €45 |
| FMM Memmingen | BRI Bari | 8 | Ryanair | €17 | €25 | €50 |
The existing click-to-expand flights sub-table still works — shows individual flight dates/times/prices for that specific origin→destination pair.
For forward scans: routes table unchanged (no Origin column).
### 8.5 ScanDetails — header
```
Forward: BDS → DE
Reverse: DE → BRI, BDS
```
### 8.6 `CreateScanRequest` type update (`api.ts`)
```typescript
export interface CreateScanRequest {
scan_mode?: 'forward' | 'reverse';
origin: string; // IATA (forward) or ISO country code (reverse)
country?: string; // forward only
destinations?: string[]; // forward (by airports) or reverse
window_months?: number;
seat_class?: 'economy' | 'premium' | 'business' | 'first';
adults?: number;
}
```
---
## 9. API Contract
### `POST /api/v1/scans`
**Forward — by country (existing, unchanged):**
```json
{ "origin": "BDS", "country": "DE", "window_months": 6, "seat_class": "economy", "adults": 1 }
```
**Forward — by airports (existing, unchanged):**
```json
{ "origin": "BDS", "destinations": ["FRA", "MUC"], "window_months": 6, "seat_class": "economy", "adults": 1 }
```
**Reverse (new):**
```json
{ "scan_mode": "reverse", "origin": "DE", "destinations": ["BRI", "BDS"], "window_months": 6, "seat_class": "economy", "adults": 1 }
```
---
## 10. Migration (`init_db.py`)
Four `ALTER TABLE … ADD COLUMN` statements, each guarded by `try/except OperationalError`:
```python
migrations = [
"ALTER TABLE scans ADD COLUMN scan_mode TEXT NOT NULL DEFAULT 'forward' CHECK (scan_mode IN ('forward', 'reverse'))",
"ALTER TABLE routes ADD COLUMN origin_airport TEXT",
"ALTER TABLE flights ADD COLUMN origin_airport TEXT",
"ALTER TABLE scheduled_scans ADD COLUMN scan_mode TEXT NOT NULL DEFAULT 'forward' CHECK (scan_mode IN ('forward', 'reverse'))",
]
for sql in migrations:
try:
conn.execute(sql)
except sqlite3.OperationalError:
pass # column already exists
conn.commit()
```
---
## 11. Acceptance Criteria
1. `GET /api/v1/countries` returns a sorted list of countries with airport counts.
2. Forward scan destination country field is a dropdown populated from that endpoint.
3. A reverse scan `DE → BRI, BDS` can be created via the form and the API.
4. The processor iterates all German airports × [BRI, BDS], storing `origin_airport` on each route and flight row.
5. ScanDetails for a reverse scan shows the Origin column in the routes table and `DE → BRI, BDS` in the header.
6. Scheduled scans accept `scan_mode`; the scheduler passes it through to child scans.
7. All existing forward scans continue to work — no regressions.
8. All four DB columns default correctly after migration with no data loss.

View File

@@ -52,6 +52,9 @@ htmlcov/
!tests/confirmed_flights.json
!frontend/package.json
!frontend/package-lock.json
!frontend/tsconfig.json
!frontend/tsconfig.app.json
!frontend/tsconfig.node.json
# Database files
*.db

View File

@@ -13,7 +13,9 @@ FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN apk add --no-cache curl
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost/ || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f http://localhost/ || exit 1

View File

@@ -69,6 +69,13 @@ COUNTRY_NAME_TO_ISO = {
}
# Airports missing from the OpenFlights dataset (opened or renamed after dataset was last updated).
# Keyed by ISO country code; dicts match the airports_by_country.json schema (iata/name/city/icao).
_MISSING_AIRPORTS: dict[str, list[dict]] = {
'DE': [{'iata': 'BER', 'name': 'Berlin Brandenburg Airport', 'city': 'Berlin', 'icao': 'EDDB'}],
}
def country_name_to_iso_code(country_name: str) -> Optional[str]:
"""
Convert country name to ISO 2-letter code.
@@ -197,7 +204,12 @@ def get_airports_for_country(country_code: str) -> list[dict]:
f"Available codes (sample): {', '.join(available)}..."
)
return airports_by_country[country_code]
result = list(airports_by_country[country_code])
existing_iatas = {a['iata'] for a in result}
for extra in _MISSING_AIRPORTS.get(country_code, []):
if extra['iata'] not in existing_iatas:
result.append(extra)
return result
def resolve_airport_list(country: Optional[str], from_airports: Optional[str]) -> list[dict]:
@@ -233,11 +245,16 @@ def _all_airports_by_iata() -> dict:
download_and_build_airport_data()
with open(AIRPORTS_JSON_PATH, 'r', encoding='utf-8') as f:
airports_by_country = json.load(f)
return {
result = {
a['iata']: a
for airports in airports_by_country.values()
for a in airports
}
for extras in _MISSING_AIRPORTS.values():
for extra in extras:
if extra['iata'] not in result:
result[extra['iata']] = extra
return result
def lookup_airport(iata: str) -> dict | None:

View File

@@ -38,9 +38,12 @@ from threading import Lock
T = TypeVar('T')
# Import existing modules
from airports import download_and_build_airport_data
from airports import download_and_build_airport_data, COUNTRY_NAME_TO_ISO
# Inverted mapping: ISO code → country name (for /countries endpoint)
_ISO_TO_COUNTRY_NAME = {v: k for k, v in COUNTRY_NAME_TO_ISO.items()}
from database import get_connection
from scan_processor import start_scan_processor
from scan_processor import start_scan_processor, start_resume_processor, pause_scan_task, stop_scan_task
# =============================================================================
@@ -221,11 +224,13 @@ rate_limiter = RateLimiter()
# Rate limit configurations (requests per minute)
RATE_LIMITS = {
'default': (200, 60), # 200 requests per 60 seconds (~3 req/sec)
'scans': (50, 60), # 50 scan creations per minute
'logs': (100, 60), # 100 log requests per minute
'airports': (500, 60), # 500 airport searches per minute
'schedules': (30, 60), # 30 schedule requests per minute
'default': (200, 60), # 200 requests per 60 seconds (~3 req/sec)
'scans': (50, 60), # 50 scan creations per minute
'logs': (100, 60), # 100 log requests per minute
'airports': (500, 60), # 500 airport searches per minute
'schedules': (30, 60), # 30 schedule requests per minute
'scan_control': (30, 60), # 30 pause/cancel requests per minute
'scan_resume': (10, 60), # 10 resume requests per minute
}
@@ -236,7 +241,11 @@ def get_rate_limit_for_path(path: str) -> tuple[str, int, int]:
Returns:
tuple: (endpoint_name, limit, window)
"""
if '/scans' in path and path.count('/') == 3: # POST /api/v1/scans
if '/scans' in path and (path.endswith('/pause') or path.endswith('/cancel')):
return 'scan_control', *RATE_LIMITS['scan_control']
elif '/scans' in path and path.endswith('/resume'):
return 'scan_resume', *RATE_LIMITS['scan_resume']
elif '/scans' in path and path.count('/') == 3: # POST /api/v1/scans
return 'scans', *RATE_LIMITS['scans']
elif '/logs' in path:
return 'logs', *RATE_LIMITS['logs']
@@ -288,7 +297,7 @@ def _check_and_run_due_schedules():
now_str = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("""
SELECT id, origin, country, window_months, seat_class, adults,
SELECT id, origin, country, scan_mode, window_months, seat_class, adults,
frequency, hour, minute, day_of_week, day_of_month
FROM scheduled_scans
WHERE enabled = 1 AND next_run_at <= ?
@@ -296,7 +305,7 @@ def _check_and_run_due_schedules():
due = cursor.fetchall()
for row in due:
(sched_id, origin, country, window_months, seat_class, adults,
(sched_id, origin, country, scan_mode, window_months, seat_class, adults,
frequency, hour, minute, day_of_week, day_of_month) = row
# Concurrency guard: skip if a scan for this schedule is still active
@@ -317,10 +326,10 @@ def _check_and_run_due_schedules():
conn.execute("""
INSERT INTO scans (
origin, country, start_date, end_date,
origin, country, scan_mode, start_date, end_date,
status, seat_class, adults, scheduled_scan_id
) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)
""", (origin, country, start_date, end_date,
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)
""", (origin, country, scan_mode, start_date, end_date,
seat_class, adults, sched_id))
conn.commit()
scan_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
@@ -699,11 +708,15 @@ class Country(BaseModel):
class ScanRequest(BaseModel):
"""Flight scan request model with comprehensive validation."""
scan_mode: str = Field(
'forward',
description="Scan direction: 'forward' (IATA → country) or 'reverse' (country → IATAs)"
)
origin: str = Field(
...,
min_length=3,
min_length=2,
max_length=3,
description="Origin airport IATA code (3 uppercase letters)"
description="Origin airport IATA code (forward) or ISO country code (reverse)"
)
destination_country: Optional[str] = Field(
None,
@@ -741,11 +754,22 @@ class ScanRequest(BaseModel):
description="Number of adults (1-9)"
)
@validator('scan_mode')
def validate_scan_mode(cls, v):
if v not in ('forward', 'reverse'):
raise ValueError("scan_mode must be 'forward' or 'reverse'")
return v
@validator('origin')
def validate_origin(cls, v):
v = v.upper() # Normalize to uppercase
if not re.match(r'^[A-Z]{3}$', v):
raise ValueError('Origin must be a 3-letter IATA code (e.g., BDS, MUC)')
def validate_origin(cls, v, values):
v = v.strip().upper()
mode = values.get('scan_mode', 'forward')
if mode == 'reverse':
if not re.match(r'^[A-Z]{2}$', v):
raise ValueError('For reverse scans, origin must be a 2-letter ISO country code (e.g., DE, IT)')
else:
if not re.match(r'^[A-Z]{3}$', v):
raise ValueError('Origin must be a 3-letter IATA code (e.g., BDS, MUC)')
return v
@validator('destination_country')
@@ -785,16 +809,20 @@ class ScanRequest(BaseModel):
@validator('destinations', pre=False, always=True)
def check_destination_mode(cls, v, values):
"""Ensure either country or destinations is provided, but not both."""
"""Ensure correct destination fields for the chosen scan_mode."""
country = values.get('destination_country')
mode = values.get('scan_mode', 'forward')
if country and v:
raise ValueError('Provide either country OR destinations, not both')
if not country and not v:
raise ValueError('Must provide either country or destinations')
return v
if mode == 'reverse':
if not v:
raise ValueError('Reverse scans require destinations (list of destination airport IATA codes)')
return v
else:
if country and v:
raise ValueError('Provide either country OR destinations, not both')
if not country and not v:
raise ValueError('Must provide either country or destinations')
return v
@validator('start_date')
def validate_start_date(cls, v):
@@ -889,6 +917,7 @@ class Route(BaseModel):
"""Route model - represents a discovered flight route."""
id: int = Field(..., description="Route ID")
scan_id: int = Field(..., description="Parent scan ID")
origin_airport: Optional[str] = Field(None, description="Origin airport IATA code (reverse scans only)")
destination: str = Field(..., description="Destination airport IATA code")
destination_name: str = Field(..., description="Destination airport name")
destination_city: Optional[str] = Field(None, description="Destination city")
@@ -904,6 +933,7 @@ class Flight(BaseModel):
"""Individual flight discovered by a scan."""
id: int = Field(..., description="Flight ID")
scan_id: int = Field(..., description="Parent scan ID")
origin_airport: Optional[str] = Field(None, description="Origin airport IATA code (reverse scans only)")
destination: str = Field(..., description="Destination airport IATA code")
date: str = Field(..., description="Flight date (YYYY-MM-DD)")
airline: Optional[str] = Field(None, description="Operating airline")
@@ -916,8 +946,9 @@ class Flight(BaseModel):
class Scan(BaseModel):
"""Scan model - represents a flight scan with full details."""
id: int = Field(..., description="Scan ID")
origin: str = Field(..., description="Origin airport IATA code")
country: str = Field(..., description="Destination country code")
scan_mode: str = Field('forward', description="Scan direction: forward or reverse")
origin: str = Field(..., description="Origin airport IATA code (forward) or ISO country code (reverse)")
country: str = Field(..., description="Destination country code or comma-separated destination IATAs")
start_date: str = Field(..., description="Start date (YYYY-MM-DD)")
end_date: str = Field(..., description="End date (YYYY-MM-DD)")
created_at: str = Field(..., description="ISO timestamp when scan was created")
@@ -930,6 +961,8 @@ class Scan(BaseModel):
seat_class: str = Field(..., description="Seat class")
adults: int = Field(..., ge=1, le=9, description="Number of adults")
scheduled_scan_id: Optional[int] = Field(None, description="ID of the schedule that created this scan")
started_at: Optional[str] = Field(None, description="ISO timestamp when scan processing started")
completed_at: Optional[str] = Field(None, description="ISO timestamp when scan completed or failed")
class ScanCreateResponse(BaseModel):
@@ -1175,16 +1208,14 @@ async def get_countries():
country = airport['country']
country_counts[country] = country_counts.get(country, 0) + 1
# Get country names (we'll need a mapping file for this)
# For now, just return codes
countries = [
countries = sorted([
Country(
code=code,
name=code, # TODO: Add country name mapping
name=_ISO_TO_COUNTRY_NAME.get(code, code),
airport_count=count
)
for code, count in sorted(country_counts.items())
]
for code, count in country_counts.items()
], key=lambda c: c.name)
return countries
@@ -1233,12 +1264,13 @@ async def create_scan(request: ScanRequest):
cursor.execute("""
INSERT INTO scans (
origin, country, start_date, end_date,
origin, country, scan_mode, start_date, end_date,
status, seat_class, adults
) VALUES (?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
request.origin,
country_or_airports,
request.scan_mode,
start_date,
end_date,
'pending',
@@ -1251,10 +1283,11 @@ async def create_scan(request: ScanRequest):
# Fetch the created scan
cursor.execute("""
SELECT id, origin, country, start_date, end_date,
SELECT id, origin, country, scan_mode, start_date, end_date,
created_at, updated_at, status, total_routes,
routes_scanned, total_flights, error_message,
seat_class, adults, scheduled_scan_id
seat_class, adults, scheduled_scan_id,
started_at, completed_at
FROM scans
WHERE id = ?
""", (scan_id,))
@@ -1269,18 +1302,21 @@ async def create_scan(request: ScanRequest):
id=row[0],
origin=row[1],
country=row[2],
start_date=row[3],
end_date=row[4],
created_at=row[5],
updated_at=row[6],
status=row[7],
total_routes=row[8],
routes_scanned=row[9],
total_flights=row[10],
error_message=row[11],
seat_class=row[12],
adults=row[13],
scheduled_scan_id=row[14] if len(row) > 14 else None
scan_mode=row[3],
start_date=row[4],
end_date=row[5],
created_at=row[6],
updated_at=row[7],
status=row[8],
total_routes=row[9],
routes_scanned=row[10],
total_flights=row[11],
error_message=row[12],
seat_class=row[13],
adults=row[14],
scheduled_scan_id=row[15] if len(row) > 15 else None,
started_at=row[16] if len(row) > 16 else None,
completed_at=row[17] if len(row) > 17 else None,
)
logging.info(f"Scan created: ID={scan_id}, origin={scan.origin}, country={scan.country}, dates={scan.start_date} to {scan.end_date}")
@@ -1330,10 +1366,10 @@ async def list_scans(
where_clause = ""
params = []
if status:
if status not in ['pending', 'running', 'completed', 'failed']:
if status not in ['pending', 'running', 'completed', 'failed', 'paused', 'cancelled']:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status}. Must be one of: pending, running, completed, failed"
detail=f"Invalid status: {status}. Must be one of: pending, running, completed, failed, paused, cancelled"
)
where_clause = "WHERE status = ?"
params.append(status)
@@ -1356,10 +1392,11 @@ async def list_scans(
# Get paginated results
offset = (page - 1) * limit
query = f"""
SELECT id, origin, country, start_date, end_date,
SELECT id, origin, country, scan_mode, start_date, end_date,
created_at, updated_at, status, total_routes,
routes_scanned, total_flights, error_message,
seat_class, adults, scheduled_scan_id
seat_class, adults, scheduled_scan_id,
started_at, completed_at
FROM scans
{where_clause}
ORDER BY created_at DESC
@@ -1376,18 +1413,21 @@ async def list_scans(
id=row[0],
origin=row[1],
country=row[2],
start_date=row[3],
end_date=row[4],
created_at=row[5],
updated_at=row[6],
status=row[7],
total_routes=row[8],
routes_scanned=row[9],
total_flights=row[10],
error_message=row[11],
seat_class=row[12],
adults=row[13],
scheduled_scan_id=row[14] if len(row) > 14 else None
scan_mode=row[3],
start_date=row[4],
end_date=row[5],
created_at=row[6],
updated_at=row[7],
status=row[8],
total_routes=row[9],
routes_scanned=row[10],
total_flights=row[11],
error_message=row[12],
seat_class=row[13],
adults=row[14],
scheduled_scan_id=row[15] if len(row) > 15 else None,
started_at=row[16] if len(row) > 16 else None,
completed_at=row[17] if len(row) > 17 else None,
))
# Build pagination metadata
@@ -1425,10 +1465,11 @@ async def get_scan_status(scan_id: int):
cursor = conn.cursor()
cursor.execute("""
SELECT id, origin, country, start_date, end_date,
SELECT id, origin, country, scan_mode, start_date, end_date,
created_at, updated_at, status, total_routes,
routes_scanned, total_flights, error_message,
seat_class, adults, scheduled_scan_id
seat_class, adults, scheduled_scan_id,
started_at, completed_at
FROM scans
WHERE id = ?
""", (scan_id,))
@@ -1446,18 +1487,21 @@ async def get_scan_status(scan_id: int):
id=row[0],
origin=row[1],
country=row[2],
start_date=row[3],
end_date=row[4],
created_at=row[5],
updated_at=row[6],
status=row[7],
total_routes=row[8],
routes_scanned=row[9],
total_flights=row[10],
error_message=row[11],
seat_class=row[12],
adults=row[13],
scheduled_scan_id=row[14] if len(row) > 14 else None
scan_mode=row[3],
start_date=row[4],
end_date=row[5],
created_at=row[6],
updated_at=row[7],
status=row[8],
total_routes=row[9],
routes_scanned=row[10],
total_flights=row[11],
error_message=row[12],
seat_class=row[13],
adults=row[14],
scheduled_scan_id=row[15] if len(row) > 15 else None,
started_at=row[16] if len(row) > 16 else None,
completed_at=row[17] if len(row) > 17 else None,
)
except HTTPException:
@@ -1507,6 +1551,155 @@ async def delete_scan(scan_id: int):
raise HTTPException(status_code=500, detail=f"Failed to delete scan: {str(e)}")
@router_v1.post("/scans/{scan_id}/pause")
async def pause_scan(scan_id: int):
"""
Pause a running or pending scan.
Stops the background task and marks the scan as 'paused'.
The scan can be resumed later via POST /scans/{id}/resume.
Returns 409 if the scan is not in a pauseable state (not pending/running).
"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT status FROM scans WHERE id = ?", (scan_id,))
row = cursor.fetchone()
if not row:
conn.close()
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_id}")
if row[0] not in ('pending', 'running'):
conn.close()
raise HTTPException(
status_code=409,
detail=f"Cannot pause a scan with status '{row[0]}'. Only pending or running scans can be paused."
)
cursor.execute("""
UPDATE scans
SET status = 'paused',
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (scan_id,))
conn.commit()
conn.close()
pause_scan_task(scan_id)
logging.info(f"Scan {scan_id} paused")
return {"id": scan_id, "status": "paused"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to pause scan: {str(e)}")
@router_v1.post("/scans/{scan_id}/cancel")
async def cancel_scan(scan_id: int):
"""
Cancel a running or pending scan permanently.
Stops the background task and marks the scan as 'cancelled'.
Partial results are preserved. Use Re-run to start a new scan.
Returns 409 if the scan is not in a cancellable state.
"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT status FROM scans WHERE id = ?", (scan_id,))
row = cursor.fetchone()
if not row:
conn.close()
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_id}")
if row[0] not in ('pending', 'running'):
conn.close()
raise HTTPException(
status_code=409,
detail=f"Cannot cancel a scan with status '{row[0]}'. Only pending or running scans can be cancelled."
)
cursor.execute("""
UPDATE scans
SET status = 'cancelled',
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (scan_id,))
conn.commit()
conn.close()
stop_scan_task(scan_id)
logging.info(f"Scan {scan_id} cancelled")
return {"id": scan_id, "status": "cancelled"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to cancel scan: {str(e)}")
@router_v1.post("/scans/{scan_id}/resume")
async def resume_scan(scan_id: int):
"""
Resume a paused scan.
Resets progress counters and restarts the background worker.
Already-queried routes are instant cache hits so progress races quickly
through them before settling on uncompleted routes.
Returns 409 if the scan is not paused.
"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT status FROM scans WHERE id = ?", (scan_id,))
row = cursor.fetchone()
if not row:
conn.close()
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_id}")
if row[0] != 'paused':
conn.close()
raise HTTPException(
status_code=409,
detail=f"Cannot resume a scan with status '{row[0]}'. Only paused scans can be resumed."
)
# Reset counters so the progress bar starts fresh; the processor will race
# through cache hits before slowing on uncompleted routes.
cursor.execute("""
UPDATE scans
SET status = 'pending',
routes_scanned = 0,
started_at = NULL,
completed_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (scan_id,))
conn.commit()
conn.close()
start_resume_processor(scan_id)
logging.info(f"Scan {scan_id} resumed")
return {"id": scan_id, "status": "pending"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to resume scan: {str(e)}")
@router_v1.get("/scans/{scan_id}/routes", response_model=PaginatedResponse[Route])
async def get_scan_routes(
scan_id: int,
@@ -1550,7 +1743,7 @@ async def get_scan_routes(
# Get paginated results
offset = (page - 1) * limit
cursor.execute("""
SELECT id, scan_id, destination, destination_name, destination_city,
SELECT id, scan_id, origin_airport, destination, destination_name, destination_city,
flight_count, airlines, min_price, max_price, avg_price, created_at
FROM routes
WHERE scan_id = ?
@@ -1570,13 +1763,13 @@ async def get_scan_routes(
for row in rows:
# Parse airlines JSON
try:
airlines = json.loads(row[6]) if row[6] else []
airlines = json.loads(row[7]) if row[7] else []
except:
airlines = []
dest = row[2]
dest_name = row[3] or dest
dest_city = row[4] or ''
dest = row[3]
dest_name = row[4] or dest
dest_city = row[5] or ''
# If name was never resolved (stored as IATA code), look it up now
if dest_name == dest:
@@ -1587,15 +1780,16 @@ async def get_scan_routes(
routes.append(Route(
id=row[0],
scan_id=row[1],
origin_airport=row[2],
destination=dest,
destination_name=dest_name,
destination_city=dest_city,
flight_count=row[5],
flight_count=row[6],
airlines=airlines,
min_price=row[7],
max_price=row[8],
avg_price=row[9],
created_at=row[10]
min_price=row[8],
max_price=row[9],
avg_price=row[10],
created_at=row[11]
))
# Build pagination metadata
@@ -1625,14 +1819,15 @@ async def get_scan_routes(
async def get_scan_flights(
scan_id: int,
destination: Optional[str] = Query(None, min_length=3, max_length=3, description="Filter by destination IATA code"),
origin_airport: Optional[str] = Query(None, min_length=3, max_length=3, description="Filter by origin airport IATA code (reverse scans)"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(50, ge=1, le=200, description="Items per page")
):
"""
Get individual flights discovered by a specific scan.
Optionally filter by destination airport code.
Results are ordered by price ascending.
Optionally filter by destination and/or origin airport code.
Results are ordered by date then price ascending.
"""
try:
conn = get_connection()
@@ -1643,45 +1838,41 @@ async def get_scan_flights(
conn.close()
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_id}")
# Build dynamic WHERE clause
conditions = ["scan_id = ?"]
params: list = [scan_id]
if destination:
cursor.execute(
"SELECT COUNT(*) FROM flights WHERE scan_id = ? AND destination = ?",
(scan_id, destination.upper())
)
else:
cursor.execute("SELECT COUNT(*) FROM flights WHERE scan_id = ?", (scan_id,))
conditions.append("destination = ?")
params.append(destination.upper())
if origin_airport:
conditions.append("origin_airport = ?")
params.append(origin_airport.upper())
where = " AND ".join(conditions)
cursor.execute(f"SELECT COUNT(*) FROM flights WHERE {where}", params)
total = cursor.fetchone()[0]
total_pages = math.ceil(total / limit) if total > 0 else 0
offset = (page - 1) * limit
if destination:
cursor.execute("""
SELECT id, scan_id, destination, date, airline,
departure_time, arrival_time, price, stops
FROM flights
WHERE scan_id = ? AND destination = ?
ORDER BY price ASC, date ASC
LIMIT ? OFFSET ?
""", (scan_id, destination.upper(), limit, offset))
else:
cursor.execute("""
SELECT id, scan_id, destination, date, airline,
departure_time, arrival_time, price, stops
FROM flights
WHERE scan_id = ?
ORDER BY price ASC, date ASC
LIMIT ? OFFSET ?
""", (scan_id, limit, offset))
cursor.execute(f"""
SELECT id, scan_id, origin_airport, destination, date, airline,
departure_time, arrival_time, price, stops
FROM flights
WHERE {where}
ORDER BY date ASC, price ASC
LIMIT ? OFFSET ?
""", params + [limit, offset])
rows = cursor.fetchall()
conn.close()
flights = [
Flight(
id=row[0], scan_id=row[1], destination=row[2], date=row[3],
airline=row[4], departure_time=row[5], arrival_time=row[6],
price=row[7], stops=row[8]
id=row[0], scan_id=row[1], origin_airport=row[2],
destination=row[3], date=row[4], airline=row[5],
departure_time=row[6], arrival_time=row[7],
price=row[8], stops=row[9]
)
for row in rows
]
@@ -1799,7 +1990,8 @@ async def get_flights_stub(route_id: str):
class CreateScheduleRequest(BaseModel):
"""Request body for creating or updating a scheduled scan."""
origin: str = Field(..., description="Origin airport IATA code (3 letters)")
scan_mode: str = Field('forward', description="Scan direction: 'forward' or 'reverse'")
origin: str = Field(..., description="Origin airport IATA code (forward) or ISO country code (reverse)")
country: str = Field(..., description="Destination country ISO code (2 letters) or comma-separated IATA codes")
window_months: int = Field(1, ge=1, le=12, description="Months of data per scan run")
seat_class: str = Field('economy', description="Seat class")
@@ -1861,6 +2053,7 @@ class UpdateScheduleRequest(BaseModel):
class Schedule(BaseModel):
"""A recurring scheduled scan."""
id: int
scan_mode: str
origin: str
country: str
window_months: int
@@ -1883,6 +2076,7 @@ def _row_to_schedule(row, recent_scan_ids: list) -> Schedule:
"""Convert a DB row (sqlite3.Row or tuple) to a Schedule model."""
return Schedule(
id=row['id'],
scan_mode=row['scan_mode'] if 'scan_mode' in row.keys() else 'forward',
origin=row['origin'],
country=row['country'],
window_months=row['window_months'],
@@ -1960,12 +2154,12 @@ async def create_schedule(request: CreateScheduleRequest):
conn = get_connection()
conn.execute("""
INSERT INTO scheduled_scans (
origin, country, window_months, seat_class, adults,
scan_mode, origin, country, window_months, seat_class, adults,
label, frequency, hour, minute, day_of_week, day_of_month,
enabled, next_run_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
""", (
request.origin, request.country, request.window_months,
request.scan_mode, request.origin, request.country, request.window_months,
request.seat_class, request.adults, request.label,
request.frequency, request.hour, request.minute,
request.day_of_week, request.day_of_month, next_run_str,
@@ -2113,11 +2307,13 @@ async def run_schedule_now(schedule_id: int):
conn.execute("""
INSERT INTO scans (
origin, country, start_date, end_date,
origin, country, scan_mode, start_date, end_date,
status, seat_class, adults, scheduled_scan_id
) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)
""", (
row['origin'], row['country'], start_date, end_date,
row['origin'], row['country'],
row['scan_mode'] if 'scan_mode' in row.keys() else 'forward',
start_date, end_date,
row['seat_class'], row['adults'], schedule_id,
))
conn.commit()

View File

@@ -138,6 +138,13 @@ def _migrate_add_routes_unique_index(conn, verbose=True):
Collapses any pre-existing duplicate (scan_id, destination) rows first
(keeps the row with the lowest id) before creating the index.
"""
# Fresh install: routes table doesn't exist yet — schema will create the index
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='routes'"
)
if not cursor.fetchone():
return
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name='uq_routes_scan_dest'"
)
@@ -177,6 +184,8 @@ def _migrate_add_scheduled_scan_id_to_scans(conn, verbose=True):
"""
cursor = conn.execute("PRAGMA table_info(scans)")
columns = [row[1] for row in cursor.fetchall()]
if not columns:
return # Fresh install: scans table doesn't exist yet — schema will create the column
if 'scheduled_scan_id' in columns:
return # Already migrated
@@ -190,6 +199,304 @@ def _migrate_add_scheduled_scan_id_to_scans(conn, verbose=True):
print(" ✅ Migration complete: scheduled_scan_id column added to scans")
def _migrate_add_timing_columns_to_scans(conn, verbose=True):
"""
Migration: add started_at and completed_at columns to the scans table.
started_at — set when status transitions to 'running'
completed_at — set when status transitions to 'completed' or 'failed'
Both are nullable so existing rows are unaffected.
"""
cursor = conn.execute("PRAGMA table_info(scans)")
columns = [row[1] for row in cursor.fetchall()]
if not columns:
return # Fresh install: scans table doesn't exist yet — schema will create the columns
if 'started_at' in columns and 'completed_at' in columns:
return # Already migrated
if verbose:
print(" 🔄 Migrating scans table: adding started_at and completed_at columns...")
if 'started_at' not in columns:
conn.execute("ALTER TABLE scans ADD COLUMN started_at TIMESTAMP")
if 'completed_at' not in columns:
conn.execute("ALTER TABLE scans ADD COLUMN completed_at TIMESTAMP")
conn.commit()
if verbose:
print(" ✅ Migration complete: started_at and completed_at columns added to scans")
def _recover_orphaned_new_tables(conn, verbose=True):
"""
Recovery: if a previous migration left behind scans_new or scheduled_scans_new
(e.g. after a crash between DROP TABLE scans and RENAME), restore them.
"""
tables = [r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()]
if 'scans_new' in tables and 'scans' not in tables:
if verbose:
print(" 🔧 Recovering: renaming orphaned scans_new → scans")
conn.execute("ALTER TABLE scans_new RENAME TO scans")
conn.commit()
if 'scheduled_scans_new' in tables and 'scheduled_scans' not in tables:
if verbose:
print(" 🔧 Recovering: renaming orphaned scheduled_scans_new → scheduled_scans")
conn.execute("ALTER TABLE scheduled_scans_new RENAME TO scheduled_scans")
conn.commit()
def _migrate_add_pause_cancel_status(conn, verbose=True):
"""
Migration: Extend status CHECK constraint to include 'paused' and 'cancelled'.
Needed for cancel/pause/resume scan flow control feature.
Uses the same table-recreation pattern as _migrate_relax_country_constraint
because SQLite doesn't support modifying CHECK constraints in-place.
"""
cursor = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='scans'"
)
row = cursor.fetchone()
if not row or 'paused' in row[0]:
return # Table doesn't exist yet (fresh install) or already migrated
if verbose:
print(" 🔄 Migrating scans table: adding 'paused' and 'cancelled' status values...")
# SQLite doesn't support ALTER TABLE MODIFY COLUMN, so recreate the table.
# Use PRAGMA foreign_keys = OFF to avoid FK errors during the swap.
conn.execute("PRAGMA foreign_keys = OFF")
# Drop views that reference scans so they can be cleanly recreated by executescript.
conn.execute("DROP VIEW IF EXISTS recent_scans")
conn.execute("DROP VIEW IF EXISTS active_scans")
# Drop any leftover _new table from a previously aborted migration.
conn.execute("DROP TABLE IF EXISTS scans_new")
# Drop triggers that reference scans (they are recreated by executescript below).
conn.execute("DROP TRIGGER IF EXISTS update_scans_timestamp")
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_insert")
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_update")
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_delete")
conn.execute("""
CREATE TABLE scans_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL CHECK(length(origin) = 3),
country TEXT NOT NULL CHECK(length(country) >= 2),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled', 'paused')),
total_routes INTEGER NOT NULL DEFAULT 0 CHECK(total_routes >= 0),
routes_scanned INTEGER NOT NULL DEFAULT 0 CHECK(routes_scanned >= 0),
total_flights INTEGER NOT NULL DEFAULT 0 CHECK(total_flights >= 0),
error_message TEXT,
seat_class TEXT DEFAULT 'economy',
adults INTEGER DEFAULT 1 CHECK(adults > 0 AND adults <= 9),
scheduled_scan_id INTEGER,
CHECK(end_date >= start_date),
CHECK(routes_scanned <= total_routes OR total_routes = 0)
)
""")
# Use named columns to handle different column orderings (ALTER TABLE vs fresh schema).
conn.execute("""
INSERT INTO scans_new (
id, origin, country, start_date, end_date,
created_at, updated_at, started_at, completed_at,
status, total_routes, routes_scanned, total_flights,
error_message, seat_class, adults, scheduled_scan_id
)
SELECT
id, origin, country, start_date, end_date,
created_at, updated_at, started_at, completed_at,
status, total_routes, routes_scanned, total_flights,
error_message, seat_class, adults, scheduled_scan_id
FROM scans
""")
conn.execute("DROP TABLE scans")
conn.execute("ALTER TABLE scans_new RENAME TO scans")
conn.execute("PRAGMA foreign_keys = ON")
conn.commit()
if verbose:
print(" ✅ Migration complete: status now accepts 'paused' and 'cancelled'")
def _migrate_add_reverse_scan_support(conn, verbose=True):
"""
Migration: Add reverse scan support across all affected tables.
Changes:
- scans: relax origin CHECK (3→>=2), add scan_mode column
- routes: add origin_airport column, replace unique index
- flights: add origin_airport column
- scheduled_scans: relax origin CHECK (3→>=2), add scan_mode column
"""
# ── scans table ──────────────────────────────────────────────────────────
cursor = conn.execute("PRAGMA table_info(scans)")
scans_cols = [row[1] for row in cursor.fetchall()]
if scans_cols and 'scan_mode' not in scans_cols:
if verbose:
print(" 🔄 Migrating scans table: relaxing origin constraint, adding scan_mode…")
conn.execute("PRAGMA foreign_keys = OFF")
conn.execute("DROP VIEW IF EXISTS recent_scans")
conn.execute("DROP VIEW IF EXISTS active_scans")
conn.execute("DROP TABLE IF EXISTS scans_new")
conn.execute("DROP TRIGGER IF EXISTS update_scans_timestamp")
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_insert")
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_update")
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_delete")
conn.execute("""
CREATE TABLE scans_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL CHECK(length(origin) >= 2),
country TEXT NOT NULL CHECK(length(country) >= 2),
scan_mode TEXT NOT NULL DEFAULT 'forward'
CHECK(scan_mode IN ('forward', 'reverse')),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled', 'paused')),
total_routes INTEGER NOT NULL DEFAULT 0 CHECK(total_routes >= 0),
routes_scanned INTEGER NOT NULL DEFAULT 0 CHECK(routes_scanned >= 0),
total_flights INTEGER NOT NULL DEFAULT 0 CHECK(total_flights >= 0),
error_message TEXT,
seat_class TEXT DEFAULT 'economy',
adults INTEGER DEFAULT 1 CHECK(adults > 0 AND adults <= 9),
scheduled_scan_id INTEGER,
CHECK(end_date >= start_date),
CHECK(routes_scanned <= total_routes OR total_routes = 0)
)
""")
conn.execute("""
INSERT INTO scans_new (
id, origin, country, scan_mode, start_date, end_date,
created_at, updated_at, started_at, completed_at,
status, total_routes, routes_scanned, total_flights,
error_message, seat_class, adults, scheduled_scan_id
)
SELECT
id, origin, country, 'forward', start_date, end_date,
created_at, updated_at, started_at, completed_at,
status, total_routes, routes_scanned, total_flights,
error_message, seat_class, adults, scheduled_scan_id
FROM scans
""")
conn.execute("DROP TABLE scans")
conn.execute("ALTER TABLE scans_new RENAME TO scans")
conn.execute("PRAGMA foreign_keys = ON")
conn.commit()
if verbose:
print(" ✅ scans table migrated")
# ── routes: add origin_airport column ────────────────────────────────────
cursor = conn.execute("PRAGMA table_info(routes)")
routes_cols = [row[1] for row in cursor.fetchall()]
if routes_cols and 'origin_airport' not in routes_cols:
if verbose:
print(" 🔄 Migrating routes table: adding origin_airport column…")
conn.execute("ALTER TABLE routes ADD COLUMN origin_airport TEXT")
conn.commit()
if verbose:
print(" ✅ routes.origin_airport column added")
# ── routes: replace unique index ─────────────────────────────────────────
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name='uq_routes_scan_dest'"
)
if cursor.fetchone():
if verbose:
print(" 🔄 Replacing routes unique index…")
conn.execute("DROP INDEX IF EXISTS uq_routes_scan_dest")
conn.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_routes_scan_origin_dest
ON routes(scan_id, COALESCE(origin_airport, ''), destination)
""")
conn.commit()
if verbose:
print(" ✅ Routes unique index replaced")
# ── flights: add origin_airport column ───────────────────────────────────
cursor = conn.execute("PRAGMA table_info(flights)")
flights_cols = [row[1] for row in cursor.fetchall()]
if flights_cols and 'origin_airport' not in flights_cols:
if verbose:
print(" 🔄 Migrating flights table: adding origin_airport column…")
conn.execute("ALTER TABLE flights ADD COLUMN origin_airport TEXT")
conn.commit()
if verbose:
print(" ✅ flights.origin_airport column added")
# ── scheduled_scans: relax origin + add scan_mode ────────────────────────
cursor = conn.execute("PRAGMA table_info(scheduled_scans)")
sched_cols = [row[1] for row in cursor.fetchall()]
if sched_cols and 'scan_mode' not in sched_cols:
if verbose:
print(" 🔄 Migrating scheduled_scans table: relaxing origin constraint, adding scan_mode…")
conn.execute("DROP TABLE IF EXISTS scheduled_scans_new")
conn.execute("DROP TRIGGER IF EXISTS update_scheduled_scans_timestamp")
conn.execute("""
CREATE TABLE scheduled_scans_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL CHECK(length(origin) >= 2),
country TEXT NOT NULL CHECK(length(country) >= 2),
scan_mode TEXT NOT NULL DEFAULT 'forward'
CHECK(scan_mode IN ('forward', 'reverse')),
window_months INTEGER NOT NULL DEFAULT 1
CHECK(window_months >= 1 AND window_months <= 12),
seat_class TEXT NOT NULL DEFAULT 'economy',
adults INTEGER NOT NULL DEFAULT 1
CHECK(adults > 0 AND adults <= 9),
frequency TEXT NOT NULL
CHECK(frequency IN ('daily', 'weekly', 'monthly')),
hour INTEGER NOT NULL DEFAULT 6
CHECK(hour >= 0 AND hour <= 23),
minute INTEGER NOT NULL DEFAULT 0
CHECK(minute >= 0 AND minute <= 59),
day_of_week INTEGER CHECK(day_of_week >= 0 AND day_of_week <= 6),
day_of_month INTEGER CHECK(day_of_month >= 1 AND day_of_month <= 28),
enabled INTEGER NOT NULL DEFAULT 1,
label TEXT,
last_run_at TIMESTAMP,
next_run_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK(
(frequency = 'weekly' AND day_of_week IS NOT NULL) OR
(frequency = 'monthly' AND day_of_month IS NOT NULL) OR
(frequency = 'daily')
)
)
""")
conn.execute("""
INSERT INTO scheduled_scans_new (
id, origin, country, scan_mode, window_months, seat_class, adults,
frequency, hour, minute, day_of_week, day_of_month,
enabled, label, last_run_at, next_run_at, created_at, updated_at
)
SELECT
id, origin, country, 'forward', window_months, seat_class, adults,
frequency, hour, minute, day_of_week, day_of_month,
enabled, label, last_run_at, next_run_at, created_at, updated_at
FROM scheduled_scans
""")
conn.execute("DROP TABLE scheduled_scans")
conn.execute("ALTER TABLE scheduled_scans_new RENAME TO scheduled_scans")
conn.commit()
if verbose:
print(" ✅ scheduled_scans table migrated")
def initialize_database(db_path=None, verbose=True):
"""
Initialize or migrate the database.
@@ -232,10 +539,16 @@ def initialize_database(db_path=None, verbose=True):
else:
print(" No existing tables found")
# Recover any orphaned _new tables left by previously aborted migrations
_recover_orphaned_new_tables(conn, verbose)
# Apply migrations before running schema
_migrate_relax_country_constraint(conn, verbose)
_migrate_add_routes_unique_index(conn, verbose)
_migrate_add_scheduled_scan_id_to_scans(conn, verbose)
_migrate_add_timing_columns_to_scans(conn, verbose)
_migrate_add_pause_cancel_status(conn, verbose)
_migrate_add_reverse_scan_support(conn, verbose)
# Load and execute schema
schema_sql = load_schema()

View File

@@ -20,18 +20,23 @@ CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Search parameters (validated by CHECK constraints)
origin TEXT NOT NULL CHECK(length(origin) = 3),
-- origin stores IATA code (forward scans) or ISO country code (reverse scans)
origin TEXT NOT NULL CHECK(length(origin) >= 2),
country TEXT NOT NULL CHECK(length(country) >= 2),
scan_mode TEXT NOT NULL DEFAULT 'forward'
CHECK(scan_mode IN ('forward', 'reverse')),
start_date TEXT NOT NULL, -- ISO 8601: YYYY-MM-DD
end_date TEXT NOT NULL,
-- Timestamps (auto-managed)
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP, -- Set when status transitions to 'running'
completed_at TIMESTAMP, -- Set when status transitions to 'completed' or 'failed'
-- Scan status (enforced enum via CHECK)
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'running', 'completed', 'failed')),
CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled', 'paused')),
-- Progress tracking
total_routes INTEGER NOT NULL DEFAULT 0 CHECK(total_routes >= 0),
@@ -79,7 +84,10 @@ CREATE TABLE IF NOT EXISTS routes (
-- Foreign key to scans (cascade delete)
scan_id INTEGER NOT NULL,
-- Destination airport
-- Route airports
-- For forward scans: origin_airport is NULL (implicit from scan.origin)
-- For reverse scans: origin_airport is the variable origin IATA
origin_airport TEXT,
destination TEXT NOT NULL CHECK(length(destination) = 3),
destination_name TEXT NOT NULL,
destination_city TEXT,
@@ -118,9 +126,9 @@ CREATE INDEX IF NOT EXISTS idx_routes_min_price
ON routes(min_price)
WHERE min_price IS NOT NULL; -- Partial index for routes with prices
-- One route row per (scan, destination) — enables incremental upsert writes
CREATE UNIQUE INDEX IF NOT EXISTS uq_routes_scan_dest
ON routes(scan_id, destination);
-- One route row per (scan, origin_airport, destination) — supports both forward and reverse scans
CREATE UNIQUE INDEX IF NOT EXISTS uq_routes_scan_origin_dest
ON routes(scan_id, COALESCE(origin_airport, ''), destination);
-- ============================================================================
-- Triggers: Auto-update timestamps and aggregates
@@ -189,6 +197,8 @@ CREATE TABLE IF NOT EXISTS flights (
scan_id INTEGER NOT NULL,
-- Route
-- origin_airport: NULL for forward scans, specific IATA for reverse scans
origin_airport TEXT,
destination TEXT NOT NULL CHECK(length(destination) = 3),
date TEXT NOT NULL, -- ISO 8601: YYYY-MM-DD
@@ -271,8 +281,10 @@ CREATE TABLE IF NOT EXISTS scheduled_scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Scan parameters (same as scans table)
origin TEXT NOT NULL CHECK(length(origin) = 3),
origin TEXT NOT NULL CHECK(length(origin) >= 2),
country TEXT NOT NULL CHECK(length(country) >= 2),
scan_mode TEXT NOT NULL DEFAULT 'forward'
CHECK(scan_mode IN ('forward', 'reverse')),
window_months INTEGER NOT NULL DEFAULT 1
CHECK(window_months >= 1 AND window_months <= 12),
seat_class TEXT NOT NULL DEFAULT 'economy',

View File

@@ -9,6 +9,8 @@ services:
restart: unless-stopped
environment:
- DATABASE_PATH=/app/data/cache.db
- ALLOWED_ORIGINS=https://flights.domverse-berlin.eu
- LOG_LEVEL=INFO
volumes:
- flight-radar-data:/app/data
networks:
@@ -28,6 +30,8 @@ services:
- default # shares default compose network with backend (nginx → http://backend:8000)
- domverse # Traefik discovers the container on this network
labels:
# Traefik routing
- "traefik.docker.network=domverse"
- "traefik.enable=true"
- "traefik.http.routers.flight-radar.rule=Host(`flights.domverse-berlin.eu`)"
- "traefik.http.routers.flight-radar.entrypoints=https"
@@ -35,6 +39,21 @@ services:
- "traefik.http.routers.flight-radar.middlewares=authentik@docker"
- "traefik.http.services.flight-radar.loadbalancer.server.port=80"
# AutoKuma monitoring
- "kuma.flight-radar.http.name=Flight Radar"
- "kuma.flight-radar.http.url=https://flights.domverse-berlin.eu"
- "kuma.flight-radar.http.interval=60"
- "kuma.flight-radar.http.max_retries=2"
- "kuma.flight-radar.http.retry_interval=60"
# Homepage dashboard
- "homepage.group=Productivity"
- "homepage.name=Flight Radar"
- "homepage.icon=mdi-airplane"
- "homepage.href=https://flights.domverse-berlin.eu"
- "homepage.description=Flight price comparison tool"
- "homepage.weight=20"
volumes:
flight-radar-data:
driver: local

View File

@@ -10,11 +10,12 @@ const api = axios.create({
// Types
export interface Scan {
id: number;
scan_mode: 'forward' | 'reverse';
origin: string;
country: string;
start_date: string;
end_date: string;
status: 'pending' | 'running' | 'completed' | 'failed';
status: 'pending' | 'running' | 'completed' | 'failed' | 'paused' | 'cancelled';
created_at: string;
updated_at: string;
total_routes: number;
@@ -24,10 +25,13 @@ export interface Scan {
seat_class: string;
adults: number;
scheduled_scan_id?: number;
started_at?: string; // ISO-8601 UTC — set when status transitions to 'running'
completed_at?: string; // ISO-8601 UTC — set when status transitions to 'completed' or 'failed'
}
export interface Schedule {
id: number;
scan_mode: 'forward' | 'reverse';
origin: string;
country: string;
window_months: number;
@@ -47,6 +51,7 @@ export interface Schedule {
}
export interface CreateScheduleRequest {
scan_mode?: 'forward' | 'reverse';
origin: string;
country: string;
window_months?: number;
@@ -63,6 +68,7 @@ export interface CreateScheduleRequest {
export interface Route {
id: number;
scan_id: number;
origin_airport?: string;
destination: string;
destination_name: string;
destination_city?: string;
@@ -77,6 +83,7 @@ export interface Route {
export interface Flight {
id: number;
scan_id: number;
origin_airport?: string;
destination: string;
date: string;
airline?: string;
@@ -114,7 +121,14 @@ export interface PaginatedResponse<T> {
};
}
export interface Country {
code: string;
name: string;
airport_count: number;
}
export interface CreateScanRequest {
scan_mode?: 'forward' | 'reverse';
origin: string;
country?: string; // Optional: provide either country or destinations
destinations?: string[]; // Optional: provide either country or destinations
@@ -153,13 +167,18 @@ export const scanApi = {
});
},
getFlights: (id: number, destination?: string, page = 1, limit = 50) => {
getFlights: (id: number, destination?: string, originAirport?: string, page = 1, limit = 50) => {
const params: Record<string, unknown> = { page, limit };
if (destination) params.destination = destination;
if (originAirport) params.origin_airport = originAirport;
return api.get<PaginatedResponse<Flight>>(`/scans/${id}/flights`, { params });
},
delete: (id: number) => api.delete(`/scans/${id}`),
pause: (id: number) => api.post(`/scans/${id}/pause`),
cancel: (id: number) => api.post(`/scans/${id}/cancel`),
resume: (id: number) => api.post(`/scans/${id}/resume`),
};
export const airportApi = {
@@ -170,6 +189,10 @@ export const airportApi = {
},
};
export const countriesApi = {
list: () => api.get<Country[]>('/countries'),
};
export const scheduleApi = {
list: (page = 1, limit = 20) =>
api.get<PaginatedResponse<Schedule>>('/schedules', { params: { page, limit } }),

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { countriesApi } from '../api';
import type { Country } from '../api';
interface CountrySelectProps {
value: string;
onChange: (code: string) => void;
placeholder?: string;
hasError?: boolean;
className?: string;
}
export default function CountrySelect({
value,
onChange,
placeholder = 'Select a country…',
hasError = false,
className,
}: CountrySelectProps) {
const [countries, setCountries] = useState<Country[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
countriesApi.list()
.then(resp => setCountries(resp.data))
.catch(() => setCountries([]))
.finally(() => setLoading(false));
}, []);
const baseCls =
'w-full h-12 px-3 border rounded-xs bg-surface text-on-surface text-sm outline-none transition-colors ' +
(hasError
? 'border-error focus:border-error focus:ring-2 focus:ring-error/20'
: 'border-outline focus:border-primary focus:ring-2 focus:ring-primary/20');
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
disabled={loading}
className={`${baseCls} ${className ?? ''}`}
>
<option value="">{loading ? 'Loading countries…' : placeholder}</option>
{countries.map(c => (
<option key={c.code} value={c.code}>
{c.name} ({c.code}) {c.airport_count} airport{c.airport_count !== 1 ? 's' : ''}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,51 @@
import type { ScanTimerResult } from '../hooks/useScanTimer';
/** Format a non-negative number of seconds into a human-readable string. */
export function formatDuration(totalSeconds: number): string {
const s = Math.floor(totalSeconds);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
const h = Math.floor(m / 60);
const remM = m % 60;
return remM > 0 ? `${h}h ${remM}m` : `${h}h`;
}
interface ScanTimerProps extends ScanTimerResult {
/** When true, renders a compact single-line format for the completed stat card. */
compact?: boolean;
}
/**
* Displays elapsed time and ETA for an active scan, or final duration for a
* completed/failed scan.
*/
export default function ScanTimer({ elapsedSeconds, remainingSeconds, isEstimating, compact }: ScanTimerProps) {
if (compact) {
return <span>{formatDuration(elapsedSeconds)}</span>;
}
const remainingLabel = isEstimating
? 'Estimating…'
: remainingSeconds !== null
? `~${formatDuration(remainingSeconds)}`
: null;
return (
<div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-0.5 text-xs">
<span className="text-on-surface-variant">Elapsed</span>
<span className="font-mono text-on-surface tabular-nums">
{formatDuration(elapsedSeconds)}
</span>
{remainingLabel !== null && (
<>
<span className="text-on-surface-variant">Remaining</span>
<span className="font-mono text-on-surface tabular-nums">
{remainingLabel}
</span>
</>
)}
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { CheckCircle2, Loader2, Clock, XCircle } from 'lucide-react';
import { CheckCircle2, Loader2, Clock, XCircle, PauseCircle, Ban } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '../lib/utils';
export type ScanStatus = 'completed' | 'running' | 'pending' | 'failed';
export type ScanStatus = 'completed' | 'running' | 'pending' | 'failed' | 'paused' | 'cancelled';
interface StatusConfig {
icon: LucideIcon;
@@ -38,6 +38,18 @@ const CONFIGS: Record<ScanStatus, StatusConfig> = {
chipClass: 'bg-[#FDECEA] text-[#A50E0E] border border-[#F5C6C6]',
iconClass: 'text-[#A50E0E]',
},
paused: {
icon: PauseCircle,
label: 'paused',
chipClass: 'bg-[#FEF7E0] text-[#7A5200] border border-[#F9D659]',
iconClass: 'text-[#7A5200]',
},
cancelled: {
icon: Ban,
label: 'cancelled',
chipClass: 'bg-[#F3F3F3] text-[#5F6368] border border-[#DADCE0]',
iconClass: 'text-[#5F6368]',
},
};
interface StatusChipProps {

View File

@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import type { Scan } from '../api';
export interface ScanTimerResult {
/** Seconds elapsed since the scan started processing. */
elapsedSeconds: number;
/**
* Estimated seconds remaining, or null when not enough data yet
* (fewer than 5 routes scanned or elapsed time is 0).
*/
remainingSeconds: number | null;
/** True while the estimate is still too early to be reliable. */
isEstimating: boolean;
}
const MIN_ROUTES_FOR_ESTIMATE = 5;
function calcElapsed(startedAt: string): number {
return Math.max(0, (Date.now() - new Date(startedAt).getTime()) / 1000);
}
function calcRemaining(
elapsed: number,
routesScanned: number,
totalRoutes: number,
): number | null {
if (elapsed <= 0 || routesScanned < MIN_ROUTES_FOR_ESTIMATE || totalRoutes <= 0) {
return null;
}
const rate = routesScanned / elapsed; // routes per second
const remaining = (totalRoutes - routesScanned) / rate;
return Math.max(0, remaining);
}
export function useScanTimer(scan: Scan | null): ScanTimerResult {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
useEffect(() => {
if (!scan) return;
// For completed / failed scans with both timestamps: compute static duration.
if (
(scan.status === 'completed' || scan.status === 'failed') &&
scan.started_at &&
scan.completed_at
) {
const duration = Math.max(
0,
(new Date(scan.completed_at).getTime() - new Date(scan.started_at).getTime()) / 1000,
);
setElapsedSeconds(duration);
setRemainingSeconds(0);
return;
}
// For running scans with a start time: run a live 1-second timer.
if (scan.status === 'running' && scan.started_at) {
const tick = () => {
const elapsed = calcElapsed(scan.started_at!);
const remaining = calcRemaining(elapsed, scan.routes_scanned, scan.total_routes);
setElapsedSeconds(elapsed);
setRemainingSeconds(remaining);
};
tick(); // run immediately
intervalRef.current = setInterval(tick, 1000);
return () => {
if (intervalRef.current !== undefined) {
clearInterval(intervalRef.current);
intervalRef.current = undefined;
}
};
}
// Pending or no started_at: reset
setElapsedSeconds(0);
setRemainingSeconds(null);
}, [scan?.status, scan?.started_at, scan?.completed_at, scan?.routes_scanned, scan?.total_routes]);
const isEstimating =
scan?.status === 'running' &&
(scan.routes_scanned < MIN_ROUTES_FOR_ESTIMATE || scan.total_routes <= 0);
return { elapsedSeconds, remainingSeconds, isEstimating };
}

View File

@@ -8,15 +8,20 @@ import {
Users,
Armchair,
Clock,
Timer,
ChevronRight,
ChevronUp,
ChevronDown,
ChevronsUpDown,
MapPin,
AlertCircle,
Loader2,
RotateCcw,
Trash2,
Info,
Pause,
Play,
X,
} from 'lucide-react';
import { scanApi } from '../api';
import type { Scan, Route, Flight } from '../api';
@@ -25,6 +30,8 @@ import type { ScanStatus } from '../components/StatusChip';
import StatCard from '../components/StatCard';
import EmptyState from '../components/EmptyState';
import { SkeletonStatCard, SkeletonTableRow } from '../components/SkeletonCard';
import ScanTimer, { formatDuration } from '../components/ScanTimer';
import { useScanTimer } from '../hooks/useScanTimer';
import { cn } from '../lib/utils';
const formatPrice = (price?: number) =>
@@ -46,12 +53,21 @@ export default function ScanDetails() {
const [totalPages, setTotalPages] = useState(1);
const [sortField, setSortField] = useState<'min_price' | 'destination' | 'flight_count'>('min_price');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [expandedRoute, setExpandedRoute] = useState<string | null>(null);
const [flightsByDest, setFlightsByDest] = useState<Record<string, Flight[]>>({});
const [expandedRoute, setExpandedRoute] = useState<string | null>(null);
const [flightsByDest, setFlightsByDest] = useState<Record<string, Flight[]>>({});
const [flightSortField, setFlightSortField] = useState<'date' | 'price'>('date');
const [flightSortDir, setFlightSortDir] = useState<'asc' | 'desc'>('asc');
const [loadingFlights, setLoadingFlights] = useState<string | null>(null);
const [rerunning, setRerunning] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const [confirmPause, setConfirmPause] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const [stopping, setStopping] = useState(false);
const [resuming, setResuming] = useState(false);
// Must be called unconditionally before any early returns (Rules of Hooks)
const timer = useScanTimer(scan);
useEffect(() => {
if (id) loadScanDetails();
@@ -102,16 +118,39 @@ export default function ScanDetails() {
}
};
const toggleFlights = async (destination: string) => {
if (expandedRoute === destination) { setExpandedRoute(null); return; }
setExpandedRoute(destination);
if (flightsByDest[destination]) return;
setLoadingFlights(destination);
const handleFlightSort = (field: 'date' | 'price') => {
if (flightSortField === field) {
setFlightSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setFlightSortField(field);
setFlightSortDir('asc');
}
};
const sortedFlights = (flights: Flight[]) =>
[...flights].sort((a, b) => {
const aVal = flightSortField === 'date' ? a.date : (a.price ?? Infinity);
const bVal = flightSortField === 'date' ? b.date : (b.price ?? Infinity);
if (aVal < bVal) return flightSortDir === 'asc' ? -1 : 1;
if (aVal > bVal) return flightSortDir === 'asc' ? 1 : -1;
return 0;
});
// For reverse scans, route key = "ORIG:DEST"; for forward scans = "DEST"
const routeKey = (route: Route) =>
route.origin_airport ? `${route.origin_airport}:${route.destination}` : route.destination;
const toggleFlights = async (route: Route) => {
const key = routeKey(route);
if (expandedRoute === key) { setExpandedRoute(null); return; }
setExpandedRoute(key);
if (flightsByDest[key]) return;
setLoadingFlights(key);
try {
const resp = await scanApi.getFlights(Number(id), destination, 1, 200);
setFlightsByDest(prev => ({ ...prev, [destination]: resp.data.data }));
const resp = await scanApi.getFlights(Number(id), route.destination, route.origin_airport, 1, 200);
setFlightsByDest(prev => ({ ...prev, [key]: resp.data.data }));
} catch {
setFlightsByDest(prev => ({ ...prev, [destination]: [] }));
setFlightsByDest(prev => ({ ...prev, [key]: [] }));
} finally {
setLoadingFlights(null);
}
@@ -121,21 +160,30 @@ export default function ScanDetails() {
if (!scan) return;
setRerunning(true);
try {
// Compute window from stored dates so the new scan covers the same span
const ms = new Date(scan.end_date).getTime() - new Date(scan.start_date).getTime();
const window_months = Math.max(1, Math.round(ms / (1000 * 60 * 60 * 24 * 30)));
// country column holds either "IT" or "BRI,BDS"
const isAirports = scan.country.includes(',');
const resp = await scanApi.create({
const base = {
scan_mode: (scan.scan_mode ?? 'forward') as 'forward' | 'reverse',
origin: scan.origin,
window_months,
seat_class: scan.seat_class as 'economy' | 'premium' | 'business' | 'first',
adults: scan.adults,
...(isAirports
};
let extra: Record<string, unknown>;
if (scan.scan_mode === 'reverse') {
// For reverse: country column holds comma-separated dest IATAs
extra = { destinations: scan.country.split(',') };
} else {
// For forward: country column holds ISO code or comma-separated IATAs
const isAirports = scan.country.includes(',');
extra = isAirports
? { destinations: scan.country.split(',') }
: { country: scan.country }),
});
: { country: scan.country };
}
const resp = await scanApi.create({ ...base, ...extra });
navigate(`/scans/${resp.data.id}`);
} catch {
// silently fall through — the navigate won't happen
@@ -156,13 +204,61 @@ export default function ScanDetails() {
}
};
const handlePause = async () => {
if (!scan) return;
setStopping(true);
try {
await scanApi.pause(scan.id);
await loadScanDetails();
} catch {
// fall through
} finally {
setStopping(false);
setConfirmPause(false);
}
};
const handleCancel = async () => {
if (!scan) return;
setStopping(true);
try {
await scanApi.cancel(scan.id);
await loadScanDetails();
} catch {
// fall through
} finally {
setStopping(false);
setConfirmCancel(false);
}
};
const handleResume = async () => {
if (!scan) return;
setResuming(true);
try {
await scanApi.resume(scan.id);
await loadScanDetails();
} catch {
// fall through
} finally {
setResuming(false);
}
};
const SortIcon = ({ field }: { field: typeof sortField }) => {
if (sortField !== field) return <ChevronUp size={14} className="opacity-30" />;
if (sortField !== field) return <ChevronsUpDown size={14} className="opacity-50" />;
return sortDirection === 'asc'
? <ChevronUp size={14} className="text-primary" />
: <ChevronDown size={14} className="text-primary" />;
};
const FlightSortIcon = ({ field }: { field: 'date' | 'price' }) => {
if (flightSortField !== field) return <ChevronsUpDown size={12} className="opacity-50" />;
return flightSortDir === 'asc'
? <ChevronUp size={12} className="text-secondary" />
: <ChevronDown size={12} className="text-secondary" />;
};
const thCls = (field?: typeof sortField) => cn(
'px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider select-none',
field
@@ -220,7 +316,9 @@ export default function ScanDetails() {
<div className="flex items-center gap-2 flex-wrap min-w-0">
<PlaneTakeoff size={20} className="text-primary shrink-0" aria-hidden="true" />
<h1 className="text-xl font-semibold text-on-surface">
{scan.origin} {scan.country}
{scan.scan_mode === 'reverse'
? `${scan.origin}${scan.country.split(',').join(', ')}`
: `${scan.origin}${scan.country}`}
</h1>
{scan.scheduled_scan_id != null && (
<Link
@@ -261,51 +359,168 @@ export default function ScanDetails() {
)}
{/* Row 4: actions */}
<div className="mt-4 pt-4 border-t border-outline flex items-center justify-end gap-2">
{/* Re-run */}
<button
onClick={handleRerun}
disabled={rerunning || isActive}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={14} className={rerunning ? 'animate-spin' : ''} />
{rerunning ? 'Starting…' : 'Re-run'}
</button>
<div className="mt-4 pt-4 border-t border-outline flex items-center justify-end gap-2 flex-wrap">
{/* Delete — inline confirm */}
{confirmDelete ? (
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-on-surface-variant">Delete this scan?</span>
{/* ── Active (pending / running): Pause + Cancel ── */}
{isActive && (
<>
{/* Pause — inline confirm */}
{confirmPause ? (
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-on-surface-variant">Pause this scan?</span>
<button
onClick={handlePause}
disabled={stopping}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-[#7A5200] text-white hover:bg-[#5C3D00] disabled:opacity-60 transition-colors"
>
{stopping ? 'Pausing…' : 'Yes, pause'}
</button>
<button
onClick={() => setConfirmPause(false)}
disabled={stopping}
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmPause(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
>
<Pause size={14} />
Pause
</button>
)}
{/* Cancel — inline confirm */}
{confirmCancel ? (
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-on-surface-variant">Cancel this scan?</span>
<button
onClick={handleCancel}
disabled={stopping}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
>
{stopping ? 'Cancelling…' : 'Yes, cancel'}
</button>
<button
onClick={() => setConfirmCancel(false)}
disabled={stopping}
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmCancel(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 transition-colors"
>
<X size={14} />
Cancel
</button>
)}
</>
)}
{/* ── Paused: Resume + Re-run + Delete ── */}
{scan.status === 'paused' && (
<>
<button
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
onClick={handleResume}
disabled={resuming}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{deleting ? 'Deleting…' : 'Yes, delete'}
<Play size={14} className={resuming ? 'animate-pulse' : ''} />
{resuming ? 'Resuming…' : 'Resume'}
</button>
<button
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
onClick={handleRerun}
disabled={rerunning}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Cancel
<RotateCcw size={14} className={rerunning ? 'animate-spin' : ''} />
{rerunning ? 'Starting…' : 'Re-run'}
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
disabled={isActive}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Trash2 size={14} />
Delete
</button>
{confirmDelete ? (
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-on-surface-variant">Delete this scan?</span>
<button
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
<button
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 transition-colors"
>
<Trash2 size={14} />
Delete
</button>
)}
</>
)}
{/* ── Completed / Failed / Cancelled: Re-run + Delete ── */}
{!isActive && scan.status !== 'paused' && (
<>
<button
onClick={handleRerun}
disabled={rerunning}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={14} className={rerunning ? 'animate-spin' : ''} />
{rerunning ? 'Starting…' : 'Re-run'}
</button>
{confirmDelete ? (
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-on-surface-variant">Delete this scan?</span>
<button
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
<button
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 transition-colors"
>
<Trash2 size={14} />
Delete
</button>
)}
</>
)}
</div>
</div>
{/* ── Stat cards ────────────────────────────────────────────── */}
<div className="grid grid-cols-3 gap-3">
<div className={`grid gap-3 ${!isActive && scan.started_at && scan.completed_at ? 'grid-cols-4' : 'grid-cols-3'}`}>
{loading ? (
[0, 1, 2].map(i => <SkeletonStatCard key={i} />)
) : (
@@ -313,6 +528,14 @@ export default function ScanDetails() {
<StatCard label="Total Routes" value={scan.total_routes} icon={MapPin} variant="primary" />
<StatCard label="Routes Scanned" value={scan.routes_scanned} icon={ChevronDown} variant="secondary" />
<StatCard label="Flights Found" value={scan.total_flights} icon={PlaneTakeoff} variant="primary" />
{!isActive && scan.started_at && scan.completed_at && (
<StatCard
label="Scan Duration"
value={formatDuration(timer.elapsedSeconds)}
icon={Timer}
variant="secondary"
/>
)}
</>
)}
</div>
@@ -340,6 +563,9 @@ export default function ScanDetails() {
<p className="mt-2 text-xs text-on-surface-variant">
{scan.routes_scanned} of {scan.total_routes > 0 ? scan.total_routes : '?'} routes · auto-refreshing every 3 s
</p>
{scan.status === 'running' && scan.started_at && (
<ScanTimer {...timer} />
)}
</div>
)}
@@ -376,6 +602,9 @@ export default function ScanDetails() {
<table className="w-full">
<thead className="bg-surface-2 border-b border-outline">
<tr>
{scan.scan_mode === 'reverse' && (
<th className={thCls()}>Origin</th>
)}
<th
className={thCls('destination')}
onClick={() => handleSort('destination')}
@@ -407,13 +636,23 @@ export default function ScanDetails() {
</thead>
<tbody className="divide-y divide-outline">
{routes.map((route) => {
const isExpanded = expandedRoute === route.destination;
const key = routeKey(route);
const isExpanded = expandedRoute === key;
const colSpan = scan.scan_mode === 'reverse' ? 7 : 6;
return (
<Fragment key={route.id}>
<tr
className="hover:bg-surface-2 cursor-pointer transition-colors duration-150"
onClick={() => toggleFlights(route.destination)}
onClick={() => toggleFlights(route)}
>
{/* Origin (reverse scans only) */}
{scan.scan_mode === 'reverse' && (
<td className="px-4 py-4">
<span className="font-mono text-secondary bg-surface-2 px-2 py-0.5 rounded-sm text-sm font-medium">
{route.origin_airport ?? '—'}
</span>
</td>
)}
{/* Destination */}
<td className="px-4 py-4">
<div className="flex items-center gap-2">
@@ -470,13 +709,13 @@ export default function ScanDetails() {
{/* Expanded flights sub-row */}
<tr key={`${route.id}-flights`}>
<td colSpan={6} className="p-0">
<td colSpan={colSpan} className="p-0">
<div
className="overflow-hidden transition-all duration-250 ease-in-out"
style={{ maxHeight: isExpanded ? '600px' : '0' }}
>
<div className="bg-[#F8FDF9]">
{loadingFlights === route.destination ? (
{loadingFlights === key ? (
<table className="w-full">
<tbody>
<SkeletonTableRow />
@@ -488,15 +727,29 @@ export default function ScanDetails() {
<table className="w-full">
<thead className="bg-[#EEF7F0]">
<tr>
<th className="pl-12 pr-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Date</th>
<th
className="pl-12 pr-4 py-2 text-left text-xs font-semibold uppercase tracking-wider select-none cursor-pointer hover:bg-[#D4EDDA] transition-colors"
onClick={() => handleFlightSort('date')}
>
<span className={cn('inline-flex items-center gap-1', flightSortField === 'date' ? 'text-secondary' : 'text-on-surface-variant')}>
Date <FlightSortIcon field="date" />
</span>
</th>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Airline</th>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Departure</th>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Arrival</th>
<th className="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Price</th>
<th
className="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider select-none cursor-pointer hover:bg-[#D4EDDA] transition-colors"
onClick={() => handleFlightSort('price')}
>
<span className={cn('inline-flex items-center justify-end gap-1', flightSortField === 'price' ? 'text-secondary' : 'text-on-surface-variant')}>
Price <FlightSortIcon field="price" />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-[#D4EDDA]">
{(flightsByDest[route.destination] || []).map((f) => (
{sortedFlights(flightsByDest[key] || []).map((f) => (
<tr key={f.id} className="hover:bg-[#EEF7F0] transition-colors">
<td className="pl-12 pr-4 py-2.5 text-sm text-on-surface">
<span className="font-mono text-xs font-semibold text-on-surface-variant mr-2">{weekday(f.date)}</span>
@@ -510,7 +763,7 @@ export default function ScanDetails() {
</td>
</tr>
))}
{(flightsByDest[route.destination] || []).length === 0 && (
{(flightsByDest[key] || []).length === 0 && (
<tr>
<td colSpan={5} className="pl-12 py-4 text-sm text-on-surface-variant">
No flight details available

View File

@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Globe, PlaneTakeoff, Minus, Plus } from 'lucide-react';
import { Globe, PlaneTakeoff, Minus, Plus, ArrowRight, ArrowLeft, CalendarDays, CalendarRange } from 'lucide-react';
import { scanApi } from '../api';
import type { CreateScanRequest } from '../api';
import AirportSearch from '../components/AirportSearch';
import SegmentedButton from '../components/SegmentedButton';
import AirportChip from '../components/AirportChip';
import CountrySelect from '../components/CountrySelect';
import Button from '../components/Button';
import Toast from '../components/Toast';
@@ -15,10 +16,17 @@ interface FormErrors {
airports?: string;
window_months?: string;
adults?: string;
start_date?: string;
end_date?: string;
}
export default function Scans() {
const navigate = useNavigate();
// Direction: forward (fixed origin → variable destinations) or reverse (variable origins → fixed destinations)
const [scanMode, setScanMode] = useState<'forward' | 'reverse'>('forward');
// Forward mode state
const [destinationMode, setDestinationMode] = useState<'country' | 'airports'>('country');
const [formData, setFormData] = useState<CreateScanRequest>({
origin: '',
@@ -27,22 +35,62 @@ export default function Scans() {
seat_class: 'economy',
adults: 1,
});
// Window mode: rolling N months or specific date range
const [windowMode, setWindowMode] = useState<'window' | 'range'>('window');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// Shared state
const [selectedAirports, setSelectedAirports] = useState<string[]>([]);
const [selectedOriginCountry, setSelectedOriginCountry] = useState('');
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const validate = (): boolean => {
const next: FormErrors = {};
if (!formData.origin || formData.origin.length !== 3) {
next.origin = 'Enter a valid 3-letter IATA code';
if (scanMode === 'reverse') {
if (!selectedOriginCountry) {
next.country = 'Select an origin country';
}
if (selectedAirports.length === 0) {
next.airports = 'Add at least one destination airport';
}
} else {
if (!formData.origin || formData.origin.length !== 3) {
next.origin = 'Enter a valid 3-letter IATA code';
}
if (destinationMode === 'country' && !formData.country) {
next.country = 'Select a destination country';
}
if (destinationMode === 'airports' && selectedAirports.length === 0) {
next.airports = 'Add at least one destination airport';
}
}
if (destinationMode === 'country' && (!formData.country || formData.country.length !== 2)) {
next.country = 'Enter a valid 2-letter country code';
}
if (destinationMode === 'airports' && selectedAirports.length === 0) {
next.airports = 'Add at least one destination airport';
if (windowMode === 'range') {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (!startDate) {
next.start_date = 'Select a start date';
} else if (new Date(startDate) <= today) {
next.start_date = 'Start date must be in the future';
}
if (!endDate) {
next.end_date = 'Select an end date';
} else if (startDate && endDate <= startDate) {
next.end_date = 'End date must be after start date';
} else if (startDate && endDate) {
const s = new Date(startDate);
const e = new Date(endDate);
const months = (e.getFullYear() - s.getFullYear()) * 12 + (e.getMonth() - s.getMonth());
if (months > 12) next.end_date = 'Date range cannot exceed 12 months';
}
}
setErrors(next);
return Object.keys(next).length === 0;
};
@@ -53,17 +101,32 @@ export default function Scans() {
setLoading(true);
try {
const requestData: any = {
origin: formData.origin,
window_months: formData.window_months,
seat_class: formData.seat_class,
adults: formData.adults,
};
let requestData: CreateScanRequest;
if (destinationMode === 'country') {
requestData.country = formData.country;
const windowParams = windowMode === 'range'
? { start_date: startDate, end_date: endDate }
: { window_months: formData.window_months };
if (scanMode === 'reverse') {
requestData = {
scan_mode: 'reverse',
origin: selectedOriginCountry,
destinations: selectedAirports,
seat_class: formData.seat_class,
adults: formData.adults,
...windowParams,
};
} else {
requestData.destinations = selectedAirports;
requestData = {
scan_mode: 'forward',
origin: formData.origin,
seat_class: formData.seat_class,
adults: formData.adults,
...windowParams,
...(destinationMode === 'country'
? { country: formData.country }
: { destinations: selectedAirports }),
};
}
const response = await scanApi.create(requestData);
@@ -86,42 +149,97 @@ export default function Scans() {
}));
};
// Shared input class
const inputCls = (hasError?: boolean) =>
`w-full h-12 px-3 border rounded-xs bg-surface text-on-surface text-sm outline-none transition-colors ` +
(hasError
? 'border-error focus:border-error focus:ring-2 focus:ring-error/20'
: 'border-outline focus:border-primary focus:ring-2 focus:ring-primary/20');
const tomorrowStr = (() => {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().split('T')[0];
})();
const minEndDate = startDate
? (() => { const d = new Date(startDate); d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]; })()
: tomorrowStr;
return (
<>
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
{/* ── Section: Direction ───────────────────────────────────── */}
<div className="bg-surface rounded-lg shadow-level-1 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">
Direction
</p>
<SegmentedButton
options={[
{ value: 'forward', label: 'Forward', icon: ArrowRight },
{ value: 'reverse', label: 'Reverse', icon: ArrowLeft },
]}
value={scanMode}
onChange={(v) => {
setScanMode(v as 'forward' | 'reverse');
setErrors({});
setSelectedAirports([]);
}}
/>
<p className="mt-2 text-xs text-on-surface-variant">
{scanMode === 'forward'
? 'Fixed origin airport → all airports in a country (or specific airports)'
: 'All airports in a country → specific destination airport(s)'}
</p>
</div>
{/* ── Section: Origin ─────────────────────────────────────── */}
<div className="bg-surface rounded-lg shadow-level-1 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">
Origin
</p>
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Origin Airport
</label>
<AirportSearch
value={formData.origin}
onChange={(value) => {
setFormData(prev => ({ ...prev, origin: value }));
if (errors.origin) setErrors(prev => ({ ...prev, origin: undefined }));
}}
placeholder="e.g. BDS, MUC, FRA"
hasError={!!errors.origin}
/>
{errors.origin ? (
<p className="mt-1 text-xs text-error">{errors.origin}</p>
) : (
<p className="mt-1 text-xs text-on-surface-variant">3-letter IATA code (e.g. BDS for Brindisi)</p>
)}
</div>
{scanMode === 'reverse' ? (
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Origin Country
</label>
<CountrySelect
value={selectedOriginCountry}
onChange={(code) => {
setSelectedOriginCountry(code);
if (errors.country) setErrors(prev => ({ ...prev, country: undefined }));
}}
placeholder="Select origin country…"
hasError={!!errors.country}
/>
{errors.country ? (
<p className="mt-1 text-xs text-error">{errors.country}</p>
) : (
<p className="mt-1 text-xs text-on-surface-variant">All airports in this country will be checked</p>
)}
</div>
) : (
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Origin Airport
</label>
<AirportSearch
value={formData.origin}
onChange={(value) => {
setFormData(prev => ({ ...prev, origin: value }));
if (errors.origin) setErrors(prev => ({ ...prev, origin: undefined }));
}}
placeholder="e.g. BDS, MUC, FRA"
hasError={!!errors.origin}
/>
{errors.origin ? (
<p className="mt-1 text-xs text-error">{errors.origin}</p>
) : (
<p className="mt-1 text-xs text-on-surface-variant">3-letter IATA code (e.g. BDS for Brindisi)</p>
)}
</div>
)}
</div>
{/* ── Section: Destination ────────────────────────────────── */}
@@ -130,42 +248,8 @@ export default function Scans() {
Destination
</p>
<SegmentedButton
options={[
{ value: 'country', label: 'By Country', icon: Globe },
{ value: 'airports', label: 'By Airports', icon: PlaneTakeoff },
]}
value={destinationMode}
onChange={(v) => {
setDestinationMode(v as 'country' | 'airports');
setErrors(prev => ({ ...prev, country: undefined, airports: undefined }));
}}
className="mb-4"
/>
{destinationMode === 'country' ? (
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Destination Country
</label>
<input
type="text"
value={formData.country}
onChange={(e) => {
setFormData(prev => ({ ...prev, country: e.target.value.toUpperCase() }));
if (errors.country) setErrors(prev => ({ ...prev, country: undefined }));
}}
maxLength={2}
placeholder="e.g. DE, IT, ES"
className={inputCls(!!errors.country)}
/>
{errors.country ? (
<p className="mt-1 text-xs text-error">{errors.country}</p>
) : (
<p className="mt-1 text-xs text-on-surface-variant">ISO 2-letter country code (e.g. DE for Germany)</p>
)}
</div>
) : (
{scanMode === 'reverse' ? (
/* Reverse: specific destination airports */
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Destination Airports
@@ -203,6 +287,82 @@ export default function Scans() {
</p>
)}
</div>
) : (
/* Forward: by country or by specific airports */
<>
<SegmentedButton
options={[
{ value: 'country', label: 'By Country', icon: Globe },
{ value: 'airports', label: 'By Airports', icon: PlaneTakeoff },
]}
value={destinationMode}
onChange={(v) => {
setDestinationMode(v as 'country' | 'airports');
setErrors(prev => ({ ...prev, country: undefined, airports: undefined }));
}}
className="mb-4"
/>
{destinationMode === 'country' ? (
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Destination Country
</label>
<CountrySelect
value={formData.country ?? ''}
onChange={(code) => {
setFormData(prev => ({ ...prev, country: code }));
if (errors.country) setErrors(prev => ({ ...prev, country: undefined }));
}}
placeholder="Select destination country…"
hasError={!!errors.country}
/>
{errors.country ? (
<p className="mt-1 text-xs text-error">{errors.country}</p>
) : (
<p className="mt-1 text-xs text-on-surface-variant">All airports in this country will be searched</p>
)}
</div>
) : (
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Destination Airports
</label>
<AirportSearch
value=""
onChange={(code) => {
if (code && code.length === 3 && !selectedAirports.includes(code)) {
setSelectedAirports(prev => [...prev, code]);
if (errors.airports) setErrors(prev => ({ ...prev, airports: undefined }));
}
}}
clearAfterSelect
placeholder="Search and add airports…"
hasError={!!errors.airports}
/>
{selectedAirports.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{selectedAirports.map((code) => (
<AirportChip
key={code}
code={code}
onRemove={() => setSelectedAirports(prev => prev.filter(c => c !== code))}
/>
))}
</div>
)}
{errors.airports ? (
<p className="mt-1 text-xs text-error">{errors.airports}</p>
) : (
<p className="mt-1 text-xs text-on-surface-variant">
{selectedAirports.length === 0
? 'Search and add destination airports (up to 50)'
: `${selectedAirports.length} airport${selectedAirports.length !== 1 ? 's' : ''} selected`}
</p>
)}
</div>
)}
</>
)}
</div>
@@ -212,54 +372,141 @@ export default function Scans() {
Parameters
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Search Window */}
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Search Window
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustNumber('window_months', -1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Decrease months"
>
<Minus size={14} />
</button>
<div className="flex-1 h-12 flex items-center justify-center border border-outline rounded-xs bg-surface text-on-surface text-sm font-medium">
{formData.window_months} {formData.window_months === 1 ? 'month' : 'months'}
</div>
<button
type="button"
onClick={() => adjustNumber('window_months', 1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Increase months"
>
<Plus size={14} />
</button>
</div>
<p className="mt-1 text-xs text-on-surface-variant">Months to look ahead (112)</p>
</div>
{/* Seat Class */}
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Seat Class
</label>
<select
value={formData.seat_class}
onChange={(e) => setFormData(prev => ({ ...prev, seat_class: e.target.value as 'economy' | 'premium' | 'business' | 'first' }))}
className={inputCls()}
>
<option value="economy">Economy</option>
<option value="premium">Premium Economy</option>
<option value="business">Business</option>
<option value="first">First Class</option>
</select>
</div>
{/* Search Window toggle */}
<div className="mb-4">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Search Window
</label>
<SegmentedButton
options={[
{ value: 'window', label: 'Rolling Window', icon: CalendarDays },
{ value: 'range', label: 'Date Range', icon: CalendarRange },
]}
value={windowMode}
onChange={(v) => {
setWindowMode(v as 'window' | 'range');
setStartDate('');
setEndDate('');
setErrors(prev => ({ ...prev, start_date: undefined, end_date: undefined }));
}}
/>
</div>
{windowMode === 'window' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Stepper */}
<div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustNumber('window_months', -1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Decrease months"
>
<Minus size={14} />
</button>
<div className="flex-1 h-12 flex items-center justify-center border border-outline rounded-xs bg-surface text-on-surface text-sm font-medium">
{formData.window_months} {formData.window_months === 1 ? 'month' : 'months'}
</div>
<button
type="button"
onClick={() => adjustNumber('window_months', 1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Increase months"
>
<Plus size={14} />
</button>
</div>
<p className="mt-1 text-xs text-on-surface-variant">Months to look ahead (112)</p>
</div>
{/* Seat Class */}
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Seat Class
</label>
<select
value={formData.seat_class}
onChange={(e) => setFormData(prev => ({ ...prev, seat_class: e.target.value as 'economy' | 'premium' | 'business' | 'first' }))}
className={inputCls()}
>
<option value="economy">Economy</option>
<option value="premium">Premium Economy</option>
<option value="business">Business</option>
<option value="first">First Class</option>
</select>
</div>
</div>
) : (
<>
{/* Date pickers */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
From
</label>
<input
type="date"
value={startDate}
min={tomorrowStr}
onChange={(e) => {
setStartDate(e.target.value);
if (errors.start_date) setErrors(prev => ({ ...prev, start_date: undefined }));
}}
className={inputCls(!!errors.start_date)}
/>
{errors.start_date ? (
<p className="mt-1 text-xs text-error">{errors.start_date}</p>
) : (
<p className="mt-1 text-xs text-on-surface-variant">First date to scan</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
To
</label>
<input
type="date"
value={endDate}
min={minEndDate}
onChange={(e) => {
setEndDate(e.target.value);
if (errors.end_date) setErrors(prev => ({ ...prev, end_date: undefined }));
}}
className={inputCls(!!errors.end_date)}
/>
{errors.end_date ? (
<p className="mt-1 text-xs text-error">{errors.end_date}</p>
) : (
<p className="mt-1 text-xs text-on-surface-variant">Last date to scan</p>
)}
</div>
</div>
<p className="mt-2 text-xs text-on-surface-variant">
Flights will be sampled on the 15th of each month within this range
</p>
{/* Seat Class */}
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Seat Class
</label>
<select
value={formData.seat_class}
onChange={(e) => setFormData(prev => ({ ...prev, seat_class: e.target.value as 'economy' | 'premium' | 'business' | 'first' }))}
className={inputCls()}
>
<option value="economy">Economy</option>
<option value="premium">Premium Economy</option>
<option value="business">Business</option>
<option value="first">First Class</option>
</select>
</div>
</div>
</>
)}
{/* Adults — full width below */}
<div className="mt-4">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -21,10 +21,38 @@ from searcher_v3 import search_multiple_routes
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Task registry — tracks running asyncio tasks so they can be cancelled.
# ─────────────────────────────────────────────────────────────────────────────
_running_tasks: dict[int, asyncio.Task] = {}
_cancel_reasons: dict[int, str] = {}
def cancel_scan_task(scan_id: int) -> bool:
"""Cancel the background task for a scan. Returns True if a task was found and cancelled."""
task = _running_tasks.get(scan_id)
if task and not task.done():
task.cancel()
return True
return False
def pause_scan_task(scan_id: int) -> bool:
"""Signal the running task to stop with status='paused'. Returns True if task was found."""
_cancel_reasons[scan_id] = 'paused'
return cancel_scan_task(scan_id)
def stop_scan_task(scan_id: int) -> bool:
"""Signal the running task to stop with status='cancelled'. Returns True if task was found."""
_cancel_reasons[scan_id] = 'cancelled'
return cancel_scan_task(scan_id)
def _write_route_incremental(scan_id: int, destination: str,
dest_name: str, dest_city: str,
new_flights: list):
new_flights: list, origin_airport: str = None):
"""
Write or update a route row and its individual flight rows immediately.
@@ -32,6 +60,9 @@ def _write_route_incremental(scan_id: int, destination: str,
query returns results. Merges into the existing route row if one already
exists, using a running weighted average for avg_price.
For reverse scans, origin_airport is the variable origin IATA code.
For forward scans, origin_airport is None.
Opens its own DB connection — safe to call from the event loop thread.
"""
prices = [f.get('price') for f in new_flights if f.get('price')]
@@ -48,21 +79,29 @@ def _write_route_incremental(scan_id: int, destination: str,
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, flight_count, min_price, max_price, avg_price, airlines
FROM routes
WHERE scan_id = ? AND destination = ?
""", (scan_id, destination))
# Fetch existing route row (key: scan_id + origin_airport + destination)
if origin_airport is None:
cursor.execute("""
SELECT id, flight_count, min_price, max_price, avg_price, airlines
FROM routes
WHERE scan_id = ? AND origin_airport IS NULL AND destination = ?
""", (scan_id, destination))
else:
cursor.execute("""
SELECT id, flight_count, min_price, max_price, avg_price, airlines
FROM routes
WHERE scan_id = ? AND origin_airport = ? AND destination = ?
""", (scan_id, origin_airport, destination))
existing = cursor.fetchone()
if existing is None:
cursor.execute("""
INSERT INTO routes (
scan_id, destination, destination_name, destination_city,
scan_id, origin_airport, destination, destination_name, destination_city,
flight_count, airlines, min_price, max_price, avg_price
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
scan_id, destination, dest_name, dest_city,
scan_id, origin_airport, destination, dest_name, dest_city,
new_count, json.dumps(new_airlines),
new_min, new_max, new_avg,
))
@@ -79,29 +118,44 @@ def _write_route_incremental(scan_id: int, destination: str,
merged_avg = (old_avg * old_count + new_avg * new_count) / merged_count
merged_airlines = json.dumps(list(set(old_airlines) | set(new_airlines)))
cursor.execute("""
UPDATE routes
SET flight_count = ?,
min_price = ?,
max_price = ?,
avg_price = ?,
airlines = ?
WHERE scan_id = ? AND destination = ?
""", (
merged_count, merged_min, merged_max, merged_avg, merged_airlines,
scan_id, destination,
))
if origin_airport is None:
cursor.execute("""
UPDATE routes
SET flight_count = ?,
min_price = ?,
max_price = ?,
avg_price = ?,
airlines = ?
WHERE scan_id = ? AND origin_airport IS NULL AND destination = ?
""", (
merged_count, merged_min, merged_max, merged_avg, merged_airlines,
scan_id, destination,
))
else:
cursor.execute("""
UPDATE routes
SET flight_count = ?,
min_price = ?,
max_price = ?,
avg_price = ?,
airlines = ?
WHERE scan_id = ? AND origin_airport = ? AND destination = ?
""", (
merged_count, merged_min, merged_max, merged_avg, merged_airlines,
scan_id, origin_airport, destination,
))
for flight in new_flights:
if not flight.get('price'):
continue
cursor.execute("""
INSERT INTO flights (
scan_id, destination, date, airline,
scan_id, origin_airport, destination, date, airline,
departure_time, arrival_time, price, stops
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
scan_id,
origin_airport,
destination,
flight.get('date', ''),
flight.get('airline'),
@@ -142,7 +196,7 @@ async def process_scan(scan_id: int):
cursor = conn.cursor()
cursor.execute("""
SELECT origin, country, start_date, end_date, seat_class, adults
SELECT origin, country, scan_mode, start_date, end_date, seat_class, adults
FROM scans
WHERE id = ?
""", (scan_id,))
@@ -152,39 +206,52 @@ async def process_scan(scan_id: int):
logger.error(f"[Scan {scan_id}] Scan not found in database")
return
origin, country_or_airports, start_date_str, end_date_str, seat_class, adults = row
origin, country_or_airports, scan_mode, start_date_str, end_date_str, seat_class, adults = row
scan_mode = scan_mode or 'forward'
logger.info(f"[Scan {scan_id}] Scan details: {origin} -> {country_or_airports}, {start_date_str} to {end_date_str}")
logger.info(f"[Scan {scan_id}] Scan details: mode={scan_mode}, {origin} -> {country_or_airports}, {start_date_str} to {end_date_str}")
# Update status to 'running'
# Update status to 'running' and record when processing started
cursor.execute("""
UPDATE scans
SET status = 'running', updated_at = CURRENT_TIMESTAMP
SET status = 'running', started_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (scan_id,))
conn.commit()
# Determine mode: country (2 letters) or specific airports (comma-separated)
# Resolve airports based on scan_mode
try:
if len(country_or_airports) == 2 and country_or_airports.isalpha():
# Country mode: resolve airports from country code
logger.info(f"[Scan {scan_id}] Mode: Country search ({country_or_airports})")
destinations = get_airports_for_country(country_or_airports)
if not destinations:
raise ValueError(f"No airports found for country: {country_or_airports}")
if scan_mode == 'reverse':
# Reverse scan: origin = ISO country, country_or_airports = comma-separated dest IATAs
logger.info(f"[Scan {scan_id}] Mode: Reverse scan ({origin} country → {country_or_airports})")
origin_airports = get_airports_for_country(origin)
if not origin_airports:
raise ValueError(f"No airports found for origin country: {origin}")
origin_iatas = [a['iata'] for a in origin_airports]
destination_codes = [d['iata'] for d in destinations]
logger.info(f"[Scan {scan_id}] Found {len(destination_codes)} destination airports: {destination_codes}")
destination_codes = [code.strip() for code in country_or_airports.split(',')]
dest_infos = {
code: lookup_airport(code) or {'iata': code, 'name': code, 'city': ''}
for code in destination_codes
}
logger.info(f"[Scan {scan_id}] {len(origin_iatas)} origins × {len(destination_codes)} destinations")
else:
# Specific airports mode: parse comma-separated list
destination_codes = [code.strip() for code in country_or_airports.split(',')]
destinations = [
lookup_airport(code) or {'iata': code, 'name': code, 'city': ''}
for code in destination_codes
]
logger.info(f"[Scan {scan_id}] Mode: Specific airports ({len(destination_codes)} destinations: {destination_codes})")
# Forward scan: origin = fixed IATA, country_or_airports = country code or dest IATAs
if len(country_or_airports) == 2 and country_or_airports.isalpha():
logger.info(f"[Scan {scan_id}] Mode: Forward country search ({country_or_airports})")
dest_list = get_airports_for_country(country_or_airports)
if not dest_list:
raise ValueError(f"No airports found for country: {country_or_airports}")
destination_codes = [d['iata'] for d in dest_list]
dest_infos = {d['iata']: d for d in dest_list}
else:
destination_codes = [code.strip() for code in country_or_airports.split(',')]
dest_infos = {
code: lookup_airport(code) or {'iata': code, 'name': code, 'city': ''}
for code in destination_codes
}
logger.info(f"[Scan {scan_id}] Mode: Forward specific airports ({destination_codes})")
except Exception as e:
logger.error(f"[Scan {scan_id}] Failed to resolve airports: {str(e)}")
@@ -192,14 +259,13 @@ async def process_scan(scan_id: int):
UPDATE scans
SET status = 'failed',
error_message = ?,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (f"Failed to resolve airports: {str(e)}", scan_id))
conn.commit()
return
# Note: Don't update total_routes yet - we'll set it after we know the actual number of route queries
# Generate dates to scan — every day in the window
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
@@ -212,11 +278,17 @@ async def process_scan(scan_id: int):
logger.info(f"[Scan {scan_id}] Will scan {len(dates)} dates: {dates}")
# Build routes list: [(origin, destination, date), ...]
# Build routes list: [(origin_iata, destination_iata, date), ...]
routes_to_scan = []
for dest in destination_codes:
for scan_date in dates:
routes_to_scan.append((origin, dest, scan_date))
if scan_mode == 'reverse':
for orig_iata in origin_iatas:
for dest_code in destination_codes:
for scan_date in dates:
routes_to_scan.append((orig_iata, dest_code, scan_date))
else:
for dest_code in destination_codes:
for scan_date in dates:
routes_to_scan.append((origin, dest_code, scan_date))
logger.info(f"[Scan {scan_id}] Total route queries: {len(routes_to_scan)}")
@@ -233,7 +305,7 @@ async def process_scan(scan_id: int):
# Signature: callback(origin, destination, date, status, count, error=None, flights=None)
routes_scanned_count = 0
def progress_callback(origin: str, destination: str, date: str,
def progress_callback(cb_origin: str, destination: str, date: str,
status: str, count: int, error: str = None,
flights: list = None):
nonlocal routes_scanned_count
@@ -245,10 +317,15 @@ async def process_scan(scan_id: int):
if flights and status in ('cache_hit', 'api_success'):
for f in flights:
f['date'] = date
dest_info = next((d for d in destinations if d['iata'] == destination), None)
dest_name = dest_info.get('name', destination) if dest_info else destination
dest_city = dest_info.get('city', '') if dest_info else ''
_write_route_incremental(scan_id, destination, dest_name, dest_city, flights)
dest_info = dest_infos.get(destination) or {'iata': destination, 'name': destination, 'city': ''}
dest_name = dest_info.get('name', destination)
dest_city = dest_info.get('city', '')
# For reverse scans, cb_origin is the variable origin airport IATA
route_origin = cb_origin if scan_mode == 'reverse' else None
_write_route_incremental(
scan_id, destination, dest_name, dest_city, flights,
origin_airport=route_origin
)
# Update progress counter
try:
@@ -266,7 +343,7 @@ async def process_scan(scan_id: int):
progress_conn.close()
if routes_scanned_count % 10 == 0:
logger.info(f"[Scan {scan_id}] Progress: {routes_scanned_count}/{len(routes_to_scan)} routes ({status}: {origin}{destination})")
logger.info(f"[Scan {scan_id}] Progress: {routes_scanned_count}/{len(routes_to_scan)} routes ({status}: {cb_origin}{destination})")
except Exception as e:
logger.error(f"[Scan {scan_id}] Failed to update progress: {str(e)}")
@@ -294,11 +371,12 @@ async def process_scan(scan_id: int):
"SELECT COALESCE(SUM(flight_count), 0) FROM routes WHERE scan_id = ?", (scan_id,)
).fetchone()[0]
# Update scan to completed
# Update scan to completed and record finish time
cursor.execute("""
UPDATE scans
SET status = 'completed',
total_flights = ?,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (total_flights_saved, scan_id))
@@ -306,6 +384,24 @@ async def process_scan(scan_id: int):
logger.info(f"[Scan {scan_id}] ✅ Scan completed successfully! {routes_saved} routes saved with {total_flights_saved} flights")
except asyncio.CancelledError:
reason = _cancel_reasons.pop(scan_id, 'cancelled')
logger.info(f"[Scan {scan_id}] Scan {reason} by user request")
try:
if conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE scans
SET status = ?,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (reason, scan_id))
conn.commit()
except Exception as update_error:
logger.error(f"[Scan {scan_id}] Failed to update {reason} status: {str(update_error)}")
raise # must re-raise so asyncio marks the task as cancelled
except Exception as e:
logger.error(f"[Scan {scan_id}] ❌ Scan failed with error: {str(e)}", exc_info=True)
@@ -317,6 +413,7 @@ async def process_scan(scan_id: int):
UPDATE scans
SET status = 'failed',
error_message = ?,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (str(e), scan_id))
@@ -340,5 +437,28 @@ def start_scan_processor(scan_id: int):
asyncio.Task: The background task
"""
task = asyncio.create_task(process_scan(scan_id))
_running_tasks[scan_id] = task
task.add_done_callback(lambda _: _running_tasks.pop(scan_id, None))
logger.info(f"[Scan {scan_id}] Background task created")
return task
def start_resume_processor(scan_id: int):
"""
Resume processing a paused scan as a background task.
The API endpoint has already reset status to 'pending' and cleared counters.
process_scan() will transition the status to 'running' and re-run all routes,
getting instant cache hits for already-queried routes.
Args:
scan_id: The ID of the paused scan to resume
Returns:
asyncio.Task: The background task
"""
task = asyncio.create_task(process_scan(scan_id))
_running_tasks[scan_id] = task
task.add_done_callback(lambda _: _running_tasks.pop(scan_id, None))
logger.info(f"[Scan {scan_id}] Resume task created")
return task

View File

@@ -245,6 +245,45 @@ class TestScanEndpoints:
assert data["data"][0]["destination"] == "FRA"
assert data["data"][0]["min_price"] == 50
def test_get_scan_paused_status(self, client: TestClient, create_test_scan):
"""Test that GET /scans/{id} returns paused status correctly."""
scan_id = create_test_scan(status='paused')
response = client.get(f"/api/v1/scans/{scan_id}")
assert response.status_code == 200
assert response.json()["status"] == "paused"
def test_get_scan_cancelled_status(self, client: TestClient, create_test_scan):
"""Test that GET /scans/{id} returns cancelled status correctly."""
scan_id = create_test_scan(status='cancelled')
response = client.get(f"/api/v1/scans/{scan_id}")
assert response.status_code == 200
assert response.json()["status"] == "cancelled"
def test_list_scans_filter_paused(self, client: TestClient, create_test_scan):
"""Test filtering scans by paused status."""
create_test_scan(status='paused')
create_test_scan(status='completed')
create_test_scan(status='running')
response = client.get("/api/v1/scans?status=paused")
assert response.status_code == 200
data = response.json()
assert len(data["data"]) == 1
assert data["data"][0]["status"] == "paused"
def test_list_scans_filter_cancelled(self, client: TestClient, create_test_scan):
"""Test filtering scans by cancelled status."""
create_test_scan(status='cancelled')
create_test_scan(status='pending')
response = client.get("/api/v1/scans?status=cancelled")
assert response.status_code == 200
data = response.json()
assert len(data["data"]) == 1
assert data["data"][0]["status"] == "cancelled"
@pytest.mark.unit
@pytest.mark.api

View File

@@ -86,6 +86,25 @@ class TestScanWorkflow:
prices = [r["min_price"] for r in routes]
assert prices == sorted(prices)
def test_pause_and_resume_preserves_scan_id(self, client: TestClient, create_test_scan):
"""Resume returns the same scan id, not a new one (unlike Re-run)."""
scan_id = create_test_scan(status='running')
# Pause
pause_resp = client.post(f"/api/v1/scans/{scan_id}/pause")
assert pause_resp.status_code == 200
assert pause_resp.json()["id"] == scan_id
# Resume
resume_resp = client.post(f"/api/v1/scans/{scan_id}/resume")
assert resume_resp.status_code == 200
assert resume_resp.json()["id"] == scan_id
# Confirm scan still exists with same id
get_resp = client.get(f"/api/v1/scans/{scan_id}")
assert get_resp.status_code == 200
assert get_resp.json()["id"] == scan_id
@pytest.mark.integration
@pytest.mark.database

View File

@@ -0,0 +1,370 @@
"""
Tests for scan control endpoints: pause, cancel, resume.
Covers API behaviour, DB state, status transitions, rate limit headers,
and schema-level acceptance of the new 'paused' and 'cancelled' values.
"""
import pytest
import sqlite3
from fastapi.testclient import TestClient
# =============================================================================
# TestScanControlEndpoints — API unit tests
# =============================================================================
@pytest.mark.unit
@pytest.mark.api
class TestScanControlEndpoints:
"""Tests for pause, cancel, and resume endpoints in isolation."""
# ── Pause ──────────────────────────────────────────────────────────────
def test_pause_running_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='running')
resp = client.post(f"/api/v1/scans/{scan_id}/pause")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "paused"
assert body["id"] == scan_id
def test_pause_pending_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='pending')
resp = client.post(f"/api/v1/scans/{scan_id}/pause")
assert resp.status_code == 200
assert resp.json()["status"] == "paused"
def test_pause_nonexistent_scan(self, client: TestClient):
resp = client.post("/api/v1/scans/99999/pause")
assert resp.status_code == 404
def test_pause_completed_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='completed')
resp = client.post(f"/api/v1/scans/{scan_id}/pause")
assert resp.status_code == 409
def test_pause_already_paused_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='paused')
resp = client.post(f"/api/v1/scans/{scan_id}/pause")
assert resp.status_code == 409
def test_pause_cancelled_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='cancelled')
resp = client.post(f"/api/v1/scans/{scan_id}/pause")
assert resp.status_code == 409
# ── Cancel ─────────────────────────────────────────────────────────────
def test_cancel_running_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='running')
resp = client.post(f"/api/v1/scans/{scan_id}/cancel")
assert resp.status_code == 200
assert resp.json()["status"] == "cancelled"
def test_cancel_pending_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='pending')
resp = client.post(f"/api/v1/scans/{scan_id}/cancel")
assert resp.status_code == 200
assert resp.json()["status"] == "cancelled"
def test_cancel_nonexistent_scan(self, client: TestClient):
resp = client.post("/api/v1/scans/99999/cancel")
assert resp.status_code == 404
def test_cancel_completed_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='completed')
resp = client.post(f"/api/v1/scans/{scan_id}/cancel")
assert resp.status_code == 409
def test_cancel_already_cancelled_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='cancelled')
resp = client.post(f"/api/v1/scans/{scan_id}/cancel")
assert resp.status_code == 409
# ── Resume ─────────────────────────────────────────────────────────────
def test_resume_paused_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='paused')
resp = client.post(f"/api/v1/scans/{scan_id}/resume")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "pending"
assert body["id"] == scan_id
def test_resume_nonexistent_scan(self, client: TestClient):
resp = client.post("/api/v1/scans/99999/resume")
assert resp.status_code == 404
def test_resume_running_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='running')
resp = client.post(f"/api/v1/scans/{scan_id}/resume")
assert resp.status_code == 409
def test_resume_cancelled_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='cancelled')
resp = client.post(f"/api/v1/scans/{scan_id}/resume")
assert resp.status_code == 409
def test_resume_completed_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='completed')
resp = client.post(f"/api/v1/scans/{scan_id}/resume")
assert resp.status_code == 409
# ── Response shape ──────────────────────────────────────────────────────
def test_pause_response_shape(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='running')
body = client.post(f"/api/v1/scans/{scan_id}/pause").json()
assert "id" in body
assert "status" in body
def test_cancel_response_shape(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='running')
body = client.post(f"/api/v1/scans/{scan_id}/cancel").json()
assert "id" in body
assert "status" in body
def test_resume_response_shape(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='paused')
body = client.post(f"/api/v1/scans/{scan_id}/resume").json()
assert "id" in body
assert "status" in body
# =============================================================================
# TestScanControlDatabaseState — verify DB state after operations
# =============================================================================
@pytest.mark.database
class TestScanControlDatabaseState:
"""Tests that verify SQLite state after pause/cancel/resume operations."""
def test_pause_sets_completed_at(self, client: TestClient, create_test_scan, clean_database):
scan_id = create_test_scan(status='running')
client.post(f"/api/v1/scans/{scan_id}/pause")
conn = sqlite3.connect(clean_database)
row = conn.execute("SELECT completed_at FROM scans WHERE id = ?", (scan_id,)).fetchone()
conn.close()
assert row[0] is not None
def test_cancel_sets_completed_at(self, client: TestClient, create_test_scan, clean_database):
scan_id = create_test_scan(status='running')
client.post(f"/api/v1/scans/{scan_id}/cancel")
conn = sqlite3.connect(clean_database)
row = conn.execute("SELECT completed_at FROM scans WHERE id = ?", (scan_id,)).fetchone()
conn.close()
assert row[0] is not None
def test_resume_clears_completed_at(self, client: TestClient, create_test_scan, clean_database):
scan_id = create_test_scan(status='paused')
client.post(f"/api/v1/scans/{scan_id}/resume")
conn = sqlite3.connect(clean_database)
row = conn.execute("SELECT completed_at FROM scans WHERE id = ?", (scan_id,)).fetchone()
conn.close()
assert row[0] is None
def test_resume_resets_started_at_from_old_value(self, client: TestClient, create_test_scan, clean_database):
"""After resume, started_at is no longer the old seeded timestamp.
The endpoint clears started_at; the background processor may then
set a new timestamp immediately. Either way, the old value is gone.
"""
old_timestamp = '2026-01-01 10:00:00'
scan_id = create_test_scan(status='paused')
conn = sqlite3.connect(clean_database)
conn.execute("UPDATE scans SET started_at = ? WHERE id = ?", (old_timestamp, scan_id))
conn.commit()
conn.close()
client.post(f"/api/v1/scans/{scan_id}/resume")
conn = sqlite3.connect(clean_database)
row = conn.execute("SELECT started_at FROM scans WHERE id = ?", (scan_id,)).fetchone()
conn.close()
# The endpoint cleared the old timestamp; the processor may have set a new one
assert row[0] != old_timestamp
def test_resume_resets_routes_scanned(self, client: TestClient, create_test_scan, clean_database):
scan_id = create_test_scan(status='paused')
conn = sqlite3.connect(clean_database)
conn.execute("UPDATE scans SET routes_scanned = 50, total_routes = 100 WHERE id = ?", (scan_id,))
conn.commit()
conn.close()
client.post(f"/api/v1/scans/{scan_id}/resume")
conn = sqlite3.connect(clean_database)
row = conn.execute("SELECT routes_scanned FROM scans WHERE id = ?", (scan_id,)).fetchone()
conn.close()
assert row[0] == 0
def test_pause_preserves_routes(
self, client: TestClient, create_test_scan, create_test_route, clean_database
):
scan_id = create_test_scan(status='running')
create_test_route(scan_id=scan_id, destination='MUC')
client.post(f"/api/v1/scans/{scan_id}/pause")
conn = sqlite3.connect(clean_database)
count = conn.execute(
"SELECT COUNT(*) FROM routes WHERE scan_id = ?", (scan_id,)
).fetchone()[0]
conn.close()
assert count == 1
def test_cancel_preserves_routes(
self, client: TestClient, create_test_scan, create_test_route, clean_database
):
scan_id = create_test_scan(status='running')
create_test_route(scan_id=scan_id, destination='MUC')
client.post(f"/api/v1/scans/{scan_id}/cancel")
conn = sqlite3.connect(clean_database)
count = conn.execute(
"SELECT COUNT(*) FROM routes WHERE scan_id = ?", (scan_id,)
).fetchone()[0]
conn.close()
assert count == 1
# =============================================================================
# TestScanControlStatusTransitions — full workflow integration tests
# =============================================================================
@pytest.mark.integration
@pytest.mark.database
class TestScanControlStatusTransitions:
"""Full workflow tests across multiple API calls."""
def test_running_to_paused_to_pending(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='running')
# Pause it
resp = client.post(f"/api/v1/scans/{scan_id}/pause")
assert resp.json()["status"] == "paused"
# Verify persisted
assert client.get(f"/api/v1/scans/{scan_id}").json()["status"] == "paused"
# Resume → pending (background processor moves to running)
resp = client.post(f"/api/v1/scans/{scan_id}/resume")
assert resp.json()["status"] == "pending"
def test_running_to_cancelled(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='running')
resp = client.post(f"/api/v1/scans/{scan_id}/cancel")
assert resp.json()["status"] == "cancelled"
assert client.get(f"/api/v1/scans/{scan_id}").json()["status"] == "cancelled"
def test_pause_then_delete(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='paused')
resp = client.delete(f"/api/v1/scans/{scan_id}")
assert resp.status_code == 204
def test_cancel_then_delete(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='cancelled')
resp = client.delete(f"/api/v1/scans/{scan_id}")
assert resp.status_code == 204
def test_cannot_delete_running_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='running')
resp = client.delete(f"/api/v1/scans/{scan_id}")
assert resp.status_code == 409
def test_cannot_delete_pending_scan(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='pending')
resp = client.delete(f"/api/v1/scans/{scan_id}")
assert resp.status_code == 409
def test_list_scans_filter_paused(self, client: TestClient, create_test_scan):
paused_id = create_test_scan(status='paused')
create_test_scan(status='running')
create_test_scan(status='completed')
resp = client.get("/api/v1/scans?status=paused")
assert resp.status_code == 200
scans = resp.json()["data"]
assert len(scans) >= 1
assert all(s["status"] == "paused" for s in scans)
assert any(s["id"] == paused_id for s in scans)
def test_list_scans_filter_cancelled(self, client: TestClient, create_test_scan):
cancelled_id = create_test_scan(status='cancelled')
create_test_scan(status='running')
resp = client.get("/api/v1/scans?status=cancelled")
assert resp.status_code == 200
scans = resp.json()["data"]
assert len(scans) >= 1
assert all(s["status"] == "cancelled" for s in scans)
assert any(s["id"] == cancelled_id for s in scans)
# =============================================================================
# TestScanControlRateLimits — rate limit headers on control endpoints
# =============================================================================
@pytest.mark.api
class TestScanControlRateLimits:
"""Verify that rate limit response headers are present on control endpoints."""
def test_pause_rate_limit_headers(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='running')
resp = client.post(f"/api/v1/scans/{scan_id}/pause")
assert "x-ratelimit-limit" in resp.headers
assert "x-ratelimit-remaining" in resp.headers
def test_cancel_rate_limit_headers(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='running')
resp = client.post(f"/api/v1/scans/{scan_id}/cancel")
assert "x-ratelimit-limit" in resp.headers
assert "x-ratelimit-remaining" in resp.headers
def test_resume_rate_limit_headers(self, client: TestClient, create_test_scan):
scan_id = create_test_scan(status='paused')
resp = client.post(f"/api/v1/scans/{scan_id}/resume")
assert "x-ratelimit-limit" in resp.headers
assert "x-ratelimit-remaining" in resp.headers
# =============================================================================
# TestScanControlNewStatuses — schema-level acceptance of new status values
# =============================================================================
@pytest.mark.database
class TestScanControlNewStatuses:
"""Verify the new status values are accepted/rejected at the SQLite level."""
def test_paused_status_accepted_by_schema(self, clean_database, create_test_scan):
scan_id = create_test_scan(status='pending')
conn = sqlite3.connect(clean_database)
conn.execute("UPDATE scans SET status='paused' WHERE id = ?", (scan_id,))
conn.commit()
row = conn.execute("SELECT status FROM scans WHERE id = ?", (scan_id,)).fetchone()
conn.close()
assert row[0] == 'paused'
def test_cancelled_status_accepted_by_schema(self, clean_database, create_test_scan):
scan_id = create_test_scan(status='pending')
conn = sqlite3.connect(clean_database)
conn.execute("UPDATE scans SET status='cancelled' WHERE id = ?", (scan_id,))
conn.commit()
row = conn.execute("SELECT status FROM scans WHERE id = ?", (scan_id,)).fetchone()
conn.close()
assert row[0] == 'cancelled'
def test_invalid_status_rejected_by_schema(self, clean_database, create_test_scan):
scan_id = create_test_scan(status='pending')
conn = sqlite3.connect(clean_database)
with pytest.raises(sqlite3.IntegrityError):
conn.execute("UPDATE scans SET status='stopped' WHERE id = ?", (scan_id,))
conn.commit()
conn.close()
def test_filter_active_scans_excludes_paused(self, clean_database, create_test_scan):
paused_id = create_test_scan(status='paused')
conn = sqlite3.connect(clean_database)
rows = conn.execute("SELECT id FROM active_scans").fetchall()
conn.close()
ids = [r[0] for r in rows]
assert paused_id not in ids
def test_filter_active_scans_excludes_cancelled(self, clean_database, create_test_scan):
cancelled_id = create_test_scan(status='cancelled')
conn = sqlite3.connect(clean_database)
rows = conn.execute("SELECT id FROM active_scans").fetchall()
conn.close()
ids = [r[0] for r in rows]
assert cancelled_id not in ids

View File

@@ -0,0 +1,127 @@
"""
Tests for scan_processor task registry and control functions.
Tests cancel_scan_task, pause_scan_task, stop_scan_task, and the
done-callback that removes tasks from the registry on completion.
"""
import asyncio
import pytest
import sys
import os
from unittest.mock import MagicMock
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from scan_processor import (
_running_tasks,
_cancel_reasons,
cancel_scan_task,
pause_scan_task,
stop_scan_task,
)
class TestScanProcessorControl:
"""Tests for task registry and cancel/pause/stop functions."""
def teardown_method(self, _method):
"""Clean up any test state from _running_tasks and _cancel_reasons."""
for key in [9001, 8001, 8002, 7001]:
_running_tasks.pop(key, None)
_cancel_reasons.pop(key, None)
# ── cancel_scan_task ───────────────────────────────────────────────────
def test_cancel_scan_task_returns_false_when_no_task(self):
"""Returns False when no task is registered for the given scan id."""
result = cancel_scan_task(99999)
assert result is False
def test_cancel_scan_task_returns_true_when_task_exists(self):
"""Returns True and calls task.cancel() when a live task is registered."""
mock_task = MagicMock()
mock_task.done.return_value = False
_running_tasks[9001] = mock_task
result = cancel_scan_task(9001)
assert result is True
mock_task.cancel.assert_called_once()
def test_cancel_scan_task_returns_false_for_completed_task(self):
"""Returns False when the registered task is already done."""
mock_task = MagicMock()
mock_task.done.return_value = True
_running_tasks[9001] = mock_task
result = cancel_scan_task(9001)
assert result is False
mock_task.cancel.assert_not_called()
# ── pause_scan_task ────────────────────────────────────────────────────
def test_pause_sets_cancel_reason_paused(self):
"""pause_scan_task sets _cancel_reasons[id] = 'paused'."""
mock_task = MagicMock()
mock_task.done.return_value = False
_running_tasks[8001] = mock_task
pause_scan_task(8001)
assert _cancel_reasons.get(8001) == 'paused'
def test_pause_calls_cancel_on_task(self):
"""pause_scan_task triggers cancellation of the underlying task."""
mock_task = MagicMock()
mock_task.done.return_value = False
_running_tasks[8001] = mock_task
result = pause_scan_task(8001)
assert result is True
mock_task.cancel.assert_called_once()
# ── stop_scan_task ─────────────────────────────────────────────────────
def test_stop_sets_cancel_reason_cancelled(self):
"""stop_scan_task sets _cancel_reasons[id] = 'cancelled'."""
mock_task = MagicMock()
mock_task.done.return_value = False
_running_tasks[8002] = mock_task
stop_scan_task(8002)
assert _cancel_reasons.get(8002) == 'cancelled'
def test_stop_calls_cancel_on_task(self):
"""stop_scan_task triggers cancellation of the underlying task."""
mock_task = MagicMock()
mock_task.done.return_value = False
_running_tasks[8002] = mock_task
result = stop_scan_task(8002)
assert result is True
mock_task.cancel.assert_called_once()
# ── done callback ──────────────────────────────────────────────────────
def test_task_removed_from_registry_on_completion(self):
"""The done-callback registered by start_scan_processor removes the task."""
async def run():
async def quick():
return
task = asyncio.create_task(quick())
_running_tasks[7001] = task
task.add_done_callback(lambda _: _running_tasks.pop(7001, None))
await task
# Yield to let done callbacks fire
await asyncio.sleep(0)
return 7001 not in _running_tasks
result = asyncio.run(run())
assert result is True