Compare commits
17 Commits
7c125dbaeb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c65e4d2ee | |||
| cf40736f0e | |||
| 4f4f7e86d1 | |||
| 77d2a46264 | |||
| 7ece1f9b45 | |||
| 69c2ddae29 | |||
| 3cad8a8447 | |||
| 9a76d7af82 | |||
| d494e80ff7 | |||
| cde496ad48 | |||
| 7b07775845 | |||
| 6c1cffbdd4 | |||
| 442e300457 | |||
| 8eeb774d4e | |||
| 000391f7fc | |||
| 9b982ad9a5 | |||
| de491dbb1f |
@@ -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
321
PRD-reverse-scan.md
Normal 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, 1–10 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.
|
||||||
3
flight-comparator/.gitignore
vendored
3
flight-comparator/.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 } }),
|
||||||
|
|||||||
51
flight-comparator/frontend/src/components/CountrySelect.tsx
Normal file
51
flight-comparator/frontend/src/components/CountrySelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
flight-comparator/frontend/src/components/ScanTimer.tsx
Normal file
51
flight-comparator/frontend/src/components/ScanTimer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
88
flight-comparator/frontend/src/hooks/useScanTimer.ts
Normal file
88
flight-comparator/frontend/src/hooks/useScanTimer.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (1–12)</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 (1–12)</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">
|
||||||
|
|||||||
1
flight-comparator/frontend/src/vite-env.d.ts
vendored
Normal file
1
flight-comparator/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
flight-comparator/frontend/tsconfig.app.json
Normal file
22
flight-comparator/frontend/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
7
flight-comparator/frontend/tsconfig.json
Normal file
7
flight-comparator/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
20
flight-comparator/frontend/tsconfig.node.json
Normal file
20
flight-comparator/frontend/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
370
flight-comparator/tests/test_scan_control.py
Normal file
370
flight-comparator/tests/test_scan_control.py
Normal 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
|
||||||
127
flight-comparator/tests/test_scan_processor_control.py
Normal file
127
flight-comparator/tests/test_scan_processor_control.py
Normal 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
|
||||||
Reference in New Issue
Block a user