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: push:
branches: branches:
- main - main
workflow_dispatch:
env: env:
COMPOSE_PROJECT: flight-radar COMPOSE_PROJECT: flight-radar
COMPOSE_FILE: flight-comparator/docker-compose.yml 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 !tests/confirmed_flights.json
!frontend/package.json !frontend/package.json
!frontend/package-lock.json !frontend/package-lock.json
!frontend/tsconfig.json
!frontend/tsconfig.app.json
!frontend/tsconfig.node.json
# Database files # Database files
*.db *.db

View File

@@ -13,7 +13,9 @@ FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN apk add --no-cache curl
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -q --spider http://localhost/ || exit 1 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]: def country_name_to_iso_code(country_name: str) -> Optional[str]:
""" """
Convert country name to ISO 2-letter code. 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)}..." 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]: 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() download_and_build_airport_data()
with open(AIRPORTS_JSON_PATH, 'r', encoding='utf-8') as f: with open(AIRPORTS_JSON_PATH, 'r', encoding='utf-8') as f:
airports_by_country = json.load(f) airports_by_country = json.load(f)
return { result = {
a['iata']: a a['iata']: a
for airports in airports_by_country.values() for airports in airports_by_country.values()
for a in airports 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: def lookup_airport(iata: str) -> dict | None:

View File

@@ -38,9 +38,12 @@ from threading import Lock
T = TypeVar('T') T = TypeVar('T')
# Import existing modules # 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 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 limit configurations (requests per minute)
RATE_LIMITS = { RATE_LIMITS = {
'default': (200, 60), # 200 requests per 60 seconds (~3 req/sec) 'default': (200, 60), # 200 requests per 60 seconds (~3 req/sec)
'scans': (50, 60), # 50 scan creations per minute 'scans': (50, 60), # 50 scan creations per minute
'logs': (100, 60), # 100 log requests per minute 'logs': (100, 60), # 100 log requests per minute
'airports': (500, 60), # 500 airport searches per minute 'airports': (500, 60), # 500 airport searches per minute
'schedules': (30, 60), # 30 schedule requests 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: Returns:
tuple: (endpoint_name, limit, window) 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'] return 'scans', *RATE_LIMITS['scans']
elif '/logs' in path: elif '/logs' in path:
return 'logs', *RATE_LIMITS['logs'] 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') now_str = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute(""" 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 frequency, hour, minute, day_of_week, day_of_month
FROM scheduled_scans FROM scheduled_scans
WHERE enabled = 1 AND next_run_at <= ? WHERE enabled = 1 AND next_run_at <= ?
@@ -296,7 +305,7 @@ def _check_and_run_due_schedules():
due = cursor.fetchall() due = cursor.fetchall()
for row in due: 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 frequency, hour, minute, day_of_week, day_of_month) = row
# Concurrency guard: skip if a scan for this schedule is still active # Concurrency guard: skip if a scan for this schedule is still active
@@ -317,10 +326,10 @@ def _check_and_run_due_schedules():
conn.execute(""" conn.execute("""
INSERT INTO scans ( INSERT INTO scans (
origin, country, start_date, end_date, origin, country, scan_mode, start_date, end_date,
status, seat_class, adults, scheduled_scan_id status, seat_class, adults, scheduled_scan_id
) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)
""", (origin, country, start_date, end_date, """, (origin, country, scan_mode, start_date, end_date,
seat_class, adults, sched_id)) seat_class, adults, sched_id))
conn.commit() conn.commit()
scan_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] scan_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
@@ -699,11 +708,15 @@ class Country(BaseModel):
class ScanRequest(BaseModel): class ScanRequest(BaseModel):
"""Flight scan request model with comprehensive validation.""" """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( origin: str = Field(
..., ...,
min_length=3, min_length=2,
max_length=3, 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( destination_country: Optional[str] = Field(
None, None,
@@ -741,11 +754,22 @@ class ScanRequest(BaseModel):
description="Number of adults (1-9)" 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') @validator('origin')
def validate_origin(cls, v): def validate_origin(cls, v, values):
v = v.upper() # Normalize to uppercase v = v.strip().upper()
if not re.match(r'^[A-Z]{3}$', v): mode = values.get('scan_mode', 'forward')
raise ValueError('Origin must be a 3-letter IATA code (e.g., BDS, MUC)') 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 return v
@validator('destination_country') @validator('destination_country')
@@ -785,16 +809,20 @@ class ScanRequest(BaseModel):
@validator('destinations', pre=False, always=True) @validator('destinations', pre=False, always=True)
def check_destination_mode(cls, v, values): 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') country = values.get('destination_country')
mode = values.get('scan_mode', 'forward')
if country and v: if mode == 'reverse':
raise ValueError('Provide either country OR destinations, not both') if not v:
raise ValueError('Reverse scans require destinations (list of destination airport IATA codes)')
if not country and not v: return v
raise ValueError('Must provide either country or destinations') else:
if country and v:
return 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') @validator('start_date')
def validate_start_date(cls, v): def validate_start_date(cls, v):
@@ -889,6 +917,7 @@ class Route(BaseModel):
"""Route model - represents a discovered flight route.""" """Route model - represents a discovered flight route."""
id: int = Field(..., description="Route ID") id: int = Field(..., description="Route ID")
scan_id: int = Field(..., description="Parent scan 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: str = Field(..., description="Destination airport IATA code")
destination_name: str = Field(..., description="Destination airport name") destination_name: str = Field(..., description="Destination airport name")
destination_city: Optional[str] = Field(None, description="Destination city") destination_city: Optional[str] = Field(None, description="Destination city")
@@ -904,6 +933,7 @@ class Flight(BaseModel):
"""Individual flight discovered by a scan.""" """Individual flight discovered by a scan."""
id: int = Field(..., description="Flight ID") id: int = Field(..., description="Flight ID")
scan_id: int = Field(..., description="Parent scan 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: str = Field(..., description="Destination airport IATA code")
date: str = Field(..., description="Flight date (YYYY-MM-DD)") date: str = Field(..., description="Flight date (YYYY-MM-DD)")
airline: Optional[str] = Field(None, description="Operating airline") airline: Optional[str] = Field(None, description="Operating airline")
@@ -916,8 +946,9 @@ class Flight(BaseModel):
class Scan(BaseModel): class Scan(BaseModel):
"""Scan model - represents a flight scan with full details.""" """Scan model - represents a flight scan with full details."""
id: int = Field(..., description="Scan ID") id: int = Field(..., description="Scan ID")
origin: str = Field(..., description="Origin airport IATA code") scan_mode: str = Field('forward', description="Scan direction: forward or reverse")
country: str = Field(..., description="Destination country code") 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)") start_date: str = Field(..., description="Start date (YYYY-MM-DD)")
end_date: str = Field(..., description="End 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") 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") seat_class: str = Field(..., description="Seat class")
adults: int = Field(..., ge=1, le=9, description="Number of adults") 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") 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): class ScanCreateResponse(BaseModel):
@@ -1175,16 +1208,14 @@ async def get_countries():
country = airport['country'] country = airport['country']
country_counts[country] = country_counts.get(country, 0) + 1 country_counts[country] = country_counts.get(country, 0) + 1
# Get country names (we'll need a mapping file for this) countries = sorted([
# For now, just return codes
countries = [
Country( Country(
code=code, code=code,
name=code, # TODO: Add country name mapping name=_ISO_TO_COUNTRY_NAME.get(code, code),
airport_count=count 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 return countries
@@ -1233,12 +1264,13 @@ async def create_scan(request: ScanRequest):
cursor.execute(""" cursor.execute("""
INSERT INTO scans ( INSERT INTO scans (
origin, country, start_date, end_date, origin, country, scan_mode, start_date, end_date,
status, seat_class, adults status, seat_class, adults
) VALUES (?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
request.origin, request.origin,
country_or_airports, country_or_airports,
request.scan_mode,
start_date, start_date,
end_date, end_date,
'pending', 'pending',
@@ -1251,10 +1283,11 @@ async def create_scan(request: ScanRequest):
# Fetch the created scan # Fetch the created scan
cursor.execute(""" 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, created_at, updated_at, status, total_routes,
routes_scanned, total_flights, error_message, routes_scanned, total_flights, error_message,
seat_class, adults, scheduled_scan_id seat_class, adults, scheduled_scan_id,
started_at, completed_at
FROM scans FROM scans
WHERE id = ? WHERE id = ?
""", (scan_id,)) """, (scan_id,))
@@ -1269,18 +1302,21 @@ async def create_scan(request: ScanRequest):
id=row[0], id=row[0],
origin=row[1], origin=row[1],
country=row[2], country=row[2],
start_date=row[3], scan_mode=row[3],
end_date=row[4], start_date=row[4],
created_at=row[5], end_date=row[5],
updated_at=row[6], created_at=row[6],
status=row[7], updated_at=row[7],
total_routes=row[8], status=row[8],
routes_scanned=row[9], total_routes=row[9],
total_flights=row[10], routes_scanned=row[10],
error_message=row[11], total_flights=row[11],
seat_class=row[12], error_message=row[12],
adults=row[13], seat_class=row[13],
scheduled_scan_id=row[14] if len(row) > 14 else None 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}") 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 = "" where_clause = ""
params = [] params = []
if status: if status:
if status not in ['pending', 'running', 'completed', 'failed']: if status not in ['pending', 'running', 'completed', 'failed', 'paused', 'cancelled']:
raise HTTPException( raise HTTPException(
status_code=400, 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 = ?" where_clause = "WHERE status = ?"
params.append(status) params.append(status)
@@ -1356,10 +1392,11 @@ async def list_scans(
# Get paginated results # Get paginated results
offset = (page - 1) * limit offset = (page - 1) * limit
query = f""" 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, created_at, updated_at, status, total_routes,
routes_scanned, total_flights, error_message, routes_scanned, total_flights, error_message,
seat_class, adults, scheduled_scan_id seat_class, adults, scheduled_scan_id,
started_at, completed_at
FROM scans FROM scans
{where_clause} {where_clause}
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -1376,18 +1413,21 @@ async def list_scans(
id=row[0], id=row[0],
origin=row[1], origin=row[1],
country=row[2], country=row[2],
start_date=row[3], scan_mode=row[3],
end_date=row[4], start_date=row[4],
created_at=row[5], end_date=row[5],
updated_at=row[6], created_at=row[6],
status=row[7], updated_at=row[7],
total_routes=row[8], status=row[8],
routes_scanned=row[9], total_routes=row[9],
total_flights=row[10], routes_scanned=row[10],
error_message=row[11], total_flights=row[11],
seat_class=row[12], error_message=row[12],
adults=row[13], seat_class=row[13],
scheduled_scan_id=row[14] if len(row) > 14 else None 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 # Build pagination metadata
@@ -1425,10 +1465,11 @@ async def get_scan_status(scan_id: int):
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" 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, created_at, updated_at, status, total_routes,
routes_scanned, total_flights, error_message, routes_scanned, total_flights, error_message,
seat_class, adults, scheduled_scan_id seat_class, adults, scheduled_scan_id,
started_at, completed_at
FROM scans FROM scans
WHERE id = ? WHERE id = ?
""", (scan_id,)) """, (scan_id,))
@@ -1446,18 +1487,21 @@ async def get_scan_status(scan_id: int):
id=row[0], id=row[0],
origin=row[1], origin=row[1],
country=row[2], country=row[2],
start_date=row[3], scan_mode=row[3],
end_date=row[4], start_date=row[4],
created_at=row[5], end_date=row[5],
updated_at=row[6], created_at=row[6],
status=row[7], updated_at=row[7],
total_routes=row[8], status=row[8],
routes_scanned=row[9], total_routes=row[9],
total_flights=row[10], routes_scanned=row[10],
error_message=row[11], total_flights=row[11],
seat_class=row[12], error_message=row[12],
adults=row[13], seat_class=row[13],
scheduled_scan_id=row[14] if len(row) > 14 else None 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: 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)}") 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]) @router_v1.get("/scans/{scan_id}/routes", response_model=PaginatedResponse[Route])
async def get_scan_routes( async def get_scan_routes(
scan_id: int, scan_id: int,
@@ -1550,7 +1743,7 @@ async def get_scan_routes(
# Get paginated results # Get paginated results
offset = (page - 1) * limit offset = (page - 1) * limit
cursor.execute(""" 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 flight_count, airlines, min_price, max_price, avg_price, created_at
FROM routes FROM routes
WHERE scan_id = ? WHERE scan_id = ?
@@ -1570,13 +1763,13 @@ async def get_scan_routes(
for row in rows: for row in rows:
# Parse airlines JSON # Parse airlines JSON
try: try:
airlines = json.loads(row[6]) if row[6] else [] airlines = json.loads(row[7]) if row[7] else []
except: except:
airlines = [] airlines = []
dest = row[2] dest = row[3]
dest_name = row[3] or dest dest_name = row[4] or dest
dest_city = row[4] or '' dest_city = row[5] or ''
# If name was never resolved (stored as IATA code), look it up now # If name was never resolved (stored as IATA code), look it up now
if dest_name == dest: if dest_name == dest:
@@ -1587,15 +1780,16 @@ async def get_scan_routes(
routes.append(Route( routes.append(Route(
id=row[0], id=row[0],
scan_id=row[1], scan_id=row[1],
origin_airport=row[2],
destination=dest, destination=dest,
destination_name=dest_name, destination_name=dest_name,
destination_city=dest_city, destination_city=dest_city,
flight_count=row[5], flight_count=row[6],
airlines=airlines, airlines=airlines,
min_price=row[7], min_price=row[8],
max_price=row[8], max_price=row[9],
avg_price=row[9], avg_price=row[10],
created_at=row[10] created_at=row[11]
)) ))
# Build pagination metadata # Build pagination metadata
@@ -1625,14 +1819,15 @@ async def get_scan_routes(
async def get_scan_flights( async def get_scan_flights(
scan_id: int, scan_id: int,
destination: Optional[str] = Query(None, min_length=3, max_length=3, description="Filter by destination IATA code"), 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"), page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(50, ge=1, le=200, description="Items per page") limit: int = Query(50, ge=1, le=200, description="Items per page")
): ):
""" """
Get individual flights discovered by a specific scan. Get individual flights discovered by a specific scan.
Optionally filter by destination airport code. Optionally filter by destination and/or origin airport code.
Results are ordered by price ascending. Results are ordered by date then price ascending.
""" """
try: try:
conn = get_connection() conn = get_connection()
@@ -1643,45 +1838,41 @@ async def get_scan_flights(
conn.close() conn.close()
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_id}") 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: if destination:
cursor.execute( conditions.append("destination = ?")
"SELECT COUNT(*) FROM flights WHERE scan_id = ? AND destination = ?", params.append(destination.upper())
(scan_id, destination.upper()) if origin_airport:
) conditions.append("origin_airport = ?")
else: params.append(origin_airport.upper())
cursor.execute("SELECT COUNT(*) FROM flights WHERE scan_id = ?", (scan_id,)) where = " AND ".join(conditions)
cursor.execute(f"SELECT COUNT(*) FROM flights WHERE {where}", params)
total = cursor.fetchone()[0] total = cursor.fetchone()[0]
total_pages = math.ceil(total / limit) if total > 0 else 0 total_pages = math.ceil(total / limit) if total > 0 else 0
offset = (page - 1) * limit offset = (page - 1) * limit
if destination: cursor.execute(f"""
cursor.execute(""" SELECT id, scan_id, origin_airport, destination, date, airline,
SELECT id, scan_id, destination, date, airline, departure_time, arrival_time, price, stops
departure_time, arrival_time, price, stops FROM flights
FROM flights WHERE {where}
WHERE scan_id = ? AND destination = ? ORDER BY date ASC, price ASC
ORDER BY price ASC, date ASC LIMIT ? OFFSET ?
LIMIT ? OFFSET ? """, params + [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))
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
flights = [ flights = [
Flight( Flight(
id=row[0], scan_id=row[1], destination=row[2], date=row[3], id=row[0], scan_id=row[1], origin_airport=row[2],
airline=row[4], departure_time=row[5], arrival_time=row[6], destination=row[3], date=row[4], airline=row[5],
price=row[7], stops=row[8] departure_time=row[6], arrival_time=row[7],
price=row[8], stops=row[9]
) )
for row in rows for row in rows
] ]
@@ -1799,7 +1990,8 @@ async def get_flights_stub(route_id: str):
class CreateScheduleRequest(BaseModel): class CreateScheduleRequest(BaseModel):
"""Request body for creating or updating a scheduled scan.""" """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") 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") window_months: int = Field(1, ge=1, le=12, description="Months of data per scan run")
seat_class: str = Field('economy', description="Seat class") seat_class: str = Field('economy', description="Seat class")
@@ -1861,6 +2053,7 @@ class UpdateScheduleRequest(BaseModel):
class Schedule(BaseModel): class Schedule(BaseModel):
"""A recurring scheduled scan.""" """A recurring scheduled scan."""
id: int id: int
scan_mode: str
origin: str origin: str
country: str country: str
window_months: int 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.""" """Convert a DB row (sqlite3.Row or tuple) to a Schedule model."""
return Schedule( return Schedule(
id=row['id'], id=row['id'],
scan_mode=row['scan_mode'] if 'scan_mode' in row.keys() else 'forward',
origin=row['origin'], origin=row['origin'],
country=row['country'], country=row['country'],
window_months=row['window_months'], window_months=row['window_months'],
@@ -1960,12 +2154,12 @@ async def create_schedule(request: CreateScheduleRequest):
conn = get_connection() conn = get_connection()
conn.execute(""" conn.execute("""
INSERT INTO scheduled_scans ( 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, label, frequency, hour, minute, day_of_week, day_of_month,
enabled, next_run_at 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.seat_class, request.adults, request.label,
request.frequency, request.hour, request.minute, request.frequency, request.hour, request.minute,
request.day_of_week, request.day_of_month, next_run_str, 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(""" conn.execute("""
INSERT INTO scans ( INSERT INTO scans (
origin, country, start_date, end_date, origin, country, scan_mode, start_date, end_date,
status, seat_class, adults, scheduled_scan_id 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, row['seat_class'], row['adults'], schedule_id,
)) ))
conn.commit() 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 Collapses any pre-existing duplicate (scan_id, destination) rows first
(keeps the row with the lowest id) before creating the index. (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( cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name='uq_routes_scan_dest'" "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)") cursor = conn.execute("PRAGMA table_info(scans)")
columns = [row[1] for row in cursor.fetchall()] 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: if 'scheduled_scan_id' in columns:
return # Already migrated 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") 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): def initialize_database(db_path=None, verbose=True):
""" """
Initialize or migrate the database. Initialize or migrate the database.
@@ -232,10 +539,16 @@ def initialize_database(db_path=None, verbose=True):
else: else:
print(" No existing tables found") 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 # Apply migrations before running schema
_migrate_relax_country_constraint(conn, verbose) _migrate_relax_country_constraint(conn, verbose)
_migrate_add_routes_unique_index(conn, verbose) _migrate_add_routes_unique_index(conn, verbose)
_migrate_add_scheduled_scan_id_to_scans(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 # Load and execute schema
schema_sql = load_schema() schema_sql = load_schema()

View File

@@ -20,18 +20,23 @@ CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Search parameters (validated by CHECK constraints) -- 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), 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 start_date TEXT NOT NULL, -- ISO 8601: YYYY-MM-DD
end_date TEXT NOT NULL, end_date TEXT NOT NULL,
-- Timestamps (auto-managed) -- Timestamps (auto-managed)
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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) -- Scan status (enforced enum via CHECK)
status TEXT NOT NULL DEFAULT 'pending' status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'running', 'completed', 'failed')), CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled', 'paused')),
-- Progress tracking -- Progress tracking
total_routes INTEGER NOT NULL DEFAULT 0 CHECK(total_routes >= 0), 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) -- Foreign key to scans (cascade delete)
scan_id INTEGER NOT NULL, 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 TEXT NOT NULL CHECK(length(destination) = 3),
destination_name TEXT NOT NULL, destination_name TEXT NOT NULL,
destination_city TEXT, destination_city TEXT,
@@ -118,9 +126,9 @@ CREATE INDEX IF NOT EXISTS idx_routes_min_price
ON routes(min_price) ON routes(min_price)
WHERE min_price IS NOT NULL; -- Partial index for routes with prices WHERE min_price IS NOT NULL; -- Partial index for routes with prices
-- One route row per (scan, destination) — enables incremental upsert writes -- One route row per (scan, origin_airport, destination) — supports both forward and reverse scans
CREATE UNIQUE INDEX IF NOT EXISTS uq_routes_scan_dest CREATE UNIQUE INDEX IF NOT EXISTS uq_routes_scan_origin_dest
ON routes(scan_id, destination); ON routes(scan_id, COALESCE(origin_airport, ''), destination);
-- ============================================================================ -- ============================================================================
-- Triggers: Auto-update timestamps and aggregates -- Triggers: Auto-update timestamps and aggregates
@@ -189,6 +197,8 @@ CREATE TABLE IF NOT EXISTS flights (
scan_id INTEGER NOT NULL, scan_id INTEGER NOT NULL,
-- Route -- Route
-- origin_airport: NULL for forward scans, specific IATA for reverse scans
origin_airport TEXT,
destination TEXT NOT NULL CHECK(length(destination) = 3), destination TEXT NOT NULL CHECK(length(destination) = 3),
date TEXT NOT NULL, -- ISO 8601: YYYY-MM-DD 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, id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Scan parameters (same as scans table) -- 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), 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 window_months INTEGER NOT NULL DEFAULT 1
CHECK(window_months >= 1 AND window_months <= 12), CHECK(window_months >= 1 AND window_months <= 12),
seat_class TEXT NOT NULL DEFAULT 'economy', seat_class TEXT NOT NULL DEFAULT 'economy',

View File

@@ -9,6 +9,8 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- DATABASE_PATH=/app/data/cache.db - DATABASE_PATH=/app/data/cache.db
- ALLOWED_ORIGINS=https://flights.domverse-berlin.eu
- LOG_LEVEL=INFO
volumes: volumes:
- flight-radar-data:/app/data - flight-radar-data:/app/data
networks: networks:
@@ -28,6 +30,8 @@ services:
- default # shares default compose network with backend (nginx → http://backend:8000) - default # shares default compose network with backend (nginx → http://backend:8000)
- domverse # Traefik discovers the container on this network - domverse # Traefik discovers the container on this network
labels: labels:
# Traefik routing
- "traefik.docker.network=domverse"
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.flight-radar.rule=Host(`flights.domverse-berlin.eu`)" - "traefik.http.routers.flight-radar.rule=Host(`flights.domverse-berlin.eu`)"
- "traefik.http.routers.flight-radar.entrypoints=https" - "traefik.http.routers.flight-radar.entrypoints=https"
@@ -35,6 +39,21 @@ services:
- "traefik.http.routers.flight-radar.middlewares=authentik@docker" - "traefik.http.routers.flight-radar.middlewares=authentik@docker"
- "traefik.http.services.flight-radar.loadbalancer.server.port=80" - "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: volumes:
flight-radar-data: flight-radar-data:
driver: local driver: local

View File

@@ -10,11 +10,12 @@ const api = axios.create({
// Types // Types
export interface Scan { export interface Scan {
id: number; id: number;
scan_mode: 'forward' | 'reverse';
origin: string; origin: string;
country: string; country: string;
start_date: string; start_date: string;
end_date: string; end_date: string;
status: 'pending' | 'running' | 'completed' | 'failed'; status: 'pending' | 'running' | 'completed' | 'failed' | 'paused' | 'cancelled';
created_at: string; created_at: string;
updated_at: string; updated_at: string;
total_routes: number; total_routes: number;
@@ -24,10 +25,13 @@ export interface Scan {
seat_class: string; seat_class: string;
adults: number; adults: number;
scheduled_scan_id?: 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 { export interface Schedule {
id: number; id: number;
scan_mode: 'forward' | 'reverse';
origin: string; origin: string;
country: string; country: string;
window_months: number; window_months: number;
@@ -47,6 +51,7 @@ export interface Schedule {
} }
export interface CreateScheduleRequest { export interface CreateScheduleRequest {
scan_mode?: 'forward' | 'reverse';
origin: string; origin: string;
country: string; country: string;
window_months?: number; window_months?: number;
@@ -63,6 +68,7 @@ export interface CreateScheduleRequest {
export interface Route { export interface Route {
id: number; id: number;
scan_id: number; scan_id: number;
origin_airport?: string;
destination: string; destination: string;
destination_name: string; destination_name: string;
destination_city?: string; destination_city?: string;
@@ -77,6 +83,7 @@ export interface Route {
export interface Flight { export interface Flight {
id: number; id: number;
scan_id: number; scan_id: number;
origin_airport?: string;
destination: string; destination: string;
date: string; date: string;
airline?: string; airline?: string;
@@ -114,7 +121,14 @@ export interface PaginatedResponse<T> {
}; };
} }
export interface Country {
code: string;
name: string;
airport_count: number;
}
export interface CreateScanRequest { export interface CreateScanRequest {
scan_mode?: 'forward' | 'reverse';
origin: string; origin: string;
country?: string; // Optional: provide either country or destinations country?: string; // Optional: provide either country or destinations
destinations?: 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 }; const params: Record<string, unknown> = { page, limit };
if (destination) params.destination = destination; if (destination) params.destination = destination;
if (originAirport) params.origin_airport = originAirport;
return api.get<PaginatedResponse<Flight>>(`/scans/${id}/flights`, { params }); return api.get<PaginatedResponse<Flight>>(`/scans/${id}/flights`, { params });
}, },
delete: (id: number) => api.delete(`/scans/${id}`), 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 = { export const airportApi = {
@@ -170,6 +189,10 @@ export const airportApi = {
}, },
}; };
export const countriesApi = {
list: () => api.get<Country[]>('/countries'),
};
export const scheduleApi = { export const scheduleApi = {
list: (page = 1, limit = 20) => list: (page = 1, limit = 20) =>
api.get<PaginatedResponse<Schedule>>('/schedules', { params: { page, limit } }), 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 type { LucideIcon } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
export type ScanStatus = 'completed' | 'running' | 'pending' | 'failed'; export type ScanStatus = 'completed' | 'running' | 'pending' | 'failed' | 'paused' | 'cancelled';
interface StatusConfig { interface StatusConfig {
icon: LucideIcon; icon: LucideIcon;
@@ -38,6 +38,18 @@ const CONFIGS: Record<ScanStatus, StatusConfig> = {
chipClass: 'bg-[#FDECEA] text-[#A50E0E] border border-[#F5C6C6]', chipClass: 'bg-[#FDECEA] text-[#A50E0E] border border-[#F5C6C6]',
iconClass: 'text-[#A50E0E]', 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 { 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, Users,
Armchair, Armchair,
Clock, Clock,
Timer,
ChevronRight, ChevronRight,
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
ChevronsUpDown,
MapPin, MapPin,
AlertCircle, AlertCircle,
Loader2, Loader2,
RotateCcw, RotateCcw,
Trash2, Trash2,
Info, Info,
Pause,
Play,
X,
} from 'lucide-react'; } from 'lucide-react';
import { scanApi } from '../api'; import { scanApi } from '../api';
import type { Scan, Route, Flight } 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 StatCard from '../components/StatCard';
import EmptyState from '../components/EmptyState'; import EmptyState from '../components/EmptyState';
import { SkeletonStatCard, SkeletonTableRow } from '../components/SkeletonCard'; import { SkeletonStatCard, SkeletonTableRow } from '../components/SkeletonCard';
import ScanTimer, { formatDuration } from '../components/ScanTimer';
import { useScanTimer } from '../hooks/useScanTimer';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
const formatPrice = (price?: number) => const formatPrice = (price?: number) =>
@@ -46,12 +53,21 @@ export default function ScanDetails() {
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [sortField, setSortField] = useState<'min_price' | 'destination' | 'flight_count'>('min_price'); const [sortField, setSortField] = useState<'min_price' | 'destination' | 'flight_count'>('min_price');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [expandedRoute, setExpandedRoute] = useState<string | null>(null); const [expandedRoute, setExpandedRoute] = useState<string | null>(null);
const [flightsByDest, setFlightsByDest] = useState<Record<string, Flight[]>>({}); 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 [loadingFlights, setLoadingFlights] = useState<string | null>(null);
const [rerunning, setRerunning] = useState(false); const [rerunning, setRerunning] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = 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(() => { useEffect(() => {
if (id) loadScanDetails(); if (id) loadScanDetails();
@@ -102,16 +118,39 @@ export default function ScanDetails() {
} }
}; };
const toggleFlights = async (destination: string) => { const handleFlightSort = (field: 'date' | 'price') => {
if (expandedRoute === destination) { setExpandedRoute(null); return; } if (flightSortField === field) {
setExpandedRoute(destination); setFlightSortDir(d => d === 'asc' ? 'desc' : 'asc');
if (flightsByDest[destination]) return; } else {
setLoadingFlights(destination); 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 { try {
const resp = await scanApi.getFlights(Number(id), destination, 1, 200); const resp = await scanApi.getFlights(Number(id), route.destination, route.origin_airport, 1, 200);
setFlightsByDest(prev => ({ ...prev, [destination]: resp.data.data })); setFlightsByDest(prev => ({ ...prev, [key]: resp.data.data }));
} catch { } catch {
setFlightsByDest(prev => ({ ...prev, [destination]: [] })); setFlightsByDest(prev => ({ ...prev, [key]: [] }));
} finally { } finally {
setLoadingFlights(null); setLoadingFlights(null);
} }
@@ -121,21 +160,30 @@ export default function ScanDetails() {
if (!scan) return; if (!scan) return;
setRerunning(true); setRerunning(true);
try { 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 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))); const window_months = Math.max(1, Math.round(ms / (1000 * 60 * 60 * 24 * 30)));
// country column holds either "IT" or "BRI,BDS" const base = {
const isAirports = scan.country.includes(','); scan_mode: (scan.scan_mode ?? 'forward') as 'forward' | 'reverse',
const resp = await scanApi.create({
origin: scan.origin, origin: scan.origin,
window_months, window_months,
seat_class: scan.seat_class as 'economy' | 'premium' | 'business' | 'first', seat_class: scan.seat_class as 'economy' | 'premium' | 'business' | 'first',
adults: scan.adults, 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(',') } ? { destinations: scan.country.split(',') }
: { country: scan.country }), : { country: scan.country };
}); }
const resp = await scanApi.create({ ...base, ...extra });
navigate(`/scans/${resp.data.id}`); navigate(`/scans/${resp.data.id}`);
} catch { } catch {
// silently fall through — the navigate won't happen // 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 }) => { 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' return sortDirection === 'asc'
? <ChevronUp size={14} className="text-primary" /> ? <ChevronUp size={14} className="text-primary" />
: <ChevronDown 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( const thCls = (field?: typeof sortField) => cn(
'px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider select-none', 'px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider select-none',
field field
@@ -220,7 +316,9 @@ export default function ScanDetails() {
<div className="flex items-center gap-2 flex-wrap min-w-0"> <div className="flex items-center gap-2 flex-wrap min-w-0">
<PlaneTakeoff size={20} className="text-primary shrink-0" aria-hidden="true" /> <PlaneTakeoff size={20} className="text-primary shrink-0" aria-hidden="true" />
<h1 className="text-xl font-semibold text-on-surface"> <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> </h1>
{scan.scheduled_scan_id != null && ( {scan.scheduled_scan_id != null && (
<Link <Link
@@ -261,51 +359,168 @@ export default function ScanDetails() {
)} )}
{/* Row 4: actions */} {/* Row 4: actions */}
<div className="mt-4 pt-4 border-t border-outline flex items-center justify-end gap-2"> <div className="mt-4 pt-4 border-t border-outline flex items-center justify-end gap-2 flex-wrap">
{/* 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>
{/* Delete — inline confirm */} {/* ── Active (pending / running): Pause + Cancel ── */}
{confirmDelete ? ( {isActive && (
<div className="inline-flex items-center gap-1.5"> <>
<span className="text-sm text-on-surface-variant">Delete this scan?</span> {/* 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 <button
onClick={handleDelete} onClick={handleResume}
disabled={deleting} disabled={resuming}
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" 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>
<button <button
onClick={() => setConfirmDelete(false)} onClick={handleRerun}
disabled={deleting} disabled={rerunning}
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors" 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> </button>
</div>
) : ( {confirmDelete ? (
<button <div className="inline-flex items-center gap-1.5">
onClick={() => setConfirmDelete(true)} <span className="text-sm text-on-surface-variant">Delete this scan?</span>
disabled={isActive} <button
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" onClick={handleDelete}
> disabled={deleting}
<Trash2 size={14} /> 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"
Delete >
</button> {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>
</div> </div>
{/* ── Stat cards ────────────────────────────────────────────── */} {/* ── 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 ? ( {loading ? (
[0, 1, 2].map(i => <SkeletonStatCard key={i} />) [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="Total Routes" value={scan.total_routes} icon={MapPin} variant="primary" />
<StatCard label="Routes Scanned" value={scan.routes_scanned} icon={ChevronDown} variant="secondary" /> <StatCard label="Routes Scanned" value={scan.routes_scanned} icon={ChevronDown} variant="secondary" />
<StatCard label="Flights Found" value={scan.total_flights} icon={PlaneTakeoff} variant="primary" /> <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> </div>
@@ -340,6 +563,9 @@ export default function ScanDetails() {
<p className="mt-2 text-xs text-on-surface-variant"> <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 {scan.routes_scanned} of {scan.total_routes > 0 ? scan.total_routes : '?'} routes · auto-refreshing every 3 s
</p> </p>
{scan.status === 'running' && scan.started_at && (
<ScanTimer {...timer} />
)}
</div> </div>
)} )}
@@ -376,6 +602,9 @@ export default function ScanDetails() {
<table className="w-full"> <table className="w-full">
<thead className="bg-surface-2 border-b border-outline"> <thead className="bg-surface-2 border-b border-outline">
<tr> <tr>
{scan.scan_mode === 'reverse' && (
<th className={thCls()}>Origin</th>
)}
<th <th
className={thCls('destination')} className={thCls('destination')}
onClick={() => handleSort('destination')} onClick={() => handleSort('destination')}
@@ -407,13 +636,23 @@ export default function ScanDetails() {
</thead> </thead>
<tbody className="divide-y divide-outline"> <tbody className="divide-y divide-outline">
{routes.map((route) => { {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 ( return (
<Fragment key={route.id}> <Fragment key={route.id}>
<tr <tr
className="hover:bg-surface-2 cursor-pointer transition-colors duration-150" 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 */} {/* Destination */}
<td className="px-4 py-4"> <td className="px-4 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -470,13 +709,13 @@ export default function ScanDetails() {
{/* Expanded flights sub-row */} {/* Expanded flights sub-row */}
<tr key={`${route.id}-flights`}> <tr key={`${route.id}-flights`}>
<td colSpan={6} className="p-0"> <td colSpan={colSpan} className="p-0">
<div <div
className="overflow-hidden transition-all duration-250 ease-in-out" className="overflow-hidden transition-all duration-250 ease-in-out"
style={{ maxHeight: isExpanded ? '600px' : '0' }} style={{ maxHeight: isExpanded ? '600px' : '0' }}
> >
<div className="bg-[#F8FDF9]"> <div className="bg-[#F8FDF9]">
{loadingFlights === route.destination ? ( {loadingFlights === key ? (
<table className="w-full"> <table className="w-full">
<tbody> <tbody>
<SkeletonTableRow /> <SkeletonTableRow />
@@ -488,15 +727,29 @@ export default function ScanDetails() {
<table className="w-full"> <table className="w-full">
<thead className="bg-[#EEF7F0]"> <thead className="bg-[#EEF7F0]">
<tr> <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">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">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-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> </tr>
</thead> </thead>
<tbody className="divide-y divide-[#D4EDDA]"> <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"> <tr key={f.id} className="hover:bg-[#EEF7F0] transition-colors">
<td className="pl-12 pr-4 py-2.5 text-sm text-on-surface"> <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> <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> </td>
</tr> </tr>
))} ))}
{(flightsByDest[route.destination] || []).length === 0 && ( {(flightsByDest[key] || []).length === 0 && (
<tr> <tr>
<td colSpan={5} className="pl-12 py-4 text-sm text-on-surface-variant"> <td colSpan={5} className="pl-12 py-4 text-sm text-on-surface-variant">
No flight details available No flight details available

View File

@@ -1,11 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; 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 { scanApi } from '../api';
import type { CreateScanRequest } from '../api'; import type { CreateScanRequest } from '../api';
import AirportSearch from '../components/AirportSearch'; import AirportSearch from '../components/AirportSearch';
import SegmentedButton from '../components/SegmentedButton'; import SegmentedButton from '../components/SegmentedButton';
import AirportChip from '../components/AirportChip'; import AirportChip from '../components/AirportChip';
import CountrySelect from '../components/CountrySelect';
import Button from '../components/Button'; import Button from '../components/Button';
import Toast from '../components/Toast'; import Toast from '../components/Toast';
@@ -15,10 +16,17 @@ interface FormErrors {
airports?: string; airports?: string;
window_months?: string; window_months?: string;
adults?: string; adults?: string;
start_date?: string;
end_date?: string;
} }
export default function Scans() { export default function Scans() {
const navigate = useNavigate(); 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 [destinationMode, setDestinationMode] = useState<'country' | 'airports'>('country');
const [formData, setFormData] = useState<CreateScanRequest>({ const [formData, setFormData] = useState<CreateScanRequest>({
origin: '', origin: '',
@@ -27,22 +35,62 @@ export default function Scans() {
seat_class: 'economy', seat_class: 'economy',
adults: 1, 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 [selectedAirports, setSelectedAirports] = useState<string[]>([]);
const [selectedOriginCountry, setSelectedOriginCountry] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({}); const [errors, setErrors] = useState<FormErrors>({});
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const validate = (): boolean => { const validate = (): boolean => {
const next: FormErrors = {}; 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 (windowMode === 'range') {
} const today = new Date();
if (destinationMode === 'airports' && selectedAirports.length === 0) { today.setHours(0, 0, 0, 0);
next.airports = 'Add at least one destination airport'; 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); setErrors(next);
return Object.keys(next).length === 0; return Object.keys(next).length === 0;
}; };
@@ -53,17 +101,32 @@ export default function Scans() {
setLoading(true); setLoading(true);
try { try {
const requestData: any = { let requestData: CreateScanRequest;
origin: formData.origin,
window_months: formData.window_months,
seat_class: formData.seat_class,
adults: formData.adults,
};
if (destinationMode === 'country') { const windowParams = windowMode === 'range'
requestData.country = formData.country; ? { 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 { } 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); const response = await scanApi.create(requestData);
@@ -86,42 +149,97 @@ export default function Scans() {
})); }));
}; };
// Shared input class
const inputCls = (hasError?: boolean) => const inputCls = (hasError?: boolean) =>
`w-full h-12 px-3 border rounded-xs bg-surface text-on-surface text-sm outline-none transition-colors ` + `w-full h-12 px-3 border rounded-xs bg-surface text-on-surface text-sm outline-none transition-colors ` +
(hasError (hasError
? 'border-error focus:border-error focus:ring-2 focus:ring-error/20' ? 'border-error focus:border-error focus:ring-2 focus:ring-error/20'
: 'border-outline focus:border-primary focus:ring-2 focus:ring-primary/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 ( return (
<> <>
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl"> <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 ─────────────────────────────────────── */} {/* ── Section: Origin ─────────────────────────────────────── */}
<div className="bg-surface rounded-lg shadow-level-1 p-6"> <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"> <p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">
Origin Origin
</p> </p>
<div> {scanMode === 'reverse' ? (
<label className="block text-sm font-medium text-on-surface-variant mb-1.5"> <div>
Origin Airport <label className="block text-sm font-medium text-on-surface-variant mb-1.5">
</label> Origin Country
<AirportSearch </label>
value={formData.origin} <CountrySelect
onChange={(value) => { value={selectedOriginCountry}
setFormData(prev => ({ ...prev, origin: value })); onChange={(code) => {
if (errors.origin) setErrors(prev => ({ ...prev, origin: undefined })); setSelectedOriginCountry(code);
}} if (errors.country) setErrors(prev => ({ ...prev, country: undefined }));
placeholder="e.g. BDS, MUC, FRA" }}
hasError={!!errors.origin} placeholder="Select origin country…"
/> hasError={!!errors.country}
{errors.origin ? ( />
<p className="mt-1 text-xs text-error">{errors.origin}</p> {errors.country ? (
) : ( <p className="mt-1 text-xs text-error">{errors.country}</p>
<p className="mt-1 text-xs text-on-surface-variant">3-letter IATA code (e.g. BDS for Brindisi)</p> ) : (
)} <p className="mt-1 text-xs text-on-surface-variant">All airports in this country will be checked</p>
</div> )}
</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> </div>
{/* ── Section: Destination ────────────────────────────────── */} {/* ── Section: Destination ────────────────────────────────── */}
@@ -130,42 +248,8 @@ export default function Scans() {
Destination Destination
</p> </p>
<SegmentedButton {scanMode === 'reverse' ? (
options={[ /* Reverse: specific destination airports */
{ 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>
) : (
<div> <div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5"> <label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Destination Airports Destination Airports
@@ -203,6 +287,82 @@ export default function Scans() {
</p> </p>
)} )}
</div> </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> </div>
@@ -212,54 +372,141 @@ export default function Scans() {
Parameters Parameters
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> {/* Search Window toggle */}
{/* Search Window */} <div className="mb-4">
<div> <label className="block text-sm font-medium text-on-surface-variant mb-1.5">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5"> Search Window
Search Window </label>
</label> <SegmentedButton
<div className="flex items-center gap-2"> options={[
<button { value: 'window', label: 'Rolling Window', icon: CalendarDays },
type="button" { value: 'range', label: 'Date Range', icon: CalendarRange },
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" value={windowMode}
aria-label="Decrease months" onChange={(v) => {
> setWindowMode(v as 'window' | 'range');
<Minus size={14} /> setStartDate('');
</button> setEndDate('');
<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"> setErrors(prev => ({ ...prev, start_date: undefined, end_date: undefined }));
{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> </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 */} {/* Adults — full width below */}
<div className="mt-4"> <div className="mt-4">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5"> <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__) 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, def _write_route_incremental(scan_id: int, destination: str,
dest_name: str, dest_city: 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. 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 query returns results. Merges into the existing route row if one already
exists, using a running weighted average for avg_price. 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. 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')] 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() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" # Fetch existing route row (key: scan_id + origin_airport + destination)
SELECT id, flight_count, min_price, max_price, avg_price, airlines if origin_airport is None:
FROM routes cursor.execute("""
WHERE scan_id = ? AND destination = ? SELECT id, flight_count, min_price, max_price, avg_price, airlines
""", (scan_id, destination)) 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() existing = cursor.fetchone()
if existing is None: if existing is None:
cursor.execute(""" cursor.execute("""
INSERT INTO routes ( 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 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_count, json.dumps(new_airlines),
new_min, new_max, new_avg, 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_avg = (old_avg * old_count + new_avg * new_count) / merged_count
merged_airlines = json.dumps(list(set(old_airlines) | set(new_airlines))) merged_airlines = json.dumps(list(set(old_airlines) | set(new_airlines)))
cursor.execute(""" if origin_airport is None:
UPDATE routes cursor.execute("""
SET flight_count = ?, UPDATE routes
min_price = ?, SET flight_count = ?,
max_price = ?, min_price = ?,
avg_price = ?, max_price = ?,
airlines = ? avg_price = ?,
WHERE scan_id = ? AND destination = ? airlines = ?
""", ( WHERE scan_id = ? AND origin_airport IS NULL AND destination = ?
merged_count, merged_min, merged_max, merged_avg, merged_airlines, """, (
scan_id, 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: for flight in new_flights:
if not flight.get('price'): if not flight.get('price'):
continue continue
cursor.execute(""" cursor.execute("""
INSERT INTO flights ( INSERT INTO flights (
scan_id, destination, date, airline, scan_id, origin_airport, destination, date, airline,
departure_time, arrival_time, price, stops departure_time, arrival_time, price, stops
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
scan_id, scan_id,
origin_airport,
destination, destination,
flight.get('date', ''), flight.get('date', ''),
flight.get('airline'), flight.get('airline'),
@@ -142,7 +196,7 @@ async def process_scan(scan_id: int):
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" 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 FROM scans
WHERE id = ? WHERE id = ?
""", (scan_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") logger.error(f"[Scan {scan_id}] Scan not found in database")
return 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(""" cursor.execute("""
UPDATE scans UPDATE scans
SET status = 'running', updated_at = CURRENT_TIMESTAMP SET status = 'running', started_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
""", (scan_id,)) """, (scan_id,))
conn.commit() conn.commit()
# Determine mode: country (2 letters) or specific airports (comma-separated) # Resolve airports based on scan_mode
try: try:
if len(country_or_airports) == 2 and country_or_airports.isalpha(): if scan_mode == 'reverse':
# Country mode: resolve airports from country code # Reverse scan: origin = ISO country, country_or_airports = comma-separated dest IATAs
logger.info(f"[Scan {scan_id}] Mode: Country search ({country_or_airports})") logger.info(f"[Scan {scan_id}] Mode: Reverse scan ({origin} country → {country_or_airports})")
destinations = get_airports_for_country(country_or_airports) origin_airports = get_airports_for_country(origin)
if not destinations: if not origin_airports:
raise ValueError(f"No airports found for country: {country_or_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] destination_codes = [code.strip() for code in country_or_airports.split(',')]
dest_infos = {
logger.info(f"[Scan {scan_id}] Found {len(destination_codes)} destination airports: {destination_codes}") 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: else:
# Specific airports mode: parse comma-separated list # Forward scan: origin = fixed IATA, country_or_airports = country code or dest IATAs
destination_codes = [code.strip() for code in country_or_airports.split(',')] if len(country_or_airports) == 2 and country_or_airports.isalpha():
destinations = [ logger.info(f"[Scan {scan_id}] Mode: Forward country search ({country_or_airports})")
lookup_airport(code) or {'iata': code, 'name': code, 'city': ''} dest_list = get_airports_for_country(country_or_airports)
for code in destination_codes if not dest_list:
] raise ValueError(f"No airports found for country: {country_or_airports}")
logger.info(f"[Scan {scan_id}] Mode: Specific airports ({len(destination_codes)} destinations: {destination_codes})") 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: except Exception as e:
logger.error(f"[Scan {scan_id}] Failed to resolve airports: {str(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 UPDATE scans
SET status = 'failed', SET status = 'failed',
error_message = ?, error_message = ?,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
""", (f"Failed to resolve airports: {str(e)}", scan_id)) """, (f"Failed to resolve airports: {str(e)}", scan_id))
conn.commit() conn.commit()
return 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 # Generate dates to scan — every day in the window
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_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}") 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 = [] routes_to_scan = []
for dest in destination_codes: if scan_mode == 'reverse':
for scan_date in dates: for orig_iata in origin_iatas:
routes_to_scan.append((origin, dest, scan_date)) 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)}") 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) # Signature: callback(origin, destination, date, status, count, error=None, flights=None)
routes_scanned_count = 0 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, status: str, count: int, error: str = None,
flights: list = None): flights: list = None):
nonlocal routes_scanned_count nonlocal routes_scanned_count
@@ -245,10 +317,15 @@ async def process_scan(scan_id: int):
if flights and status in ('cache_hit', 'api_success'): if flights and status in ('cache_hit', 'api_success'):
for f in flights: for f in flights:
f['date'] = date f['date'] = date
dest_info = next((d for d in destinations if d['iata'] == destination), None) dest_info = dest_infos.get(destination) or {'iata': destination, 'name': destination, 'city': ''}
dest_name = dest_info.get('name', destination) if dest_info else destination dest_name = dest_info.get('name', destination)
dest_city = dest_info.get('city', '') if dest_info else '' dest_city = dest_info.get('city', '')
_write_route_incremental(scan_id, destination, dest_name, dest_city, flights) # 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 # Update progress counter
try: try:
@@ -266,7 +343,7 @@ async def process_scan(scan_id: int):
progress_conn.close() progress_conn.close()
if routes_scanned_count % 10 == 0: 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: except Exception as e:
logger.error(f"[Scan {scan_id}] Failed to update progress: {str(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,) "SELECT COALESCE(SUM(flight_count), 0) FROM routes WHERE scan_id = ?", (scan_id,)
).fetchone()[0] ).fetchone()[0]
# Update scan to completed # Update scan to completed and record finish time
cursor.execute(""" cursor.execute("""
UPDATE scans UPDATE scans
SET status = 'completed', SET status = 'completed',
total_flights = ?, total_flights = ?,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
""", (total_flights_saved, scan_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") 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: except Exception as e:
logger.error(f"[Scan {scan_id}] ❌ Scan failed with error: {str(e)}", exc_info=True) 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 UPDATE scans
SET status = 'failed', SET status = 'failed',
error_message = ?, error_message = ?,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
""", (str(e), scan_id)) """, (str(e), scan_id))
@@ -340,5 +437,28 @@ def start_scan_processor(scan_id: int):
asyncio.Task: The background task asyncio.Task: The background task
""" """
task = asyncio.create_task(process_scan(scan_id)) 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") logger.info(f"[Scan {scan_id}] Background task created")
return task 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]["destination"] == "FRA"
assert data["data"][0]["min_price"] == 50 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.unit
@pytest.mark.api @pytest.mark.api

View File

@@ -86,6 +86,25 @@ class TestScanWorkflow:
prices = [r["min_price"] for r in routes] prices = [r["min_price"] for r in routes]
assert prices == sorted(prices) 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.integration
@pytest.mark.database @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