Compare commits
18 Commits
7b2de645db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a49dfe219 | |||
| 8d8cb9ccad | |||
| 4b6dd9c93c | |||
| 74c21fe988 | |||
| c2e4d2adeb | |||
| 870bc0649b | |||
| 5e12561fb2 | |||
| f33c12f541 | |||
| 05e7859447 | |||
| a3f1a10bf5 | |||
| 5e33c1d47b | |||
| 10ab215396 | |||
| a9b5ec67ba | |||
| 733e9b426f | |||
| 044ce40e69 | |||
| fc394b8555 | |||
| 78d738b0ee | |||
| 1952b89ecf |
+2
-1
@@ -20,6 +20,7 @@ MAIL_USERNAME=your_mail_username_here
|
||||
MAIL_PASSWORD=your_mail_password_here
|
||||
MAIL_FROM=your_mail_from_address_here
|
||||
MAIL_FROM_NAME=your_mail_from_name_here
|
||||
DRONE_REQUEST_TOWER_EMAIL=tower@example.com
|
||||
|
||||
# Application settings
|
||||
BASE_URL=your_base_url_here
|
||||
@@ -38,4 +39,4 @@ WEB_PORT_EXTERNAL=8082
|
||||
# phpMyAdmin Configuration
|
||||
PMA_HOST=db
|
||||
UPLOAD_LIMIT=50M
|
||||
PMA_PORT_EXTERNAL=8083
|
||||
PMA_PORT_EXTERNAL=8083
|
||||
|
||||
+5
-1
@@ -84,4 +84,8 @@ htmlcov/
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# Playwright artifacts
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -6,7 +6,6 @@ A modern, containerized Prior Permission Required (PPR) system for aircraft oper
|
||||
|
||||
- **Backend**: FastAPI with Python 3.11
|
||||
- **Database**: MySQL 8.0
|
||||
- **Cache**: Redis 7
|
||||
- **Container**: Docker & Docker Compose
|
||||
|
||||
## Features
|
||||
@@ -63,7 +62,7 @@ The container automatically handles:
|
||||
- Database connection verification
|
||||
- Schema creation/migration (Alembic)
|
||||
- Reference data seeding (if needed)
|
||||
- Production server startup (4 workers)
|
||||
- Production server startup (single worker for in-process WebSocket broadcasts)
|
||||
|
||||
**Monitor deployment:**
|
||||
```bash
|
||||
@@ -181,11 +180,48 @@ docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen
|
||||
```
|
||||
|
||||
### Testing
|
||||
The backend API tests use pytest, FastAPI's `TestClient`, and an isolated in-memory SQLite database with dependency overrides for database sessions and authenticated users.
|
||||
|
||||
See [`backend/tests/README.md`](./backend/tests/README.md) for a module-by-module explanation of what the tests cover and why.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest tests/
|
||||
pytest
|
||||
```
|
||||
|
||||
Or, with the Docker development stack running:
|
||||
|
||||
```bash
|
||||
docker compose exec api pytest
|
||||
```
|
||||
|
||||
To inspect API test coverage:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest --cov=app --cov-report=term-missing
|
||||
```
|
||||
|
||||
Or in Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec api pytest --cov=app --cov-report=term-missing
|
||||
```
|
||||
|
||||
### End-to-End Testing
|
||||
Browser e2e tests use pytest plus Playwright. The recommended path is the containerized runner, which joins the same Compose network as the app and opens the web service at `http://web`.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d db api web
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
|
||||
```
|
||||
|
||||
Authenticated browser tests are skipped unless `E2E_ADMIN_USERNAME` and `E2E_ADMIN_PASSWORD` are supplied. See [`tests/e2e/README.md`](./tests/e2e/README.md) for credential examples, host-run instructions, and guidance for adding specs.
|
||||
|
||||
The e2e Compose override uses a separate MySQL container and volume, so tests do not use the normal dev/prod database configured in `.env`. It is still a real MySQL database, but isolated for e2e.
|
||||
|
||||
E2e reports are written to `test-results/e2e-report.html` and `test-results/e2e-junit.xml`.
|
||||
|
||||
## Additional Features
|
||||
|
||||
### Email Notifications
|
||||
@@ -266,7 +302,6 @@ This ensures consistency across different time zones and complies with aviation
|
||||
|
||||
- Database connection pooling
|
||||
- Indexed columns for fast queries
|
||||
- Redis caching (ready for implementation)
|
||||
- Async/await for non-blocking operations
|
||||
|
||||
## Monitoring
|
||||
@@ -293,4 +328,4 @@ docker-compose down
|
||||
To remove volumes (database data):
|
||||
```bash
|
||||
docker-compose down -v
|
||||
```
|
||||
```
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
TODO
|
||||
|
||||
Allow corrections
|
||||
|
||||
Post-strip reporting
|
||||
|
||||
Implement mark's 'tick off the PPRs' in the old admin screen
|
||||
|
||||
Define schema for 'movements' table. We generate movement records as they happen so as not to reply on maths
|
||||
@@ -9,3 +13,4 @@ Flow to create an arrival and maybe departure from a PPR. Perhaps we need a corr
|
||||
Ability to add a position report to a strip
|
||||
|
||||
Improve journaling
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Add PPR paper strip acknowledgement fields
|
||||
|
||||
Revision ID: 008_ppr_strip_acknowledgement
|
||||
Revises: 007_ppr_activated_status
|
||||
Create Date: 2026-06-15 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '008_ppr_strip_acknowledgement'
|
||||
down_revision = '007_ppr_activated_status'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('submitted', sa.Column('acknowledged_dt', sa.DateTime(), nullable=True))
|
||||
op.add_column('submitted', sa.Column('acknowledged_by', sa.String(length=50), nullable=True))
|
||||
op.alter_column('local_flights', 'pob', existing_type=sa.Integer(), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("UPDATE local_flights SET pob = 1 WHERE pob IS NULL")
|
||||
op.alter_column('local_flights', 'pob', existing_type=sa.Integer(), nullable=False)
|
||||
op.drop_column('submitted', 'acknowledged_by')
|
||||
op.drop_column('submitted', 'acknowledged_dt')
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Add drone flight requests
|
||||
|
||||
Revision ID: 009_drone_requests
|
||||
Revises: 008_ppr_strip_acknowledgement
|
||||
Create Date: 2026-06-19 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '009_drone_requests'
|
||||
down_revision = '008_ppr_strip_acknowledgement'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
drone_status = sa.Enum(
|
||||
'NEW',
|
||||
'APPROVED',
|
||||
'DENIED',
|
||||
'CANCELED',
|
||||
'INFLIGHT',
|
||||
'COMPLETED',
|
||||
name='dronerequeststatus',
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'drone_requests',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('reference_number', sa.String(length=24), nullable=False),
|
||||
sa.Column('public_token', sa.String(length=128), nullable=True),
|
||||
sa.Column('status', drone_status, nullable=False),
|
||||
sa.Column('operator_name', sa.String(length=128), nullable=False),
|
||||
sa.Column('operator_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('flyer_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('flyer_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('email', sa.String(length=128), nullable=False),
|
||||
sa.Column('phone', sa.String(length=32), nullable=True),
|
||||
sa.Column('flight_date', sa.Date(), nullable=True),
|
||||
sa.Column('estimated_takeoff_time', sa.String(length=8), nullable=True),
|
||||
sa.Column('estimated_completion_time', sa.String(length=8), nullable=True),
|
||||
sa.Column('estimated_takeoff_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('estimated_completion_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('maximum_elevation_ft_amsl', sa.Integer(), nullable=False),
|
||||
sa.Column('location_description', sa.Text(), nullable=True),
|
||||
sa.Column('location_latitude', sa.Float(), nullable=False),
|
||||
sa.Column('location_longitude', sa.Float(), nullable=False),
|
||||
sa.Column('location_inside_frz', sa.Boolean(), nullable=True),
|
||||
sa.Column('prototype_overlay', sa.JSON(), nullable=True),
|
||||
sa.Column('applicant_notes', sa.Text(), nullable=True),
|
||||
sa.Column('operator_comments', sa.Text(), nullable=True),
|
||||
sa.Column('submitted_via', sa.String(length=32), nullable=False, server_default='PUBLIC'),
|
||||
sa.Column('submitted_ip', sa.String(length=45), nullable=True),
|
||||
sa.Column('created_by', sa.String(length=50), nullable=True),
|
||||
sa.Column('submitted_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('status_changed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('status_changed_by', sa.String(length=50), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('reference_number'),
|
||||
)
|
||||
op.create_index(op.f('ix_drone_requests_created_by'), 'drone_requests', ['created_by'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_email'), 'drone_requests', ['email'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_estimated_completion_at'), 'drone_requests', ['estimated_completion_at'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_estimated_takeoff_at'), 'drone_requests', ['estimated_takeoff_at'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_flight_date'), 'drone_requests', ['flight_date'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_operator_name'), 'drone_requests', ['operator_name'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_public_token'), 'drone_requests', ['public_token'], unique=True)
|
||||
op.create_index(op.f('ix_drone_requests_reference_number'), 'drone_requests', ['reference_number'], unique=True)
|
||||
op.create_index(op.f('ix_drone_requests_status'), 'drone_requests', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_drone_requests_submitted_at'), 'drone_requests', ['submitted_at'], unique=False)
|
||||
op.create_index('idx_drone_status_takeoff', 'drone_requests', ['status', 'estimated_takeoff_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_drone_status_takeoff', table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_submitted_at'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_status'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_reference_number'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_public_token'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_operator_name'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_flight_date'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_estimated_takeoff_at'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_estimated_completion_at'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_email'), table_name='drone_requests')
|
||||
op.drop_index(op.f('ix_drone_requests_created_by'), table_name='drone_requests')
|
||||
op.drop_table('drone_requests')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Rename drone request altitude to AGL
|
||||
|
||||
Revision ID: 010_drone_request_agl_altitude
|
||||
Revises: 009_drone_requests
|
||||
Create Date: 2026-06-29 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '010_drone_request_agl_altitude'
|
||||
down_revision = '009_drone_requests'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
'drone_requests',
|
||||
'maximum_elevation_ft_amsl',
|
||||
new_column_name='maximum_elevation_ft_agl',
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
'drone_requests',
|
||||
'maximum_elevation_ft_agl',
|
||||
new_column_name='maximum_elevation_ft_amsl',
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add LOCAL status to PPR records
|
||||
|
||||
Revision ID: 011_ppr_local_status
|
||||
Revises: 010_drone_request_agl_altitude
|
||||
Create Date: 2026-06-29 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = '011_ppr_local_status'
|
||||
down_revision = '010_drone_request_agl_altitude'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE submitted CHANGE COLUMN departed_dt takeoff_dt DATETIME NULL")
|
||||
op.execute("ALTER TABLE submitted ADD COLUMN qsy_dt DATETIME NULL AFTER takeoff_dt")
|
||||
op.execute(
|
||||
"ALTER TABLE submitted MODIFY COLUMN status "
|
||||
"ENUM('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') "
|
||||
"NOT NULL DEFAULT 'NEW'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("UPDATE submitted SET status = 'LANDED' WHERE status = 'LOCAL'")
|
||||
op.execute("ALTER TABLE submitted DROP COLUMN qsy_dt")
|
||||
op.execute("ALTER TABLE submitted CHANGE COLUMN takeoff_dt departed_dt DATETIME NULL")
|
||||
op.execute(
|
||||
"ALTER TABLE submitted MODIFY COLUMN status "
|
||||
"ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED','ACTIVATED') "
|
||||
"NOT NULL DEFAULT 'NEW'"
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements
|
||||
from app.api.endpoints import auth, pprs, public, aircraft, airport, local_flights, departures, arrivals, circuits, journal, overflights, public_book, movements, drone_requests, contact_requests
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -12,7 +12,9 @@ api_router.include_router(overflights.router, prefix="/overflights", tags=["over
|
||||
api_router.include_router(circuits.router, prefix="/circuits", tags=["circuits"])
|
||||
api_router.include_router(journal.router, prefix="/journal", tags=["journal"])
|
||||
api_router.include_router(movements.router, prefix="/movements", tags=["movements"])
|
||||
api_router.include_router(drone_requests.router, prefix="/drone-requests", tags=["drone_requests"])
|
||||
api_router.include_router(contact_requests.router, prefix="/contact-requests", tags=["contact_requests"])
|
||||
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||
api_router.include_router(public_book.router, prefix="/public-book", tags=["public_booking"])
|
||||
api_router.include_router(aircraft.router, prefix="/aircraft", tags=["aircraft"])
|
||||
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
||||
api_router.include_router(airport.router, prefix="/airport", tags=["airport"])
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.core.email import email_service
|
||||
from app.core.utils import get_client_ip
|
||||
from app.schemas.contact_request import ContactRequestCreate, ContactRequestReceipt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CONTACT_REQUEST_RECIPIENT = "tower@swansea-airport.wales"
|
||||
|
||||
|
||||
@router.post("/public", response_model=ContactRequestReceipt)
|
||||
async def create_public_contact_request(
|
||||
contact_request: ContactRequestCreate,
|
||||
request: Request,
|
||||
):
|
||||
submitted_at = datetime.now(timezone.utc)
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
print(
|
||||
"Public contact request received "
|
||||
f"at={submitted_at.isoformat()} "
|
||||
f"type={contact_request.enquiry_type.value} "
|
||||
f"name={contact_request.name!r} "
|
||||
f"email={contact_request.email} "
|
||||
f"source={contact_request.source_page or '-'} "
|
||||
f"ip={client_ip}"
|
||||
)
|
||||
|
||||
await email_service.send_email(
|
||||
to_email=CONTACT_REQUEST_RECIPIENT,
|
||||
subject=f"Website contact: {contact_request.subject}",
|
||||
template_name="contact_request.html",
|
||||
reply_to=f"{contact_request.name} <{contact_request.email}>",
|
||||
template_vars={
|
||||
"submitted_at": submitted_at.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"client_ip": client_ip,
|
||||
"name": contact_request.name,
|
||||
"email": contact_request.email,
|
||||
"phone": contact_request.phone,
|
||||
"enquiry_type": contact_request.enquiry_type.value,
|
||||
"subject": contact_request.subject,
|
||||
"message": contact_request.message,
|
||||
"source_page": contact_request.source_page,
|
||||
},
|
||||
)
|
||||
|
||||
return ContactRequestReceipt()
|
||||
@@ -0,0 +1,356 @@
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_operator_user, get_current_read_user, get_db
|
||||
from app.core.email import email_service
|
||||
from app.core.config import settings
|
||||
from app.core.frz import swansea_frz_geojson
|
||||
from app.core.utils import get_client_ip
|
||||
from app.crud.crud_drone_request import drone_request as crud_drone_request
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
from app.models.journal import EntityType
|
||||
from app.models.ppr import User
|
||||
from app.schemas.drone_request import (
|
||||
DroneRequest,
|
||||
DroneRequestComment,
|
||||
DroneRequestCreate,
|
||||
DroneRequestPublicSubmission,
|
||||
DroneRequestStatus,
|
||||
DroneRequestStatusUpdate,
|
||||
DroneRequestUpdate,
|
||||
)
|
||||
from app.schemas.journal import JournalEntryResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _broadcast(request: Request, event_type: str, drone_request: DroneRequest):
|
||||
if hasattr(request.app.state, "connection_manager"):
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": event_type,
|
||||
"data": {
|
||||
"id": drone_request.id,
|
||||
"reference_number": drone_request.reference_number,
|
||||
"status": drone_request.status.value,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
async def _send_drone_email(drone_request, subject: str, message: str):
|
||||
await email_service.send_email(
|
||||
to_email=drone_request.email,
|
||||
subject=subject,
|
||||
template_name="drone_request_update.html",
|
||||
template_vars={
|
||||
"name": drone_request.operator_name,
|
||||
"reference_number": drone_request.reference_number,
|
||||
"status": drone_request.status.value,
|
||||
"message": message,
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _send_drone_submitted_email(drone_request):
|
||||
await email_service.send_email(
|
||||
to_email=drone_request.email,
|
||||
subject=f"Drone flight request received {drone_request.reference_number}",
|
||||
template_name="drone_request_submitted.html",
|
||||
template_vars={
|
||||
"name": drone_request.operator_name,
|
||||
"reference_number": drone_request.reference_number,
|
||||
"status": drone_request.status.value,
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _send_drone_tower_notification(drone_request):
|
||||
tower_email = settings.drone_request_tower_email or settings.mail_from
|
||||
await email_service.send_email(
|
||||
to_email=tower_email,
|
||||
subject=f"Drone flight request awaiting review {drone_request.reference_number}",
|
||||
template_name="drone_request_tower_notification.html",
|
||||
template_vars={
|
||||
"reference_number": drone_request.reference_number,
|
||||
"operator_name": drone_request.operator_name,
|
||||
"operator_id": drone_request.operator_id,
|
||||
"flyer_name": drone_request.flyer_name,
|
||||
"flyer_id": drone_request.flyer_id,
|
||||
"email": drone_request.email,
|
||||
"phone": drone_request.phone,
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||
"inside_frz": "Yes" if drone_request.location_inside_frz else "No",
|
||||
"notes": drone_request.applicant_notes,
|
||||
"requests_url": f"{settings.base_url}/drone-requests",
|
||||
},
|
||||
reply_to=f"{drone_request.operator_name} <{drone_request.email}>",
|
||||
)
|
||||
|
||||
|
||||
async def _send_drone_approved_email(drone_request, message: Optional[str] = None):
|
||||
await email_service.send_email(
|
||||
to_email=drone_request.email,
|
||||
subject=f"Drone request {drone_request.reference_number} APPROVED",
|
||||
template_name="drone_request_approved.html",
|
||||
template_vars={
|
||||
"name": drone_request.operator_name,
|
||||
"reference_number": drone_request.reference_number,
|
||||
"status": drone_request.status.value,
|
||||
"message": message,
|
||||
"takeoff_time": drone_request.estimated_takeoff_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"completion_time": drone_request.estimated_completion_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"location": drone_request.location_description or f"{drone_request.location_latitude}, {drone_request.location_longitude}",
|
||||
"maximum_elevation_ft_agl": drone_request.maximum_elevation_ft_agl,
|
||||
"edit_url": f"{settings.base_url}/drone-request.html?token={drone_request.public_token}" if drone_request.public_token else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _public_submission_response(drone_request):
|
||||
payload = DroneRequest.model_validate(drone_request, from_attributes=True).model_dump(mode="json")
|
||||
payload["request_id"] = drone_request.reference_number
|
||||
payload["secure_link"] = f"{settings.base_url}/drone-request.html?token={drone_request.public_token}"
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/", response_model=List[DroneRequest])
|
||||
async def get_drone_requests(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[DroneRequestStatus] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user),
|
||||
):
|
||||
return crud_drone_request.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/public", response_model=DroneRequestPublicSubmission)
|
||||
async def create_public_drone_request(
|
||||
request: Request,
|
||||
drone_request_in: DroneRequestCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.create(
|
||||
db,
|
||||
obj_in=drone_request_in,
|
||||
created_by="public",
|
||||
user_ip=client_ip,
|
||||
submitted_via="PUBLIC",
|
||||
)
|
||||
|
||||
await _broadcast(request, "drone_request_created", drone_request)
|
||||
await _send_drone_submitted_email(drone_request)
|
||||
await _send_drone_tower_notification(drone_request)
|
||||
return _public_submission_response(drone_request)
|
||||
|
||||
|
||||
@router.get("/public/edit/{token}", response_model=DroneRequest)
|
||||
async def get_drone_request_for_edit(
|
||||
token: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.patch("/public/edit/{token}", response_model=DroneRequest)
|
||||
async def update_drone_request_public(
|
||||
token: str,
|
||||
drone_request_in: DroneRequestUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.APPROVED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Drone request cannot be edited while {drone_request.status.value}",
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
updated_request = crud_drone_request.update(
|
||||
db,
|
||||
db_obj=drone_request,
|
||||
obj_in=drone_request_in,
|
||||
user="public",
|
||||
user_ip=client_ip,
|
||||
)
|
||||
await _broadcast(request, "drone_request_updated", updated_request)
|
||||
return updated_request
|
||||
|
||||
|
||||
@router.delete("/public/cancel/{token}", response_model=DroneRequest)
|
||||
async def cancel_drone_request_public(
|
||||
token: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drone_request = crud_drone_request.get_by_public_token(db, token)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired token")
|
||||
if drone_request.status not in [DroneRequestStatus.NEW, DroneRequestStatus.APPROVED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Drone request cannot be cancelled while {drone_request.status.value}",
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
cancelled_request = crud_drone_request.update_status(
|
||||
db,
|
||||
request_id=drone_request.id,
|
||||
status=DroneRequestStatus.CANCELED,
|
||||
comment="Cancelled by operator using secure link",
|
||||
user="public",
|
||||
user_ip=client_ip,
|
||||
)
|
||||
await _broadcast(request, "drone_request_status_update", cancelled_request)
|
||||
await _send_drone_email(
|
||||
cancelled_request,
|
||||
f"Drone request {cancelled_request.reference_number} CANCELED",
|
||||
"Your drone flight request has been cancelled.",
|
||||
)
|
||||
return cancelled_request
|
||||
|
||||
|
||||
@router.get("/frz")
|
||||
async def get_swansea_drone_frz():
|
||||
return swansea_frz_geojson()
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_model=DroneRequest)
|
||||
async def get_drone_request(
|
||||
request_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user),
|
||||
):
|
||||
drone_request = crud_drone_request.get(db, request_id)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.patch("/{request_id}", response_model=DroneRequest)
|
||||
async def update_drone_request(
|
||||
request: Request,
|
||||
request_id: int,
|
||||
drone_request_in: DroneRequestUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user),
|
||||
):
|
||||
db_request = crud_drone_request.get(db, request_id)
|
||||
if not db_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.update(
|
||||
db,
|
||||
db_obj=db_request,
|
||||
obj_in=drone_request_in,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip,
|
||||
)
|
||||
await _broadcast(request, "drone_request_updated", drone_request)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.patch("/{request_id}/status", response_model=DroneRequest)
|
||||
async def update_drone_request_status(
|
||||
request: Request,
|
||||
request_id: int,
|
||||
status_update: DroneRequestStatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user),
|
||||
):
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.update_status(
|
||||
db,
|
||||
request_id=request_id,
|
||||
status=status_update.status,
|
||||
comment=status_update.comment,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip,
|
||||
)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
|
||||
await _broadcast(request, "drone_request_status_update", drone_request)
|
||||
message = status_update.comment or f"Your drone flight request status is now {drone_request.status.value}."
|
||||
if drone_request.status == DroneRequestStatus.APPROVED:
|
||||
await _send_drone_approved_email(drone_request, status_update.comment)
|
||||
else:
|
||||
await _send_drone_email(
|
||||
drone_request,
|
||||
f"Drone request {drone_request.reference_number} {drone_request.status.value}",
|
||||
message,
|
||||
)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.post("/{request_id}/comments", response_model=DroneRequest)
|
||||
async def add_drone_request_comment(
|
||||
request: Request,
|
||||
request_id: int,
|
||||
comment_in: DroneRequestComment,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user),
|
||||
):
|
||||
client_ip = get_client_ip(request)
|
||||
drone_request = crud_drone_request.add_comment(
|
||||
db,
|
||||
request_id=request_id,
|
||||
comment=comment_in.comment,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip,
|
||||
)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
|
||||
await _broadcast(request, "drone_request_comment_added", drone_request)
|
||||
if comment_in.email_applicant:
|
||||
await _send_drone_email(
|
||||
drone_request,
|
||||
f"Drone request {drone_request.reference_number} update",
|
||||
comment_in.comment,
|
||||
)
|
||||
return drone_request
|
||||
|
||||
|
||||
@router.get("/{request_id}/journal", response_model=List[JournalEntryResponse])
|
||||
async def get_drone_request_journal(
|
||||
request_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user),
|
||||
):
|
||||
drone_request = crud_drone_request.get(db, request_id)
|
||||
if not drone_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Drone request not found")
|
||||
return crud_journal.get_entity_journal(db, EntityType.DRONE_REQUEST, request_id)
|
||||
@@ -1,12 +1,22 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import date
|
||||
from app.api.deps import get_db, get_current_read_user
|
||||
from sqlalchemy import func, or_
|
||||
from datetime import date, datetime, time
|
||||
from app.api.deps import get_db, get_current_operator_user, get_current_read_user
|
||||
from app.crud.crud_movement import movement as crud_movement
|
||||
from app.schemas.movement import Movement
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
from app.schemas.movement import BulkMovementContext, BulkMovementLog, BulkMovementResult, Movement, MovementCreate
|
||||
from app.models.ppr import User
|
||||
from app.models.movement import MovementType
|
||||
from app.models.arrival import Arrival, ArrivalStatus, SubmissionSource as ArrivalSubmissionSource
|
||||
from app.models.circuit import Circuit
|
||||
from app.models.departure import Departure, DepartureStatus, SubmissionSource as DepartureSubmissionSource
|
||||
from app.models.journal import EntityType
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType, SubmissionSource as LocalSubmissionSource
|
||||
from app.models.movement import Movement as MovementModel, MovementType
|
||||
from app.models.overflight import Overflight, OverflightStatus
|
||||
from app.models.ppr import PPRRecord, PPRStatus
|
||||
from app.core.utils import get_client_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -32,6 +42,595 @@ async def get_movements(
|
||||
return movements
|
||||
|
||||
|
||||
def _clean_reg(registration: str) -> str:
|
||||
return (registration or "").strip().upper()
|
||||
|
||||
|
||||
def _clean_alnum(value: str) -> str:
|
||||
return "".join(char for char in (value or "").upper() if char.isalnum())
|
||||
|
||||
|
||||
def _sql_clean_alnum(column):
|
||||
return func.upper(func.replace(func.replace(column, "-", ""), " ", ""))
|
||||
|
||||
|
||||
def _combine_date_time(movement_date: date, movement_time: str) -> datetime:
|
||||
if not movement_time:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="A time is required for this strip"
|
||||
)
|
||||
try:
|
||||
parsed_time = time.fromisoformat(movement_time)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="movement_time must be HH:MM or HH:MM:SS"
|
||||
)
|
||||
return datetime.combine(movement_date, parsed_time)
|
||||
|
||||
|
||||
def _kind_to_movement_type(flight_kind: str) -> MovementType:
|
||||
kind = (flight_kind or "").strip().upper()
|
||||
if kind == "ARRIVAL":
|
||||
return MovementType.LANDING
|
||||
if kind == "DEPARTURE":
|
||||
return MovementType.TAKEOFF
|
||||
if kind == "LOCAL":
|
||||
return MovementType.TAKEOFF
|
||||
if kind == "OVERFLIGHT":
|
||||
return MovementType.OVERFLIGHT
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="flight_kind must be ARRIVAL, DEPARTURE, LOCAL or OVERFLIGHT"
|
||||
)
|
||||
|
||||
|
||||
def _strip_entity_type(flight_kind: str) -> Optional[str]:
|
||||
kind = (flight_kind or "").strip().upper()
|
||||
return {
|
||||
"LOCAL": "LOCAL_FLIGHT",
|
||||
"OVERFLIGHT": "OVERFLIGHT",
|
||||
}.get(kind)
|
||||
|
||||
|
||||
def _compatible_entity_types(flight_kind: str) -> List[str]:
|
||||
kind = (flight_kind or "").strip().upper()
|
||||
if kind == "ARRIVAL":
|
||||
return ["PPR", "ARRIVAL"]
|
||||
if kind == "DEPARTURE":
|
||||
return ["PPR", "DEPARTURE"]
|
||||
if kind == "LOCAL":
|
||||
return ["LOCAL_FLIGHT"]
|
||||
if kind == "OVERFLIGHT":
|
||||
return ["OVERFLIGHT"]
|
||||
return []
|
||||
|
||||
|
||||
def _ppr_to_dict(ppr: PPRRecord) -> dict:
|
||||
return {
|
||||
"id": ppr.id,
|
||||
"status": ppr.status.value,
|
||||
"aircraft_registration": ppr.ac_reg,
|
||||
"aircraft_type": ppr.ac_type,
|
||||
"callsign": ppr.ac_call,
|
||||
"from_location": ppr.in_from,
|
||||
"to_location": ppr.out_to,
|
||||
"eta": ppr.eta.isoformat() if ppr.eta else None,
|
||||
"etd": ppr.etd.isoformat() if ppr.etd else None,
|
||||
"pob_in": ppr.pob_in,
|
||||
"pob_out": ppr.pob_out,
|
||||
"notes": ppr.notes,
|
||||
}
|
||||
|
||||
|
||||
def _movement_to_dict(movement: MovementModel) -> dict:
|
||||
return {
|
||||
"id": movement.id,
|
||||
"movement_type": movement.movement_type.value,
|
||||
"aircraft_registration": movement.aircraft_registration,
|
||||
"aircraft_type": movement.aircraft_type,
|
||||
"callsign": movement.callsign,
|
||||
"timestamp": movement.timestamp.isoformat() if movement.timestamp else None,
|
||||
"entity_type": movement.entity_type,
|
||||
"entity_id": movement.entity_id,
|
||||
"from_location": movement.from_location,
|
||||
"to_location": movement.to_location,
|
||||
"runway": movement.runway,
|
||||
"wind": movement.wind,
|
||||
"pressure_setting": movement.pressure_setting,
|
||||
"notes": movement.notes,
|
||||
}
|
||||
|
||||
|
||||
def _local_flight_to_dict(local: LocalFlight) -> dict:
|
||||
return {
|
||||
"id": local.id,
|
||||
"aircraft_registration": local.registration,
|
||||
"aircraft_type": local.type,
|
||||
"callsign": local.callsign,
|
||||
"pob": local.pob,
|
||||
"flight_type": local.flight_type.value if local.flight_type else None,
|
||||
"status": local.status.value if local.status else None,
|
||||
"etd": local.etd.isoformat() if local.etd else None,
|
||||
"takeoff_time": local.takeoff_dt.isoformat() if local.takeoff_dt else None,
|
||||
"departed_time": local.departed_dt.isoformat() if local.departed_dt else None,
|
||||
"landing_time": local.landed_dt.isoformat() if local.landed_dt else None,
|
||||
"circuits": local.circuits,
|
||||
"notes": local.notes,
|
||||
}
|
||||
|
||||
|
||||
def _build_suggestion(pprs: List[PPRRecord], movements: List[MovementModel], flight_kind: str) -> dict:
|
||||
if movements:
|
||||
movement = movements[0]
|
||||
return {
|
||||
"source": "movement",
|
||||
"movement_id": movement.id,
|
||||
"aircraft_registration": movement.aircraft_registration,
|
||||
"aircraft_type": movement.aircraft_type,
|
||||
"callsign": movement.callsign,
|
||||
"movement_time": movement.timestamp.strftime("%H:%M") if movement.timestamp else None,
|
||||
"from_location": movement.from_location,
|
||||
"to_location": movement.to_location,
|
||||
"runway": movement.runway,
|
||||
"wind": movement.wind,
|
||||
"pressure_setting": movement.pressure_setting,
|
||||
"notes": movement.notes,
|
||||
}
|
||||
|
||||
if pprs:
|
||||
ppr = pprs[0]
|
||||
is_arrival = flight_kind.upper() == "ARRIVAL"
|
||||
timestamp = ppr.eta if is_arrival else ppr.etd
|
||||
return {
|
||||
"source": "ppr",
|
||||
"ppr_id": ppr.id,
|
||||
"aircraft_registration": ppr.ac_reg,
|
||||
"aircraft_type": ppr.ac_type,
|
||||
"callsign": ppr.ac_call,
|
||||
"movement_time": timestamp.strftime("%H:%M") if timestamp else None,
|
||||
"from_location": ppr.in_from,
|
||||
"to_location": ppr.out_to,
|
||||
"pob": ppr.pob_in if is_arrival else (ppr.pob_out or ppr.pob_in),
|
||||
"notes": ppr.notes,
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _movement_for_entity(
|
||||
db: Session,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
movement_type: MovementType
|
||||
) -> Optional[MovementModel]:
|
||||
return db.query(MovementModel).filter(
|
||||
MovementModel.entity_type == entity_type,
|
||||
MovementModel.entity_id == entity_id,
|
||||
MovementModel.movement_type == movement_type
|
||||
).order_by(MovementModel.timestamp).first()
|
||||
|
||||
|
||||
def _create_or_update_movement(db: Session, movement_data: MovementCreate) -> MovementModel:
|
||||
existing = _movement_for_entity(
|
||||
db,
|
||||
movement_data.entity_type,
|
||||
movement_data.entity_id,
|
||||
movement_data.movement_type
|
||||
)
|
||||
if existing:
|
||||
return crud_movement.update(db, existing, movement_data)
|
||||
return crud_movement.create(db, movement_data)
|
||||
|
||||
|
||||
def _clear_local_circuit_details(
|
||||
db: Session,
|
||||
local_flight: LocalFlight
|
||||
) -> None:
|
||||
db.query(Circuit).filter(Circuit.local_flight_id == local_flight.id).delete()
|
||||
db.query(MovementModel).filter(
|
||||
MovementModel.entity_type == "LOCAL_FLIGHT",
|
||||
MovementModel.entity_id == local_flight.id,
|
||||
MovementModel.movement_type == MovementType.TOUCH_AND_GO
|
||||
).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/bulk-context", response_model=BulkMovementContext)
|
||||
async def get_bulk_movement_context(
|
||||
target_date: date,
|
||||
aircraft_registration: str,
|
||||
flight_kind: str = "ARRIVAL",
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_read_user)
|
||||
):
|
||||
"""Find same-day PPRs and movements that may match a bulk entry strip."""
|
||||
clean_reg = _clean_reg(aircraft_registration)
|
||||
clean_lookup = _clean_alnum(aircraft_registration)
|
||||
movement_type = _kind_to_movement_type(flight_kind)
|
||||
entity_type_filter = _strip_entity_type(flight_kind)
|
||||
|
||||
pprs = []
|
||||
local_flights = []
|
||||
if clean_lookup and flight_kind.upper() != "LOCAL":
|
||||
pprs = db.query(PPRRecord).filter(
|
||||
_sql_clean_alnum(PPRRecord.ac_reg).like(f"{clean_lookup}%"),
|
||||
or_(
|
||||
func.date(PPRRecord.eta) == target_date,
|
||||
func.date(PPRRecord.etd) == target_date
|
||||
),
|
||||
PPRRecord.status != PPRStatus.DELETED
|
||||
).order_by(PPRRecord.eta).limit(10).all()
|
||||
|
||||
if clean_lookup and flight_kind.upper() == "LOCAL":
|
||||
local_flights = db.query(LocalFlight).filter(
|
||||
_sql_clean_alnum(LocalFlight.registration).like(f"{clean_lookup}%"),
|
||||
or_(
|
||||
func.date(LocalFlight.takeoff_dt) == target_date,
|
||||
func.date(LocalFlight.departed_dt) == target_date,
|
||||
func.date(LocalFlight.landed_dt) == target_date,
|
||||
func.date(LocalFlight.etd) == target_date,
|
||||
func.date(LocalFlight.created_dt) == target_date
|
||||
),
|
||||
LocalFlight.flight_type.in_([LocalFlightType.LOCAL, LocalFlightType.CIRCUITS]),
|
||||
LocalFlight.status != LocalFlightStatus.CANCELLED
|
||||
).order_by(LocalFlight.takeoff_dt, LocalFlight.etd, LocalFlight.created_dt).limit(10).all()
|
||||
|
||||
movements = []
|
||||
if clean_lookup:
|
||||
movements = db.query(MovementModel).filter(
|
||||
func.date(MovementModel.timestamp) == target_date,
|
||||
_sql_clean_alnum(MovementModel.aircraft_registration).like(f"{clean_lookup}%"),
|
||||
MovementModel.movement_type == movement_type
|
||||
).order_by(MovementModel.timestamp.desc()).limit(10).all()
|
||||
compatible_types = _compatible_entity_types(flight_kind)
|
||||
if compatible_types:
|
||||
movements = [movement for movement in movements if movement.entity_type in compatible_types]
|
||||
|
||||
return BulkMovementContext(
|
||||
pprs=[_ppr_to_dict(ppr) for ppr in pprs],
|
||||
local_flights=[_local_flight_to_dict(local) for local in local_flights],
|
||||
movements=[_movement_to_dict(movement) for movement in movements],
|
||||
suggested=_build_suggestion(pprs, movements, flight_kind)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk-log", response_model=BulkMovementResult)
|
||||
async def bulk_log_movement(
|
||||
request: Request,
|
||||
entry: BulkMovementLog,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user)
|
||||
):
|
||||
"""Create or update one same-day movement from a paper strip."""
|
||||
client_ip = get_client_ip(request)
|
||||
username = current_user.username
|
||||
clean_reg = _clean_reg(entry.aircraft_registration)
|
||||
clean_lookup = _clean_alnum(entry.aircraft_registration)
|
||||
if not clean_reg:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Aircraft registration is required")
|
||||
|
||||
movement_type = _kind_to_movement_type(entry.flight_kind)
|
||||
flight_kind = entry.flight_kind.strip().upper()
|
||||
primary_time = (
|
||||
entry.landing_time if flight_kind == "ARRIVAL"
|
||||
else entry.takeoff_time if flight_kind in ("DEPARTURE", "LOCAL")
|
||||
else entry.contact_time if flight_kind == "OVERFLIGHT"
|
||||
else entry.movement_time
|
||||
) or entry.movement_time
|
||||
timestamp = _combine_date_time(entry.movement_date, primary_time)
|
||||
existing_movement = crud_movement.get(db, entry.movement_id) if entry.movement_id and flight_kind != "LOCAL" else None
|
||||
if not existing_movement and flight_kind != "LOCAL":
|
||||
existing_movement = crud_movement.find_daily_match(
|
||||
db,
|
||||
entry.movement_date,
|
||||
clean_reg,
|
||||
movement_type,
|
||||
entity_type=_strip_entity_type(flight_kind),
|
||||
entity_types=None if _strip_entity_type(flight_kind) else _compatible_entity_types(flight_kind)
|
||||
)
|
||||
if existing_movement and existing_movement.entity_type not in _compatible_entity_types(flight_kind):
|
||||
existing_movement = None
|
||||
|
||||
if flight_kind == "LOCAL":
|
||||
takeoff_dt = _combine_date_time(entry.movement_date, entry.takeoff_time)
|
||||
landing_dt = _combine_date_time(entry.movement_date, entry.landing_time)
|
||||
if landing_dt < takeoff_dt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="LOCAL landing time cannot be before takeoff time"
|
||||
)
|
||||
local_type = LocalFlightType.CIRCUITS if (entry.local_nature or "").upper() == "CIRCUITS" else LocalFlightType.LOCAL
|
||||
local = LocalFlight(
|
||||
registration=clean_reg,
|
||||
type=entry.aircraft_type or "",
|
||||
callsign=entry.callsign,
|
||||
pob=entry.pob,
|
||||
flight_type=local_type,
|
||||
status=LocalFlightStatus.LANDED,
|
||||
duration=int((landing_dt - takeoff_dt).total_seconds() / 60) if landing_dt > takeoff_dt else None,
|
||||
circuits=entry.circuits or 0,
|
||||
notes=entry.notes,
|
||||
etd=takeoff_dt,
|
||||
departed_dt=takeoff_dt,
|
||||
takeoff_dt=takeoff_dt,
|
||||
landed_dt=landing_dt,
|
||||
created_by=username,
|
||||
submitted_via=LocalSubmissionSource.ADMIN
|
||||
)
|
||||
db.add(local)
|
||||
db.commit()
|
||||
db.refresh(local)
|
||||
action = "created"
|
||||
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, "Local strip created from bulk flight log", username, client_ip)
|
||||
|
||||
takeoff_movement = _create_or_update_movement(db, MovementCreate(
|
||||
movement_type=MovementType.TAKEOFF,
|
||||
aircraft_registration=clean_reg,
|
||||
aircraft_type=local.type,
|
||||
callsign=local.callsign,
|
||||
timestamp=takeoff_dt,
|
||||
entity_type="LOCAL_FLIGHT",
|
||||
entity_id=local.id,
|
||||
runway=entry.runway,
|
||||
wind=entry.wind,
|
||||
pressure_setting=entry.pressure_setting,
|
||||
created_by=username,
|
||||
ip_address=client_ip,
|
||||
notes=entry.notes
|
||||
))
|
||||
_create_or_update_movement(db, MovementCreate(
|
||||
movement_type=MovementType.LANDING,
|
||||
aircraft_registration=clean_reg,
|
||||
aircraft_type=local.type,
|
||||
callsign=local.callsign,
|
||||
timestamp=landing_dt,
|
||||
entity_type="LOCAL_FLIGHT",
|
||||
entity_id=local.id,
|
||||
runway=entry.runway,
|
||||
wind=entry.wind,
|
||||
pressure_setting=entry.pressure_setting,
|
||||
created_by=username,
|
||||
ip_address=client_ip,
|
||||
notes=entry.notes
|
||||
))
|
||||
_clear_local_circuit_details(db, local)
|
||||
crud_journal.log_change(db, EntityType.LOCAL_FLIGHT, local.id, f"Bulk local strip {action}: takeoff {takeoff_dt.strftime('%H:%M')}, landing {landing_dt.strftime('%H:%M')}, circuits {entry.circuits or 0}", username, client_ip)
|
||||
return BulkMovementResult(action=action, movement=takeoff_movement, entity_type="LOCAL_FLIGHT", entity_id=local.id, message=f"Local strip {action} for {clean_reg}")
|
||||
|
||||
if flight_kind == "OVERFLIGHT":
|
||||
contact_dt = _combine_date_time(entry.movement_date, entry.contact_time)
|
||||
qsy_dt = _combine_date_time(entry.movement_date, entry.qsy_time) if entry.qsy_time else None
|
||||
overflight = None
|
||||
if existing_movement and existing_movement.entity_type == "OVERFLIGHT":
|
||||
overflight = db.query(Overflight).filter(Overflight.id == existing_movement.entity_id).first()
|
||||
if not overflight:
|
||||
overflight = db.query(Overflight).filter(
|
||||
_sql_clean_alnum(Overflight.registration) == clean_lookup,
|
||||
func.date(Overflight.call_dt) == entry.movement_date
|
||||
).first()
|
||||
if not overflight:
|
||||
overflight = Overflight(
|
||||
registration=clean_reg,
|
||||
pob=entry.pob,
|
||||
type=entry.aircraft_type,
|
||||
departure_airfield=entry.from_location,
|
||||
destination_airfield=entry.to_location,
|
||||
status=OverflightStatus.INACTIVE if qsy_dt else OverflightStatus.ACTIVE,
|
||||
call_dt=contact_dt,
|
||||
qsy_dt=qsy_dt,
|
||||
notes=entry.notes,
|
||||
created_by=username
|
||||
)
|
||||
db.add(overflight)
|
||||
db.commit()
|
||||
db.refresh(overflight)
|
||||
action = "created"
|
||||
crud_journal.log_change(db, EntityType.OVERFLIGHT, overflight.id, "Overflight strip created from bulk flight log", username, client_ip)
|
||||
else:
|
||||
overflight.registration = clean_reg
|
||||
overflight.pob = entry.pob
|
||||
overflight.type = entry.aircraft_type
|
||||
overflight.departure_airfield = entry.from_location
|
||||
overflight.destination_airfield = entry.to_location
|
||||
overflight.status = OverflightStatus.INACTIVE if qsy_dt else OverflightStatus.ACTIVE
|
||||
overflight.call_dt = contact_dt
|
||||
overflight.qsy_dt = qsy_dt
|
||||
overflight.notes = entry.notes
|
||||
db.add(overflight)
|
||||
db.commit()
|
||||
db.refresh(overflight)
|
||||
action = "updated"
|
||||
|
||||
movement = _create_or_update_movement(db, MovementCreate(
|
||||
movement_type=MovementType.OVERFLIGHT,
|
||||
aircraft_registration=clean_reg,
|
||||
aircraft_type=entry.aircraft_type,
|
||||
callsign=entry.callsign,
|
||||
timestamp=contact_dt,
|
||||
entity_type="OVERFLIGHT",
|
||||
entity_id=overflight.id,
|
||||
from_location=entry.from_location,
|
||||
to_location=entry.to_location,
|
||||
runway=entry.runway,
|
||||
wind=entry.wind,
|
||||
pressure_setting=entry.pressure_setting,
|
||||
created_by=username,
|
||||
ip_address=client_ip,
|
||||
notes=entry.notes
|
||||
))
|
||||
crud_journal.log_change(db, EntityType.OVERFLIGHT, overflight.id, f"Bulk overflight strip {action}: contact {contact_dt.strftime('%H:%M')}" + (f", QSY {qsy_dt.strftime('%H:%M')}" if qsy_dt else ""), username, client_ip)
|
||||
return BulkMovementResult(action=action, movement=movement, entity_type="OVERFLIGHT", entity_id=overflight.id, message=f"Overflight strip {action} for {clean_reg}")
|
||||
|
||||
ppr = None
|
||||
if entry.ppr_id:
|
||||
ppr = db.query(PPRRecord).filter(PPRRecord.id == entry.ppr_id).first()
|
||||
if not ppr and existing_movement and existing_movement.entity_type == "PPR":
|
||||
ppr = db.query(PPRRecord).filter(PPRRecord.id == existing_movement.entity_id).first()
|
||||
if not ppr:
|
||||
ppr = db.query(PPRRecord).filter(
|
||||
_sql_clean_alnum(PPRRecord.ac_reg) == clean_lookup,
|
||||
or_(
|
||||
func.date(PPRRecord.eta) == entry.movement_date,
|
||||
func.date(PPRRecord.etd) == entry.movement_date
|
||||
),
|
||||
PPRRecord.status != PPRStatus.DELETED
|
||||
).order_by(PPRRecord.eta).first()
|
||||
|
||||
entity_type = existing_movement.entity_type if existing_movement else None
|
||||
entity_id = existing_movement.entity_id if existing_movement else None
|
||||
|
||||
if not entity_type:
|
||||
if ppr:
|
||||
entity_type = "PPR"
|
||||
entity_id = ppr.id
|
||||
elif movement_type == MovementType.LANDING:
|
||||
arrival = Arrival(
|
||||
registration=clean_reg,
|
||||
type=entry.aircraft_type,
|
||||
callsign=entry.callsign,
|
||||
pob=entry.pob or 1,
|
||||
in_from=entry.from_location or "ZZZZ",
|
||||
status=ArrivalStatus.LANDED,
|
||||
notes=entry.notes,
|
||||
eta=timestamp,
|
||||
landed_dt=timestamp,
|
||||
created_by=username,
|
||||
submitted_via=ArrivalSubmissionSource.ADMIN
|
||||
)
|
||||
db.add(arrival)
|
||||
db.commit()
|
||||
db.refresh(arrival)
|
||||
entity_type = "ARRIVAL"
|
||||
entity_id = arrival.id
|
||||
crud_journal.log_change(db, EntityType.ARRIVAL, arrival.id, "Arrival created from bulk flight log", username, client_ip)
|
||||
else:
|
||||
departure = Departure(
|
||||
registration=clean_reg,
|
||||
type=entry.aircraft_type,
|
||||
callsign=entry.callsign,
|
||||
pob=entry.pob or 1,
|
||||
out_to=entry.to_location or "ZZZZ",
|
||||
status=DepartureStatus.DEPARTED,
|
||||
notes=entry.notes,
|
||||
etd=timestamp,
|
||||
takeoff_dt=timestamp,
|
||||
departed_dt=timestamp,
|
||||
created_by=username,
|
||||
submitted_via=DepartureSubmissionSource.ADMIN
|
||||
)
|
||||
db.add(departure)
|
||||
db.commit()
|
||||
db.refresh(departure)
|
||||
entity_type = "DEPARTURE"
|
||||
entity_id = departure.id
|
||||
crud_journal.log_change(db, EntityType.DEPARTURE, departure.id, "Departure created from bulk flight log", username, client_ip)
|
||||
|
||||
movement_data = MovementCreate(
|
||||
movement_type=movement_type,
|
||||
aircraft_registration=clean_reg,
|
||||
aircraft_type=entry.aircraft_type,
|
||||
callsign=entry.callsign,
|
||||
timestamp=timestamp,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
from_location=entry.from_location,
|
||||
to_location=entry.to_location,
|
||||
runway=entry.runway,
|
||||
wind=entry.wind,
|
||||
pressure_setting=entry.pressure_setting,
|
||||
created_by=username,
|
||||
ip_address=client_ip,
|
||||
notes=entry.notes
|
||||
)
|
||||
|
||||
if existing_movement:
|
||||
movement = crud_movement.update(db, existing_movement, movement_data)
|
||||
action = "updated"
|
||||
else:
|
||||
movement = crud_movement.create(db, movement_data)
|
||||
action = "created"
|
||||
|
||||
if entity_type == "PPR" and ppr:
|
||||
ppr.ac_type = entry.aircraft_type or ppr.ac_type
|
||||
ppr.ac_call = entry.callsign or ppr.ac_call
|
||||
if movement_type == MovementType.LANDING:
|
||||
ppr.in_from = entry.from_location or ppr.in_from
|
||||
ppr.pob_in = entry.pob or ppr.pob_in
|
||||
ppr.landed_dt = timestamp
|
||||
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED, PPRStatus.DEPARTED):
|
||||
ppr.status = PPRStatus.LANDED
|
||||
else:
|
||||
ppr.out_to = entry.to_location or ppr.out_to
|
||||
ppr.pob_out = entry.pob or ppr.pob_out
|
||||
ppr.takeoff_dt = timestamp
|
||||
if ppr.status not in (PPRStatus.DELETED, PPRStatus.CANCELED):
|
||||
ppr.status = PPRStatus.DEPARTED
|
||||
if entry.notes:
|
||||
ppr.notes = entry.notes
|
||||
db.add(ppr)
|
||||
db.commit()
|
||||
crud_journal.log_change(
|
||||
db,
|
||||
EntityType.PPR,
|
||||
ppr.id,
|
||||
f"Bulk flight log {action}: {movement_type.value} at {timestamp.strftime('%Y-%m-%d %H:%M')}",
|
||||
username,
|
||||
client_ip
|
||||
)
|
||||
elif entity_type == "ARRIVAL":
|
||||
arrival = db.query(Arrival).filter(Arrival.id == entity_id).first()
|
||||
if arrival:
|
||||
arrival.registration = clean_reg
|
||||
arrival.type = entry.aircraft_type
|
||||
arrival.callsign = entry.callsign
|
||||
arrival.pob = entry.pob or arrival.pob
|
||||
arrival.in_from = entry.from_location or arrival.in_from
|
||||
arrival.eta = timestamp
|
||||
arrival.landed_dt = timestamp
|
||||
arrival.status = ArrivalStatus.LANDED
|
||||
arrival.notes = entry.notes
|
||||
db.add(arrival)
|
||||
db.commit()
|
||||
crud_journal.log_change(db, EntityType.ARRIVAL, arrival.id, f"Bulk flight log {action}: landing at {timestamp.strftime('%Y-%m-%d %H:%M')}", username, client_ip)
|
||||
elif entity_type == "DEPARTURE":
|
||||
departure = db.query(Departure).filter(Departure.id == entity_id).first()
|
||||
if departure:
|
||||
departure.registration = clean_reg
|
||||
departure.type = entry.aircraft_type
|
||||
departure.callsign = entry.callsign
|
||||
departure.pob = entry.pob or departure.pob
|
||||
departure.out_to = entry.to_location or departure.out_to
|
||||
departure.etd = timestamp
|
||||
departure.takeoff_dt = timestamp
|
||||
departure.departed_dt = timestamp
|
||||
departure.status = DepartureStatus.DEPARTED
|
||||
departure.notes = entry.notes
|
||||
db.add(departure)
|
||||
db.commit()
|
||||
crud_journal.log_change(db, EntityType.DEPARTURE, departure.id, f"Bulk flight log {action}: takeoff at {timestamp.strftime('%Y-%m-%d %H:%M')}", username, client_ip)
|
||||
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": "movement_bulk_logged",
|
||||
"data": {
|
||||
"id": movement.id,
|
||||
"aircraft_registration": movement.aircraft_registration,
|
||||
"movement_type": movement.movement_type.value,
|
||||
"action": action
|
||||
}
|
||||
})
|
||||
|
||||
return BulkMovementResult(
|
||||
action=action,
|
||||
movement=movement,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
message=f"Movement {action} for {clean_reg}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{movement_id}", response_model=Movement)
|
||||
async def get_movement(
|
||||
movement_id: int,
|
||||
@@ -45,4 +644,4 @@ async def get_movement(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Movement record not found"
|
||||
)
|
||||
return movement
|
||||
return movement
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import date
|
||||
from datetime import date, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from app.api.deps import get_db, get_current_read_user, get_current_operator_user
|
||||
from app.crud.crud_ppr import ppr as crud_ppr
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
from app.crud.crud_arrival import arrival as crud_arrival
|
||||
from app.crud.crud_departure import departure as crud_departure
|
||||
from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal
|
||||
from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate
|
||||
from app.schemas.journal import JournalEntryResponse
|
||||
from app.schemas.arrival import ArrivalCreate
|
||||
from app.schemas.departure import DepartureCreate
|
||||
from app.models.ppr import User
|
||||
@@ -18,6 +20,14 @@ from app.core.config import settings
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def format_local_datetime(dt):
|
||||
if not dt:
|
||||
return "N/A"
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(ZoneInfo(settings.local_timezone)).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
@router.get("/", response_model=List[PPR])
|
||||
async def get_pprs(
|
||||
request: Request,
|
||||
@@ -93,8 +103,8 @@ async def create_public_ppr(
|
||||
template_vars={
|
||||
"name": ppr_in.captain,
|
||||
"aircraft": ppr_in.ac_reg,
|
||||
"arrival_time": ppr_in.eta.strftime("%Y-%m-%d %H:%M"),
|
||||
"departure_time": ppr_in.etd.strftime("%Y-%m-%d %H:%M") if ppr_in.etd else "N/A",
|
||||
"arrival_time": format_local_datetime(ppr_in.eta),
|
||||
"departure_time": format_local_datetime(ppr_in.etd),
|
||||
"purpose": ppr_in.notes or "N/A",
|
||||
"public_token": ppr.public_token,
|
||||
"base_url": settings.base_url
|
||||
@@ -212,13 +222,21 @@ async def update_ppr_status(
|
||||
|
||||
# Send real-time update
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
event_timestamp = None
|
||||
if ppr.status == PPRStatus.LANDED and ppr.landed_dt:
|
||||
event_timestamp = ppr.landed_dt.isoformat()
|
||||
elif ppr.status == PPRStatus.LOCAL and ppr.takeoff_dt:
|
||||
event_timestamp = ppr.takeoff_dt.isoformat()
|
||||
elif ppr.status == PPRStatus.DEPARTED and ppr.qsy_dt:
|
||||
event_timestamp = ppr.qsy_dt.isoformat()
|
||||
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": "status_update",
|
||||
"data": {
|
||||
"id": ppr.id,
|
||||
"ac_reg": ppr.ac_reg,
|
||||
"status": ppr.status.value,
|
||||
"timestamp": ppr.landed_dt.isoformat() if ppr.landed_dt else (ppr.departed_dt.isoformat() if ppr.departed_dt else None)
|
||||
"timestamp": event_timestamp
|
||||
}
|
||||
})
|
||||
|
||||
@@ -231,14 +249,49 @@ async def update_ppr_status(
|
||||
template_vars={
|
||||
"name": ppr.captain,
|
||||
"aircraft": ppr.ac_reg,
|
||||
"arrival_time": ppr.eta.strftime("%Y-%m-%d %H:%M"),
|
||||
"departure_time": ppr.etd.strftime("%Y-%m-%d %H:%M") if ppr.etd else "N/A"
|
||||
"arrival_time": format_local_datetime(ppr.eta),
|
||||
"departure_time": format_local_datetime(ppr.etd)
|
||||
}
|
||||
)
|
||||
|
||||
return ppr
|
||||
|
||||
|
||||
@router.post("/{ppr_id}/acknowledge", response_model=PPR)
|
||||
async def acknowledge_ppr_strip(
|
||||
request: Request,
|
||||
ppr_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_operator_user)
|
||||
):
|
||||
"""Acknowledge that the operator has created the paper strip for a PPR."""
|
||||
client_ip = get_client_ip(request)
|
||||
ppr = crud_ppr.acknowledge_strip(
|
||||
db,
|
||||
ppr_id=ppr_id,
|
||||
user=current_user.username,
|
||||
user_ip=client_ip
|
||||
)
|
||||
if not ppr:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="PPR record not found"
|
||||
)
|
||||
|
||||
if hasattr(request.app.state, 'connection_manager'):
|
||||
await request.app.state.connection_manager.broadcast({
|
||||
"type": "ppr_acknowledged",
|
||||
"data": {
|
||||
"id": ppr.id,
|
||||
"ac_reg": ppr.ac_reg,
|
||||
"acknowledged_dt": ppr.acknowledged_dt.isoformat() if ppr.acknowledged_dt else None,
|
||||
"acknowledged_by": ppr.acknowledged_by
|
||||
}
|
||||
})
|
||||
|
||||
return ppr
|
||||
|
||||
|
||||
@router.delete("/{ppr_id}", response_model=PPR)
|
||||
async def delete_ppr(
|
||||
request: Request,
|
||||
@@ -280,11 +333,10 @@ async def get_ppr_for_edit(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
# Only allow editing if not already processed
|
||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
|
||||
if ppr.status == PPRStatus.DELETED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PPR cannot be edited at this stage"
|
||||
detail="PPR is no longer available"
|
||||
)
|
||||
return ppr
|
||||
|
||||
@@ -304,7 +356,7 @@ async def update_ppr_public(
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
# Only allow editing if not already processed
|
||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
|
||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.LOCAL, PPRStatus.DEPARTED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PPR cannot be edited at this stage"
|
||||
@@ -329,7 +381,7 @@ async def cancel_ppr_public(
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
# Only allow canceling if not already processed
|
||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.DEPARTED]:
|
||||
if ppr.status in [PPRStatus.CANCELED, PPRStatus.DELETED, PPRStatus.LANDED, PPRStatus.LOCAL, PPRStatus.DEPARTED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PPR cannot be cancelled at this stage"
|
||||
@@ -354,15 +406,15 @@ async def cancel_ppr_public(
|
||||
template_vars={
|
||||
"name": cancelled_ppr.captain,
|
||||
"aircraft": cancelled_ppr.ac_reg,
|
||||
"arrival_time": cancelled_ppr.eta.strftime("%Y-%m-%d %H:%M"),
|
||||
"departure_time": cancelled_ppr.etd.strftime("%Y-%m-%d %H:%M") if cancelled_ppr.etd else "N/A"
|
||||
"arrival_time": format_local_datetime(cancelled_ppr.eta),
|
||||
"departure_time": format_local_datetime(cancelled_ppr.etd)
|
||||
}
|
||||
)
|
||||
|
||||
return cancelled_ppr
|
||||
|
||||
|
||||
@router.get("/{ppr_id}/journal", response_model=List[Journal])
|
||||
@router.get("/{ppr_id}/journal", response_model=List[JournalEntryResponse])
|
||||
async def get_ppr_journal(
|
||||
ppr_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -377,7 +429,7 @@ async def get_ppr_journal(
|
||||
detail="PPR record not found"
|
||||
)
|
||||
|
||||
return crud_journal.get_by_ppr_id(db, ppr_id=ppr_id)
|
||||
return crud_journal.get_ppr_journal(db, ppr_id=ppr_id)
|
||||
|
||||
|
||||
@router.post("/{ppr_id}/activate")
|
||||
@@ -451,4 +503,4 @@ async def activate_ppr(
|
||||
f"PPR activated: arrival #{new_arrival.id} created"
|
||||
+ (f", departure #{new_departure.id} queued (will appear when aircraft lands)" if new_departure else "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,28 +59,36 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
|
||||
'isLocalFlight': False
|
||||
})
|
||||
|
||||
# Add local flights with DEPARTED status that were booked out today
|
||||
local_flights = crud_local_flight.get_multi(
|
||||
db,
|
||||
status=LocalFlightStatus.DEPARTED,
|
||||
limit=1000
|
||||
)
|
||||
|
||||
# Get today's date boundaries
|
||||
today = date.today()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
||||
|
||||
# Add airborne local flights that were booked out today.
|
||||
# Admin now moves local flights from GROUND to LOCAL/CIRCUIT rather than DEPARTED.
|
||||
airborne_local_statuses = {
|
||||
LocalFlightStatus.DEPARTED,
|
||||
LocalFlightStatus.LOCAL,
|
||||
LocalFlightStatus.CIRCUIT,
|
||||
LocalFlightStatus.CIRCUIT_DOWNWIND,
|
||||
LocalFlightStatus.CIRCUIT_BASE,
|
||||
LocalFlightStatus.CIRCUIT_FINAL,
|
||||
}
|
||||
local_flights = crud_local_flight.get_multi(db, limit=1000)
|
||||
|
||||
# Convert local flights to match the PPR format for display
|
||||
for flight in local_flights:
|
||||
# Only include flights booked out today
|
||||
if not (today_start <= flight.created_dt < today_end):
|
||||
continue
|
||||
if flight.status not in airborne_local_statuses:
|
||||
continue
|
||||
|
||||
# Calculate ETA from departed_dt + duration (if both are available)
|
||||
eta = flight.departed_dt
|
||||
if flight.departed_dt and flight.duration:
|
||||
eta = flight.departed_dt + timedelta(minutes=flight.duration)
|
||||
# Calculate ETA from actual takeoff/departure + duration, falling back to ETD.
|
||||
departure_time = flight.takeoff_dt or flight.departed_dt or flight.etd
|
||||
eta = departure_time
|
||||
if departure_time and flight.duration:
|
||||
eta = departure_time + timedelta(minutes=flight.duration)
|
||||
|
||||
arrivals_list.append({
|
||||
'ac_call': flight.callsign or flight.registration,
|
||||
@@ -89,7 +97,7 @@ async def get_public_arrivals(db: Session = Depends(get_db)):
|
||||
'in_from': None,
|
||||
'eta': eta,
|
||||
'landed_dt': None,
|
||||
'status': 'DEPARTED',
|
||||
'status': flight.status.value,
|
||||
'isLocalFlight': True,
|
||||
'flight_type': flight.flight_type.value
|
||||
})
|
||||
@@ -137,29 +145,33 @@ async def get_public_departures(db: Session = Depends(get_db)):
|
||||
'ac_type': departure.ac_type,
|
||||
'out_to': departure.out_to,
|
||||
'etd': departure.etd,
|
||||
'departed_dt': departure.departed_dt,
|
||||
'takeoff_dt': departure.takeoff_dt,
|
||||
'qsy_dt': departure.qsy_dt,
|
||||
'status': departure.status.value,
|
||||
'isLocalFlight': False,
|
||||
'isDeparture': False
|
||||
})
|
||||
|
||||
# Add local flights with BOOKED_OUT status that were booked out today
|
||||
local_flights = crud_local_flight.get_multi(
|
||||
db,
|
||||
status=LocalFlightStatus.BOOKED_OUT,
|
||||
limit=1000
|
||||
)
|
||||
|
||||
# Get today's date boundaries
|
||||
today = date.today()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today + timedelta(days=1), datetime.min.time())
|
||||
|
||||
# Add local flights awaiting takeoff that were booked out today.
|
||||
# Admin-created flights start at GROUND, while public pilot submissions start at BOOKED_OUT.
|
||||
local_departure_statuses = {
|
||||
LocalFlightStatus.BOOKED_OUT,
|
||||
LocalFlightStatus.GROUND,
|
||||
}
|
||||
local_flights = crud_local_flight.get_multi(db, limit=1000)
|
||||
|
||||
# Convert local flights to match the PPR format for display
|
||||
for flight in local_flights:
|
||||
# Only include flights booked out today
|
||||
if not (today_start <= flight.created_dt < today_end):
|
||||
continue
|
||||
if flight.status not in local_departure_statuses:
|
||||
continue
|
||||
departures_list.append({
|
||||
'ac_call': flight.callsign or flight.registration,
|
||||
'ac_reg': flight.registration,
|
||||
@@ -167,7 +179,7 @@ async def get_public_departures(db: Session = Depends(get_db)):
|
||||
'out_to': None,
|
||||
'etd': flight.etd or flight.created_dt,
|
||||
'departed_dt': None,
|
||||
'status': 'BOOKED_OUT',
|
||||
'status': 'CONTACT' if flight.status == LocalFlightStatus.GROUND else 'BOOKED_OUT',
|
||||
'isLocalFlight': True,
|
||||
'flight_type': flight.flight_type.value,
|
||||
'isDeparture': False
|
||||
@@ -247,4 +259,4 @@ async def get_ui_config():
|
||||
"top_bar_gradient_end": lighten_color(base_color, 0.4), # Lighten for gradient end
|
||||
"footer_color": darken_color(base_color, 0.2), # Darken for footer
|
||||
"environment": settings.environment
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -22,11 +21,13 @@ class Settings(BaseSettings):
|
||||
mail_password: str
|
||||
mail_from: str
|
||||
mail_from_name: str
|
||||
drone_request_tower_email: str | None = None
|
||||
|
||||
# Application settings
|
||||
api_v1_str: str = "/api/v1"
|
||||
project_name: str = "Airfield PPR API"
|
||||
base_url: str
|
||||
local_timezone: str = "Europe/London"
|
||||
|
||||
# UI Configuration
|
||||
tag: str = ""
|
||||
@@ -36,9 +37,6 @@ class Settings(BaseSettings):
|
||||
# Public booking settings
|
||||
allow_public_booking: bool = False # Enable/disable public flight booking
|
||||
|
||||
# Redis settings (for future use)
|
||||
redis_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
@@ -48,4 +46,4 @@ class Settings(BaseSettings):
|
||||
return f"mysql+pymysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
settings = Settings()
|
||||
|
||||
@@ -19,7 +19,14 @@ class EmailService:
|
||||
template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates')
|
||||
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
|
||||
|
||||
async def send_email(self, to_email: str, subject: str, template_name: str, template_vars: dict):
|
||||
async def send_email(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
template_name: str,
|
||||
template_vars: dict,
|
||||
reply_to: str | None = None,
|
||||
):
|
||||
# Render the template
|
||||
template = self.jinja_env.get_template(template_name)
|
||||
html_content = template.render(**template_vars)
|
||||
@@ -29,6 +36,8 @@ class EmailService:
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f"{self.from_name} <{self.from_email}>"
|
||||
msg['To'] = to_email
|
||||
if reply_to:
|
||||
msg['Reply-To'] = reply_to
|
||||
|
||||
# Attach HTML content
|
||||
html_part = MIMEText(html_content, 'html')
|
||||
@@ -45,4 +54,4 @@ class EmailService:
|
||||
# In production, use logging
|
||||
|
||||
|
||||
email_service = EmailService()
|
||||
email_service = EmailService()
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
SWANSEA_FRZ_POLYGONS = [
|
||||
{
|
||||
"name": "EGR1U003A SWANSEA",
|
||||
"coordinates": [
|
||||
[51.6385691199, -4.0677777778],
|
||||
[51.6383925433, -4.0732789905],
|
||||
[51.6378646894, -4.0787217619],
|
||||
[51.6369911656, -4.0840482758],
|
||||
[51.6357812508, -4.0892019586],
|
||||
[51.6342477966, -4.0941280843],
|
||||
[51.6324070894, -4.0987743581],
|
||||
[51.6302786768, -4.1030914745],
|
||||
[51.6278851591, -4.1070336417],
|
||||
[51.6252519474, -4.1105590688],
|
||||
[51.6224069933, -4.1136304084],
|
||||
[51.6193804903, -4.1162151518],
|
||||
[51.6162045529, -4.1182859716],
|
||||
[51.6129128741, -4.1198210079],
|
||||
[51.6095403677, -4.1208040969],
|
||||
[51.606122797, -4.1212249367],
|
||||
[51.6026963949, -4.1210791924],
|
||||
[51.5992974802, -4.120368536],
|
||||
[51.595962072, -4.1191006239],
|
||||
[51.592725509, -4.1172890099],
|
||||
[51.5896220749, -4.1149529973],
|
||||
[51.5866846367, -4.1121174298],
|
||||
[51.5839442973, -4.1088124245],
|
||||
[51.5814300673, -4.1050730509],
|
||||
[51.5791685587, -4.1009389566],
|
||||
[51.5771837053, -4.0964539477],
|
||||
[51.5754965099, -4.0916655245],
|
||||
[51.5741248235, -4.0866243798],
|
||||
[51.5730831575, -4.0813838645],
|
||||
[51.572382531, -4.0759994259],
|
||||
[51.5720303553, -4.0705280237],
|
||||
[51.5720303553, -4.0650275318],
|
||||
[51.572382531, -4.0595561297],
|
||||
[51.5730831575, -4.054171691],
|
||||
[51.5741248235, -4.0489311758],
|
||||
[51.5754965099, -4.0438900311],
|
||||
[51.5771837053, -4.0391016078],
|
||||
[51.5791685587, -4.0346165989],
|
||||
[51.5814300673, -4.0304825047],
|
||||
[51.5839442973, -4.026743131],
|
||||
[51.5866846367, -4.0234381258],
|
||||
[51.5896220749, -4.0206025582],
|
||||
[51.592725509, -4.0182665456],
|
||||
[51.595962072, -4.0164549317],
|
||||
[51.5992974802, -4.0151870195],
|
||||
[51.6026963949, -4.0144763632],
|
||||
[51.606122797, -4.0143306189],
|
||||
[51.6095403677, -4.0147514587],
|
||||
[51.6129128741, -4.0157345476],
|
||||
[51.6162045529, -4.017269584],
|
||||
[51.6193804903, -4.0193404037],
|
||||
[51.6224069933, -4.0219251472],
|
||||
[51.6252519474, -4.0249964868],
|
||||
[51.6278851591, -4.0285219138],
|
||||
[51.6302786768, -4.0324640811],
|
||||
[51.6324070894, -4.0367811974],
|
||||
[51.6342477966, -4.0414274713],
|
||||
[51.6357812508, -4.0463535969],
|
||||
[51.6369911656, -4.0515072798],
|
||||
[51.6378646894, -4.0568337937],
|
||||
[51.6383925433, -4.0622765651],
|
||||
[51.6385691199, -4.0677777778],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "EGR1U003B SWANSEA RWY 04",
|
||||
"coordinates": [
|
||||
[51.5614305556, -4.1105694444],
|
||||
[51.5760447778, -4.0933516667],
|
||||
[51.5775992074, -4.0974772875],
|
||||
[51.5793789018, -4.1013615228],
|
||||
[51.5813693889, -4.1049727778],
|
||||
[51.5667527778, -4.1221888889],
|
||||
[51.5614305556, -4.1105694444],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "EGR1U003C SWANSEA RWY 22",
|
||||
"coordinates": [
|
||||
[51.6483027778, -4.0259555556],
|
||||
[51.6345286389, -4.0422406389],
|
||||
[51.632975828, -4.0381074205],
|
||||
[51.631197314, -4.0342163026],
|
||||
[51.6292076111, -4.030599],
|
||||
[51.6429805556, -4.0143111111],
|
||||
[51.6483027778, -4.0259555556],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "EGR1U003D SWANSEA RWY 10",
|
||||
"coordinates": [
|
||||
[51.6016305556, -4.1483194444],
|
||||
[51.5997253611, -4.1204896111],
|
||||
[51.602737017, -4.1210842429],
|
||||
[51.605769878, -4.1212361072],
|
||||
[51.60879875, -4.1209438611],
|
||||
[51.6105638889, -4.1467305556],
|
||||
[51.6016305556, -4.1483194444],
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "EGR1U003E SWANSEA RWY 28",
|
||||
"coordinates": [
|
||||
[51.5998777778, -3.9918916667],
|
||||
[51.6014628333, -4.0146683056],
|
||||
[51.5984676719, -4.015448363],
|
||||
[51.5955291688, -4.0166629251],
|
||||
[51.5926717222, -4.0183018333],
|
||||
[51.5909444444, -3.9934777778],
|
||||
[51.5998777778, -3.9918916667],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def point_inside_swansea_frz(lat: float, lng: float) -> bool:
|
||||
"""Return whether a point is inside the Swansea UAS FRZ polygons from the KML source."""
|
||||
return any(_point_inside_polygon(lat, lng, polygon["coordinates"]) for polygon in SWANSEA_FRZ_POLYGONS)
|
||||
|
||||
|
||||
def swansea_frz_geojson() -> dict:
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"name": polygon["name"]},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[
|
||||
[lng, lat] for lat, lng in polygon["coordinates"]
|
||||
]],
|
||||
},
|
||||
}
|
||||
for polygon in SWANSEA_FRZ_POLYGONS
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _point_inside_polygon(lat: float, lng: float, coordinates: list[list[float]]) -> bool:
|
||||
inside = False
|
||||
j = len(coordinates) - 1
|
||||
for i, (point_lat, point_lng) in enumerate(coordinates):
|
||||
previous_lat, previous_lng = coordinates[j]
|
||||
intersects = (
|
||||
(point_lat > lat) != (previous_lat > lat)
|
||||
and lng < (previous_lng - point_lng) * (lat - point_lat) / (previous_lat - point_lat) + point_lng
|
||||
)
|
||||
if intersects:
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
@@ -0,0 +1,189 @@
|
||||
from datetime import date, datetime
|
||||
import secrets
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.frz import point_inside_swansea_frz
|
||||
from app.crud.crud_journal import journal as crud_journal
|
||||
from app.models.drone_request import DroneRequest, DroneRequestStatus
|
||||
from app.models.journal import EntityType
|
||||
from app.schemas.drone_request import DroneRequestCreate, DroneRequestUpdate
|
||||
|
||||
|
||||
class CRUDDroneRequest:
|
||||
def get(self, db: Session, request_id: int) -> Optional[DroneRequest]:
|
||||
return db.query(DroneRequest).filter(DroneRequest.id == request_id).first()
|
||||
|
||||
def get_by_reference(self, db: Session, reference_number: str) -> Optional[DroneRequest]:
|
||||
return db.query(DroneRequest).filter(DroneRequest.reference_number == reference_number).first()
|
||||
|
||||
def get_by_public_token(self, db: Session, token: str) -> Optional[DroneRequest]:
|
||||
return db.query(DroneRequest).filter(DroneRequest.public_token == token).first()
|
||||
|
||||
def get_multi(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[DroneRequestStatus] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
) -> List[DroneRequest]:
|
||||
query = db.query(DroneRequest)
|
||||
|
||||
if status:
|
||||
query = query.filter(DroneRequest.status == status)
|
||||
if date_from:
|
||||
query = query.filter(func.date(DroneRequest.estimated_takeoff_at) >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(func.date(DroneRequest.estimated_takeoff_at) <= date_to)
|
||||
|
||||
return query.order_by(desc(DroneRequest.submitted_at)).offset(skip).limit(limit).all()
|
||||
|
||||
def create(
|
||||
self,
|
||||
db: Session,
|
||||
obj_in: DroneRequestCreate,
|
||||
created_by: str = "public",
|
||||
user_ip: str = "127.0.0.1",
|
||||
submitted_via: str = "PUBLIC",
|
||||
) -> DroneRequest:
|
||||
reference_number = self._generate_reference(db)
|
||||
payload = obj_in.dict()
|
||||
notes = payload.pop("notes", None)
|
||||
payload.pop("prototype_overlay", None)
|
||||
payload.pop("location_inside_frz", None)
|
||||
|
||||
db_obj = DroneRequest(
|
||||
**payload,
|
||||
applicant_notes=notes,
|
||||
location_inside_frz=point_inside_swansea_frz(payload["location_latitude"], payload["location_longitude"]),
|
||||
reference_number=reference_number,
|
||||
public_token=secrets.token_urlsafe(64),
|
||||
status=DroneRequestStatus.NEW,
|
||||
created_by=created_by,
|
||||
submitted_ip=user_ip,
|
||||
submitted_via=submitted_via,
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
crud_journal.log_change(
|
||||
db,
|
||||
EntityType.DRONE_REQUEST,
|
||||
db_obj.id,
|
||||
f"Drone request {db_obj.reference_number} created",
|
||||
created_by,
|
||||
user_ip,
|
||||
)
|
||||
return db_obj
|
||||
|
||||
def update(
|
||||
self,
|
||||
db: Session,
|
||||
db_obj: DroneRequest,
|
||||
obj_in: DroneRequestUpdate,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1",
|
||||
) -> DroneRequest:
|
||||
update_data = obj_in.dict(exclude_unset=True)
|
||||
if "notes" in update_data:
|
||||
update_data["applicant_notes"] = update_data.pop("notes")
|
||||
update_data.pop("prototype_overlay", None)
|
||||
update_data.pop("location_inside_frz", None)
|
||||
|
||||
if "location_latitude" in update_data or "location_longitude" in update_data:
|
||||
lat = update_data.get("location_latitude", db_obj.location_latitude)
|
||||
lng = update_data.get("location_longitude", db_obj.location_longitude)
|
||||
update_data["location_inside_frz"] = point_inside_swansea_frz(lat, lng)
|
||||
changes = []
|
||||
|
||||
for field, value in update_data.items():
|
||||
old_value = getattr(db_obj, field)
|
||||
if old_value != value:
|
||||
changes.append(f"{field} changed from '{old_value}' to '{value}'")
|
||||
setattr(db_obj, field, value)
|
||||
|
||||
if changes:
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
for change in changes:
|
||||
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, change, user, user_ip)
|
||||
|
||||
return db_obj
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
db: Session,
|
||||
request_id: int,
|
||||
status: DroneRequestStatus,
|
||||
comment: Optional[str] = None,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1",
|
||||
) -> Optional[DroneRequest]:
|
||||
db_obj = self.get(db, request_id)
|
||||
if not db_obj:
|
||||
return None
|
||||
|
||||
old_status = db_obj.status
|
||||
db_obj.status = status
|
||||
db_obj.status_changed_at = datetime.utcnow()
|
||||
db_obj.status_changed_by = user
|
||||
if comment:
|
||||
db_obj.operator_comments = comment
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
entry = f"Status changed from {old_status.value} to {status.value}"
|
||||
if comment:
|
||||
entry = f"{entry}: {comment}"
|
||||
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, entry, user, user_ip)
|
||||
return db_obj
|
||||
|
||||
def add_comment(
|
||||
self,
|
||||
db: Session,
|
||||
request_id: int,
|
||||
comment: str,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1",
|
||||
) -> Optional[DroneRequest]:
|
||||
db_obj = self.get(db, request_id)
|
||||
if not db_obj:
|
||||
return None
|
||||
|
||||
db_obj.operator_comments = comment
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
crud_journal.log_change(db, EntityType.DRONE_REQUEST, db_obj.id, f"Comment added: {comment}", user, user_ip)
|
||||
return db_obj
|
||||
|
||||
def _generate_reference(self, db: Session) -> str:
|
||||
prefix = f"DRN-{datetime.utcnow().strftime('%y%m%d')}"
|
||||
references = db.query(DroneRequest.reference_number).filter(
|
||||
DroneRequest.reference_number.like(f"{prefix}-%")
|
||||
).all()
|
||||
|
||||
highest_sequence = 0
|
||||
for (reference_number,) in references:
|
||||
suffix = reference_number.rsplit("-", 1)[-1]
|
||||
if suffix.isdigit():
|
||||
highest_sequence = max(highest_sequence, int(suffix))
|
||||
|
||||
for sequence in range(highest_sequence + 1, highest_sequence + 11):
|
||||
candidate = f"{prefix}-{sequence}"
|
||||
if not self.get_by_reference(db, candidate):
|
||||
return candidate
|
||||
|
||||
return f"{prefix}-{highest_sequence + 11}"
|
||||
|
||||
|
||||
drone_request = CRUDDroneRequest()
|
||||
@@ -187,7 +187,7 @@ class CRUDLocalFlight:
|
||||
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||
if status == LocalFlightStatus.GROUND:
|
||||
db_obj.contact_dt = current_time
|
||||
elif status == LocalFlightStatus.DEPARTED:
|
||||
elif status == LocalFlightStatus.DEPARTED and not db_obj.departed_dt:
|
||||
db_obj.departed_dt = current_time
|
||||
elif status == LocalFlightStatus.LANDED and not db_obj.landed_dt:
|
||||
db_obj.landed_dt = current_time
|
||||
@@ -200,6 +200,8 @@ class CRUDLocalFlight:
|
||||
# Takeoff: happens once when transitioning away from GROUND
|
||||
if old_status == LocalFlightStatus.GROUND and status in (LocalFlightStatus.DEPARTED, LocalFlightStatus.LOCAL, LocalFlightStatus.CIRCUIT) and not db_obj.takeoff_dt:
|
||||
db_obj.takeoff_dt = current_time
|
||||
if not db_obj.departed_dt:
|
||||
db_obj.departed_dt = current_time
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
|
||||
@@ -47,6 +47,37 @@ class CRUDMovement:
|
||||
db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def update(self, db: Session, db_obj: Movement, obj_in: MovementCreate) -> Movement:
|
||||
update_data = obj_in.dict()
|
||||
for field, value in update_data.items():
|
||||
setattr(db_obj, field, value)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def find_daily_match(
|
||||
self,
|
||||
db: Session,
|
||||
target_date: date,
|
||||
aircraft_registration: str,
|
||||
movement_type: MovementType,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_types: Optional[List[str]] = None
|
||||
) -> Optional[Movement]:
|
||||
clean_reg = "".join(char for char in aircraft_registration.upper() if char.isalnum())
|
||||
clean_column = func.upper(func.replace(func.replace(Movement.aircraft_registration, "-", ""), " ", ""))
|
||||
query = db.query(Movement).filter(
|
||||
func.date(Movement.timestamp) == target_date,
|
||||
clean_column == clean_reg,
|
||||
Movement.movement_type == movement_type
|
||||
)
|
||||
if entity_type:
|
||||
query = query.filter(Movement.entity_type == entity_type)
|
||||
if entity_types:
|
||||
query = query.filter(Movement.entity_type.in_(entity_types))
|
||||
return query.order_by(Movement.timestamp.desc()).first()
|
||||
|
||||
def get_movements_by_entity(self, db: Session, entity_type: str, entity_id: int) -> List[Movement]:
|
||||
return db.query(Movement).filter(
|
||||
and_(Movement.entity_type == entity_type, Movement.entity_id == entity_id)
|
||||
@@ -58,4 +89,4 @@ class CRUDMovement:
|
||||
).order_by(Movement.timestamp).all()
|
||||
|
||||
|
||||
movement = CRUDMovement()
|
||||
movement = CRUDMovement()
|
||||
|
||||
@@ -58,6 +58,7 @@ class CRUDPPR:
|
||||
PPRRecord.status == PPRStatus.NEW,
|
||||
PPRRecord.status == PPRStatus.CONFIRMED,
|
||||
PPRRecord.status == PPRStatus.LANDED,
|
||||
PPRRecord.status == PPRStatus.LOCAL,
|
||||
PPRRecord.status == PPRStatus.DEPARTED
|
||||
)
|
||||
)
|
||||
@@ -71,6 +72,7 @@ class CRUDPPR:
|
||||
func.date(PPRRecord.etd) == today,
|
||||
or_(
|
||||
PPRRecord.status == PPRStatus.LANDED,
|
||||
PPRRecord.status == PPRStatus.LOCAL,
|
||||
PPRRecord.status == PPRStatus.DEPARTED
|
||||
)
|
||||
)
|
||||
@@ -151,8 +153,10 @@ class CRUDPPR:
|
||||
current_time = timestamp if timestamp is not None else datetime.utcnow()
|
||||
if status == PPRStatus.LANDED:
|
||||
db_obj.landed_dt = current_time
|
||||
elif status == PPRStatus.LOCAL:
|
||||
db_obj.takeoff_dt = current_time
|
||||
elif status == PPRStatus.DEPARTED:
|
||||
db_obj.departed_dt = current_time
|
||||
db_obj.qsy_dt = current_time
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
@@ -169,6 +173,36 @@ class CRUDPPR:
|
||||
|
||||
return db_obj
|
||||
|
||||
def acknowledge_strip(
|
||||
self,
|
||||
db: Session,
|
||||
ppr_id: int,
|
||||
user: str = "system",
|
||||
user_ip: str = "127.0.0.1"
|
||||
) -> Optional[PPRRecord]:
|
||||
db_obj = self.get(db, ppr_id)
|
||||
if not db_obj:
|
||||
return None
|
||||
|
||||
if db_obj.acknowledged_dt:
|
||||
return db_obj
|
||||
|
||||
db_obj.acknowledged_dt = datetime.utcnow()
|
||||
db_obj.acknowledged_by = user
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
crud_journal.log_ppr_change(
|
||||
db,
|
||||
db_obj.id,
|
||||
f"Paper strip acknowledged by {user}",
|
||||
user,
|
||||
user_ip
|
||||
)
|
||||
|
||||
return db_obj
|
||||
|
||||
def delete(self, db: Session, ppr_id: int, user: str = "system", user_ip: str = "127.0.0.1") -> Optional[PPRRecord]:
|
||||
db_obj = self.get(db, ppr_id)
|
||||
if db_obj:
|
||||
@@ -182,6 +216,7 @@ class CRUDPPR:
|
||||
# Log the deletion in journal
|
||||
crud_journal.log_change(
|
||||
db,
|
||||
EntityType.PPR,
|
||||
db_obj.id,
|
||||
f"PPR marked as DELETED (was {old_status.value})",
|
||||
user,
|
||||
@@ -190,4 +225,4 @@ class CRUDPPR:
|
||||
return db_obj
|
||||
|
||||
|
||||
ppr = CRUDPPR()
|
||||
ppr = CRUDPPR()
|
||||
|
||||
+9
-90
@@ -1,10 +1,8 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from typing import List
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import redis.asyncio as redis
|
||||
from app.core.config import settings
|
||||
from app.api.api import api_router
|
||||
|
||||
@@ -16,15 +14,12 @@ from app.models.departure import Departure
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.circuit import Circuit
|
||||
from app.models.movement import Movement
|
||||
from app.models.drone_request import DroneRequest
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis client for pub/sub (cross-worker communication)
|
||||
redis_client = None
|
||||
pubsub = None
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.project_name,
|
||||
openapi_url=f"{settings.api_v1_str}/openapi.json",
|
||||
@@ -45,7 +40,6 @@ app.add_middleware(
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
self.redis_listener_task = None
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
@@ -60,102 +54,27 @@ class ConnectionManager:
|
||||
async def send_personal_message(self, message: str, websocket: WebSocket):
|
||||
await websocket.send_text(message)
|
||||
|
||||
async def broadcast_local(self, message_str: str):
|
||||
"""Broadcast to connections on this worker only"""
|
||||
async def broadcast(self, message: dict):
|
||||
"""Broadcast an update to every websocket connected to this process."""
|
||||
message_str = json.dumps(message)
|
||||
dead_connections = []
|
||||
for connection in self.active_connections:
|
||||
for connection in list(self.active_connections):
|
||||
try:
|
||||
await connection.send_text(message_str)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send to connection: {e}")
|
||||
dead_connections.append(connection)
|
||||
|
||||
|
||||
# Remove dead connections
|
||||
for connection in dead_connections:
|
||||
if connection in self.active_connections:
|
||||
self.active_connections.remove(connection)
|
||||
|
||||
|
||||
if dead_connections:
|
||||
logger.info(f"Removed {len(dead_connections)} dead connections")
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Broadcast via Redis pub/sub to all workers"""
|
||||
message_str = json.dumps(message)
|
||||
print(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
|
||||
logger.info(f"Publishing message to Redis channel: {message.get('type', 'unknown')}")
|
||||
|
||||
try:
|
||||
if redis_client:
|
||||
await redis_client.publish('ppr_updates', message_str)
|
||||
print(f"✓ Message published to Redis")
|
||||
else:
|
||||
# Fallback to local broadcast if Redis not available
|
||||
print("⚠ Redis not available, falling back to local broadcast")
|
||||
logger.warning("Redis not available, falling back to local broadcast")
|
||||
await self.broadcast_local(message_str)
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to publish to Redis: {e}")
|
||||
logger.error(f"Failed to publish to Redis: {e}")
|
||||
# Fallback to local broadcast
|
||||
await self.broadcast_local(message_str)
|
||||
|
||||
async def start_redis_listener(self):
|
||||
"""Listen for Redis pub/sub messages and broadcast to local connections"""
|
||||
global redis_client, pubsub
|
||||
|
||||
try:
|
||||
# Connect to Redis
|
||||
redis_url = settings.redis_url or "redis://redis:6379"
|
||||
print(f"Connecting to Redis at: {redis_url}")
|
||||
redis_client = await redis.from_url(redis_url, encoding="utf-8", decode_responses=True)
|
||||
pubsub = redis_client.pubsub()
|
||||
await pubsub.subscribe('ppr_updates')
|
||||
|
||||
print("✓ Redis listener started for PPR updates")
|
||||
logger.info("Redis listener started for PPR updates")
|
||||
|
||||
async for message in pubsub.listen():
|
||||
if message['type'] == 'message':
|
||||
message_data = message['data']
|
||||
print(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
|
||||
logger.info(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections")
|
||||
await self.broadcast_local(message_data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Redis listener error: {e}")
|
||||
logger.error(f"Redis listener error: {e}")
|
||||
await asyncio.sleep(5) # Wait before retry
|
||||
# Retry connection
|
||||
if self.redis_listener_task and not self.redis_listener_task.done():
|
||||
asyncio.create_task(self.start_redis_listener())
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Start Redis listener when application starts"""
|
||||
print("=" * 50)
|
||||
print("STARTUP: Starting application and Redis listener...")
|
||||
print("=" * 50)
|
||||
logger.info("Starting application and Redis listener...")
|
||||
manager.redis_listener_task = asyncio.create_task(manager.start_redis_listener())
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Clean up Redis connections on shutdown"""
|
||||
logger.info("Shutting down application...")
|
||||
global redis_client, pubsub
|
||||
|
||||
if manager.redis_listener_task:
|
||||
manager.redis_listener_task.cancel()
|
||||
|
||||
if pubsub:
|
||||
await pubsub.unsubscribe('ppr_updates')
|
||||
await pubsub.close()
|
||||
|
||||
if redis_client:
|
||||
await redis_client.close()
|
||||
|
||||
@app.websocket("/ws/tower-updates")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await manager.connect(websocket)
|
||||
@@ -206,4 +125,4 @@ async def health_check():
|
||||
app.include_router(api_router, prefix=settings.api_v1_str)
|
||||
|
||||
# Make connection manager available to the app
|
||||
app.state.connection_manager = manager
|
||||
app.state.connection_manager = manager
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
Base = declarative_base()
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class SubmissionSource(str, Enum):
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Column, Date, DateTime, Enum as SQLEnum, Float, Index, Integer, JSON, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class DroneRequestStatus(str, Enum):
|
||||
NEW = "NEW"
|
||||
APPROVED = "APPROVED"
|
||||
DENIED = "DENIED"
|
||||
CANCELED = "CANCELED"
|
||||
INFLIGHT = "INFLIGHT"
|
||||
COMPLETED = "COMPLETED"
|
||||
|
||||
|
||||
class DroneRequest(Base):
|
||||
__tablename__ = "drone_requests"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
reference_number = Column(String(24), nullable=False, unique=True, index=True)
|
||||
public_token = Column(String(128), nullable=True, unique=True, index=True)
|
||||
status = Column(SQLEnum(DroneRequestStatus), nullable=False, default=DroneRequestStatus.NEW, index=True)
|
||||
|
||||
operator_name = Column(String(128), nullable=False, index=True)
|
||||
operator_id = Column(String(64), nullable=True)
|
||||
flyer_name = Column(String(128), nullable=True)
|
||||
flyer_id = Column(String(64), nullable=True)
|
||||
email = Column(String(128), nullable=False, index=True)
|
||||
phone = Column(String(32), nullable=True)
|
||||
|
||||
flight_date = Column(Date, nullable=True, index=True)
|
||||
estimated_takeoff_time = Column(String(8), nullable=True)
|
||||
estimated_completion_time = Column(String(8), nullable=True)
|
||||
estimated_takeoff_at = Column(DateTime, nullable=False, index=True)
|
||||
estimated_completion_at = Column(DateTime, nullable=False, index=True)
|
||||
maximum_elevation_ft_agl = Column(Integer, nullable=False)
|
||||
|
||||
location_description = Column(Text, nullable=True)
|
||||
location_latitude = Column(Float, nullable=False)
|
||||
location_longitude = Column(Float, nullable=False)
|
||||
location_inside_frz = Column(Boolean, nullable=True)
|
||||
prototype_overlay = Column(JSON, nullable=True)
|
||||
|
||||
applicant_notes = Column(Text, nullable=True)
|
||||
operator_comments = Column(Text, nullable=True)
|
||||
submitted_via = Column(String(32), nullable=False, default="PUBLIC")
|
||||
submitted_ip = Column(String(45), nullable=True)
|
||||
created_by = Column(String(50), nullable=True, index=True)
|
||||
|
||||
submitted_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||
status_changed_at = Column(DateTime, nullable=True)
|
||||
status_changed_by = Column(String(50), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_drone_status_takeoff", "status", "estimated_takeoff_at"),
|
||||
)
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
return self.applicant_notes
|
||||
@@ -7,6 +7,7 @@ from app.db.session import Base
|
||||
class EntityType(str, PyEnum):
|
||||
"""Entity types that can have journal entries"""
|
||||
PPR = "PPR"
|
||||
DRONE_REQUEST = "DRONE_REQUEST"
|
||||
LOCAL_FLIGHT = "LOCAL_FLIGHT"
|
||||
ARRIVAL = "ARRIVAL"
|
||||
DEPARTURE = "DEPARTURE"
|
||||
|
||||
@@ -35,7 +35,7 @@ class LocalFlight(Base):
|
||||
registration = Column(String(16), nullable=False, index=True)
|
||||
type = Column(String(32), nullable=False) # Aircraft type
|
||||
callsign = Column(String(16), nullable=True)
|
||||
pob = Column(Integer, nullable=False) # Persons on board
|
||||
pob = Column(Integer, nullable=True) # Persons on board may be unknown for post-event logging
|
||||
flight_type = Column(SQLEnum(LocalFlightType), nullable=False, index=True)
|
||||
status = Column(SQLEnum(LocalFlightStatus), nullable=False, default=LocalFlightStatus.BOOKED_OUT, index=True)
|
||||
duration = Column(Integer, nullable=True) # Duration in minutes
|
||||
|
||||
@@ -9,6 +9,7 @@ class PPRStatus(str, Enum):
|
||||
CONFIRMED = "CONFIRMED"
|
||||
CANCELED = "CANCELED"
|
||||
LANDED = "LANDED"
|
||||
LOCAL = "LOCAL"
|
||||
DELETED = "DELETED"
|
||||
DEPARTED = "DEPARTED"
|
||||
ACTIVATED = "ACTIVATED"
|
||||
@@ -40,9 +41,12 @@ class PPRRecord(Base):
|
||||
phone = Column(String(16), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
landed_dt = Column(DateTime, nullable=True)
|
||||
departed_dt = Column(DateTime, nullable=True)
|
||||
takeoff_dt = Column(DateTime, nullable=True)
|
||||
qsy_dt = Column(DateTime, nullable=True)
|
||||
created_by = Column(String(16), nullable=True, index=True)
|
||||
submitted_dt = Column(DateTime, nullable=False, server_default=func.current_timestamp(), index=True)
|
||||
acknowledged_dt = Column(DateTime, nullable=True)
|
||||
acknowledged_by = Column(String(50), nullable=True)
|
||||
public_token = Column(String(128), nullable=True, unique=True, index=True)
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||
|
||||
@@ -101,4 +105,4 @@ class UserAircraft(Base):
|
||||
clean_reg = Column(String(25), nullable=False, index=True)
|
||||
created_by = Column(String(16), nullable=False, index=True)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
|
||||
|
||||
class ContactEnquiryType(str, Enum):
|
||||
GENERAL = "general"
|
||||
AVIATION_BUSINESS = "aviation_business"
|
||||
PILOT = "pilot"
|
||||
EVENTS = "events"
|
||||
COMMUNITY = "community"
|
||||
|
||||
|
||||
class ContactRequestCreate(BaseModel):
|
||||
name: str = Field(..., max_length=128)
|
||||
email: EmailStr
|
||||
phone: Optional[str] = Field(None, max_length=32)
|
||||
enquiry_type: ContactEnquiryType
|
||||
subject: str = Field(..., max_length=160)
|
||||
message: str = Field(..., min_length=1, max_length=4000)
|
||||
source_page: Optional[str] = Field(None, max_length=256)
|
||||
|
||||
@validator("name", "subject", "message")
|
||||
def validate_required_text(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("Field is required")
|
||||
return value
|
||||
|
||||
@validator("phone", "source_page")
|
||||
def strip_optional_text(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class ContactRequestReceipt(BaseModel):
|
||||
status: str = "received"
|
||||
@@ -0,0 +1,118 @@
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, EmailStr, Field, validator
|
||||
|
||||
|
||||
class DroneRequestStatus(str, Enum):
|
||||
NEW = "NEW"
|
||||
APPROVED = "APPROVED"
|
||||
DENIED = "DENIED"
|
||||
CANCELED = "CANCELED"
|
||||
INFLIGHT = "INFLIGHT"
|
||||
COMPLETED = "COMPLETED"
|
||||
|
||||
|
||||
class DroneRequestBase(BaseModel):
|
||||
operator_name: str = Field(..., max_length=128)
|
||||
operator_id: Optional[str] = Field(None, max_length=64)
|
||||
flight_date: Optional[date] = None
|
||||
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
||||
maximum_elevation_ft_agl: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("maximum_elevation_ft_agl", "maximum_elevation_ft_amsl"),
|
||||
)
|
||||
location_description: Optional[str] = None
|
||||
location_latitude: float = Field(..., ge=-90, le=90)
|
||||
location_longitude: float = Field(..., ge=-180, le=180)
|
||||
location_inside_frz: Optional[bool] = None
|
||||
flyer_name: Optional[str] = Field(None, max_length=128)
|
||||
flyer_id: Optional[str] = Field(None, max_length=64)
|
||||
email: EmailStr
|
||||
phone: Optional[str] = Field(None, max_length=32)
|
||||
notes: Optional[str] = None
|
||||
estimated_takeoff_at: datetime
|
||||
estimated_completion_at: datetime
|
||||
prototype_overlay: Optional[dict[str, Any]] = None
|
||||
|
||||
@validator("operator_name")
|
||||
def validate_operator_name(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("Operator name is required")
|
||||
return value
|
||||
|
||||
@validator("location_inside_frz", pre=True)
|
||||
def parse_inside_frz(cls, value):
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"yes", "true", "1", "y"}:
|
||||
return True
|
||||
if normalized in {"no", "false", "0", "n"}:
|
||||
return False
|
||||
return value
|
||||
|
||||
|
||||
class DroneRequestCreate(DroneRequestBase):
|
||||
pass
|
||||
|
||||
|
||||
class DroneRequestUpdate(BaseModel):
|
||||
operator_name: Optional[str] = Field(None, max_length=128)
|
||||
operator_id: Optional[str] = Field(None, max_length=64)
|
||||
flyer_name: Optional[str] = Field(None, max_length=128)
|
||||
flyer_id: Optional[str] = Field(None, max_length=64)
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = Field(None, max_length=32)
|
||||
flight_date: Optional[date] = None
|
||||
estimated_takeoff_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_completion_time: Optional[str] = Field(None, max_length=8)
|
||||
estimated_takeoff_at: Optional[datetime] = None
|
||||
estimated_completion_at: Optional[datetime] = None
|
||||
maximum_elevation_ft_agl: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("maximum_elevation_ft_agl", "maximum_elevation_ft_amsl"),
|
||||
)
|
||||
location_description: Optional[str] = None
|
||||
location_latitude: Optional[float] = Field(None, ge=-90, le=90)
|
||||
location_longitude: Optional[float] = Field(None, ge=-180, le=180)
|
||||
location_inside_frz: Optional[bool] = None
|
||||
notes: Optional[str] = None
|
||||
prototype_overlay: Optional[dict[str, Any]] = None
|
||||
operator_comments: Optional[str] = None
|
||||
|
||||
|
||||
class DroneRequestStatusUpdate(BaseModel):
|
||||
status: DroneRequestStatus
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
class DroneRequestComment(BaseModel):
|
||||
comment: str = Field(..., min_length=1)
|
||||
email_applicant: bool = True
|
||||
|
||||
|
||||
class DroneRequest(DroneRequestBase):
|
||||
id: int
|
||||
reference_number: str
|
||||
status: DroneRequestStatus
|
||||
operator_comments: Optional[str] = None
|
||||
submitted_via: str
|
||||
submitted_ip: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
submitted_at: datetime
|
||||
updated_at: datetime
|
||||
status_changed_at: Optional[datetime] = None
|
||||
status_changed_by: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DroneRequestPublicSubmission(DroneRequest):
|
||||
request_id: str
|
||||
secure_link: str
|
||||
@@ -32,7 +32,7 @@ class LocalFlightBase(BaseModel):
|
||||
registration: str
|
||||
type: Optional[str] = None # Aircraft type - optional, can be looked up later
|
||||
callsign: Optional[str] = None
|
||||
pob: int
|
||||
pob: Optional[int] = None
|
||||
flight_type: LocalFlightType
|
||||
duration: Optional[int] = 45 # Duration in minutes, default 45
|
||||
etd: Optional[datetime] = None # Estimated Time of Departure
|
||||
@@ -105,4 +105,4 @@ class LocalFlightInDBBase(LocalFlightBase):
|
||||
|
||||
|
||||
class LocalFlight(LocalFlightInDBBase):
|
||||
pass
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import date, datetime
|
||||
from app.models.movement import MovementType
|
||||
|
||||
|
||||
@@ -26,9 +26,48 @@ class MovementCreate(MovementBase):
|
||||
pass
|
||||
|
||||
|
||||
class BulkMovementLog(BaseModel):
|
||||
flight_kind: str
|
||||
movement_date: date
|
||||
movement_time: Optional[str] = None
|
||||
takeoff_time: Optional[str] = None
|
||||
landing_time: Optional[str] = None
|
||||
contact_time: Optional[str] = None
|
||||
qsy_time: Optional[str] = None
|
||||
aircraft_registration: str
|
||||
aircraft_type: Optional[str] = None
|
||||
callsign: Optional[str] = None
|
||||
from_location: Optional[str] = None
|
||||
to_location: Optional[str] = None
|
||||
pob: Optional[int] = None
|
||||
local_nature: Optional[str] = None
|
||||
circuits: Optional[int] = None
|
||||
runway: Optional[str] = None
|
||||
wind: Optional[str] = None
|
||||
pressure_setting: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
ppr_id: Optional[int] = None
|
||||
movement_id: Optional[int] = None
|
||||
|
||||
|
||||
class BulkMovementContext(BaseModel):
|
||||
pprs: List[dict]
|
||||
local_flights: List[dict] = Field(default_factory=list)
|
||||
movements: List[dict]
|
||||
suggested: dict
|
||||
|
||||
|
||||
class Movement(MovementBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BulkMovementResult(BaseModel):
|
||||
action: str
|
||||
movement: Movement
|
||||
entity_type: str
|
||||
entity_id: int
|
||||
message: str
|
||||
|
||||
@@ -9,6 +9,7 @@ class PPRStatus(str, Enum):
|
||||
CONFIRMED = "CONFIRMED"
|
||||
CANCELED = "CANCELED"
|
||||
LANDED = "LANDED"
|
||||
LOCAL = "LOCAL"
|
||||
DELETED = "DELETED"
|
||||
DEPARTED = "DEPARTED"
|
||||
ACTIVATED = "ACTIVATED"
|
||||
@@ -85,9 +86,12 @@ class PPRInDBBase(PPRBase):
|
||||
id: int
|
||||
status: PPRStatus
|
||||
landed_dt: Optional[datetime] = None
|
||||
departed_dt: Optional[datetime] = None
|
||||
takeoff_dt: Optional[datetime] = None
|
||||
qsy_dt: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
submitted_dt: datetime
|
||||
acknowledged_dt: Optional[datetime] = None
|
||||
acknowledged_by: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -109,7 +113,8 @@ class PPRPublic(BaseModel):
|
||||
out_to: Optional[str] = None
|
||||
etd: Optional[datetime] = None
|
||||
landed_dt: Optional[datetime] = None
|
||||
departed_dt: Optional[datetime] = None
|
||||
takeoff_dt: Optional[datetime] = None
|
||||
qsy_dt: Optional[datetime] = None
|
||||
submitted_dt: datetime
|
||||
|
||||
class Config:
|
||||
@@ -235,4 +240,4 @@ class UserAircraft(UserAircraftBase):
|
||||
|
||||
class UserAircraftCreate(BaseModel):
|
||||
registration: str
|
||||
type_code: str
|
||||
type_code: str
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Website Contact Request</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f6f8; color: #24313d; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 1.55;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f6f8; padding: 24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-collapse: collapse; border-radius: 8px; overflow: hidden; border: 1px solid #dfe5eb;">
|
||||
<tr>
|
||||
<td style="background: #263645; color: #ffffff; padding: 24px 28px;">
|
||||
<h1 style="margin: 0; font-size: 24px; line-height: 1.25;">Website Contact Request</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 17px;">{{ enquiry_type | e }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 28px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; margin: 0 0 22px;">
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc; width: 38%;"><strong>Submitted</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ submitted_at | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Name</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ name | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Email</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ email | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Phone</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ phone | default("-", true) | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Category</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ enquiry_type | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Subject</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ subject | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Source page</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ source_page | default("-", true) | e }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Client IP</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ client_ip | e }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 style="font-size: 19px; margin: 0 0 10px;">Message</h2>
|
||||
<div style="border: 1px solid #dfe5eb; background: #f8fafc; padding: 14px 16px; white-space: pre-wrap;">{{ message | e }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drone Flight Request Approved</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f6f8; color: #24313d; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 1.55;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f6f8; padding: 24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-collapse: collapse; border-radius: 8px; overflow: hidden; border: 1px solid #dfe5eb;">
|
||||
<tr>
|
||||
<td style="background: #1f7a4d; color: #ffffff; padding: 24px 28px;">
|
||||
<h1 style="margin: 0; font-size: 24px; line-height: 1.25;">Drone Flight Request Approved</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 17px;">Reference {{ reference_number }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 28px;">
|
||||
<p style="margin: 0 0 16px;">Hello {{ name }},</p>
|
||||
<p style="margin: 0 0 20px;">Your drone flight request has been approved.</p>
|
||||
|
||||
{% if message %}
|
||||
<div style="border-left: 5px solid #3498db; background: #eef7ff; padding: 16px 18px; margin: 0 0 22px;">
|
||||
<p style="margin: 0;"><strong>Airport comment:</strong> {{ message }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="border-left: 5px solid #f39c12; background: #fff7e6; padding: 16px 18px; margin: 0 0 22px;">
|
||||
<p style="margin: 0; font-size: 17px;"><strong>Before you fly:</strong> you must call the tower approximately 20 minutes before commencing flight. Do not commence unless you have made this pre-flight call and can comply with any tower instructions given at the time.</p>
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; margin: 0 0 22px;">
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc; width: 38%;"><strong>Reference</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ reference_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Status</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ status }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Takeoff</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Completion</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ completion_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Location</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ location }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if edit_url %}
|
||||
<p style="margin: 0 0 22px;">
|
||||
<a href="{{ edit_url }}" style="background: #3498db; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">View, update, or cancel request</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 0; color: #5d6d7e;">Please quote your reference number in any replies or phone calls.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drone Flight Request Submitted</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f6f8; color: #24313d; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 1.55;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f6f8; padding: 24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-collapse: collapse; border-radius: 8px; overflow: hidden; border: 1px solid #dfe5eb;">
|
||||
<tr>
|
||||
<td style="background: #263645; color: #ffffff; padding: 24px 28px;">
|
||||
<h1 style="margin: 0; font-size: 24px; line-height: 1.25;">Drone Flight Request Submitted</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 17px;">Reference {{ reference_number }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 28px;">
|
||||
<p style="margin: 0 0 16px;">Hello {{ name }},</p>
|
||||
<p style="margin: 0 0 20px;">We have received your drone flight request. We will email you when the approval status changes or if we need more information.</p>
|
||||
|
||||
<div style="border-left: 5px solid #f39c12; background: #fff7e6; padding: 16px 18px; margin: 0 0 22px;">
|
||||
<p style="margin: 0; font-size: 17px;"><strong>Before you fly:</strong> if you have not received approval on the day of flight, you must contact the tower to chase your request before commencing flight, and at least 20 minutes before your planned takeoff time.</p>
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; margin: 0 0 22px;">
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc; width: 38%;"><strong>Reference</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ reference_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Status</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ status }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Takeoff</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Completion</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ completion_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Location</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ location }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if edit_url %}
|
||||
<p style="margin: 0 0 22px;">
|
||||
<a href="{{ edit_url }}" style="background: #3498db; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">View, update, or cancel request</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 0; color: #5d6d7e;">Please quote your reference number in any replies.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Drone Request Awaiting Review</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f7fa; font-family: Arial, sans-serif; color: #263645;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background: #f4f7fa; padding: 24px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-radius: 8px; overflow: hidden;">
|
||||
<tr>
|
||||
<td style="background: #34495e; color: #ffffff; padding: 24px;">
|
||||
<h1 style="margin: 0; font-size: 24px;">Drone request awaiting review</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 16px;">{{ reference_number }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 24px;">
|
||||
<p style="font-size: 17px; margin: 0 0 18px;">
|
||||
A new drone flight request has been submitted. Please review it and approve or deny it as soon as practical.
|
||||
</p>
|
||||
<p style="margin: 0 0 22px;">
|
||||
<a href="{{ requests_url }}" style="background: #2f93d1; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">Open drone requests</a>
|
||||
</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; font-size: 15px;">
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Operator</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ operator_name }}{% if operator_id %} ({{ operator_id }}){% endif %}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Flyer</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ flyer_name or '-' }}{% if flyer_id %} ({{ flyer_id }}){% endif %}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Contact</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ email }}{% if phone %} / {{ phone }}{% endif %}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Takeoff</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Completion</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ completion_time }}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Location</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ location }}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Inside FRZ</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ inside_frz }}</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Max elevation</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td></tr>
|
||||
<tr><td style="padding: 10px; border: 1px solid #dfe5eb; font-weight: bold;">Applicant notes</td><td style="padding: 10px; border: 1px solid #dfe5eb;">{{ notes or '-' }}</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drone Flight Request Update</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f6f8; color: #24313d; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 1.55;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f6f8; padding: 24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 680px; background: #ffffff; border-collapse: collapse; border-radius: 8px; overflow: hidden; border: 1px solid #dfe5eb;">
|
||||
<tr>
|
||||
<td style="background: #263645; color: #ffffff; padding: 24px 28px;">
|
||||
<h1 style="margin: 0; font-size: 24px; line-height: 1.25;">Drone Flight Request Update</h1>
|
||||
<p style="margin: 8px 0 0; font-size: 17px;">Reference {{ reference_number }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 28px;">
|
||||
<p style="margin: 0 0 16px;">Hello {{ name }},</p>
|
||||
<p style="margin: 0 0 20px;">{{ message }}</p>
|
||||
|
||||
<div style="border-left: 5px solid #f39c12; background: #fff7e6; padding: 16px 18px; margin: 0 0 22px;">
|
||||
<p style="margin: 0; font-size: 17px;"><strong>Before you fly:</strong> if you have not received approval on the day of flight, you must contact the tower to chase your request before commencing flight, and at least 20 minutes before your planned takeoff time.</p>
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse; margin: 0 0 22px;">
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc; width: 38%;"><strong>Reference</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ reference_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Status</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ status }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Takeoff</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ takeoff_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Completion</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ completion_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Location</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ location }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb; background: #f8fafc;"><strong>Max elevation</strong></td>
|
||||
<td style="padding: 12px; border: 1px solid #dfe5eb;">{{ maximum_elevation_ft_agl }} ft AGL</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if edit_url %}
|
||||
<p style="margin: 0 0 22px;">
|
||||
<a href="{{ edit_url }}" style="background: #3498db; color: #ffffff; display: inline-block; padding: 12px 18px; border-radius: 5px; text-decoration: none; font-weight: bold;">View, update, or cancel request</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 0; color: #5d6d7e;">Please quote your reference number in any replies.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,10 +10,10 @@
|
||||
<p><strong>PPR Details:</strong></p>
|
||||
<ul>
|
||||
<li>Aircraft: {{ aircraft }}</li>
|
||||
<li>Original Arrival: {{ arrival_time }}</li>
|
||||
<li>Original Departure: {{ departure_time }}</li>
|
||||
<li>Original Arrival (local time): {{ arrival_time }}</li>
|
||||
<li>Original Departure (local time): {{ departure_time }}</li>
|
||||
</ul>
|
||||
<p>If this was not intended, please contact us.</p>
|
||||
<p>Best regards,<br>Swansea Airport Team</p>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
<p><strong>PPR Details:</strong></p>
|
||||
<ul>
|
||||
<li>Aircraft: {{ aircraft }}</li>
|
||||
<li>Arrival: {{ arrival_time }}</li>
|
||||
<li>Departure: {{ departure_time }}</li>
|
||||
<li>Purpose: {{ purpose }}</li>
|
||||
<li>Arrival (local time): {{ arrival_time }}</li>
|
||||
<li>Departure (local time): {{ departure_time }}</li>
|
||||
</ul>
|
||||
<p>You can <a href="{{ base_url }}/edit.html?token={{ public_token }}">edit or cancel</a> your PPR using this secure link.</p>
|
||||
<p>You will receive further updates via email.</p>
|
||||
<p>Best regards,<br>Swansea Airport Team</p>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -188,8 +188,8 @@ echo ""
|
||||
|
||||
# Start the application with appropriate settings
|
||||
if [ "${ENVIRONMENT}" = "production" ]; then
|
||||
echo "Starting in PRODUCTION mode with multiple workers..."
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${WORKERS:-4}
|
||||
echo "Starting in PRODUCTION mode with a single worker for in-process WebSocket broadcasts..."
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
else
|
||||
echo "Starting in DEVELOPMENT mode with auto-reload..."
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
pythonpath = .
|
||||
addopts = -q
|
||||
filterwarnings =
|
||||
ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic\._internal\._config
|
||||
ignore:Pydantic V1 style `@validator` validators are deprecated:DeprecationWarning:app\.schemas\..*
|
||||
ignore:The `dict` method is deprecated; use `model_dump` instead:DeprecationWarning:app\.crud\..*
|
||||
ignore:The `dict` method is deprecated; use `model_dump` instead:DeprecationWarning:pydantic\.main
|
||||
ignore:The ``declarative_base\(\)`` function is now available as sqlalchemy\.orm\.declarative_base\(\):sqlalchemy.exc.MovedIn20Warning:app\.db\.session
|
||||
ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib\.utils
|
||||
ignore:\s*on_event is deprecated, use lifespan event handlers instead:DeprecationWarning:app\.main
|
||||
ignore:\s*on_event is deprecated, use lifespan event handlers instead:DeprecationWarning:fastapi\.applications
|
||||
@@ -12,8 +12,8 @@ email-validator==2.1.0
|
||||
pydantic[email]==2.5.0
|
||||
pydantic-settings==2.0.3
|
||||
pytest==7.4.3
|
||||
pytest-cov==4.1.0
|
||||
pytest-asyncio==0.21.1
|
||||
httpx==0.25.2
|
||||
redis==5.0.1
|
||||
aiosmtplib==3.0.1
|
||||
jinja2==3.1.2
|
||||
jinja2==3.1.2
|
||||
|
||||
@@ -81,7 +81,7 @@ DROP TABLE IF EXISTS `submitted`;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `submitted` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`status` enum('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED') CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT 'NEW',
|
||||
`status` enum('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT 'NEW',
|
||||
`ac_reg` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`ac_type` varchar(32) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`ac_call` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL,
|
||||
@@ -97,7 +97,8 @@ CREATE TABLE `submitted` (
|
||||
`phone` varchar(16) DEFAULT NULL,
|
||||
`notes` varchar(2000) DEFAULT NULL,
|
||||
`landed_dt` datetime DEFAULT NULL,
|
||||
`departed_dt` datetime DEFAULT NULL,
|
||||
`takeoff_dt` datetime DEFAULT NULL,
|
||||
`qsy_dt` datetime DEFAULT NULL,
|
||||
`created_by` varchar(16) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT NULL,
|
||||
`submitted_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `id` (`id`)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Backend API Test Guide
|
||||
|
||||
This directory contains the backend API test suite. The tests use pytest, FastAPI's `TestClient`, and an isolated in-memory SQLite database. The goal is to cover the business-critical API behaviour without relying on MySQL, SMTP, or a running browser.
|
||||
|
||||
## How To Run
|
||||
|
||||
From `backend/`:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
pytest --cov=app --cov-report=term-missing
|
||||
```
|
||||
|
||||
From the project root with Docker Compose running:
|
||||
|
||||
```bash
|
||||
docker compose exec api pytest
|
||||
docker compose exec api pytest --cov=app --cov-report=term-missing
|
||||
```
|
||||
|
||||
## Shared Fixtures
|
||||
|
||||
### `conftest.py`
|
||||
|
||||
Sets up the test harness used by every module.
|
||||
|
||||
What it does:
|
||||
- Provides safe default environment variables before the app imports settings.
|
||||
- Creates an in-memory SQLite database and overrides FastAPI's `get_db` dependency.
|
||||
- Recreates all tables for every test so tests cannot leak state into each other.
|
||||
- Patches SQLite primary-key handling for models that use `BigInteger` ids in production.
|
||||
- Provides `client` for unauthenticated requests and `auth_client` for administrator/operator requests.
|
||||
- Provides reusable PPR payload and factory fixtures.
|
||||
|
||||
Why it exists:
|
||||
- Keeps API tests fast, deterministic, and independent from Docker MySQL data.
|
||||
- Lets tests exercise the real FastAPI routes, schemas, CRUD calls, and dependency overrides.
|
||||
|
||||
## Test Modules
|
||||
|
||||
### `test_app_health.py`
|
||||
|
||||
Covers the simplest application-level endpoints.
|
||||
|
||||
What it tests:
|
||||
- `/` returns API metadata.
|
||||
- `/health` reports a healthy application and database connection.
|
||||
|
||||
Why it matters:
|
||||
- These tests catch broken app imports, router setup problems, and database dependency regressions early.
|
||||
|
||||
### `test_auth_api.py`
|
||||
|
||||
Covers authentication and admin user-management routes.
|
||||
|
||||
What it tests:
|
||||
- Login rejects invalid credentials.
|
||||
- Login returns a bearer token for a valid user.
|
||||
- Admin users can create, list, update, and change passwords for users.
|
||||
- Duplicate users and missing users return the expected errors.
|
||||
|
||||
Why it matters:
|
||||
- Auth is the gatekeeper for most operational endpoints.
|
||||
- The admin user flow is also a good end-to-end check of password hashing, token creation, CRUD, and journal side effects.
|
||||
|
||||
### `test_pprs_api.py`
|
||||
|
||||
Covers the core PPR lifecycle.
|
||||
|
||||
What it tests:
|
||||
- PPR routes require authentication where appropriate.
|
||||
- Authenticated users can create, read, update, patch, acknowledge, status-update, soft-delete, and audit PPRs.
|
||||
- List filters work for status, dates, skip, and limit.
|
||||
- Public PPR creation sends email and generates secure edit tokens.
|
||||
- Public edit and cancel token flows work and reject invalid or processed requests.
|
||||
- Activation creates an arrival and pending departure.
|
||||
- Missing PPRs return 404.
|
||||
- Invalid payloads return validation errors.
|
||||
|
||||
Why it matters:
|
||||
- PPRs are the central workflow in the system.
|
||||
- These tests protect the operational state transitions that drive tower/admin views and audit history.
|
||||
|
||||
### `test_public_api.py`
|
||||
|
||||
Covers public read-only board and lookup endpoints.
|
||||
|
||||
What it tests:
|
||||
- Public arrivals and departures start empty.
|
||||
- Today's PPRs, local flights, arrivals, and departures appear on public boards.
|
||||
- Old or cancelled records are excluded.
|
||||
- Public airport and aircraft lookups return seeded records.
|
||||
- Short or invalid lookup queries return empty lists.
|
||||
|
||||
Why it matters:
|
||||
- Public boards and lookup helpers are user-facing and unauthenticated.
|
||||
- These tests check that the public API exposes useful operational information without requiring login.
|
||||
|
||||
### `test_flight_strip_apis.py`
|
||||
|
||||
Covers authenticated flight-strip style CRUD endpoints.
|
||||
|
||||
What it tests:
|
||||
- Arrival lifecycle: create, list/filter, read, update, land, cancel, and not-found paths.
|
||||
- Landing an arrival promotes a linked pending departure.
|
||||
- Departure lifecycle: create, list/filter, update, takeoff/departure status, cancel, and not-found paths.
|
||||
- Local flight lifecycle: create, list/filter, update, depart, land, special lists, cancel, and not-found paths.
|
||||
- Overflight lifecycle: create, active/today lists, list/filter, update, mark inactive/QSY, cancel, and not-found paths.
|
||||
- Movement records are created for real takeoff, landing, touch-and-go, and overflight events where relevant.
|
||||
|
||||
Why it matters:
|
||||
- These endpoints represent day-to-day tower strip operations.
|
||||
- They also exercise important CRUD side effects: status timestamps, movements, linked departures, and journal entries.
|
||||
|
||||
### `test_circuits_api.py`
|
||||
|
||||
Covers circuit/touch-and-go records.
|
||||
|
||||
What it tests:
|
||||
- Circuits can be recorded for local flights.
|
||||
- Circuits can be recorded for arrivals.
|
||||
- Circuit list, lookup-by-flight, lookup-by-arrival, update, and delete work.
|
||||
- Invalid circuit creation requests are rejected when neither or both parent ids are supplied.
|
||||
- Missing circuits return 404.
|
||||
- Recording a circuit creates a touch-and-go movement.
|
||||
|
||||
Why it matters:
|
||||
- Circuit traffic is a distinct operational pattern and feeds movement logging.
|
||||
- The parent-id validation prevents ambiguous audit/movement records.
|
||||
|
||||
### `test_movements_api.py`
|
||||
|
||||
Covers movement listing, context lookup, and bulk paper-strip logging.
|
||||
|
||||
What it tests:
|
||||
- Movement list filters and single-record reads.
|
||||
- Bulk movement context suggests matching PPRs and existing movements.
|
||||
- Bulk logging can create and update PPR-linked arrivals.
|
||||
- Bulk logging can create unmatched arrival and departure records.
|
||||
- Bulk logging handles local flight strips with takeoff, landing, duration, and circuits.
|
||||
- Bulk logging handles overflight strips and updates existing overflight records.
|
||||
- Invalid bulk-log requests return helpful 400 errors.
|
||||
|
||||
Why it matters:
|
||||
- Bulk movement logging is one of the densest workflows in the API.
|
||||
- These tests protect the behaviour that translates paper-strip data into PPR, arrival, departure, local flight, overflight, movement, and journal records.
|
||||
|
||||
### `test_drone_requests_api.py`
|
||||
|
||||
Covers drone flight request workflows.
|
||||
|
||||
What it tests:
|
||||
- Public drone request creation generates references/tokens and sends confirmation email.
|
||||
- Public edit and cancel token flows work.
|
||||
- Processed drone requests cannot be edited or cancelled publicly.
|
||||
- Authenticated users can list, read, update, status-update, comment on, and audit drone requests.
|
||||
- Missing records and invalid payloads return expected errors.
|
||||
|
||||
Why it matters:
|
||||
- Drone requests are a newer workflow with public and authenticated surfaces.
|
||||
- The tests protect email notification, public token, status, comment, and journal behaviour.
|
||||
|
||||
### `test_public_book_api.py`
|
||||
|
||||
Covers the optional public booking portal.
|
||||
|
||||
What it tests:
|
||||
- Public booking rejects requests when disabled.
|
||||
- Public local flight booking creates a public-submitted local flight.
|
||||
- Public circuit recording creates a circuit and touch-and-go movement.
|
||||
- Public departure and arrival booking create public-submitted records with pilot emails.
|
||||
- Invalid public booking payloads return validation errors.
|
||||
|
||||
Why it matters:
|
||||
- Public booking is controlled by configuration and should be safe to disable.
|
||||
- When enabled, it creates operational records without authentication, so validation and submitted-via metadata matter.
|
||||
|
||||
### `test_journal_api.py`
|
||||
|
||||
Covers generic audit/journal endpoints.
|
||||
|
||||
What it tests:
|
||||
- Journal search filters by date, entity type, entity id, and user.
|
||||
- Invalid entity types are rejected.
|
||||
- User journal and entity journal endpoints return entries and summary counts.
|
||||
|
||||
Why it matters:
|
||||
- The journal is the audit trail across PPRs, flights, users, drone requests, and movements.
|
||||
- These tests make sure audit entries remain queryable as the system grows.
|
||||
|
||||
## Current Scope
|
||||
|
||||
The suite intentionally focuses on API behaviour, local WebSocket broadcast behaviour, and database side effects. It does not deeply test:
|
||||
- Full browser WebSocket lifecycle.
|
||||
- Real SMTP delivery.
|
||||
- Browser UI behaviour.
|
||||
- Every branch of low-level validators or helper functions.
|
||||
|
||||
Those areas are better handled with focused unit tests or E2E tests later.
|
||||
@@ -0,0 +1,151 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import BigInteger, Integer, create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
os.environ.setdefault("DB_USER", "test_user")
|
||||
os.environ.setdefault("DB_PASSWORD", "test_password")
|
||||
os.environ.setdefault("DB_NAME", "test_db")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key")
|
||||
os.environ.setdefault("MAIL_HOST", "localhost")
|
||||
os.environ.setdefault("MAIL_USERNAME", "test")
|
||||
os.environ.setdefault("MAIL_PASSWORD", "test")
|
||||
os.environ.setdefault("MAIL_FROM", "noreply@example.test")
|
||||
os.environ.setdefault("MAIL_FROM_NAME", "PPR Tests")
|
||||
os.environ.setdefault("DRONE_REQUEST_TOWER_EMAIL", "tower@swansea-airport.wales")
|
||||
os.environ.setdefault("BASE_URL", "http://testserver")
|
||||
os.environ.setdefault("ENVIRONMENT", "test")
|
||||
|
||||
from app.api import deps
|
||||
from app.db import session as db_session
|
||||
from app.db.session import Base
|
||||
from app.main import app
|
||||
from app.models.ppr import PPRRecord, PPRStatus, UserRole
|
||||
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def use_sqlite_integer_primary_keys():
|
||||
for table in Base.metadata.tables.values():
|
||||
for column in table.columns:
|
||||
if column.primary_key and isinstance(column.type, BigInteger):
|
||||
column.type = Integer()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def db_session_override():
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
use_sqlite_integer_primary_keys()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
def override_get_db():
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
original_session_local = db_session.SessionLocal
|
||||
db_session.SessionLocal = TestingSessionLocal
|
||||
app.dependency_overrides[deps.get_db] = override_get_db
|
||||
|
||||
yield
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
db_session.SessionLocal = original_session_local
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client():
|
||||
test_user = SimpleNamespace(
|
||||
id=1,
|
||||
username="test-operator",
|
||||
role=UserRole.ADMINISTRATOR,
|
||||
is_active=1,
|
||||
)
|
||||
|
||||
app.dependency_overrides[deps.get_current_user] = lambda: test_user
|
||||
app.dependency_overrides[deps.get_current_active_user] = lambda: test_user
|
||||
app.dependency_overrides[deps.get_current_read_user] = lambda: test_user
|
||||
app.dependency_overrides[deps.get_current_operator_user] = lambda: test_user
|
||||
app.dependency_overrides[deps.get_current_admin_user] = lambda: test_user
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ppr_payload():
|
||||
return {
|
||||
"ac_reg": "g-test",
|
||||
"ac_type": "C172",
|
||||
"ac_call": "GTEST",
|
||||
"captain": "Test Pilot",
|
||||
"fuel": "AVGAS",
|
||||
"in_from": "EGLL",
|
||||
"eta": "2026-06-20T10:00:00",
|
||||
"pob_in": 2,
|
||||
"out_to": "EGKK",
|
||||
"etd": "2026-06-20T12:00:00",
|
||||
"pob_out": 2,
|
||||
"email": "pilot@example.com",
|
||||
"phone": "0123456789",
|
||||
"notes": "API test flight",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ppr_factory(db):
|
||||
def create_ppr(**overrides):
|
||||
values = {
|
||||
"status": PPRStatus.NEW,
|
||||
"ac_reg": "G-FACT",
|
||||
"ac_type": "PA28",
|
||||
"ac_call": "GFACT",
|
||||
"captain": "Factory Pilot",
|
||||
"fuel": "AVGAS",
|
||||
"in_from": "EGLL",
|
||||
"eta": datetime(2026, 6, 20, 10, 0),
|
||||
"pob_in": 2,
|
||||
"out_to": "EGKK",
|
||||
"etd": datetime(2026, 6, 20, 12, 0),
|
||||
"pob_out": 2,
|
||||
"email": None,
|
||||
"phone": None,
|
||||
"notes": "Factory test flight",
|
||||
"created_by": "factory",
|
||||
"public_token": "token-factory",
|
||||
}
|
||||
values.update(overrides)
|
||||
ppr = PPRRecord(**values)
|
||||
db.add(ppr)
|
||||
db.commit()
|
||||
db.refresh(ppr)
|
||||
return ppr
|
||||
|
||||
return create_ppr
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.main import ConnectionManager
|
||||
|
||||
|
||||
def test_root_returns_api_metadata(client):
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Airfield PPR API"
|
||||
assert response.json()["docs"] == "/docs"
|
||||
|
||||
|
||||
def test_health_check_reports_database_connection(client):
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
assert response.json()["database"] == "connected"
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self, fail_send=False):
|
||||
self.accepted = False
|
||||
self.fail_send = fail_send
|
||||
self.messages = []
|
||||
|
||||
async def accept(self):
|
||||
self.accepted = True
|
||||
|
||||
async def send_text(self, message):
|
||||
if self.fail_send:
|
||||
raise RuntimeError("socket closed")
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_manager_broadcasts_to_active_connections_and_removes_dead_ones():
|
||||
manager = ConnectionManager()
|
||||
active_socket = FakeWebSocket()
|
||||
dead_socket = FakeWebSocket(fail_send=True)
|
||||
|
||||
await manager.connect(active_socket)
|
||||
await manager.connect(dead_socket)
|
||||
await manager.broadcast({"type": "ppr_updated", "id": 123})
|
||||
|
||||
assert active_socket.accepted is True
|
||||
assert dead_socket.accepted is True
|
||||
assert json.loads(active_socket.messages[0]) == {"type": "ppr_updated", "id": 123}
|
||||
assert manager.active_connections == [active_socket]
|
||||
@@ -0,0 +1,80 @@
|
||||
from app.crud.crud_user import user as crud_user
|
||||
from app.models.ppr import UserRole
|
||||
from app.schemas.ppr import UserCreate
|
||||
|
||||
|
||||
def test_login_rejects_invalid_credentials(client):
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "missing", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Incorrect username or password"
|
||||
|
||||
|
||||
def test_login_returns_bearer_token_for_valid_user(client, db):
|
||||
crud_user.create(
|
||||
db,
|
||||
UserCreate(username="tower", password="secret-password", role=UserRole.OPERATOR),
|
||||
admin_user="test",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "tower", "password": "secret-password"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["access_token"]
|
||||
assert body["token_type"] == "bearer"
|
||||
assert body["expires_in"] > 0
|
||||
|
||||
|
||||
def test_admin_user_crud_endpoints(auth_client):
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/auth/users",
|
||||
json={"username": "operator-one", "password": "secret-password", "role": "OPERATOR"},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["username"] == "operator-one"
|
||||
assert created["role"] == "OPERATOR"
|
||||
|
||||
duplicate_response = auth_client.post(
|
||||
"/api/v1/auth/users",
|
||||
json={"username": "operator-one", "password": "secret-password", "role": "OPERATOR"},
|
||||
)
|
||||
|
||||
assert duplicate_response.status_code == 400
|
||||
|
||||
list_response = auth_client.get("/api/v1/auth/users")
|
||||
assert list_response.status_code == 200
|
||||
assert [user["username"] for user in list_response.json()] == ["operator-one"]
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/auth/users/{created['id']}",
|
||||
json={"role": "READ_ONLY"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["role"] == "READ_ONLY"
|
||||
|
||||
password_response = auth_client.post(
|
||||
f"/api/v1/auth/users/{created['id']}/change-password",
|
||||
json={"password": "new-secret-password"},
|
||||
)
|
||||
assert password_response.status_code == 200
|
||||
|
||||
|
||||
def test_admin_user_endpoints_return_not_found(auth_client):
|
||||
assert auth_client.get("/api/v1/auth/users/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/auth/users/404", json={"role": "OPERATOR"}).status_code == 404
|
||||
assert (
|
||||
auth_client.post(
|
||||
"/api/v1/auth/users/404/change-password",
|
||||
json={"password": "new-secret-password"},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||
from app.models.movement import Movement, MovementType
|
||||
|
||||
|
||||
def test_circuit_lifecycle_for_local_flight(auth_client, db):
|
||||
flight = LocalFlight(
|
||||
registration="G-CIR1",
|
||||
type="C152",
|
||||
callsign="GCIR1",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.CIRCUITS,
|
||||
status=LocalFlightStatus.CIRCUIT,
|
||||
created_by="test",
|
||||
)
|
||||
db.add(flight)
|
||||
db.commit()
|
||||
db.refresh(flight)
|
||||
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/circuits/",
|
||||
json={"local_flight_id": flight.id, "circuit_timestamp": "2026-06-20T10:10:00"},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
circuit = create_response.json()
|
||||
assert circuit["local_flight_id"] == flight.id
|
||||
|
||||
assert auth_client.get(f"/api/v1/circuits/{circuit['id']}").status_code == 200
|
||||
assert auth_client.get("/api/v1/circuits/").json()[0]["id"] == circuit["id"]
|
||||
assert auth_client.get(f"/api/v1/circuits/flight/{flight.id}").json()[0]["id"] == circuit["id"]
|
||||
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").one()
|
||||
assert movement.movement_type == MovementType.TOUCH_AND_GO
|
||||
assert movement.aircraft_registration == "G-CIR1"
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/circuits/{circuit['id']}",
|
||||
json={"circuit_timestamp": "2026-06-20T10:20:00"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["circuit_timestamp"] == "2026-06-20T10:20:00"
|
||||
|
||||
delete_response = auth_client.delete(f"/api/v1/circuits/{circuit['id']}")
|
||||
assert delete_response.status_code == 200
|
||||
assert delete_response.json()["detail"] == "Circuit record deleted"
|
||||
|
||||
|
||||
def test_circuit_lifecycle_for_arrival_and_error_paths(auth_client, db):
|
||||
arrival = Arrival(
|
||||
registration="G-CIR2",
|
||||
type="PA28",
|
||||
callsign="GCIR2",
|
||||
pob=2,
|
||||
in_from="EGLL",
|
||||
status="INBOUND",
|
||||
eta=datetime(2026, 6, 20, 10, 0),
|
||||
created_by="test",
|
||||
)
|
||||
db.add(arrival)
|
||||
db.commit()
|
||||
db.refresh(arrival)
|
||||
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/circuits/",
|
||||
json={"arrival_id": arrival.id, "circuit_timestamp": "2026-06-20T10:10:00"},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
circuit = create_response.json()
|
||||
assert circuit["arrival_id"] == arrival.id
|
||||
assert auth_client.get(f"/api/v1/circuits/arrival/{arrival.id}").json()[0]["id"] == circuit["id"]
|
||||
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "ARRIVAL").one()
|
||||
assert movement.movement_type == MovementType.TOUCH_AND_GO
|
||||
assert movement.from_location == "EGLL"
|
||||
|
||||
missing_entity = auth_client.post(
|
||||
"/api/v1/circuits/",
|
||||
json={"circuit_timestamp": "2026-06-20T10:10:00"},
|
||||
)
|
||||
both_entities = auth_client.post(
|
||||
"/api/v1/circuits/",
|
||||
json={
|
||||
"local_flight_id": 1,
|
||||
"arrival_id": arrival.id,
|
||||
"circuit_timestamp": "2026-06-20T10:10:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert missing_entity.status_code == 400
|
||||
assert both_entities.status_code == 400
|
||||
assert auth_client.get("/api/v1/circuits/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/circuits/404", json={"circuit_timestamp": "2026-06-20T10:20:00"}).status_code == 404
|
||||
assert auth_client.delete("/api/v1/circuits/404").status_code == 404
|
||||
@@ -0,0 +1,65 @@
|
||||
def contact_payload(**overrides):
|
||||
payload = {
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com",
|
||||
"phone": "07123 456789",
|
||||
"enquiry_type": "aviation_business",
|
||||
"subject": "Basing a maintenance business at Swansea",
|
||||
"message": "We would like to explore operating from Swansea Airport.",
|
||||
"source_page": "/contact/",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_public_contact_request_emails_tower_and_logs(client, monkeypatch, capsys):
|
||||
sent_emails = []
|
||||
|
||||
async def fake_send_email(**kwargs):
|
||||
sent_emails.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.contact_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/contact-requests/public",
|
||||
json=contact_payload(),
|
||||
headers={"X-Forwarded-For": "203.0.113.10, 10.0.0.1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "received"}
|
||||
assert len(sent_emails) == 1
|
||||
|
||||
email = sent_emails[0]
|
||||
assert email["to_email"] == "tower@swansea-airport.wales"
|
||||
assert email["reply_to"] == "Jane Smith <jane@example.com>"
|
||||
assert email["subject"] == "Website contact: Basing a maintenance business at Swansea"
|
||||
assert email["template_name"] == "contact_request.html"
|
||||
assert email["template_vars"]["name"] == "Jane Smith"
|
||||
assert email["template_vars"]["enquiry_type"] == "aviation_business"
|
||||
assert email["template_vars"]["client_ip"] == "203.0.113.10"
|
||||
|
||||
log_output = capsys.readouterr().out
|
||||
assert "Public contact request received" in log_output
|
||||
assert "aviation_business" in log_output
|
||||
assert "jane@example.com" in log_output
|
||||
|
||||
|
||||
def test_public_contact_request_validation(client, monkeypatch):
|
||||
async def fake_send_email(**kwargs):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.contact_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
invalid_category = client.post(
|
||||
"/api/v1/contact-requests/public",
|
||||
json=contact_payload(enquiry_type="sales"),
|
||||
)
|
||||
blank_required = client.post(
|
||||
"/api/v1/contact-requests/public",
|
||||
json=contact_payload(name=" ", subject="", message=" "),
|
||||
)
|
||||
|
||||
assert invalid_category.status_code == 422
|
||||
assert blank_required.status_code == 422
|
||||
@@ -0,0 +1,175 @@
|
||||
from app.core.frz import point_inside_swansea_frz
|
||||
from app.models.drone_request import DroneRequest
|
||||
|
||||
|
||||
def drone_payload(**overrides):
|
||||
payload = {
|
||||
"operator_name": "Rotor Ops",
|
||||
"operator_id": "OP-123",
|
||||
"flyer_name": "Remote Pilot",
|
||||
"flyer_id": "FLY-456",
|
||||
"email": "pilot@example.com",
|
||||
"phone": "0123456789",
|
||||
"flight_date": "2026-06-20",
|
||||
"estimated_takeoff_time": "10:00",
|
||||
"estimated_completion_time": "10:30",
|
||||
"estimated_takeoff_at": "2026-06-20T10:00:00",
|
||||
"estimated_completion_at": "2026-06-20T10:30:00",
|
||||
"maximum_elevation_ft_agl": 250,
|
||||
"location_description": "North apron",
|
||||
"location_latitude": 51.623389,
|
||||
"location_longitude": -4.069231,
|
||||
"location_inside_frz": "no",
|
||||
"notes": "Survey flight",
|
||||
"prototype_overlay": {
|
||||
"airport_reference_point": {"lat": 0, "lng": 0},
|
||||
"frz_radius_metres": 1,
|
||||
},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeypatch):
|
||||
sent_emails = []
|
||||
|
||||
async def fake_send_email(**kwargs):
|
||||
sent_emails.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.drone_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
create_response = client.post("/api/v1/drone-requests/public", json=drone_payload())
|
||||
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["reference_number"].startswith("DRN-")
|
||||
assert created["status"] == "NEW"
|
||||
assert created["location_inside_frz"] is True
|
||||
assert created["created_by"] == "public"
|
||||
assert len(sent_emails) == 2
|
||||
assert sent_emails[1]["to_email"] == "tower@swansea-airport.wales"
|
||||
assert sent_emails[1]["template_name"] == "drone_request_tower_notification.html"
|
||||
|
||||
db_request = db.query(DroneRequest).filter(DroneRequest.id == created["id"]).one()
|
||||
assert db_request.public_token
|
||||
|
||||
get_response = client.get(f"/api/v1/drone-requests/public/edit/{db_request.public_token}")
|
||||
patch_response = client.patch(
|
||||
f"/api/v1/drone-requests/public/edit/{db_request.public_token}",
|
||||
json={"operator_name": "Updated Rotor Ops", "notes": "Updated notes"},
|
||||
)
|
||||
cancel_response = client.delete(f"/api/v1/drone-requests/public/cancel/{db_request.public_token}")
|
||||
|
||||
assert get_response.status_code == 200
|
||||
assert patch_response.status_code == 200
|
||||
assert patch_response.json()["operator_name"] == "Updated Rotor Ops"
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELED"
|
||||
assert len(sent_emails) == 3
|
||||
|
||||
blocked_patch = client.patch(
|
||||
f"/api/v1/drone-requests/public/edit/{db_request.public_token}",
|
||||
json={"operator_name": "Too Late"},
|
||||
)
|
||||
blocked_cancel = client.delete(f"/api/v1/drone-requests/public/cancel/{db_request.public_token}")
|
||||
|
||||
assert blocked_patch.status_code == 400
|
||||
assert blocked_cancel.status_code == 400
|
||||
assert client.get("/api/v1/drone-requests/public/edit/missing-token").status_code == 404
|
||||
assert client.patch("/api/v1/drone-requests/public/edit/missing-token", json={}).status_code == 404
|
||||
assert client.delete("/api/v1/drone-requests/public/cancel/missing-token").status_code == 404
|
||||
|
||||
|
||||
def test_drone_request_accepts_legacy_amsl_altitude_key(client, db, monkeypatch):
|
||||
async def fake_send_email(**kwargs):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.drone_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
payload = drone_payload()
|
||||
payload["maximum_elevation_ft_amsl"] = payload.pop("maximum_elevation_ft_agl")
|
||||
|
||||
create_response = client.post("/api/v1/drone-requests/public", json=payload)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
assert create_response.json()["maximum_elevation_ft_agl"] == 250
|
||||
db_request = db.query(DroneRequest).filter(DroneRequest.id == create_response.json()["id"]).one()
|
||||
assert db_request.maximum_elevation_ft_agl == 250
|
||||
|
||||
|
||||
def test_authenticated_drone_request_list_update_status_comment_and_journal(auth_client, db, monkeypatch):
|
||||
sent_emails = []
|
||||
|
||||
async def fake_send_email(**kwargs):
|
||||
sent_emails.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.drone_requests.email_service.send_email", fake_send_email)
|
||||
|
||||
create_response = auth_client.post("/api/v1/drone-requests/public", json=drone_payload())
|
||||
created = create_response.json()
|
||||
|
||||
list_response = auth_client.get(
|
||||
"/api/v1/drone-requests/",
|
||||
params={"status": "NEW", "date_from": "2026-06-20", "date_to": "2026-06-20"},
|
||||
)
|
||||
get_response = auth_client.get(f"/api/v1/drone-requests/{created['id']}")
|
||||
update_response = auth_client.patch(
|
||||
f"/api/v1/drone-requests/{created['id']}",
|
||||
json={"operator_comments": "Needs tower review", "maximum_elevation_ft_agl": 200},
|
||||
)
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/drone-requests/{created['id']}/status",
|
||||
json={"status": "APPROVED", "comment": "Approved below 200ft"},
|
||||
)
|
||||
comment_response = auth_client.post(
|
||||
f"/api/v1/drone-requests/{created['id']}/comments",
|
||||
json={"comment": "Call tower before launch", "email_applicant": True},
|
||||
)
|
||||
journal_response = auth_client.get(f"/api/v1/drone-requests/{created['id']}/journal")
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert [request["id"] for request in list_response.json()] == [created["id"]]
|
||||
assert get_response.status_code == 200
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["maximum_elevation_ft_agl"] == 200
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["status"] == "APPROVED"
|
||||
assert status_response.json()["operator_comments"] == "Approved below 200ft"
|
||||
assert comment_response.status_code == 200
|
||||
assert comment_response.json()["operator_comments"] == "Call tower before launch"
|
||||
assert journal_response.status_code == 200
|
||||
entries = [entry["entry"] for entry in journal_response.json()]
|
||||
assert any("Drone request" in entry and "created" in entry for entry in entries)
|
||||
assert any("Status changed from NEW to APPROVED" in entry for entry in entries)
|
||||
assert any("Comment added" in entry for entry in entries)
|
||||
assert len(sent_emails) == 4
|
||||
|
||||
|
||||
def test_drone_request_not_found_and_validation_paths(auth_client, client):
|
||||
invalid_response = client.post(
|
||||
"/api/v1/drone-requests/public",
|
||||
json=drone_payload(operator_name=" ", location_latitude=100, maximum_elevation_ft_agl=-1),
|
||||
)
|
||||
|
||||
assert invalid_response.status_code == 422
|
||||
assert auth_client.get("/api/v1/drone-requests/404").status_code == 404
|
||||
assert auth_client.patch("/api/v1/drone-requests/404", json={"operator_name": "Missing"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/drone-requests/404/status", json={"status": "APPROVED"}).status_code == 404
|
||||
assert (
|
||||
auth_client.post(
|
||||
"/api/v1/drone-requests/404/comments",
|
||||
json={"comment": "Missing", "email_applicant": False},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert auth_client.get("/api/v1/drone-requests/404/journal").status_code == 404
|
||||
|
||||
|
||||
def test_swansea_frz_runway_extensions_start_at_thresholds():
|
||||
assert point_inside_swansea_frz(51.626825, -4.037672) is True
|
||||
assert point_inside_swansea_frz(51.583775, -4.097928) is True
|
||||
assert point_inside_swansea_frz(51.603007, -4.025604) is True
|
||||
assert point_inside_swansea_frz(51.607593, -4.109996) is True
|
||||
assert point_inside_swansea_frz(51.68000, -4.06780) is False
|
||||
@@ -0,0 +1,318 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.departure import Departure, DepartureStatus
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||
from app.models.movement import Movement, MovementType
|
||||
from app.models.overflight import Overflight, OverflightStatus
|
||||
|
||||
|
||||
def test_arrival_lifecycle_and_not_found_paths(auth_client, db):
|
||||
payload = {
|
||||
"registration": "g-arr",
|
||||
"type": "DA40",
|
||||
"callsign": "GARR",
|
||||
"pob": 2,
|
||||
"in_from": "egll",
|
||||
"eta": "2026-06-20T09:30:00",
|
||||
"notes": "Inbound test",
|
||||
}
|
||||
|
||||
create_response = auth_client.post("/api/v1/arrivals/", json=payload)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["registration"] == "G-ARR"
|
||||
assert created["status"] == "INBOUND"
|
||||
|
||||
assert auth_client.get(f"/api/v1/arrivals/{created['id']}").status_code == 200
|
||||
|
||||
list_response = auth_client.get(
|
||||
"/api/v1/arrivals/",
|
||||
params={"status": "INBOUND", "date_from": "2026-06-20", "date_to": "2026-06-20"},
|
||||
)
|
||||
assert list_response.status_code == 200
|
||||
assert [arrival["id"] for arrival in list_response.json()] == [created["id"]]
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/arrivals/{created['id']}",
|
||||
json={"notes": "Updated inbound", "callsign": "ARRIVE"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["notes"] == "Updated inbound"
|
||||
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/arrivals/{created['id']}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T10:00:00"},
|
||||
)
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["landed_dt"] == "2026-06-20T10:00:00"
|
||||
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "ARRIVAL").one()
|
||||
assert movement.movement_type == MovementType.LANDING
|
||||
assert movement.aircraft_registration == "G-ARR"
|
||||
|
||||
cancel_response = auth_client.delete(f"/api/v1/arrivals/{created['id']}")
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELLED"
|
||||
|
||||
assert auth_client.get("/api/v1/arrivals/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/arrivals/404", json={"notes": "x"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/arrivals/404/status", json={"status": "LANDED"}).status_code == 404
|
||||
assert auth_client.delete("/api/v1/arrivals/404").status_code == 404
|
||||
|
||||
|
||||
def test_landing_arrival_promotes_linked_pending_departure(auth_client, db):
|
||||
arrival = Arrival(
|
||||
registration="G-LINK",
|
||||
type="PA28",
|
||||
callsign="GLINK",
|
||||
pob=2,
|
||||
in_from="EGLL",
|
||||
status="INBOUND",
|
||||
eta=datetime(2026, 6, 20, 9, 30),
|
||||
created_by="test",
|
||||
)
|
||||
db.add(arrival)
|
||||
db.commit()
|
||||
db.refresh(arrival)
|
||||
|
||||
departure = Departure(
|
||||
registration="G-LINK",
|
||||
type="PA28",
|
||||
callsign="GLINK",
|
||||
pob=2,
|
||||
out_to="EGKK",
|
||||
status=DepartureStatus.PENDING,
|
||||
arrival_id=arrival.id,
|
||||
created_by="test",
|
||||
)
|
||||
db.add(departure)
|
||||
db.commit()
|
||||
|
||||
response = auth_client.patch(
|
||||
f"/api/v1/arrivals/{arrival.id}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T10:00:00"},
|
||||
)
|
||||
|
||||
db.refresh(departure)
|
||||
assert response.status_code == 200
|
||||
assert departure.status == DepartureStatus.BOOKED_OUT
|
||||
|
||||
|
||||
def test_departure_lifecycle_and_not_found_paths(auth_client, db):
|
||||
payload = {
|
||||
"registration": "g-dep",
|
||||
"type": "SR22",
|
||||
"callsign": "GDEP",
|
||||
"pob": 2,
|
||||
"out_to": "egkk",
|
||||
"etd": "2026-06-20T11:00:00",
|
||||
"notes": "Outbound test",
|
||||
}
|
||||
|
||||
create_response = auth_client.post("/api/v1/departures/", json=payload)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["registration"] == "G-DEP"
|
||||
assert created["status"] == "GROUND"
|
||||
|
||||
list_response = auth_client.get(
|
||||
"/api/v1/departures/",
|
||||
params={"status": "GROUND", "date_from": "2026-06-20", "date_to": "2026-06-20"},
|
||||
)
|
||||
assert list_response.status_code == 200
|
||||
assert [departure["id"] for departure in list_response.json()] == [created["id"]]
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/departures/{created['id']}",
|
||||
json={"notes": "Updated outbound", "callsign": "DEPART"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["notes"] == "Updated outbound"
|
||||
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/departures/{created['id']}/status",
|
||||
json={"status": "LOCAL", "timestamp": "2026-06-20T11:10:00"},
|
||||
)
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["takeoff_dt"] == "2026-06-20T11:10:00"
|
||||
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "DEPARTURE").one()
|
||||
assert movement.movement_type == MovementType.TAKEOFF
|
||||
assert movement.to_location == "egkk"
|
||||
|
||||
cancel_response = auth_client.delete(f"/api/v1/departures/{created['id']}")
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELLED"
|
||||
|
||||
assert auth_client.get("/api/v1/departures/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/departures/404", json={"notes": "x"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/departures/404/status", json={"status": "DEPARTED"}).status_code == 404
|
||||
assert auth_client.delete("/api/v1/departures/404").status_code == 404
|
||||
|
||||
|
||||
def test_local_flight_lifecycle_special_lists_and_not_found_paths(auth_client, db):
|
||||
payload = {
|
||||
"registration": "g-loc",
|
||||
"type": "C152",
|
||||
"callsign": "GLOC",
|
||||
"pob": 1,
|
||||
"flight_type": "LOCAL",
|
||||
"duration": 45,
|
||||
"etd": "2026-06-20T10:00:00",
|
||||
"notes": "Local test",
|
||||
}
|
||||
|
||||
create_response = auth_client.post("/api/v1/local-flights/", json=payload)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["registration"] == "G-LOC"
|
||||
assert created["status"] == "GROUND"
|
||||
|
||||
filter_response = auth_client.get(
|
||||
"/api/v1/local-flights/",
|
||||
params={"status": "GROUND", "flight_type": "LOCAL", "date_from": "2026-06-20"},
|
||||
)
|
||||
assert filter_response.status_code == 200
|
||||
assert [flight["id"] for flight in filter_response.json()] == [created["id"]]
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/local-flights/{created['id']}",
|
||||
json={"notes": "Updated local", "duration": 60},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["duration"] == 60
|
||||
|
||||
departed_response = auth_client.patch(
|
||||
f"/api/v1/local-flights/{created['id']}/status",
|
||||
json={"status": "DEPARTED", "timestamp": "2026-06-20T10:05:00"},
|
||||
)
|
||||
landed_response = auth_client.patch(
|
||||
f"/api/v1/local-flights/{created['id']}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T10:45:00"},
|
||||
)
|
||||
|
||||
assert departed_response.status_code == 200
|
||||
assert departed_response.json()["takeoff_dt"] == "2026-06-20T10:05:00"
|
||||
assert departed_response.json()["departed_dt"] == "2026-06-20T10:05:00"
|
||||
assert landed_response.status_code == 200
|
||||
assert landed_response.json()["landed_dt"] == "2026-06-20T10:45:00"
|
||||
|
||||
movement_types = {
|
||||
movement.movement_type
|
||||
for movement in db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").all()
|
||||
}
|
||||
assert movement_types == {MovementType.TAKEOFF, MovementType.LANDING}
|
||||
|
||||
active_response = auth_client.get("/api/v1/local-flights/active/current")
|
||||
today_departures_response = auth_client.get("/api/v1/local-flights/today/departures")
|
||||
booked_out_response = auth_client.get("/api/v1/local-flights/today/booked-out")
|
||||
|
||||
assert active_response.status_code == 200
|
||||
assert today_departures_response.status_code == 200
|
||||
assert booked_out_response.status_code == 200
|
||||
assert booked_out_response.json()[0]["id"] == created["id"]
|
||||
|
||||
cancel_response = auth_client.delete(f"/api/v1/local-flights/{created['id']}")
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELLED"
|
||||
|
||||
assert auth_client.get("/api/v1/local-flights/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/local-flights/404", json={"notes": "x"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/local-flights/404/status", json={"status": "LANDED"}).status_code == 404
|
||||
assert auth_client.delete("/api/v1/local-flights/404").status_code == 404
|
||||
|
||||
|
||||
def test_local_flight_takeoff_to_local_sets_departed_dt(auth_client):
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/local-flights/",
|
||||
json={
|
||||
"registration": "g-air",
|
||||
"type": "PA28",
|
||||
"pob": 2,
|
||||
"flight_type": "LOCAL",
|
||||
"duration": 30,
|
||||
"etd": "2026-06-20T09:00:00",
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
|
||||
takeoff_response = auth_client.patch(
|
||||
f"/api/v1/local-flights/{create_response.json()['id']}/status",
|
||||
json={"status": "LOCAL", "timestamp": "2026-06-20T09:05:00"},
|
||||
)
|
||||
|
||||
assert takeoff_response.status_code == 200
|
||||
assert takeoff_response.json()["status"] == "LOCAL"
|
||||
assert takeoff_response.json()["takeoff_dt"] == "2026-06-20T09:05:00"
|
||||
assert takeoff_response.json()["departed_dt"] == "2026-06-20T09:05:00"
|
||||
|
||||
landing_response = auth_client.patch(
|
||||
f"/api/v1/local-flights/{create_response.json()['id']}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T09:35:00"},
|
||||
)
|
||||
|
||||
assert landing_response.status_code == 200
|
||||
assert landing_response.json()["status"] == "LANDED"
|
||||
assert landing_response.json()["landed_dt"] == "2026-06-20T09:35:00"
|
||||
|
||||
|
||||
def test_overflight_lifecycle_special_lists_and_not_found_paths(auth_client, db):
|
||||
payload = {
|
||||
"registration": "g-ovr",
|
||||
"pob": 1,
|
||||
"type": "PA28",
|
||||
"departure_airfield": "egll",
|
||||
"destination_airfield": "egkk",
|
||||
"call_dt": "2026-06-20T09:00:00",
|
||||
"notes": "Overflight test",
|
||||
}
|
||||
|
||||
create_response = auth_client.post("/api/v1/overflights/", json=payload)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["registration"] == "G-OVR"
|
||||
assert created["departure_airfield"] == "EGLL"
|
||||
assert created["status"] == "ACTIVE"
|
||||
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "OVERFLIGHT").one()
|
||||
assert movement.movement_type == MovementType.OVERFLIGHT
|
||||
assert movement.from_location == "EGLL"
|
||||
assert movement.to_location == "EGKK"
|
||||
|
||||
active_response = auth_client.get("/api/v1/overflights/active/list")
|
||||
today_response = auth_client.get("/api/v1/overflights/today/list")
|
||||
assert active_response.status_code == 200
|
||||
assert active_response.json()[0]["id"] == created["id"]
|
||||
assert today_response.status_code == 200
|
||||
assert today_response.json()[0]["id"] == created["id"]
|
||||
|
||||
list_response = auth_client.get(
|
||||
"/api/v1/overflights/",
|
||||
params={"status": "ACTIVE", "date_from": "2026-06-20", "date_to": "2026-06-20"},
|
||||
)
|
||||
assert list_response.status_code == 200
|
||||
assert [overflight["id"] for overflight in list_response.json()] == [created["id"]]
|
||||
|
||||
update_response = auth_client.put(
|
||||
f"/api/v1/overflights/{created['id']}",
|
||||
json={"notes": "Updated overflight", "destination_airfield": "egcc"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["destination_airfield"] == "EGCC"
|
||||
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/overflights/{created['id']}/status",
|
||||
json={"status": "INACTIVE", "qsy_dt": "2026-06-20T09:20:00"},
|
||||
)
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["qsy_dt"] == "2026-06-20T09:20:00"
|
||||
|
||||
cancel_response = auth_client.delete(f"/api/v1/overflights/{created['id']}")
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELLED"
|
||||
|
||||
assert auth_client.get("/api/v1/overflights/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/overflights/404", json={"notes": "x"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/overflights/404/status", json={"status": "INACTIVE"}).status_code == 404
|
||||
assert auth_client.delete("/api/v1/overflights/404").status_code == 404
|
||||
@@ -0,0 +1,76 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.models.journal import EntityType
|
||||
from app.crud.crud_journal import journal
|
||||
|
||||
|
||||
def test_search_journal_filters_entries(auth_client, db):
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
matching = journal.log_change(
|
||||
db,
|
||||
EntityType.PPR,
|
||||
10,
|
||||
"Matching PPR change",
|
||||
"tower",
|
||||
"127.0.0.1",
|
||||
)
|
||||
other = journal.log_change(
|
||||
db,
|
||||
EntityType.USER,
|
||||
20,
|
||||
"Other user change",
|
||||
"admin",
|
||||
"127.0.0.1",
|
||||
)
|
||||
matching.entry_dt = yesterday
|
||||
other.entry_dt = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
response = auth_client.get(
|
||||
"/api/v1/journal/search/all",
|
||||
params={
|
||||
"date_from": yesterday.date().isoformat(),
|
||||
"date_to": yesterday.date().isoformat(),
|
||||
"entity_type": "PPR",
|
||||
"entity_id": 10,
|
||||
"user": "tower",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
entries = response.json()
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["entry"] == "Matching PPR change"
|
||||
|
||||
|
||||
def test_search_journal_rejects_invalid_entity_type(auth_client):
|
||||
response = auth_client.get(
|
||||
"/api/v1/journal/search/all",
|
||||
params={"entity_type": "NOT_A_THING"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid entity_type" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_get_user_and_entity_journal(auth_client, db):
|
||||
journal.log_change(db, EntityType.PPR, 55, "PPR audit entry", "tower", None)
|
||||
|
||||
user_response = auth_client.get("/api/v1/journal/user/tower")
|
||||
entity_response = auth_client.get("/api/v1/journal/PPR/55")
|
||||
|
||||
assert user_response.status_code == 200
|
||||
assert user_response.json()[0]["entry"] == "PPR audit entry"
|
||||
assert entity_response.status_code == 200
|
||||
body = entity_response.json()
|
||||
assert body["entity_type"] == "PPR"
|
||||
assert body["entity_id"] == 55
|
||||
assert body["total_entries"] == 1
|
||||
assert body["entries"][0]["entry"] == "PPR audit entry"
|
||||
|
||||
|
||||
def test_get_entity_journal_rejects_invalid_entity_type(auth_client):
|
||||
response = auth_client.get("/api/v1/journal/NOPE/1")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid entity_type" in response.json()["detail"]
|
||||
@@ -1,42 +0,0 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.main import app
|
||||
from app.api.deps import get_db
|
||||
from app.db.session import Base
|
||||
|
||||
# Create test database
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
db = TestingSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_read_main():
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "Airfield PPR API" in response.json()["message"]
|
||||
|
||||
def test_health_check():
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
|
||||
def test_get_public_arrivals():
|
||||
response = client.get("/api/v1/public/arrivals")
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
@@ -0,0 +1,227 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.departure import Departure
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||
from app.models.movement import Movement, MovementType
|
||||
from app.models.overflight import Overflight, OverflightStatus
|
||||
from app.models.ppr import PPRRecord, PPRStatus
|
||||
|
||||
|
||||
def movement_payload(**overrides):
|
||||
payload = {
|
||||
"flight_kind": "ARRIVAL",
|
||||
"movement_date": "2026-06-20",
|
||||
"movement_time": "10:00",
|
||||
"aircraft_registration": "G-MOV1",
|
||||
"aircraft_type": "PA28",
|
||||
"callsign": "GMOV1",
|
||||
"from_location": "EGLL",
|
||||
"to_location": "EGKK",
|
||||
"pob": 2,
|
||||
"runway": "27",
|
||||
"wind": "270/10",
|
||||
"pressure_setting": "QNH1013",
|
||||
"notes": "Bulk movement",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_movement_list_get_and_context_for_ppr(auth_client, db):
|
||||
ppr = PPRRecord(
|
||||
status=PPRStatus.NEW,
|
||||
ac_reg="G-MOV1",
|
||||
ac_type="PA28",
|
||||
ac_call="GMOV1",
|
||||
captain="Movement Pilot",
|
||||
in_from="EGLL",
|
||||
eta=datetime(2026, 6, 20, 10, 0),
|
||||
pob_in=2,
|
||||
out_to="EGKK",
|
||||
etd=datetime(2026, 6, 20, 11, 0),
|
||||
created_by="test",
|
||||
public_token="movement-ppr",
|
||||
)
|
||||
db.add(ppr)
|
||||
db.commit()
|
||||
db.refresh(ppr)
|
||||
|
||||
bulk_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(ppr_id=ppr.id, landing_time="10:05"),
|
||||
)
|
||||
|
||||
assert bulk_response.status_code == 200
|
||||
result = bulk_response.json()
|
||||
assert result["action"] == "created"
|
||||
assert result["entity_type"] == "PPR"
|
||||
assert result["entity_id"] == ppr.id
|
||||
|
||||
list_response = auth_client.get(
|
||||
"/api/v1/movements/",
|
||||
params={
|
||||
"movement_type": "LANDING",
|
||||
"aircraft_registration": "MOV1",
|
||||
"date_from": "2026-06-20",
|
||||
"date_to": "2026-06-20",
|
||||
"entity_type": "PPR",
|
||||
},
|
||||
)
|
||||
get_response = auth_client.get(f"/api/v1/movements/{result['movement']['id']}")
|
||||
context_response = auth_client.get(
|
||||
"/api/v1/movements/bulk-context",
|
||||
params={
|
||||
"target_date": "2026-06-20",
|
||||
"aircraft_registration": "G-MOV1",
|
||||
"flight_kind": "ARRIVAL",
|
||||
},
|
||||
)
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert [movement["id"] for movement in list_response.json()] == [result["movement"]["id"]]
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json()["aircraft_registration"] == "G-MOV1"
|
||||
assert context_response.status_code == 200
|
||||
context = context_response.json()
|
||||
assert context["pprs"][0]["id"] == ppr.id
|
||||
assert context["movements"][0]["id"] == result["movement"]["id"]
|
||||
assert context["suggested"]["source"] == "movement"
|
||||
|
||||
|
||||
def test_bulk_log_updates_existing_movement_and_creates_unmatched_arrival_departure(auth_client, db):
|
||||
arrival_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(aircraft_registration="G-NEW1", landing_time="10:00", from_location="EGBB"),
|
||||
)
|
||||
update_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
aircraft_registration="G-NEW1",
|
||||
landing_time="10:15",
|
||||
from_location="EGBB",
|
||||
notes="Updated movement",
|
||||
),
|
||||
)
|
||||
departure_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
flight_kind="DEPARTURE",
|
||||
aircraft_registration="G-NEW2",
|
||||
takeoff_time="11:00",
|
||||
to_location="EGCC",
|
||||
),
|
||||
)
|
||||
|
||||
assert arrival_response.status_code == 200
|
||||
assert arrival_response.json()["entity_type"] == "ARRIVAL"
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["action"] == "updated"
|
||||
assert update_response.json()["movement"]["timestamp"] == "2026-06-20T10:15:00"
|
||||
assert departure_response.status_code == 200
|
||||
assert departure_response.json()["entity_type"] == "DEPARTURE"
|
||||
|
||||
arrival = db.query(Arrival).filter(Arrival.registration == "G-NEW1").one()
|
||||
departure = db.query(Departure).filter(Departure.registration == "G-NEW2").one()
|
||||
assert arrival.status.value == "LANDED"
|
||||
assert departure.status.value == "DEPARTED"
|
||||
|
||||
|
||||
def test_bulk_log_local_and_overflight_branches(auth_client, db):
|
||||
local_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
flight_kind="LOCAL",
|
||||
aircraft_registration="G-LOCX",
|
||||
takeoff_time="09:00",
|
||||
landing_time="09:45",
|
||||
local_nature="CIRCUITS",
|
||||
circuits=3,
|
||||
),
|
||||
)
|
||||
overflight_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
flight_kind="OVERFLIGHT",
|
||||
aircraft_registration="G-OVRX",
|
||||
contact_time="12:00",
|
||||
qsy_time="12:15",
|
||||
from_location="EGLL",
|
||||
to_location="EGKK",
|
||||
),
|
||||
)
|
||||
overflight_update_response = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
flight_kind="OVERFLIGHT",
|
||||
aircraft_registration="G-OVRX",
|
||||
contact_time="12:05",
|
||||
qsy_time="12:20",
|
||||
from_location="EGLL",
|
||||
to_location="EGCC",
|
||||
),
|
||||
)
|
||||
|
||||
assert local_response.status_code == 200
|
||||
assert local_response.json()["entity_type"] == "LOCAL_FLIGHT"
|
||||
local = db.query(LocalFlight).filter(LocalFlight.registration == "G-LOCX").one()
|
||||
assert local.status == LocalFlightStatus.LANDED
|
||||
assert local.flight_type == LocalFlightType.CIRCUITS
|
||||
assert local.circuits == 3
|
||||
|
||||
local_movements = db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").all()
|
||||
assert {movement.movement_type for movement in local_movements} == {
|
||||
MovementType.TAKEOFF,
|
||||
MovementType.LANDING,
|
||||
}
|
||||
|
||||
assert overflight_response.status_code == 200
|
||||
assert overflight_response.json()["entity_type"] == "OVERFLIGHT"
|
||||
assert overflight_update_response.status_code == 200
|
||||
assert overflight_update_response.json()["action"] == "updated"
|
||||
overflight = db.query(Overflight).filter(Overflight.registration == "G-OVRX").one()
|
||||
assert overflight.status == OverflightStatus.INACTIVE
|
||||
assert overflight.destination_airfield == "EGCC"
|
||||
|
||||
|
||||
def test_movement_error_paths(auth_client):
|
||||
missing_registration = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(aircraft_registration=""),
|
||||
)
|
||||
invalid_kind = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(flight_kind="BALLOON"),
|
||||
)
|
||||
missing_time = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(movement_time=None, landing_time=None),
|
||||
)
|
||||
invalid_time = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(landing_time="not-time"),
|
||||
)
|
||||
bad_local_times = auth_client.post(
|
||||
"/api/v1/movements/bulk-log",
|
||||
json=movement_payload(
|
||||
flight_kind="LOCAL",
|
||||
takeoff_time="11:00",
|
||||
landing_time="10:00",
|
||||
),
|
||||
)
|
||||
bad_context = auth_client.get(
|
||||
"/api/v1/movements/bulk-context",
|
||||
params={
|
||||
"target_date": "2026-06-20",
|
||||
"aircraft_registration": "G-BAD",
|
||||
"flight_kind": "BALLOON",
|
||||
},
|
||||
)
|
||||
|
||||
assert missing_registration.status_code == 400
|
||||
assert invalid_kind.status_code == 400
|
||||
assert missing_time.status_code == 400
|
||||
assert invalid_time.status_code == 400
|
||||
assert bad_local_times.status_code == 400
|
||||
assert bad_context.status_code == 400
|
||||
assert auth_client.get("/api/v1/movements/404").status_code == 404
|
||||
@@ -0,0 +1,235 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.departure import Departure, DepartureStatus
|
||||
from app.models.ppr import PPRRecord
|
||||
|
||||
|
||||
def test_ppr_routes_require_authentication(client):
|
||||
response = client.get("/api/v1/pprs/")
|
||||
|
||||
assert response.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_authenticated_user_can_create_read_update_and_audit_ppr(auth_client, ppr_payload):
|
||||
create_response = auth_client.post("/api/v1/pprs/", json=ppr_payload)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["id"] > 0
|
||||
assert created["status"] == "NEW"
|
||||
assert created["ac_reg"] == "G-TEST"
|
||||
assert created["created_by"] == "test-operator"
|
||||
|
||||
read_response = auth_client.get(f"/api/v1/pprs/{created['id']}")
|
||||
|
||||
assert read_response.status_code == 200
|
||||
assert read_response.json()["ac_reg"] == "G-TEST"
|
||||
|
||||
status_response = auth_client.patch(
|
||||
f"/api/v1/pprs/{created['id']}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T10:30:00"},
|
||||
)
|
||||
|
||||
assert status_response.status_code == 200
|
||||
assert status_response.json()["status"] == "LANDED"
|
||||
assert status_response.json()["landed_dt"] == "2026-06-20T10:30:00"
|
||||
|
||||
journal_response = auth_client.get(f"/api/v1/pprs/{created['id']}/journal")
|
||||
|
||||
assert journal_response.status_code == 200
|
||||
entries = [entry["entry"] for entry in journal_response.json()]
|
||||
assert any("PPR created for G-TEST" in entry for entry in entries)
|
||||
assert any("Status changed from NEW to LANDED" in entry for entry in entries)
|
||||
|
||||
|
||||
def test_ppr_departure_lifecycle_goes_landed_local_departed(auth_client, ppr_payload):
|
||||
created = auth_client.post("/api/v1/pprs/", json=ppr_payload).json()
|
||||
|
||||
landed_response = auth_client.patch(
|
||||
f"/api/v1/pprs/{created['id']}/status",
|
||||
json={"status": "LANDED", "timestamp": "2026-06-20T10:30:00"},
|
||||
)
|
||||
local_response = auth_client.patch(
|
||||
f"/api/v1/pprs/{created['id']}/status",
|
||||
json={"status": "LOCAL", "timestamp": "2026-06-20T12:55:00"},
|
||||
)
|
||||
departed_response = auth_client.patch(
|
||||
f"/api/v1/pprs/{created['id']}/status",
|
||||
json={"status": "DEPARTED", "timestamp": "2026-06-20T13:05:00"},
|
||||
)
|
||||
|
||||
assert landed_response.status_code == 200
|
||||
assert local_response.status_code == 200
|
||||
assert local_response.json()["status"] == "LOCAL"
|
||||
assert local_response.json()["landed_dt"] == "2026-06-20T10:30:00"
|
||||
assert local_response.json()["takeoff_dt"] == "2026-06-20T12:55:00"
|
||||
assert local_response.json()["qsy_dt"] is None
|
||||
assert departed_response.status_code == 200
|
||||
assert departed_response.json()["status"] == "DEPARTED"
|
||||
assert departed_response.json()["takeoff_dt"] == "2026-06-20T12:55:00"
|
||||
assert departed_response.json()["qsy_dt"] == "2026-06-20T13:05:00"
|
||||
|
||||
|
||||
def test_ppr_list_supports_status_date_and_pagination_filters(auth_client, ppr_factory):
|
||||
ppr_factory(
|
||||
ac_reg="G-NEW1",
|
||||
status="NEW",
|
||||
eta=datetime(2026, 6, 20, 10, 0),
|
||||
etd=datetime(2026, 6, 20, 12, 0),
|
||||
public_token="token-new",
|
||||
)
|
||||
ppr_factory(
|
||||
ac_reg="G-CAN1",
|
||||
status="CANCELED",
|
||||
eta=datetime(2026, 6, 21, 10, 0),
|
||||
etd=datetime(2026, 6, 21, 12, 0),
|
||||
public_token="token-canceled",
|
||||
)
|
||||
|
||||
status_response = auth_client.get("/api/v1/pprs/", params={"status": "NEW"})
|
||||
date_response = auth_client.get(
|
||||
"/api/v1/pprs/",
|
||||
params={"date_from": "2026-06-21", "date_to": "2026-06-21"},
|
||||
)
|
||||
limited_response = auth_client.get("/api/v1/pprs/", params={"skip": 1, "limit": 1})
|
||||
|
||||
assert status_response.status_code == 200
|
||||
assert [ppr["ac_reg"] for ppr in status_response.json()] == ["G-NEW1"]
|
||||
assert date_response.status_code == 200
|
||||
assert [ppr["ac_reg"] for ppr in date_response.json()] == ["G-CAN1"]
|
||||
assert limited_response.status_code == 200
|
||||
assert len(limited_response.json()) == 1
|
||||
|
||||
|
||||
def test_authenticated_user_can_put_patch_acknowledge_and_delete_ppr(auth_client, ppr_payload):
|
||||
created = auth_client.post("/api/v1/pprs/", json=ppr_payload).json()
|
||||
|
||||
put_response = auth_client.put(
|
||||
f"/api/v1/pprs/{created['id']}",
|
||||
json={**ppr_payload, "captain": "Updated Pilot"},
|
||||
)
|
||||
patch_response = auth_client.patch(
|
||||
f"/api/v1/pprs/{created['id']}",
|
||||
json={"notes": "Updated by patch"},
|
||||
)
|
||||
acknowledge_response = auth_client.post(f"/api/v1/pprs/{created['id']}/acknowledge")
|
||||
delete_response = auth_client.delete(f"/api/v1/pprs/{created['id']}")
|
||||
|
||||
assert put_response.status_code == 200
|
||||
assert put_response.json()["captain"] == "Updated Pilot"
|
||||
assert patch_response.status_code == 200
|
||||
assert patch_response.json()["notes"] == "Updated by patch"
|
||||
assert acknowledge_response.status_code == 200
|
||||
assert acknowledge_response.json()["acknowledged_by"] == "test-operator"
|
||||
assert delete_response.status_code == 200
|
||||
assert delete_response.json()["status"] == "DELETED"
|
||||
|
||||
|
||||
def test_ppr_not_found_paths(auth_client):
|
||||
assert auth_client.get("/api/v1/pprs/404").status_code == 404
|
||||
assert auth_client.put("/api/v1/pprs/404", json={"captain": "Nobody"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/pprs/404", json={"captain": "Nobody"}).status_code == 404
|
||||
assert auth_client.patch("/api/v1/pprs/404/status", json={"status": "LANDED"}).status_code == 404
|
||||
assert auth_client.post("/api/v1/pprs/404/acknowledge").status_code == 404
|
||||
assert auth_client.delete("/api/v1/pprs/404").status_code == 404
|
||||
assert auth_client.get("/api/v1/pprs/404/journal").status_code == 404
|
||||
assert auth_client.post("/api/v1/pprs/404/activate").status_code == 404
|
||||
|
||||
|
||||
def test_public_ppr_create_sends_email_and_generates_token(client, db, ppr_payload, monkeypatch):
|
||||
sent_email = {}
|
||||
|
||||
async def fake_send_email(**kwargs):
|
||||
sent_email.update(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.pprs.email_service.send_email", fake_send_email)
|
||||
|
||||
create_response = client.post("/api/v1/pprs/public", json=ppr_payload)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["created_by"] == "public"
|
||||
assert sent_email["to_email"] == "pilot@example.com"
|
||||
assert sent_email["template_vars"]["arrival_time"] == "2026-06-20 11:00"
|
||||
assert sent_email["template_vars"]["departure_time"] == "2026-06-20 13:00"
|
||||
|
||||
db_ppr = db.query(PPRRecord).filter(PPRRecord.id == created["id"]).one()
|
||||
assert db_ppr.public_token
|
||||
|
||||
|
||||
def test_public_ppr_token_edit_and_cancel_paths(client, ppr_factory, db):
|
||||
ppr = ppr_factory(public_token="public-edit-token", email=None)
|
||||
|
||||
get_response = client.get("/api/v1/pprs/public/edit/public-edit-token")
|
||||
patch_response = client.patch(
|
||||
"/api/v1/pprs/public/edit/public-edit-token",
|
||||
json={"captain": "Public Editor"},
|
||||
)
|
||||
cancel_response = client.delete("/api/v1/pprs/public/cancel/public-edit-token")
|
||||
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json()["id"] == ppr.id
|
||||
assert patch_response.status_code == 200
|
||||
assert patch_response.json()["captain"] == "Public Editor"
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json()["status"] == "CANCELED"
|
||||
|
||||
assert client.get("/api/v1/pprs/public/edit/public-edit-token").status_code == 200
|
||||
assert client.patch("/api/v1/pprs/public/edit/public-edit-token", json={}).status_code == 400
|
||||
assert client.delete("/api/v1/pprs/public/cancel/public-edit-token").status_code == 400
|
||||
assert client.patch("/api/v1/pprs/public/edit/missing-token", json={}).status_code == 404
|
||||
assert client.delete("/api/v1/pprs/public/cancel/missing-token").status_code == 404
|
||||
|
||||
|
||||
def test_activate_ppr_creates_arrival_and_pending_departure(auth_client, ppr_factory, db):
|
||||
ppr = ppr_factory(public_token="activate-token")
|
||||
|
||||
response = auth_client.post(f"/api/v1/pprs/{ppr.id}/activate")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["arrival_id"]
|
||||
assert body["departure_id"]
|
||||
|
||||
arrival = db.query(Arrival).filter(Arrival.id == body["arrival_id"]).one()
|
||||
departure = db.query(Departure).filter(Departure.id == body["departure_id"]).one()
|
||||
|
||||
assert arrival.registration == "G-FACT"
|
||||
assert arrival.in_from == "EGLL"
|
||||
assert departure.status == DepartureStatus.PENDING
|
||||
assert departure.arrival_id == arrival.id
|
||||
|
||||
|
||||
def test_activate_rejects_processed_ppr(auth_client, ppr_factory):
|
||||
ppr = ppr_factory(status="LANDED", public_token="processed-token")
|
||||
|
||||
response = auth_client.post(f"/api/v1/pprs/{ppr.id}/activate")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "cannot be activated" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_public_ppr_processed_token_can_view_but_not_edit_or_cancel(client, ppr_factory):
|
||||
ppr = ppr_factory(status="LANDED", public_token="processed-token")
|
||||
|
||||
get_response = client.get("/api/v1/pprs/public/edit/processed-token")
|
||||
patch_response = client.patch(
|
||||
"/api/v1/pprs/public/edit/processed-token",
|
||||
json={"captain": "Too Late"},
|
||||
)
|
||||
cancel_response = client.delete("/api/v1/pprs/public/cancel/processed-token")
|
||||
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json()["id"] == ppr.id
|
||||
assert patch_response.status_code == 400
|
||||
assert cancel_response.status_code == 400
|
||||
|
||||
|
||||
def test_invalid_ppr_payload_returns_validation_error(auth_client, ppr_payload):
|
||||
ppr_payload["pob_in"] = -1
|
||||
|
||||
response = auth_client.post("/api/v1/pprs/", json=ppr_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,241 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.models.arrival import Arrival, ArrivalStatus
|
||||
from app.models.departure import Departure, DepartureStatus
|
||||
from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType
|
||||
from app.models.ppr import Aircraft, Airport, PPRRecord, PPRStatus
|
||||
|
||||
|
||||
def test_public_arrivals_and_departures_start_empty(client):
|
||||
arrivals = client.get("/api/v1/public/arrivals")
|
||||
departures = client.get("/api/v1/public/departures")
|
||||
|
||||
assert arrivals.status_code == 200
|
||||
assert arrivals.json() == []
|
||||
assert departures.status_code == 200
|
||||
assert departures.json() == []
|
||||
|
||||
|
||||
def test_public_boards_include_todays_flights(client, db):
|
||||
now = datetime.now().replace(microsecond=0)
|
||||
ppr = PPRRecord(
|
||||
status=PPRStatus.LANDED,
|
||||
ac_reg="G-PPR1",
|
||||
ac_type="PA28",
|
||||
ac_call="GPPR1",
|
||||
captain="Arriving Pilot",
|
||||
in_from="EGLL",
|
||||
eta=now,
|
||||
pob_in=2,
|
||||
out_to="EGKK",
|
||||
etd=now,
|
||||
pob_out=2,
|
||||
created_by="test",
|
||||
)
|
||||
local_flight = LocalFlight(
|
||||
registration="G-LOC1",
|
||||
type="C152",
|
||||
callsign="GLOC1",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.LOCAL,
|
||||
status=LocalFlightStatus.BOOKED_OUT,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
)
|
||||
arrival = Arrival(
|
||||
registration="G-ARR1",
|
||||
type="DA40",
|
||||
callsign="GARR1",
|
||||
pob=3,
|
||||
in_from="EGBB",
|
||||
status=ArrivalStatus.INBOUND,
|
||||
created_dt=now,
|
||||
eta=now,
|
||||
)
|
||||
departure = Departure(
|
||||
registration="G-DEP1",
|
||||
type="SR22",
|
||||
callsign="GDEP1",
|
||||
pob=2,
|
||||
out_to="EGCC",
|
||||
status=DepartureStatus.BOOKED_OUT,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
)
|
||||
db.add_all([ppr, local_flight, arrival, departure])
|
||||
db.commit()
|
||||
|
||||
arrivals = client.get("/api/v1/public/arrivals")
|
||||
departures = client.get("/api/v1/public/departures")
|
||||
|
||||
assert arrivals.status_code == 200
|
||||
assert {item.get("ac_reg") or item.get("registration") for item in arrivals.json()} == {
|
||||
"G-PPR1",
|
||||
"G-ARR1",
|
||||
}
|
||||
assert departures.status_code == 200
|
||||
assert {item["ac_reg"] for item in departures.json()} == {
|
||||
"G-PPR1",
|
||||
"G-LOC1",
|
||||
"G-DEP1",
|
||||
}
|
||||
|
||||
|
||||
def test_public_boards_include_current_local_flight_statuses(client, db):
|
||||
now = datetime.now().replace(microsecond=0)
|
||||
ground_local = LocalFlight(
|
||||
registration="G-GRND",
|
||||
type="C152",
|
||||
callsign="GGRND",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.LOCAL,
|
||||
status=LocalFlightStatus.GROUND,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
)
|
||||
airborne_local = LocalFlight(
|
||||
registration="G-AIR1",
|
||||
type="PA28",
|
||||
callsign="GAIR1",
|
||||
pob=2,
|
||||
flight_type=LocalFlightType.LOCAL,
|
||||
status=LocalFlightStatus.LOCAL,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
takeoff_dt=now,
|
||||
duration=45,
|
||||
)
|
||||
circuit_local = LocalFlight(
|
||||
registration="G-CCT1",
|
||||
type="C152",
|
||||
callsign="GCCT1",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.CIRCUITS,
|
||||
status=LocalFlightStatus.CIRCUIT,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
takeoff_dt=now,
|
||||
duration=30,
|
||||
)
|
||||
landed_local = LocalFlight(
|
||||
registration="G-LND1",
|
||||
type="C172",
|
||||
callsign="GLND1",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.LOCAL,
|
||||
status=LocalFlightStatus.LANDED,
|
||||
created_dt=now,
|
||||
etd=now,
|
||||
takeoff_dt=now,
|
||||
landed_dt=now,
|
||||
)
|
||||
db.add_all([ground_local, airborne_local, circuit_local, landed_local])
|
||||
db.commit()
|
||||
|
||||
arrivals = client.get("/api/v1/public/arrivals")
|
||||
departures = client.get("/api/v1/public/departures")
|
||||
|
||||
assert arrivals.status_code == 200
|
||||
assert {
|
||||
item["ac_reg"] for item in arrivals.json() if item.get("isLocalFlight")
|
||||
} == {"G-AIR1", "G-CCT1"}
|
||||
|
||||
assert departures.status_code == 200
|
||||
assert {
|
||||
item["ac_reg"] for item in departures.json() if item.get("isLocalFlight")
|
||||
} == {"G-GRND"}
|
||||
|
||||
|
||||
def test_public_reference_lookups_return_seeded_records(client, db):
|
||||
db.add(
|
||||
Airport(
|
||||
icao="EGLL",
|
||||
iata="LHR",
|
||||
name="London Heathrow",
|
||||
country="United Kingdom",
|
||||
city="London",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
Aircraft(
|
||||
registration="G-ABCD",
|
||||
type_code="PA28",
|
||||
clean_reg="GABCD",
|
||||
manufacturer_name="Piper",
|
||||
model="Cherokee",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
airport_response = client.get("/api/v1/airport/public/lookup/EGLL")
|
||||
aircraft_response = client.get("/api/v1/aircraft/public/lookup/G-ABC")
|
||||
|
||||
assert airport_response.status_code == 200
|
||||
assert airport_response.json()[0]["icao"] == "EGLL"
|
||||
assert aircraft_response.status_code == 200
|
||||
assert aircraft_response.json()[0]["registration"] == "G-ABCD"
|
||||
|
||||
|
||||
def test_public_reference_lookups_return_empty_for_short_queries(client):
|
||||
airport_response = client.get("/api/v1/airport/public/lookup/E")
|
||||
aircraft_response = client.get("/api/v1/aircraft/public/lookup/G-A")
|
||||
|
||||
assert airport_response.status_code == 200
|
||||
assert airport_response.json() == []
|
||||
assert aircraft_response.status_code == 200
|
||||
assert aircraft_response.json() == []
|
||||
|
||||
|
||||
def test_public_airport_lookup_searches_by_partial_name(client, db):
|
||||
db.add(
|
||||
Airport(
|
||||
icao="EGBB",
|
||||
iata="BHX",
|
||||
name="Birmingham Airport",
|
||||
country="United Kingdom",
|
||||
city="Birmingham",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
response = client.get("/api/v1/airport/public/lookup/Birmingham")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()[0]["icao"] == "EGBB"
|
||||
|
||||
|
||||
def test_public_boards_exclude_old_and_cancelled_records(client, db):
|
||||
yesterday = datetime.now().replace(microsecond=0) - timedelta(days=1)
|
||||
db.add(
|
||||
PPRRecord(
|
||||
status=PPRStatus.CANCELED,
|
||||
ac_reg="G-OLD1",
|
||||
ac_type="PA28",
|
||||
captain="Old Pilot",
|
||||
in_from="EGLL",
|
||||
eta=yesterday,
|
||||
pob_in=1,
|
||||
out_to="EGKK",
|
||||
etd=yesterday,
|
||||
created_by="test",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
LocalFlight(
|
||||
registration="G-OLD2",
|
||||
type="C152",
|
||||
pob=1,
|
||||
flight_type=LocalFlightType.LOCAL,
|
||||
status=LocalFlightStatus.BOOKED_OUT,
|
||||
created_dt=yesterday,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
arrivals = client.get("/api/v1/public/arrivals")
|
||||
departures = client.get("/api/v1/public/departures")
|
||||
|
||||
assert arrivals.status_code == 200
|
||||
assert arrivals.json() == []
|
||||
assert departures.status_code == 200
|
||||
assert departures.json() == []
|
||||
@@ -0,0 +1,138 @@
|
||||
from app.models.arrival import Arrival
|
||||
from app.models.departure import Departure
|
||||
from app.models.local_flight import LocalFlight
|
||||
from app.models.movement import Movement, MovementType
|
||||
|
||||
|
||||
def enable_public_booking(monkeypatch, enabled=True):
|
||||
monkeypatch.setattr("app.api.endpoints.public_book.settings.allow_public_booking", enabled)
|
||||
|
||||
|
||||
def test_public_booking_rejects_requests_when_disabled(client, monkeypatch):
|
||||
enable_public_booking(monkeypatch, enabled=False)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/public-book/local-flights",
|
||||
json={
|
||||
"registration": "G-PUB1",
|
||||
"pob": 1,
|
||||
"flight_type": "LOCAL",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Public booking is currently disabled"
|
||||
|
||||
|
||||
def test_public_booking_creates_local_flight_and_circuit(client, db, monkeypatch):
|
||||
enable_public_booking(monkeypatch)
|
||||
|
||||
flight_response = client.post(
|
||||
"/api/v1/public-book/local-flights",
|
||||
json={
|
||||
"registration": "g-pub1",
|
||||
"type": "C152",
|
||||
"callsign": "GPUB1",
|
||||
"pob": 1,
|
||||
"flight_type": "LOCAL",
|
||||
"duration": 30,
|
||||
"etd": "2026-06-20T10:00:00",
|
||||
"notes": "Public local",
|
||||
"pilot_email": " PILOT@EXAMPLE.COM ",
|
||||
},
|
||||
)
|
||||
|
||||
assert flight_response.status_code == 200
|
||||
flight = flight_response.json()
|
||||
assert flight["registration"] == "G-PUB1"
|
||||
assert flight["status"] == "BOOKED_OUT"
|
||||
assert flight["submitted_via"] == "PUBLIC"
|
||||
assert flight["pilot_email"] == "pilot@example.com"
|
||||
|
||||
circuit_response = client.post(
|
||||
"/api/v1/public-book/circuits",
|
||||
json={
|
||||
"local_flight_id": flight["id"],
|
||||
"circuit_timestamp": "2026-06-20T10:15:00",
|
||||
"pilot_email": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert circuit_response.status_code == 200
|
||||
assert circuit_response.json()["local_flight_id"] == flight["id"]
|
||||
|
||||
db_flight = db.query(LocalFlight).filter(LocalFlight.id == flight["id"]).one()
|
||||
assert db_flight.created_by == "PUBLIC_PILOT"
|
||||
movement = db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").one()
|
||||
assert movement.movement_type == MovementType.TOUCH_AND_GO
|
||||
|
||||
|
||||
def test_public_booking_creates_departure_and_arrival(client, db, monkeypatch):
|
||||
enable_public_booking(monkeypatch)
|
||||
|
||||
departure_response = client.post(
|
||||
"/api/v1/public-book/departures",
|
||||
json={
|
||||
"registration": "g-pub2",
|
||||
"type": "PA28",
|
||||
"callsign": "GPUB2",
|
||||
"pob": 2,
|
||||
"out_to": "egkk",
|
||||
"etd": "2026-06-20T11:00:00",
|
||||
"notes": "Public departure",
|
||||
"pilot_email": "depart@example.com",
|
||||
},
|
||||
)
|
||||
arrival_response = client.post(
|
||||
"/api/v1/public-book/arrivals",
|
||||
json={
|
||||
"registration": "g-pub3",
|
||||
"type": "DA40",
|
||||
"callsign": "GPUB3",
|
||||
"pob": 3,
|
||||
"in_from": "egll",
|
||||
"eta": "2026-06-20T12:00:00",
|
||||
"notes": "Public arrival",
|
||||
"pilot_email": "arrive@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert departure_response.status_code == 200
|
||||
departure = departure_response.json()
|
||||
assert departure["registration"] == "G-PUB2"
|
||||
assert departure["status"] == "BOOKED_OUT"
|
||||
assert departure["submitted_via"] == "PUBLIC"
|
||||
assert departure["pilot_email"] == "depart@example.com"
|
||||
|
||||
assert arrival_response.status_code == 200
|
||||
arrival = arrival_response.json()
|
||||
assert arrival["registration"] == "G-PUB3"
|
||||
assert arrival["status"] == "BOOKED_IN"
|
||||
assert arrival["submitted_via"] == "PUBLIC"
|
||||
assert arrival["pilot_email"] == "arrive@example.com"
|
||||
|
||||
db_departure = db.query(Departure).filter(Departure.id == departure["id"]).one()
|
||||
db_arrival = db.query(Arrival).filter(Arrival.id == arrival["id"]).one()
|
||||
assert db_departure.created_by == "PUBLIC_PILOT"
|
||||
assert db_arrival.created_by == "PUBLIC_PILOT"
|
||||
|
||||
|
||||
def test_public_booking_validates_payloads(client, monkeypatch):
|
||||
enable_public_booking(monkeypatch)
|
||||
|
||||
local_response = client.post(
|
||||
"/api/v1/public-book/local-flights",
|
||||
json={"registration": "", "pob": 0, "flight_type": "LOCAL"},
|
||||
)
|
||||
departure_response = client.post(
|
||||
"/api/v1/public-book/departures",
|
||||
json={"registration": "G-BAD", "pob": 0, "out_to": ""},
|
||||
)
|
||||
arrival_response = client.post(
|
||||
"/api/v1/public-book/arrivals",
|
||||
json={"registration": "G-BAD", "pob": 0, "in_from": ""},
|
||||
)
|
||||
|
||||
assert local_response.status_code == 422
|
||||
assert departure_response.status_code == 422
|
||||
assert arrival_response.status_code == 422
|
||||
+6
-3
@@ -22,7 +22,7 @@ CREATE TABLE users (
|
||||
-- Main PPR submissions table with improvements
|
||||
CREATE TABLE submitted (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
status ENUM('NEW','CONFIRMED','CANCELED','LANDED','DELETED','DEPARTED') NOT NULL DEFAULT 'NEW',
|
||||
status ENUM('NEW','CONFIRMED','CANCELED','LANDED','LOCAL','DELETED','DEPARTED','ACTIVATED') NOT NULL DEFAULT 'NEW',
|
||||
ac_reg VARCHAR(16) NOT NULL,
|
||||
ac_type VARCHAR(32) NOT NULL,
|
||||
ac_call VARCHAR(16) DEFAULT NULL,
|
||||
@@ -38,9 +38,12 @@ CREATE TABLE submitted (
|
||||
phone VARCHAR(16) DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
landed_dt DATETIME DEFAULT NULL,
|
||||
departed_dt DATETIME DEFAULT NULL,
|
||||
takeoff_dt DATETIME DEFAULT NULL,
|
||||
qsy_dt DATETIME DEFAULT NULL,
|
||||
created_by VARCHAR(16) DEFAULT NULL,
|
||||
submitted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
acknowledged_dt DATETIME DEFAULT NULL,
|
||||
acknowledged_by VARCHAR(50) DEFAULT NULL,
|
||||
public_token VARCHAR(128) DEFAULT NULL UNIQUE,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
@@ -130,4 +133,4 @@ WHERE s.status != 'DELETED';
|
||||
|
||||
-- Create indexes for the view performance
|
||||
-- ALTER TABLE submitted ADD INDEX idx_in_from (in_from);
|
||||
-- ALTER TABLE submitted ADD INDEX idx_out_to (out_to);
|
||||
-- ALTER TABLE submitted ADD INDEX idx_out_to (out_to);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
services:
|
||||
db:
|
||||
container_name: ppr_e2e_db
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tests/e2e/mysql.Dockerfile
|
||||
image: pprdev-e2e-db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: e2e_root_password
|
||||
MYSQL_DATABASE: ppr_e2e
|
||||
MYSQL_USER: ppr_e2e
|
||||
MYSQL_PASSWORD: ppr_e2e_password
|
||||
volumes:
|
||||
- ppr_e2e_mysql_data:/var/lib/mysql
|
||||
|
||||
api:
|
||||
container_name: ppr_e2e_api
|
||||
ports:
|
||||
- "${E2E_API_PORT_EXTERNAL:-18002}:8000"
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: 3306
|
||||
DB_NAME: ppr_e2e
|
||||
DB_USER: ppr_e2e
|
||||
DB_PASSWORD: ppr_e2e_password
|
||||
BASE_URL: http://web
|
||||
MAIL_HOST: 127.0.0.1
|
||||
MAIL_PORT: 1
|
||||
MAIL_USERNAME: e2e
|
||||
MAIL_PASSWORD: e2e
|
||||
MAIL_FROM: e2e@example.com
|
||||
MAIL_FROM_NAME: PPR E2E
|
||||
DRONE_REQUEST_TOWER_EMAIL: tower@example.com
|
||||
|
||||
web:
|
||||
container_name: ppr_e2e_web
|
||||
ports:
|
||||
- "${E2E_WEB_PORT_EXTERNAL:-18055}:80"
|
||||
environment:
|
||||
BASE_URL: ""
|
||||
command: >
|
||||
sh -c "if [ -z \"$${BASE_URL}\" ]; then API_BASE='/api/v1'; else API_BASE=\"$${BASE_URL}/api/v1\"; fi;
|
||||
printf 'window.PPR_CONFIG = { apiBase: \"%s\" };' \"$${API_BASE}\" > /usr/share/nginx/html/config.js;
|
||||
nginx -g 'daemon off;'"
|
||||
|
||||
e2e:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tests/e2e/Dockerfile
|
||||
working_dir: /workspace
|
||||
volumes:
|
||||
- .:/workspace
|
||||
environment:
|
||||
E2E_BASE_URL: http://web
|
||||
E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-}
|
||||
E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-}
|
||||
E2E_HEALTH_URL: http://api:8000/health
|
||||
E2E_ARTIFACT_UID: ${E2E_ARTIFACT_UID:-1000}
|
||||
E2E_ARTIFACT_GID: ${E2E_ARTIFACT_GID:-1000}
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
depends_on:
|
||||
- web
|
||||
networks:
|
||||
- public_network
|
||||
command: >
|
||||
bash -lc "mkdir -p test-results &&
|
||||
python tests/e2e/wait_for_web.py &&
|
||||
pytest tests/e2e
|
||||
--browser chromium
|
||||
--tracing=retain-on-failure
|
||||
--screenshot=only-on-failure
|
||||
--junitxml=test-results/e2e-junit.xml
|
||||
--html=test-results/e2e-report.html
|
||||
--self-contained-html;
|
||||
status=$$?;
|
||||
chown -R \"$${E2E_ARTIFACT_UID}:$${E2E_ARTIFACT_GID}\" test-results || true;
|
||||
exit $$status"
|
||||
|
||||
volumes:
|
||||
ppr_e2e_mysql_data:
|
||||
+1
-14
@@ -24,13 +24,12 @@ services:
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||
MAIL_FROM: ${MAIL_FROM}
|
||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
|
||||
BASE_URL: ${BASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
TAG: ${TAG}
|
||||
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||
ALLOW_PUBLIC_BOOKING: ${ALLOW_PUBLIC_BOOKING}
|
||||
ENVIRONMENT: production
|
||||
WORKERS: "4"
|
||||
ports:
|
||||
- "${API_PORT_EXTERNAL}:8000"
|
||||
volumes:
|
||||
@@ -56,18 +55,6 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Redis for caching (optional)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: always
|
||||
networks:
|
||||
- app_network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
# Nginx web server for public frontend
|
||||
web:
|
||||
image: nginx:alpine
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Production docker-compose configuration
|
||||
# This uses an external database and optimized settings
|
||||
|
||||
services:
|
||||
# FastAPI Backend
|
||||
api:
|
||||
build: ./backend
|
||||
restart: always
|
||||
environment:
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_PORT: ${DB_PORT}
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES}
|
||||
API_V1_STR: ${API_V1_STR}
|
||||
PROJECT_NAME: ${PROJECT_NAME}
|
||||
MAIL_HOST: ${MAIL_HOST}
|
||||
MAIL_PORT: ${MAIL_PORT}
|
||||
MAIL_USERNAME: ${MAIL_USERNAME}
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||
MAIL_FROM: ${MAIL_FROM}
|
||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||
BASE_URL: ${BASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
TAG: ${TAG}
|
||||
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||
ENVIRONMENT: production
|
||||
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
|
||||
ports:
|
||||
- "${API_PORT_EXTERNAL}:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./db-init:/db-init:ro # Mount CSV data for seeding
|
||||
- ./web/assets:/web/assets # Mount assets for QR code generation
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Nginx web server for public frontend
|
||||
web:
|
||||
image: nginx:alpine
|
||||
restart: always
|
||||
environment:
|
||||
BASE_URL: ${BASE_URL}
|
||||
command: >
|
||||
sh -c "echo 'window.PPR_CONFIG = { apiBase: \"'\$BASE_URL'/api/v1\" };' > /usr/share/nginx/html/config.js &&
|
||||
nginx -g 'daemon off;'"
|
||||
ports:
|
||||
- "${WEB_PORT_EXTERNAL}:80"
|
||||
volumes:
|
||||
- ./web:/usr/share/nginx/html
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- default
|
||||
- webapps
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
|
||||
networks:
|
||||
default:
|
||||
webapps:
|
||||
external: true
|
||||
+2
-10
@@ -36,8 +36,8 @@ services:
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||
MAIL_FROM: ${MAIL_FROM}
|
||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME}
|
||||
DRONE_REQUEST_TOWER_EMAIL: ${DRONE_REQUEST_TOWER_EMAIL:-}
|
||||
BASE_URL: ${BASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
TOWER_NAME: ${TOWER_NAME}
|
||||
TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR}
|
||||
ENVIRONMENT: ${ENVIRONMENT}
|
||||
@@ -72,14 +72,6 @@ services:
|
||||
networks:
|
||||
- public_network
|
||||
|
||||
# Redis for caching (optional for now)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ppr_nextgen_redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- private_network
|
||||
|
||||
# phpMyAdmin for database management
|
||||
phpmyadmin:
|
||||
image: phpmyadmin/phpmyadmin
|
||||
@@ -106,4 +98,4 @@ networks:
|
||||
private_network:
|
||||
driver: bridge
|
||||
public_network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY tests/e2e/requirements.txt /tmp/e2e-requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/e2e-requirements.txt
|
||||
@@ -0,0 +1,90 @@
|
||||
# End-to-End Tests
|
||||
|
||||
The e2e suite uses pytest plus Playwright. The preferred path is the containerized runner, which joins the same Docker Compose network as the web app and opens `http://web`.
|
||||
|
||||
## Containerized Run
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d db api web
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
|
||||
```
|
||||
|
||||
The e2e override sets `E2E_BASE_URL=http://web`, makes the web container generate relative API config for browser-side requests, and forces the API container to use an e2e-only Compose database.
|
||||
|
||||
The e2e database is a real MySQL server, but it is isolated from the normal dev database:
|
||||
|
||||
- DB container: `ppr_e2e_db`
|
||||
- API container: `ppr_e2e_api`
|
||||
- Web container: `ppr_e2e_web`
|
||||
- Database name: `ppr_e2e`
|
||||
- Volume: `pprdev_ppr_e2e_mysql_data`
|
||||
|
||||
The e2e DB image is plain `mysql:8.0`, so the API should see a fresh empty database and create schema through Alembic migrations instead of stamping an older `db-init` schema.
|
||||
|
||||
Authenticated tests are skipped unless credentials are supplied:
|
||||
|
||||
```bash
|
||||
E2E_ADMIN_USERNAME=admin \
|
||||
E2E_ADMIN_PASSWORD=admin123 \
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
|
||||
```
|
||||
|
||||
## Rebuild The Test Image
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml build e2e
|
||||
```
|
||||
|
||||
## Test Evidence
|
||||
|
||||
Containerized runs write evidence to `test-results/`:
|
||||
|
||||
- `test-results/e2e-report.html` for a human-readable report
|
||||
- `test-results/e2e-junit.xml` for CI systems
|
||||
- Playwright traces and screenshots on failures
|
||||
|
||||
If the report files are not owned by your host user, pass your UID/GID:
|
||||
|
||||
```bash
|
||||
E2E_ARTIFACT_UID=$(id -u) \
|
||||
E2E_ARTIFACT_GID=$(id -g) \
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
|
||||
```
|
||||
|
||||
## Host Run
|
||||
|
||||
Running on the host is still supported for quick debugging if Python and Playwright are installed locally.
|
||||
|
||||
First-time host setup:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r backend/requirements.txt
|
||||
pip install -r tests/e2e/requirements.txt
|
||||
python -m playwright install --with-deps chromium
|
||||
```
|
||||
|
||||
Run against a host-exposed web port:
|
||||
|
||||
```bash
|
||||
E2E_BASE_URL=http://localhost:8082 pytest tests/e2e
|
||||
```
|
||||
|
||||
Run authenticated host tests:
|
||||
|
||||
```bash
|
||||
E2E_BASE_URL=http://localhost:8082 \
|
||||
E2E_ADMIN_USERNAME=admin \
|
||||
E2E_ADMIN_PASSWORD=admin123 \
|
||||
pytest tests/e2e
|
||||
```
|
||||
|
||||
## Adding Tests
|
||||
|
||||
Put browser specs in `tests/e2e/test_*.py`. Start with user-visible behavior and stable selectors:
|
||||
|
||||
- Navigate through the same URLs users open.
|
||||
- Prefer roles and labels, such as `get_by_role()` and `get_by_label()`.
|
||||
- Use API setup only when a test needs specific records to exist.
|
||||
- Keep specs independent so they can run in any order.
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
|
||||
ADMIN_USERNAME = os.getenv("E2E_ADMIN_USERNAME")
|
||||
ADMIN_PASSWORD = os.getenv("E2E_ADMIN_PASSWORD")
|
||||
|
||||
|
||||
def app_url(path):
|
||||
return f"{BASE_URL}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def login_as_admin(page):
|
||||
if page.locator("#login-username").is_visible():
|
||||
page.locator("#login-username").fill(ADMIN_USERNAME)
|
||||
page.locator("#login-password").fill(ADMIN_PASSWORD)
|
||||
page.locator("#login-btn").click()
|
||||
expect(page.locator("#current-user")).to_have_text(ADMIN_USERNAME)
|
||||
if page.get_by_role("heading", name="Login").count() > 0:
|
||||
expect(page.get_by_role("heading", name="Login")).to_be_hidden()
|
||||
|
||||
|
||||
def skip_without_admin_credentials(pytest):
|
||||
if not ADMIN_USERNAME or not ADMIN_PASSWORD:
|
||||
pytest.skip("Set E2E_ADMIN_USERNAME and E2E_ADMIN_PASSWORD to run authenticated e2e tests")
|
||||
@@ -0,0 +1 @@
|
||||
FROM mysql:8.0
|
||||
@@ -0,0 +1,3 @@
|
||||
playwright==1.45.0
|
||||
pytest-playwright>=0.5,<0.6
|
||||
pytest-html>=4,<5
|
||||
@@ -0,0 +1,73 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from helpers import app_url, login_as_admin, skip_without_admin_credentials
|
||||
|
||||
|
||||
def open_admin_dropdown(page):
|
||||
page.locator("#adminDropdownBtn").click()
|
||||
admin_menu = page.locator("#adminDropdownMenu")
|
||||
expect(admin_menu).to_have_class(re.compile("active"))
|
||||
return admin_menu
|
||||
|
||||
|
||||
def open_admin_page(page):
|
||||
skip_without_admin_credentials(pytest)
|
||||
page.goto(app_url("/admin"))
|
||||
expect(page).to_have_title(re.compile("PPR Admin Interface"))
|
||||
expect(page.get_by_role("heading", name=re.compile("Swansea Tower"))).to_be_visible()
|
||||
login_as_admin(page)
|
||||
|
||||
|
||||
def test_actions_and_admin_dropdowns_toggle_exclusively(page):
|
||||
open_admin_page(page)
|
||||
|
||||
actions_menu = page.locator("#actionsDropdownMenu")
|
||||
admin_menu = page.locator("#adminDropdownMenu")
|
||||
|
||||
expect(actions_menu).not_to_have_class(re.compile("active"))
|
||||
expect(admin_menu).not_to_have_class(re.compile("active"))
|
||||
|
||||
page.locator("#actionsDropdownBtn").click()
|
||||
expect(actions_menu).to_have_class(re.compile("active"))
|
||||
expect(admin_menu).not_to_have_class(re.compile("active"))
|
||||
expect(actions_menu.get_by_role("link", name=re.compile("New PPR"))).to_be_visible()
|
||||
expect(actions_menu.get_by_role("link", name=re.compile("Book Out"))).to_be_visible()
|
||||
expect(actions_menu.get_by_role("link", name=re.compile("Book In"))).to_be_visible()
|
||||
expect(actions_menu.get_by_role("link", name=re.compile("Overflight"))).to_be_visible()
|
||||
|
||||
admin_menu = open_admin_dropdown(page)
|
||||
expect(actions_menu).not_to_have_class(re.compile("active"))
|
||||
expect(admin_menu.get_by_role("link", name=re.compile("Admin View"))).to_be_visible()
|
||||
expect(admin_menu.get_by_role("link", name=re.compile("ATC View"))).to_be_visible()
|
||||
expect(admin_menu.get_by_role("link", name=re.compile("Reports"))).to_be_visible()
|
||||
expect(admin_menu.get_by_role("link", name=re.compile("Drone Requests"))).to_be_visible()
|
||||
expect(admin_menu.get_by_role("link", name=re.compile("Journal Log"))).to_be_visible()
|
||||
|
||||
page.locator(".container").click()
|
||||
expect(admin_menu).not_to_have_class(re.compile("active"))
|
||||
|
||||
|
||||
def test_admin_menu_links_navigate_to_expected_pages(page):
|
||||
open_admin_page(page)
|
||||
|
||||
menu_expectations = [
|
||||
("Admin View", re.compile(r"/admin$"), re.compile("PPR Admin Interface"), "title"),
|
||||
("ATC View", re.compile(r"/atc$"), re.compile("ATC Management Interface"), "title"),
|
||||
("Reports", re.compile(r"/reports$"), re.compile("PPR Reports"), "heading"),
|
||||
("Drone Requests", re.compile(r"/drone-requests$"), re.compile("Drone Flight Requests"), "heading"),
|
||||
("Journal Log", re.compile(r"/journal$"), re.compile("Journal Log"), "heading"),
|
||||
]
|
||||
|
||||
for label, url_pattern, expected_text, assertion_type in menu_expectations:
|
||||
page.goto(app_url("/admin"))
|
||||
login_as_admin(page)
|
||||
admin_menu = open_admin_dropdown(page)
|
||||
admin_menu.get_by_role("link", name=re.compile(label)).click()
|
||||
expect(page).to_have_url(url_pattern)
|
||||
if assertion_type == "title":
|
||||
expect(page).to_have_title(expected_text)
|
||||
else:
|
||||
expect(page.get_by_role("heading", name=expected_text)).to_be_visible()
|
||||
@@ -0,0 +1,64 @@
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from helpers import app_url, login_as_admin, skip_without_admin_credentials
|
||||
|
||||
|
||||
def drone_payload(operator_name):
|
||||
return {
|
||||
"operator_name": operator_name,
|
||||
"operator_id": "E2E-OP",
|
||||
"flyer_name": "E2E Remote Pilot",
|
||||
"flyer_id": "E2E-FLYER",
|
||||
"email": "drone-e2e@example.com",
|
||||
"phone": "0123456789",
|
||||
"flight_date": "2026-06-21",
|
||||
"estimated_takeoff_time": "10:00",
|
||||
"estimated_completion_time": "10:30",
|
||||
"estimated_takeoff_at": "2026-06-21T10:00:00",
|
||||
"estimated_completion_at": "2026-06-21T10:30:00",
|
||||
"maximum_elevation_ft_agl": 200,
|
||||
"location_description": "E2E north apron survey",
|
||||
"location_latitude": 51.623389,
|
||||
"location_longitude": -4.069231,
|
||||
"location_inside_frz": "yes",
|
||||
"notes": "Created by Playwright e2e",
|
||||
}
|
||||
|
||||
|
||||
def test_drone_requests_requires_login_and_loads_queue(page):
|
||||
skip_without_admin_credentials(pytest)
|
||||
|
||||
page.goto(app_url("/drone-requests"))
|
||||
|
||||
expect(page).to_have_title(re.compile("Drone Flight Requests"))
|
||||
expect(page.get_by_role("heading", name="Drone Flight Requests")).to_be_visible()
|
||||
expect(page.get_by_role("heading", name="Login")).to_be_visible()
|
||||
|
||||
login_as_admin(page)
|
||||
|
||||
expect(page.locator("#request-list-body")).not_to_contain_text("Loading requests...")
|
||||
expect(page.locator("#request-count")).to_have_text(re.compile(r"\d+"))
|
||||
|
||||
|
||||
def test_public_drone_request_appears_in_tower_queue(page):
|
||||
skip_without_admin_credentials(pytest)
|
||||
|
||||
operator_name = f"E2E Rotor Ops {datetime.utcnow().strftime('%H%M%S%f')}"
|
||||
create_response = page.request.post(
|
||||
app_url("/api/v1/drone-requests/public"),
|
||||
data=json.dumps(drone_payload(operator_name)),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert create_response.ok, create_response.text()
|
||||
created = create_response.json()
|
||||
|
||||
page.goto(app_url("/drone-requests"))
|
||||
login_as_admin(page)
|
||||
|
||||
expect(page.locator("#request-list-body")).to_contain_text(created["reference_number"])
|
||||
expect(page.locator("#request-list-body")).to_contain_text(operator_name)
|
||||
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from helpers import app_url, login_as_admin, skip_without_admin_credentials
|
||||
|
||||
|
||||
def local_flight_row(page, registration):
|
||||
return page.locator("#local-flights-table-body tr").filter(has_text=registration)
|
||||
|
||||
|
||||
def confirm_timestamp(page, button_text):
|
||||
expect(page.locator("#timestampModal")).to_be_visible()
|
||||
expect(page.locator("#timestamp-submit-btn")).to_contain_text(button_text)
|
||||
page.locator("#timestamp-submit-btn").click()
|
||||
expect(page.locator("#timestampModal")).to_be_hidden()
|
||||
|
||||
|
||||
def test_operator_books_out_local_flight_records_touch_and_go_and_lands(page):
|
||||
skip_without_admin_credentials(pytest)
|
||||
registration = f"GE2E{datetime.utcnow().strftime('%H%M%S')}"
|
||||
|
||||
page.goto(app_url("/admin"))
|
||||
login_as_admin(page)
|
||||
|
||||
page.locator("#actionsDropdownBtn").click()
|
||||
page.locator("#actionsDropdownMenu").get_by_role("link", name="🛫 Book Out (L)").click()
|
||||
|
||||
expect(page.locator("#localFlightModal")).to_be_visible()
|
||||
page.locator("#local_registration").fill(registration)
|
||||
page.locator("#local_type").fill("PA28")
|
||||
page.locator("#local_pob").fill("1")
|
||||
page.locator("#local_flight_type").select_option("LOCAL")
|
||||
page.locator("#local_duration").fill("45")
|
||||
page.locator("#local_notes").fill("E2E local flight lifecycle")
|
||||
page.locator("#local-flight-form").get_by_role("button", name="🛫 Book Out").click()
|
||||
|
||||
expect(page.locator("#localFlightModal")).to_be_hidden()
|
||||
row = local_flight_row(page, registration)
|
||||
expect(row).to_be_visible()
|
||||
expect(row).to_contain_text("GROUND")
|
||||
|
||||
row.get_by_role("button", name="TAKE OFF").click()
|
||||
confirm_timestamp(page, "Confirm Takeoff")
|
||||
expect(row).to_contain_text("LOCAL")
|
||||
|
||||
row.get_by_role("button", name="T&G").click()
|
||||
expect(page.locator("#circuitModal")).to_be_visible()
|
||||
page.locator("#circuit-form").get_by_role("button", name="Record Circuit").click()
|
||||
expect(page.locator("#circuitModal")).to_be_hidden()
|
||||
expect(row.locator("td").nth(7)).to_have_text("1")
|
||||
|
||||
row.get_by_role("button", name="LAND").click()
|
||||
confirm_timestamp(page, "Confirm Landing")
|
||||
expect(local_flight_row(page, registration)).to_have_count(0)
|
||||
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
|
||||
|
||||
|
||||
def app_url(path):
|
||||
return f"{BASE_URL}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def test_flight_information_display_loads(page):
|
||||
page.goto(app_url("/"))
|
||||
|
||||
expect(page).to_have_title(re.compile("Swansea Airport - Arrivals & Departures"))
|
||||
expect(page.get_by_role("heading", name="Flight Information")).to_be_visible()
|
||||
|
||||
|
||||
def test_public_ppr_form_loads(page):
|
||||
page.goto(app_url("/ppr.html"))
|
||||
|
||||
expect(page).to_have_title(re.compile("Swansea PPR"))
|
||||
expect(page.get_by_role("heading", name=re.compile("PPR Request"))).to_be_visible()
|
||||
expect(page.get_by_role("button", name="Submit PPR Request")).to_be_visible()
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
|
||||
|
||||
|
||||
def app_url(path):
|
||||
return f"{BASE_URL}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def test_public_ppr_form_submits_successfully(page):
|
||||
unique_registration = f"GE{datetime.utcnow().strftime('%H%M')}"
|
||||
|
||||
page.goto(app_url("/ppr.html"))
|
||||
|
||||
page.locator("#ac_reg").fill(unique_registration)
|
||||
page.locator("#ac_type").fill("PA28")
|
||||
page.locator("#ac_call").fill(unique_registration)
|
||||
page.locator("#captain").fill("E2E Test Pilot")
|
||||
page.locator("#in_from").fill("EGLL")
|
||||
page.locator("#eta-date").fill("2026-06-21")
|
||||
page.locator("#eta-time").select_option("10:00")
|
||||
page.locator("#pob_in").fill("2")
|
||||
page.locator("#fuel").select_option("100LL")
|
||||
page.locator("#out_to").fill("EGFF")
|
||||
page.locator("#etd-date").fill("2026-06-21")
|
||||
page.locator("#etd-time").select_option("12:00")
|
||||
page.locator("#pob_out").fill("2")
|
||||
page.locator("#phone").fill("0123456789")
|
||||
page.locator("#notes").fill("Submitted by Playwright e2e")
|
||||
|
||||
page.get_by_role("button", name="Submit PPR Request").click()
|
||||
|
||||
expect(page.locator("#success-message")).to_be_visible()
|
||||
expect(page.locator("#success-message")).to_contain_text("PPR Request Submitted")
|
||||
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
base_url = os.getenv("E2E_BASE_URL", "http://web").rstrip("/")
|
||||
health_url = os.getenv("E2E_HEALTH_URL", f"{base_url}/")
|
||||
deadline = time.time() + int(os.getenv("E2E_WEB_TIMEOUT_SECONDS", "120"))
|
||||
last_error = None
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(health_url, timeout=5) as response:
|
||||
if response.status < 500:
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise SystemExit(f"Timed out waiting for {health_url}: {last_error}")
|
||||
|
||||
with urllib.request.urlopen(f"{base_url}/", timeout=5) as response:
|
||||
if response.status >= 500:
|
||||
raise SystemExit(f"Web returned HTTP {response.status} at {base_url}/")
|
||||
@@ -160,6 +160,15 @@ body {
|
||||
background-color: #e67e22;
|
||||
}
|
||||
|
||||
.btn-ack {
|
||||
background-color: #8e44ad;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-ack:hover {
|
||||
background-color: #71368a;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
@@ -330,6 +339,21 @@ tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
tbody tr.ppr-strip-unacknowledged {
|
||||
background-color: #fff0c2;
|
||||
box-shadow: inset 4px 0 0 #f39c12;
|
||||
}
|
||||
|
||||
tbody tr.ppr-strip-unacknowledged:hover {
|
||||
background-color: #ffe6a1;
|
||||
}
|
||||
|
||||
.ack-complete {
|
||||
color: #1e7e34;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.6rem;
|
||||
|
||||
+351
-116
@@ -6,6 +6,7 @@
|
||||
<title>PPR Admin Interface</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<script src="lookups.js"></script>
|
||||
<script src="topbar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
@@ -32,6 +33,8 @@
|
||||
<a href="#" onclick="window.location.href = '/atc'">🎛️ ATC View</a>
|
||||
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
|
||||
<a href="#" onclick="window.location.href = '/movements'">📈 Movements</a>
|
||||
<a href="#" onclick="window.location.href = '/drone-requests'">Drone Requests</a>
|
||||
<a href="#" onclick="window.location.href = '/bulk-log'">🧾 Bulk Flight Log</a>
|
||||
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
|
||||
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
|
||||
<a href="#" onclick="openUserManagementModal(); closeAdminDropdown()" id="user-management-dropdown" style="display: none;">👥 User Management</a>
|
||||
@@ -46,8 +49,47 @@
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Arrivals Table -->
|
||||
<!-- Local Traffic Table -->
|
||||
<div class="ppr-table">
|
||||
<div class="table-header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>🛩️ Local Traffic - <span id="local-flights-count">0</span></span>
|
||||
<span class="info-icon" onclick="showTableHelp('local-flights')" title="What is this?">ℹ️</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="local-flights-loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
Loading local flights...
|
||||
</div>
|
||||
|
||||
<div id="local-flights-table-content" style="display: none;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Registration</th>
|
||||
<th style="width: 30px; text-align: center;"></th>
|
||||
<th>Type</th>
|
||||
<th>Flight</th>
|
||||
<th>ETD</th>
|
||||
<th>POB</th>
|
||||
<th>Status</th>
|
||||
<th>Circuits</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="local-flights-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="local-flights-no-data" class="no-data" style="display: none;">
|
||||
<h3>No Local Traffic</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrivals Table -->
|
||||
<div class="ppr-table" style="margin-top: 2rem;">
|
||||
<div class="table-header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>🛬 Today's Pending Arrivals - <span id="arrivals-count">0</span></span>
|
||||
@@ -260,6 +302,7 @@
|
||||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">From</th>
|
||||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ETA</th>
|
||||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">Notes</th>
|
||||
<th style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">ACK</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="upcoming-table-body">
|
||||
@@ -278,6 +321,50 @@
|
||||
|
||||
<script src="shared.js"></script>
|
||||
<script>
|
||||
function normalizeUtcDateString(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
let utcDateStr = String(dateStr).trim();
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
return utcDateStr;
|
||||
}
|
||||
|
||||
function parseUtcDate(dateStr) {
|
||||
const normalized = normalizeUtcDateString(dateStr);
|
||||
return normalized ? new Date(normalized) : null;
|
||||
}
|
||||
|
||||
function formatUtcDateInput(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatUtcTimeInput(date) {
|
||||
return date.toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
function formatTimeOnly(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
return date && !Number.isNaN(date.getTime()) ? formatUtcTimeInput(date) : '-';
|
||||
}
|
||||
|
||||
function formatUtcDayMonth(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||
const isoDate = formatUtcDateInput(date);
|
||||
return `${isoDate.slice(8, 10)}/${isoDate.slice(5, 7)}`;
|
||||
}
|
||||
|
||||
function formatUtcWeekdayDayMonth(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||
const dayName = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getUTCDay()];
|
||||
return `${dayName} ${formatUtcDayMonth(dateStr)}`;
|
||||
}
|
||||
|
||||
async function loadPPRs() {
|
||||
if (!accessToken) return;
|
||||
|
||||
@@ -289,22 +376,21 @@
|
||||
|
||||
loadPPRsTimeout = setTimeout(async () => {
|
||||
// Load all tables simultaneously
|
||||
await Promise.all([loadArrivals(), loadDepartures(), loadOverflights(), loadDeparted(), loadParked(), loadUpcoming()]);
|
||||
await Promise.all([loadArrivals(), loadDepartures(), loadLocalFlights(), loadOverflights(), loadDeparted(), loadParked(), loadUpcoming()]);
|
||||
loadPPRsTimeout = null;
|
||||
}, 100); // Wait 100ms before executing to batch multiple calls
|
||||
}
|
||||
|
||||
// Load arrivals (NEW and CONFIRMED status for PPR, DEPARTED for local flights)
|
||||
// Load arrivals (NEW and CONFIRMED status for PPR only)
|
||||
async function loadArrivals() {
|
||||
document.getElementById('arrivals-loading').style.display = 'block';
|
||||
document.getElementById('arrivals-table-content').style.display = 'none';
|
||||
document.getElementById('arrivals-no-data').style.display = 'none';
|
||||
|
||||
try {
|
||||
// Load PPRs, local flights, and booked-in arrivals
|
||||
const [pprResponse, localResponse, bookInResponse] = await Promise.all([
|
||||
// Load PPRs and booked-in arrivals
|
||||
const [pprResponse, bookInResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=DEPARTED&limit=1000'),
|
||||
authenticatedFetch('/api/v1/arrivals/?limit=1000')
|
||||
]);
|
||||
|
||||
@@ -325,24 +411,6 @@
|
||||
return etaDate === today;
|
||||
});
|
||||
|
||||
// Add local flights in DEPARTED status (in the air, heading back) - only those booked out today
|
||||
if (localResponse.ok) {
|
||||
const localFlights = await localResponse.json();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const localInAir = localFlights
|
||||
.filter(flight => {
|
||||
// Only include flights booked out today (created_dt)
|
||||
if (!flight.created_dt) return false;
|
||||
const createdDate = flight.created_dt.split('T')[0];
|
||||
return createdDate === today;
|
||||
})
|
||||
.map(flight => ({
|
||||
...flight,
|
||||
isLocalFlight: true // Flag to distinguish from PPR
|
||||
}));
|
||||
arrivals.push(...localInAir);
|
||||
}
|
||||
|
||||
// Add booked-in arrivals from the arrivals table
|
||||
if (bookInResponse.ok) {
|
||||
const bookedInArrivals = await bookInResponse.json();
|
||||
@@ -372,23 +440,18 @@
|
||||
document.getElementById('arrivals-loading').style.display = 'none';
|
||||
}
|
||||
|
||||
// Load departures (LANDED status for PPR, GROUND/LOCAL for local flights)
|
||||
// Load departures (LANDED status for PPR plus non-local airport departures)
|
||||
async function loadDepartures() {
|
||||
document.getElementById('departures-loading').style.display = 'block';
|
||||
document.getElementById('departures-table-content').style.display = 'none';
|
||||
document.getElementById('departures-no-data').style.display = 'none';
|
||||
|
||||
try {
|
||||
// Load PPR departures, local flight departures, and airport departures simultaneously
|
||||
const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, localCircuitResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([
|
||||
// Load PPR departures and airport departures that are still pending departure
|
||||
const [pprResponse, depBookedOutResponse, depOutGroundResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=CIRCUIT&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
|
||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000')
|
||||
]);
|
||||
|
||||
if (!pprResponse.ok) {
|
||||
@@ -396,18 +459,11 @@
|
||||
}
|
||||
|
||||
const allPPRs = await pprResponse.json();
|
||||
const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : [];
|
||||
const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : [];
|
||||
const localLocal = localLocalResponse.ok ? await localLocalResponse.json() : [];
|
||||
const localCircuit = localCircuitResponse.ok ? await localCircuitResponse.json() : [];
|
||||
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
||||
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
|
||||
const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : [];
|
||||
|
||||
// Combine local flights
|
||||
const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal, ...localCircuit];
|
||||
// Combine departures
|
||||
const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal];
|
||||
const allDepartures = [...depBookedOut, ...depOutGround];
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Filter for PPR departures with ETD today and LANDED status only
|
||||
@@ -420,21 +476,7 @@
|
||||
return etdDate === today;
|
||||
});
|
||||
|
||||
// Add local flights (GROUND and LOCAL status - ready to go) - only those booked out today
|
||||
const localDepartures = allLocalFlights
|
||||
.filter(flight => {
|
||||
// Only include flights booked out today (created_dt)
|
||||
if (!flight.created_dt) return false;
|
||||
const createdDate = flight.created_dt.split('T')[0];
|
||||
return createdDate === today;
|
||||
})
|
||||
.map(flight => ({
|
||||
...flight,
|
||||
isLocalFlight: true // Flag to distinguish from PPR
|
||||
}));
|
||||
departures.push(...localDepartures);
|
||||
|
||||
// Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status)
|
||||
// Add departures to other airports that are not yet airborne locally
|
||||
const depDepartures = allDepartures.map(flight => ({
|
||||
...flight,
|
||||
isDeparture: true // Flag to distinguish from PPR
|
||||
@@ -452,6 +494,57 @@
|
||||
document.getElementById('departures-loading').style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadLocalFlights() {
|
||||
document.getElementById('local-flights-loading').style.display = 'block';
|
||||
document.getElementById('local-flights-table-content').style.display = 'none';
|
||||
document.getElementById('local-flights-no-data').style.display = 'none';
|
||||
|
||||
try {
|
||||
const [localResponse, pprResponse, depResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/v1/local-flights/?limit=1000'),
|
||||
authenticatedFetch('/api/v1/pprs/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
|
||||
]);
|
||||
|
||||
if (!localResponse.ok || !pprResponse.ok || !depResponse.ok) {
|
||||
throw new Error('Failed to fetch local flights');
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const localFlights = (await localResponse.json()).filter(flight => {
|
||||
if (!flight.created_dt || ['CANCELLED', 'LANDED'].includes(flight.status)) return false;
|
||||
return flight.created_dt.split('T')[0] === today;
|
||||
});
|
||||
const pprLocalTraffic = (await pprResponse.json())
|
||||
.filter(ppr => {
|
||||
const dateFields = [ppr.etd, ppr.landed_dt, ppr.submitted_dt];
|
||||
return dateFields.some(value => value && value.split('T')[0] === today);
|
||||
})
|
||||
.map(ppr => ({
|
||||
...ppr,
|
||||
isPPRLocalTraffic: true
|
||||
}));
|
||||
const departureLocalTraffic = (await depResponse.json())
|
||||
.filter(departure => {
|
||||
const dateFields = [departure.created_dt, departure.etd, departure.takeoff_dt, departure.departed_dt];
|
||||
return dateFields.some(value => value && value.split('T')[0] === today);
|
||||
})
|
||||
.map(departure => ({
|
||||
...departure,
|
||||
isDepartureLocalTraffic: true
|
||||
}));
|
||||
|
||||
displayLocalFlights([...localFlights, ...pprLocalTraffic, ...departureLocalTraffic]);
|
||||
} catch (error) {
|
||||
console.error('Error loading local flights:', error);
|
||||
if (error.message !== 'Session expired. Please log in again.') {
|
||||
showNotification('Error loading local flights', true);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('local-flights-loading').style.display = 'none';
|
||||
}
|
||||
|
||||
// Load overflights (ACTIVE status only)
|
||||
async function loadOverflights() {
|
||||
document.getElementById('overflights-loading').style.display = 'block';
|
||||
@@ -488,7 +581,7 @@
|
||||
}
|
||||
|
||||
// Sort by call_dt most recent
|
||||
overflights.sort((a, b) => new Date(b.call_dt) - new Date(a.call_dt));
|
||||
overflights.sort((a, b) => parseUtcDate(b.call_dt) - parseUtcDate(a.call_dt));
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
@@ -537,7 +630,7 @@
|
||||
}
|
||||
|
||||
|
||||
// Load departed aircraft (DEPARTED status with departed_dt today)
|
||||
// Load departed aircraft (DEPARTED status with QSY/departed time today)
|
||||
async function loadDeparted() {
|
||||
document.getElementById('departed-loading').style.display = 'block';
|
||||
document.getElementById('departed-table-content').style.display = 'none';
|
||||
@@ -558,10 +651,10 @@
|
||||
|
||||
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
|
||||
const departed = allPPRs.filter(ppr => {
|
||||
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') {
|
||||
if (!ppr.qsy_dt || ppr.status !== 'DEPARTED') {
|
||||
return false;
|
||||
}
|
||||
const departedDate = ppr.departed_dt.split('T')[0];
|
||||
const departedDate = ppr.qsy_dt.split('T')[0];
|
||||
return departedDate === today;
|
||||
});
|
||||
|
||||
@@ -602,9 +695,9 @@
|
||||
|
||||
// Sort by departed time
|
||||
departed.sort((a, b) => {
|
||||
const aTime = a.departed_dt;
|
||||
const bTime = b.departed_dt;
|
||||
return new Date(aTime) - new Date(bTime);
|
||||
const aTime = a.isDeparture ? a.departed_dt : a.qsy_dt;
|
||||
const bTime = b.isDeparture ? b.departed_dt : b.qsy_dt;
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
@@ -648,7 +741,7 @@
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.qsy_dt)}</td>
|
||||
`;
|
||||
}
|
||||
tbody.appendChild(row);
|
||||
@@ -709,7 +802,7 @@
|
||||
parked.sort((a, b) => {
|
||||
if (!a.landed_dt) return 1;
|
||||
if (!b.landed_dt) return -1;
|
||||
return new Date(a.landed_dt) - new Date(b.landed_dt);
|
||||
return parseUtcDate(a.landed_dt) - parseUtcDate(b.landed_dt);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
@@ -742,16 +835,14 @@
|
||||
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
|
||||
} else {
|
||||
// Not today - show date (DD/MM)
|
||||
const date = new Date(ppr.landed_dt);
|
||||
arrivedDisplay = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||||
arrivedDisplay = formatUtcDayMonth(ppr.landed_dt);
|
||||
}
|
||||
}
|
||||
|
||||
// Format ETD as just the date (DD/MM)
|
||||
let etdDisplay = '-';
|
||||
if (ppr.etd) {
|
||||
const etdDate = new Date(ppr.etd);
|
||||
etdDisplay = etdDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||||
etdDisplay = formatUtcDayMonth(ppr.etd);
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
@@ -813,7 +904,7 @@
|
||||
}
|
||||
|
||||
// Sort by ETA date and time
|
||||
upcoming.sort((a, b) => new Date(a.eta) - new Date(b.eta));
|
||||
upcoming.sort((a, b) => parseUtcDate(a.eta) - parseUtcDate(b.eta));
|
||||
|
||||
tbody.innerHTML = '';
|
||||
// Don't auto-expand, keep collapsed by default
|
||||
@@ -822,12 +913,12 @@
|
||||
const row = document.createElement('tr');
|
||||
row.onclick = () => openPPRModal(ppr.id);
|
||||
row.style.cssText = 'font-size: 0.85rem !important;';
|
||||
if (pprNeedsStripAck(ppr)) {
|
||||
row.classList.add('ppr-strip-unacknowledged');
|
||||
}
|
||||
|
||||
// Format date as Day DD/MM (e.g., Wed 11/12)
|
||||
const etaDate = new Date(ppr.eta);
|
||||
const dayName = etaDate.toLocaleDateString('en-GB', { weekday: 'short' });
|
||||
const dateStr = etaDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||||
const dateDisplay = `${dayName} ${dateStr}`;
|
||||
const dateDisplay = formatUtcWeekdayDayMonth(ppr.eta);
|
||||
|
||||
// Create notes indicator if notes exist
|
||||
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
||||
@@ -835,6 +926,9 @@
|
||||
<span class="notes-indicator">📝</span>
|
||||
<span class="tooltip-text">${ppr.notes}</span>
|
||||
</span>` : '';
|
||||
const ackButton = pprNeedsStripAck(ppr)
|
||||
? `<button class="btn btn-ack btn-icon" onclick='event.stopPropagation(); acknowledgePPRStrip(${ppr.id}, ${JSON.stringify(ppr.ac_reg || 'PPR')})' title="Acknowledge paper strip created">ACK</button>`
|
||||
: '<span class="ack-complete" title="Paper strip acknowledged">ACK</span>';
|
||||
|
||||
row.innerHTML = `
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${dateDisplay}</td>
|
||||
@@ -844,6 +938,7 @@
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ppr.in_from || '-'}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(ppr.eta)}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${notesIndicator}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${ackButton}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
@@ -909,7 +1004,7 @@
|
||||
const bTime = b.eta || b.departure_dt;
|
||||
if (!aTime) return 1;
|
||||
if (!bTime) return -1;
|
||||
return new Date(aTime) - new Date(bTime);
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('arrivals-table-content').style.display = 'block';
|
||||
@@ -917,6 +1012,9 @@
|
||||
const row = document.createElement('tr');
|
||||
const isLocal = flight.isLocalFlight;
|
||||
const isBookedIn = flight.isBookedIn;
|
||||
if (!isLocal && !isBookedIn && pprNeedsStripAck(flight)) {
|
||||
row.classList.add('ppr-strip-unacknowledged');
|
||||
}
|
||||
|
||||
// Click handler that routes to correct modal
|
||||
row.onclick = () => {
|
||||
@@ -954,7 +1052,7 @@
|
||||
let departureTime = flight.departed_dt || flight.etd;
|
||||
let etaTime = departureTime;
|
||||
if (departureTime && flight.duration) {
|
||||
const departTime = new Date(departureTime);
|
||||
const departTime = parseUtcDate(departureTime);
|
||||
etaTime = new Date(departTime.getTime() + flight.duration * 60000).toISOString(); // duration is in minutes
|
||||
}
|
||||
eta = etaTime ? formatTimeOnly(etaTime) : '-';
|
||||
@@ -998,9 +1096,6 @@
|
||||
// Different action buttons based on status
|
||||
if (flight.status === 'INBOUND') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Mark as Local">
|
||||
LOCAL
|
||||
</button>
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentBookedInArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
|
||||
LAND
|
||||
</button>
|
||||
@@ -1014,9 +1109,6 @@
|
||||
T&G
|
||||
</button>`;
|
||||
actionButtons = `
|
||||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'CIRCUIT')" title="Join Circuit">
|
||||
CIRCUIT
|
||||
</button>
|
||||
${circuitButton}
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
|
||||
LAND
|
||||
@@ -1028,9 +1120,6 @@
|
||||
T&G
|
||||
</button>`;
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
|
||||
LOCAL
|
||||
</button>
|
||||
${circuitButton}
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, false, false, true)" title="Mark as Landed">
|
||||
LAND
|
||||
@@ -1066,10 +1155,13 @@
|
||||
eta = formatTimeOnly(flight.eta);
|
||||
pob = flight.pob_in;
|
||||
fuel = flight.fuel || '-';
|
||||
const ackButton = pprNeedsStripAck(flight)
|
||||
? `<button class="btn btn-ack btn-icon" onclick='event.stopPropagation(); acknowledgePPRStrip(${flight.id}, ${JSON.stringify(flight.ac_reg || 'PPR')})' title="Acknowledge paper strip created">
|
||||
ACK
|
||||
</button>`
|
||||
: '';
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); activatePPR(${flight.id}, '${flight.ac_reg}', ${flight.out_to ? 'true' : 'false'})" title="Activate PPR - create arrival/departure records">
|
||||
ACTIVATE
|
||||
</button>
|
||||
${ackButton}
|
||||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); showTimestampModal('LANDED', ${flight.id})" title="Mark as Landed">
|
||||
LAND
|
||||
</button>
|
||||
@@ -1092,24 +1184,163 @@
|
||||
setupTooltips();
|
||||
}
|
||||
|
||||
async function activatePPR(pprId, acReg, hasDeparture) {
|
||||
const msg = `Activate PPR for ${acReg}?\nThis will create an INBOUND arrival.`
|
||||
+ (hasDeparture ? '\nThe outbound departure will appear automatically when the aircraft lands.' : '');
|
||||
if (!confirm(msg)) return;
|
||||
function localFlightStatusBadge(status) {
|
||||
const labels = {
|
||||
BOOKED_OUT: 'BOOKED OUT',
|
||||
GROUND: 'GROUND',
|
||||
DEPARTED: 'AIRBORNE',
|
||||
LOCAL: 'LOCAL',
|
||||
CIRCUIT: 'CIRCUIT',
|
||||
CIRCUIT_DOWNWIND: 'DOWNWIND',
|
||||
CIRCUIT_BASE: 'BASE',
|
||||
CIRCUIT_FINAL: 'FINAL',
|
||||
LANDED: 'LANDED'
|
||||
};
|
||||
return `<span style="background-color: #6c757d; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.8rem;">${labels[status] || status || '-'}</span>`;
|
||||
}
|
||||
|
||||
async function displayLocalFlights(localFlights) {
|
||||
const tbody = document.getElementById('local-flights-table-body');
|
||||
const recordCount = document.getElementById('local-flights-count');
|
||||
|
||||
recordCount.textContent = localFlights.length;
|
||||
if (localFlights.length === 0) {
|
||||
document.getElementById('local-flights-no-data').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
localFlights.sort((a, b) => {
|
||||
const aTime = a.etd || a.created_dt;
|
||||
const bTime = b.etd || b.created_dt;
|
||||
if (!aTime) return 1;
|
||||
if (!bTime) return -1;
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('local-flights-table-content').style.display = 'block';
|
||||
|
||||
const circuitCounts = await loadLocalFlightCircuitCounts(localFlights.filter(flight => !flight.isPPRLocalTraffic && !flight.isDepartureLocalTraffic));
|
||||
|
||||
for (const flight of localFlights) {
|
||||
const row = document.createElement('tr');
|
||||
const isPPR = flight.isPPRLocalTraffic;
|
||||
const isDeparture = flight.isDepartureLocalTraffic;
|
||||
row.onclick = () => isPPR ? openPPRModal(flight.id) : (isDeparture ? openDepartureEditModal(flight.id) : openLocalFlightEditModal(flight.id));
|
||||
|
||||
const aircraftDisplay = isPPR
|
||||
? (flight.ac_call && flight.ac_call.trim()
|
||||
? `<strong>${flight.ac_call}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.ac_reg}</span>`
|
||||
: `<strong>${flight.ac_reg}</strong>`)
|
||||
: isDeparture
|
||||
? (flight.callsign && flight.callsign.trim()
|
||||
? `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`
|
||||
: `<strong>${flight.registration}</strong>`)
|
||||
: (flight.callsign && flight.callsign.trim()
|
||||
? `<strong>${flight.callsign}</strong><br><span style="font-size: 0.8em; color: #666; font-style: italic;">${flight.registration}</span>`
|
||||
: `<strong>${flight.registration}</strong>`);
|
||||
const typeIcon = isPPR
|
||||
? '<span style="color: #032cfc; font-weight: bold; font-size: 0.9em;" title="From PPR">P</span>'
|
||||
: isDeparture
|
||||
? (flight.submitted_via === 'PUBLIC'
|
||||
? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>'
|
||||
: '<span style="color: #6f42c1; font-weight: bold; font-size: 0.9em;" title="Airport departure">D</span>')
|
||||
: flight.submitted_via === 'PUBLIC'
|
||||
? '<span style="color: #b8860b; font-weight: bold; font-size: 0.9em;" title="Submitted by Pilot Online">O</span>'
|
||||
: '<span style="color: #228b22; font-weight: bold; font-size: 0.9em;" title="Local flight">L</span>';
|
||||
const flightType = isPPR ? (flight.out_to ? `To ${flight.out_to}` : 'PPR Departure') : isDeparture ? (flight.out_to ? `To ${flight.out_to}` : 'Departure') : flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local' : 'Departure';
|
||||
const etd = flight.etd ? formatTimeOnly(flight.etd) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-');
|
||||
const circuits = (isPPR || isDeparture) ? '-' : circuitCounts[flight.id] ?? flight.circuits ?? 0;
|
||||
|
||||
let actionButtons = '';
|
||||
if (isPPR) {
|
||||
actionButtons = `
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
QSY
|
||||
</button>
|
||||
`;
|
||||
} else if (isDeparture) {
|
||||
actionButtons = `
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentDepartureId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, false, true)" title="Mark as Departed">
|
||||
QSY
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'BOOKED_OUT') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('GROUND', ${flight.id}, true)" title="Contact Pilot">
|
||||
CONTACT
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'GROUND') {
|
||||
const takeoffStatus = flight.flight_type === 'CIRCUITS' ? 'CIRCUIT' : 'LOCAL';
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('${takeoffStatus}', ${flight.id}, true)" title="Mark as airborne">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
} else if (['DEPARTED', 'LOCAL', 'CIRCUIT', 'CIRCUIT_DOWNWIND', 'CIRCUIT_BASE', 'CIRCUIT_FINAL'].includes(flight.status)) {
|
||||
actionButtons = `
|
||||
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
|
||||
T&G
|
||||
</button>
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
|
||||
LAND
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
actionButtons = '<span style="color: #999;">-</span>';
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${aircraftDisplay}</td>
|
||||
<td style="text-align: center; width: 30px;">${typeIcon}</td>
|
||||
<td>${isPPR ? flight.ac_type || '-' : flight.type || '-'}</td>
|
||||
<td>${flightType}</td>
|
||||
<td>${etd}</td>
|
||||
<td>${isPPR ? (flight.pob_out || flight.pob_in || '-') : (flight.pob || '-')}</td>
|
||||
<td>${localFlightStatusBadge(flight.status)}</td>
|
||||
<td>${circuits}</td>
|
||||
<td style="white-space: nowrap;">${actionButtons}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocalFlightCircuitCounts(localFlights) {
|
||||
const counts = {};
|
||||
await Promise.all(localFlights.map(async (flight) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/v1/circuits/flight/${flight.id}`);
|
||||
if (!response.ok) return;
|
||||
const circuits = await response.json();
|
||||
counts[flight.id] = circuits.length;
|
||||
} catch (error) {
|
||||
console.warn(`Unable to load circuits for local flight ${flight.id}:`, error);
|
||||
}
|
||||
}));
|
||||
return counts;
|
||||
}
|
||||
|
||||
function pprNeedsStripAck(ppr) {
|
||||
return (ppr.status === 'NEW' || ppr.status === 'CONFIRMED') && !ppr.acknowledged_dt;
|
||||
}
|
||||
|
||||
async function acknowledgePPRStrip(pprId, acReg) {
|
||||
if (!confirm(`Confirm paper strip has been created for ${acReg}?`)) return;
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/v1/pprs/${pprId}/activate`, { method: 'POST' });
|
||||
const response = await authenticatedFetch(`/api/v1/pprs/${pprId}/acknowledge`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
showNotification(err.detail || 'Failed to activate PPR', true);
|
||||
showNotification(err.detail || 'Failed to acknowledge PPR strip', true);
|
||||
return;
|
||||
}
|
||||
const result = await response.json();
|
||||
showNotification(result.message || 'PPR activated');
|
||||
|
||||
showNotification('PPR strip acknowledged');
|
||||
await loadPPRs();
|
||||
} catch (error) {
|
||||
console.error('Error activating PPR:', error);
|
||||
showNotification('Error activating PPR', true);
|
||||
console.error('Error acknowledging PPR strip:', error);
|
||||
showNotification('Error acknowledging PPR strip', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,7 +1360,7 @@
|
||||
const bTime = b.etd || b.created_dt;
|
||||
if (!aTime) return 1;
|
||||
if (!bTime) return -1;
|
||||
return new Date(aTime) - new Date(bTime);
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('departures-table-content').style.display = 'block';
|
||||
@@ -1203,8 +1434,8 @@
|
||||
`;
|
||||
} else if (flight.status === 'LOCAL') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; updateLocalFlightStatusFromTable(${flight.id}, 'CIRCUIT')" title="Rejoin Circuit">
|
||||
REJOIN
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
|
||||
LAND
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'CIRCUIT') {
|
||||
@@ -1213,9 +1444,6 @@
|
||||
T&G
|
||||
</button>`;
|
||||
actionButtons = `
|
||||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; updateLocalFlightStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
|
||||
LOCAL
|
||||
</button>
|
||||
${circuitButton}
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('LANDED', ${flight.id}, true)" title="Mark as Landed">
|
||||
LAND
|
||||
@@ -1282,15 +1510,12 @@
|
||||
// Action buttons for arrival
|
||||
if (flight.status === 'LOCAL') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; updateArrivalStatusFromTable(${flight.id}, 'CIRCUIT')" title="Rejoin Circuit">
|
||||
REJOIN
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; updateArrivalStatusFromTable(${flight.id}, 'LANDED')" title="Mark as Landed">
|
||||
LAND
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'CIRCUIT') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-warning btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
|
||||
LOCAL
|
||||
</button>
|
||||
<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentArrivalId = ${flight.id}; showCircuitModal(null, ${flight.id})" title="Record Touch & Go">
|
||||
T&G
|
||||
</button>
|
||||
@@ -1318,11 +1543,21 @@
|
||||
fuel = flight.fuel || '-';
|
||||
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
|
||||
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
if (flight.status === 'LANDED') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('LOCAL', ${flight.id})" title="Mark as Local">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'LOCAL') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
QSY
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
actionButtons = '<span style="color: #999;">-</span>';
|
||||
}
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
@@ -1430,7 +1665,7 @@
|
||||
// Override: admin uses loadDepartures after circuit save
|
||||
async function afterCircuitSaved() {
|
||||
closeCircuitModal();
|
||||
loadDepartures();
|
||||
loadLocalFlights();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1439,4 +1674,4 @@
|
||||
Please contact James Pattinson if you have any ideas about or problems with this system
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+164
-66
@@ -6,6 +6,7 @@
|
||||
<title>ATC Management Interface</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<script src="lookups.js"></script>
|
||||
<script src="topbar.js"></script>
|
||||
<style>
|
||||
/* ATC-specific styles */
|
||||
.atc-container {
|
||||
@@ -231,8 +232,9 @@
|
||||
⚙️ Admin
|
||||
</button>
|
||||
<div class="dropdown-menu" id="adminDropdownMenu">
|
||||
<a href="#" onclick="window.location.href = '/admin'">🏠 Admin View</a>
|
||||
<a href="#" onclick="window.location.href = '/admin'">🏠 Home</a>
|
||||
<a href="#" onclick="window.location.href = '/reports'">📊 Reports</a>
|
||||
<a href="#" onclick="window.location.href = '/bulk-log'">🧾 Bulk Flight Log</a>
|
||||
<a href="#" onclick="window.location.href = '/journal'">📔 Journal Log</a>
|
||||
<a href="#" onclick="openUserAircraftModal(); closeAdminDropdown()" id="user-aircraft-dropdown">✈️ User Aircraft</a>
|
||||
</div>
|
||||
@@ -253,11 +255,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1: Local Area -->
|
||||
<!-- Row 1: Local Traffic -->
|
||||
<div class="atc-section">
|
||||
<h2>📍 Local Area <span class="count" id="local-count">0</span></h2>
|
||||
<h2>📍 Local Traffic <span class="count" id="local-count">0</span></h2>
|
||||
<div class="aircraft-list" id="local-list">
|
||||
<div class="no-aircraft">No aircraft in local area</div>
|
||||
<div class="no-aircraft">No local traffic</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -298,6 +300,50 @@
|
||||
|
||||
<script src="shared.js"></script>
|
||||
<script>
|
||||
function normalizeUtcDateString(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
let utcDateStr = String(dateStr).trim();
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
return utcDateStr;
|
||||
}
|
||||
|
||||
function parseUtcDate(dateStr) {
|
||||
const normalized = normalizeUtcDateString(dateStr);
|
||||
return normalized ? new Date(normalized) : null;
|
||||
}
|
||||
|
||||
function formatUtcDateInput(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatUtcTimeInput(date) {
|
||||
return date.toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
function formatTimeOnly(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
return date && !Number.isNaN(date.getTime()) ? formatUtcTimeInput(date) : '-';
|
||||
}
|
||||
|
||||
function formatUtcDayMonth(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||
const isoDate = formatUtcDateInput(date);
|
||||
return `${isoDate.slice(8, 10)}/${isoDate.slice(5, 7)}`;
|
||||
}
|
||||
|
||||
function formatUtcWeekdayDayMonth(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||
const dayName = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getUTCDay()];
|
||||
return `${dayName} ${formatUtcDayMonth(dateStr)}`;
|
||||
}
|
||||
|
||||
async function loadPPRs() {
|
||||
if (!accessToken) return;
|
||||
|
||||
@@ -399,15 +445,13 @@
|
||||
document.getElementById('departures-no-data').style.display = 'none';
|
||||
|
||||
try {
|
||||
// Load PPR departures, local flight departures, and airport departures simultaneously
|
||||
const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([
|
||||
// Load PPR departures, local flight departures, and airport departures that are still pending departure
|
||||
const [pprResponse, localBookedOutResponse, localOutGroundResponse, depBookedOutResponse, depOutGroundResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000')
|
||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000')
|
||||
]);
|
||||
|
||||
if (!pprResponse.ok) {
|
||||
@@ -417,15 +461,13 @@
|
||||
const allPPRs = await pprResponse.json();
|
||||
const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : [];
|
||||
const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : [];
|
||||
const localLocal = localLocalResponse.ok ? await localLocalResponse.json() : [];
|
||||
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
||||
const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : [];
|
||||
const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : [];
|
||||
|
||||
// Combine local flights
|
||||
const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal];
|
||||
const allLocalFlights = [...localBookedOut, ...localOutGround];
|
||||
// Combine departures
|
||||
const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal];
|
||||
const allDepartures = [...depBookedOut, ...depOutGround];
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Filter for PPR departures with ETD today and LANDED status only
|
||||
@@ -438,7 +480,7 @@
|
||||
return etdDate === today;
|
||||
});
|
||||
|
||||
// Add local flights (GROUND and LOCAL status - ready to go) - only those booked out today
|
||||
// Add local flights that are not yet airborne locally - only those booked out today
|
||||
const localDepartures = allLocalFlights
|
||||
.filter(flight => {
|
||||
// Only include flights booked out today (created_dt)
|
||||
@@ -452,7 +494,7 @@
|
||||
}));
|
||||
departures.push(...localDepartures);
|
||||
|
||||
// Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status)
|
||||
// Add departures to other airports that are not yet airborne locally
|
||||
const depDepartures = allDepartures.map(flight => ({
|
||||
...flight,
|
||||
isDeparture: true // Flag to distinguish from PPR
|
||||
@@ -506,7 +548,7 @@
|
||||
}
|
||||
|
||||
// Sort by call_dt most recent
|
||||
overflights.sort((a, b) => new Date(b.call_dt) - new Date(a.call_dt));
|
||||
overflights.sort((a, b) => parseUtcDate(b.call_dt) - parseUtcDate(a.call_dt));
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
@@ -556,7 +598,7 @@
|
||||
}
|
||||
|
||||
|
||||
// Load departed aircraft (DEPARTED status with departed_dt today)
|
||||
// Load departed aircraft (DEPARTED status with QSY/departed time today)
|
||||
async function loadDeparted() {
|
||||
document.getElementById('departed-loading').style.display = 'block';
|
||||
document.getElementById('departed-table-content').style.display = 'none';
|
||||
@@ -577,10 +619,10 @@
|
||||
|
||||
// Filter for PPRs departed today (only PPR'd departures, exclude local/circuits)
|
||||
const departed = allPPRs.filter(ppr => {
|
||||
if (!ppr.departed_dt || ppr.status !== 'DEPARTED') {
|
||||
if (!ppr.qsy_dt || ppr.status !== 'DEPARTED') {
|
||||
return false;
|
||||
}
|
||||
const departedDate = ppr.departed_dt.split('T')[0];
|
||||
const departedDate = ppr.qsy_dt.split('T')[0];
|
||||
return departedDate === today;
|
||||
});
|
||||
|
||||
@@ -621,9 +663,9 @@
|
||||
|
||||
// Sort by departed time
|
||||
departed.sort((a, b) => {
|
||||
const aTime = a.departed_dt;
|
||||
const bTime = b.departed_dt;
|
||||
return new Date(aTime) - new Date(bTime);
|
||||
const aTime = a.isDeparture ? a.departed_dt : a.qsy_dt;
|
||||
const bTime = b.isDeparture ? b.departed_dt : b.qsy_dt;
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
@@ -667,7 +709,7 @@
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important; text-align: center; width: 30px;"><span style="color: #032cfc; font-weight: bold;" title="From PPR">P</span></td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.ac_call || '-'}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${flight.out_to || '-'}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.departed_dt)}</td>
|
||||
<td style="padding: 0.3rem 0.4rem !important; font-size: 0.85rem !important;">${formatTimeOnly(flight.qsy_dt)}</td>
|
||||
`;
|
||||
}
|
||||
tbody.appendChild(row);
|
||||
@@ -728,7 +770,7 @@
|
||||
parked.sort((a, b) => {
|
||||
if (!a.landed_dt) return 1;
|
||||
if (!b.landed_dt) return -1;
|
||||
return new Date(a.landed_dt) - new Date(b.landed_dt);
|
||||
return parseUtcDate(a.landed_dt) - parseUtcDate(b.landed_dt);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
@@ -761,16 +803,14 @@
|
||||
arrivedDisplay = formatTimeOnly(ppr.landed_dt);
|
||||
} else {
|
||||
// Not today - show date (DD/MM)
|
||||
const date = new Date(ppr.landed_dt);
|
||||
arrivedDisplay = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||||
arrivedDisplay = formatUtcDayMonth(ppr.landed_dt);
|
||||
}
|
||||
}
|
||||
|
||||
// Format ETD as just the date (DD/MM)
|
||||
let etdDisplay = '-';
|
||||
if (ppr.etd) {
|
||||
const etdDate = new Date(ppr.etd);
|
||||
etdDisplay = etdDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||||
etdDisplay = formatUtcDayMonth(ppr.etd);
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
@@ -832,7 +872,7 @@
|
||||
}
|
||||
|
||||
// Sort by ETA date and time
|
||||
upcoming.sort((a, b) => new Date(a.eta) - new Date(b.eta));
|
||||
upcoming.sort((a, b) => parseUtcDate(a.eta) - parseUtcDate(b.eta));
|
||||
|
||||
tbody.innerHTML = '';
|
||||
// Don't auto-expand, keep collapsed by default
|
||||
@@ -843,10 +883,7 @@
|
||||
row.style.cssText = 'font-size: 0.85rem !important;';
|
||||
|
||||
// Format date as Day DD/MM (e.g., Wed 11/12)
|
||||
const etaDate = new Date(ppr.eta);
|
||||
const dayName = etaDate.toLocaleDateString('en-GB', { weekday: 'short' });
|
||||
const dateStr = etaDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||||
const dateDisplay = `${dayName} ${dateStr}`;
|
||||
const dateDisplay = formatUtcWeekdayDayMonth(ppr.eta);
|
||||
|
||||
// Create notes indicator if notes exist
|
||||
const notesIndicator = ppr.notes && ppr.notes.trim() ?
|
||||
@@ -928,7 +965,7 @@
|
||||
const bTime = b.eta || b.departure_dt;
|
||||
if (!aTime) return 1;
|
||||
if (!bTime) return -1;
|
||||
return new Date(aTime) - new Date(bTime);
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('arrivals-table-content').style.display = 'block';
|
||||
@@ -973,7 +1010,7 @@
|
||||
let departureTime = flight.departed_dt || flight.etd;
|
||||
let etaTime = departureTime;
|
||||
if (departureTime && flight.duration) {
|
||||
const departTime = new Date(departureTime);
|
||||
const departTime = parseUtcDate(departureTime);
|
||||
etaTime = new Date(departTime.getTime() + flight.duration * 60000).toISOString(); // duration is in minutes
|
||||
}
|
||||
eta = etaTime ? formatTimeOnly(etaTime) : '-';
|
||||
@@ -1047,7 +1084,7 @@
|
||||
T&G
|
||||
</button>`;
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Area">
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); updateArrivalStatusFromTable(${flight.id}, 'LOCAL')" title="Move to Local Traffic">
|
||||
LOCAL
|
||||
</button>
|
||||
${circuitButton}
|
||||
@@ -1124,7 +1161,7 @@
|
||||
const bTime = b.etd || b.created_dt;
|
||||
if (!aTime) return 1;
|
||||
if (!bTime) return -1;
|
||||
return new Date(aTime) - new Date(bTime);
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
tbody.innerHTML = '';
|
||||
document.getElementById('departures-table-content').style.display = 'block';
|
||||
@@ -1175,12 +1212,13 @@
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'GROUND') {
|
||||
const takeoffStatus = flight.flight_type === 'CIRCUITS' ? 'CIRCUIT' : 'LOCAL';
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('DEPARTED', ${flight.id}, true)" title="Mark as Departed">
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showTimestampModal('${takeoffStatus}', ${flight.id}, true)" title="Mark as airborne">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'DEPARTED') {
|
||||
} else if (['DEPARTED', 'LOCAL', 'CIRCUIT', 'CIRCUIT_DOWNWIND', 'CIRCUIT_BASE', 'CIRCUIT_FINAL'].includes(flight.status)) {
|
||||
// Allow touch and go for all local flight types
|
||||
let circuitButton = `<button class="btn btn-info btn-icon" onclick="event.stopPropagation(); currentLocalFlightId = ${flight.id}; showCircuitModal()" title="Record Touch & Go">
|
||||
T&G
|
||||
@@ -1252,11 +1290,21 @@
|
||||
fuel = flight.fuel || '-';
|
||||
landedDt = flight.landed_dt ? formatTimeOnly(flight.landed_dt) : '-';
|
||||
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
if (flight.status === 'LANDED') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-primary btn-icon" onclick="event.stopPropagation(); showTimestampModal('LOCAL', ${flight.id})" title="Mark as Local">
|
||||
TAKE OFF
|
||||
</button>
|
||||
`;
|
||||
} else if (flight.status === 'LOCAL') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-success btn-icon" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${flight.id})" title="Mark as Departed">
|
||||
QSY
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
actionButtons = '<span style="color: #999;">-</span>';
|
||||
}
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
@@ -1356,21 +1404,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalDateString(date = new Date()) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function isTodayDateTime(value) {
|
||||
return Boolean(value) && value.split('T')[0] === getLocalDateString();
|
||||
}
|
||||
|
||||
function isTodayRecord(record, fields = ['created_dt']) {
|
||||
return fields.some(field => isTodayDateTime(record[field]));
|
||||
}
|
||||
|
||||
// Load departing aircraft (ready to take off)
|
||||
async function loadDepartingAircraft() {
|
||||
try {
|
||||
const [groundDeparturesResponse, groundLocalResponse] = await Promise.all([
|
||||
const [pprResponse, groundDeparturesResponse, groundLocalResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/v1/pprs/?limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000')
|
||||
]);
|
||||
|
||||
let groundAircraft = [];
|
||||
if (groundDeparturesResponse.ok) groundAircraft = await groundDeparturesResponse.json();
|
||||
if (pprResponse.ok) {
|
||||
const today = getLocalDateString();
|
||||
groundAircraft = (await pprResponse.json())
|
||||
.filter(ppr => ppr.status === 'LANDED' && ppr.etd && ppr.etd.split('T')[0] === today)
|
||||
.map(ppr => ({ ...ppr, isPPR: true }));
|
||||
}
|
||||
if (groundDeparturesResponse.ok) groundAircraft = groundAircraft.concat(await groundDeparturesResponse.json());
|
||||
if (groundLocalResponse.ok) groundAircraft = groundAircraft.concat((await groundLocalResponse.json()).map(l => ({ ...l, isLocalFlight: true })));
|
||||
groundAircraft = groundAircraft.filter(ac => isTodayRecord(ac, ['created_dt', 'etd']));
|
||||
|
||||
displayDepartingAircraft(groundAircraft.map(ac => ({
|
||||
...ac,
|
||||
isDeparture: !ac.isLocalFlight
|
||||
isDeparture: !ac.isLocalFlight && !ac.isPPR
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error loading departing aircraft:', error);
|
||||
@@ -1393,6 +1461,7 @@
|
||||
const type = ac.ac_type || ac.type;
|
||||
const dest = ac.out_to;
|
||||
const isLocal = ac.isLocalFlight;
|
||||
const isPPR = ac.isPPR;
|
||||
|
||||
// All aircraft in awaiting departure are in GROUND status
|
||||
let takeoffOnclick, buttonText, buttonTitle, clickType;
|
||||
@@ -1403,13 +1472,18 @@
|
||||
buttonText = 'TAKE OFF';
|
||||
buttonTitle = takeoffTitle;
|
||||
clickType = 'local';
|
||||
} else if (isPPR) {
|
||||
takeoffOnclick = `event.stopPropagation(); showTimestampModal('LOCAL', ${ac.id})`;
|
||||
buttonText = 'TAKE OFF';
|
||||
buttonTitle = 'Mark as Local';
|
||||
clickType = 'ppr';
|
||||
} else {
|
||||
takeoffOnclick = `event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('LOCAL', ${ac.id}, false, true)`;
|
||||
buttonText = 'TAKE OFF';
|
||||
buttonTitle = 'Mark as Local';
|
||||
clickType = 'departure';
|
||||
}
|
||||
const itemClass = isLocal ? 'local-flight' : 'departure';
|
||||
const itemClass = isLocal ? 'local-flight' : (isPPR ? 'departure' : 'departure');
|
||||
|
||||
return `
|
||||
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${clickType}')">
|
||||
@@ -1427,6 +1501,7 @@
|
||||
async function loadLocalAircraft() {
|
||||
try {
|
||||
const response = await Promise.all([
|
||||
authenticatedFetch('/api/v1/pprs/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000'),
|
||||
authenticatedFetch('/api/v1/arrivals/?status=LOCAL&limit=1000'),
|
||||
@@ -1434,10 +1509,19 @@
|
||||
]);
|
||||
|
||||
let locals = [];
|
||||
if (response[0].ok) locals = (await response[0].json()).map(l => ({ ...l, isLocalFlight: true }));
|
||||
if (response[1].ok) locals = locals.concat((await response[1].json()).map(d => ({ ...d, isDeparture: true })));
|
||||
if (response[2].ok) locals = locals.concat((await response[2].json()).map(a => ({ ...a, isArrival: true })));
|
||||
if (response[3].ok) locals = locals.concat((await response[3].json()).map(o => ({ ...o, isOverflight: true })));
|
||||
if (response[0].ok) locals = (await response[0].json()).map(p => ({ ...p, isPPR: true }));
|
||||
if (response[1].ok) locals = locals.concat((await response[1].json()).map(l => ({ ...l, isLocalFlight: true })));
|
||||
if (response[2].ok) locals = locals.concat((await response[2].json()).map(d => ({ ...d, isDeparture: true })));
|
||||
if (response[3].ok) locals = locals.concat((await response[3].json()).map(a => ({ ...a, isArrival: true })));
|
||||
if (response[4].ok) locals = locals.concat((await response[4].json()).map(o => ({ ...o, isOverflight: true })));
|
||||
locals = locals.filter(ac => {
|
||||
if (ac.isOverflight) return true;
|
||||
if (ac.isPPR) return isTodayRecord(ac, ['etd', 'landed_dt', 'submitted_dt']);
|
||||
if (ac.isLocalFlight) return isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt']);
|
||||
if (ac.isArrival) return isTodayRecord(ac, ['created_dt', 'eta']);
|
||||
if (ac.isDeparture) return isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt']);
|
||||
return true;
|
||||
});
|
||||
|
||||
displayLocalAircraft(locals);
|
||||
} catch (error) {
|
||||
@@ -1452,7 +1536,7 @@
|
||||
countEl.textContent = aircraft.length;
|
||||
|
||||
if (aircraft.length === 0) {
|
||||
container.innerHTML = '<div class="no-aircraft">No aircraft in local area</div>';
|
||||
container.innerHTML = '<div class="no-aircraft">No local traffic</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1461,9 +1545,12 @@
|
||||
const type = ac.type || ac.ac_type || ac.aircraft_type || '';
|
||||
const dest = ac.out_to;
|
||||
const isDeparture = ac.isDeparture;
|
||||
const isPPR = ac.isPPR;
|
||||
|
||||
let buttons;
|
||||
if (isDeparture) {
|
||||
if (isPPR) {
|
||||
buttons = `<button class="status-btn" onclick="event.stopPropagation(); showTimestampModal('DEPARTED', ${ac.id})">QSY</button>`;
|
||||
} else if (isDeparture) {
|
||||
// Departure in LOCAL status - show QSY and REJOIN buttons
|
||||
buttons = `
|
||||
<button class="status-btn" onclick="event.stopPropagation(); currentDepartureId = '${ac.id}'; showTimestampModal('DEPARTED', ${ac.id}, false, true)">QSY</button>
|
||||
@@ -1476,9 +1563,9 @@
|
||||
// Overflight in ACTIVE status - show QSY button
|
||||
buttons = `<button class="status-btn" onclick="event.stopPropagation(); currentOverflightId = '${ac.id}'; showOverflightQSYModal()">QSY</button>`;
|
||||
}
|
||||
const itemClass = isDeparture ? 'departure' : (ac.isArrival ? 'inbound' : (ac.isOverflight ? 'overflight' : 'local-flight'));
|
||||
const detailsText = isDeparture ? `${type}${dest ? ` → ${dest}` : ` (Local)`}` : (ac.isOverflight ? `${ac.departure_airfield || '?'} → ${ac.destination_airfield || '?'}` : (ac.isArrival ? `${type} from ${ac.in_from || '?'}` : `${type}${dest ? ` → ${dest}` : ` Local Flight`}`));
|
||||
const entityType = isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local'));
|
||||
const itemClass = isDeparture || isPPR ? 'departure' : (ac.isArrival ? 'inbound' : (ac.isOverflight ? 'overflight' : 'local-flight'));
|
||||
const detailsText = isDeparture || isPPR ? `${type}${dest ? ` → ${dest}` : ` (Local)`}` : (ac.isOverflight ? `${ac.departure_airfield || '?'} → ${ac.destination_airfield || '?'}` : (ac.isArrival ? `${type} from ${ac.in_from || '?'}` : `${type}${dest ? ` → ${dest}` : ` Local Flight`}`));
|
||||
const entityType = isPPR ? 'ppr' : (isDeparture ? 'departure' : (ac.isArrival ? 'arrival' : (ac.isOverflight ? 'overflight' : 'local')));
|
||||
|
||||
return `
|
||||
<div class="aircraft-item ${itemClass}" onclick="handleATCClick('${ac.id}', '${entityType}')">
|
||||
@@ -1505,9 +1592,13 @@
|
||||
const pprs = response[0].ok ? await response[0].json() : [];
|
||||
const arrivals = response[1].ok ? await response[1].json() : [];
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = getLocalDateString();
|
||||
let inbound = pprs.filter(p => p.status === 'CONFIRMED' && p.eta && p.eta.split('T')[0] === today);
|
||||
inbound = inbound.concat(arrivals.map(a => ({ ...a, isArrival: true })));
|
||||
inbound = inbound.concat(
|
||||
arrivals
|
||||
.filter(a => isTodayRecord(a, ['created_dt', 'eta']))
|
||||
.map(a => ({ ...a, isArrival: true }))
|
||||
);
|
||||
|
||||
displayInboundAircraft(inbound);
|
||||
} catch (error) {
|
||||
@@ -1563,6 +1654,11 @@
|
||||
if (response[0].ok) circuits = circuits.concat(await response[0].json());
|
||||
if (response[1].ok) circuits = circuits.concat((await response[1].json()).map(l => ({ ...l, circuitStatus: getCircuitStatus(l.status) })));
|
||||
if (response[2].ok) circuits = circuits.concat((await response[2].json()).map(a => ({ ...a, isArrival: true, circuitStatus: getCircuitStatus(a.status) })));
|
||||
circuits = circuits.filter(ac => (
|
||||
ac.isArrival
|
||||
? isTodayRecord(ac, ['created_dt', 'eta'])
|
||||
: isTodayRecord(ac, ['created_dt', 'etd', 'takeoff_dt', 'departed_dt'])
|
||||
));
|
||||
|
||||
displayCircuitAircraft(circuits);
|
||||
} catch (error) {
|
||||
@@ -1657,7 +1753,12 @@
|
||||
const response = await authenticatedFetch('/api/v1/pprs/?limit=1000');
|
||||
const pprs = response.ok ? await response.json() : [];
|
||||
|
||||
const pending = pprs.filter(p => p.status === 'NEW' || p.status === 'CONFIRMED');
|
||||
const today = getLocalDateString();
|
||||
const pending = pprs.filter(p => (
|
||||
(p.status === 'NEW' || p.status === 'CONFIRMED') &&
|
||||
p.eta &&
|
||||
p.eta.split('T')[0] === today
|
||||
));
|
||||
displayPendingPPRs(pending);
|
||||
} catch (error) {
|
||||
console.error('Error loading pending PPRs:', error);
|
||||
@@ -1722,18 +1823,15 @@
|
||||
const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : [];
|
||||
|
||||
// Filter for today's bookings
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const bookedOutAircraft = [
|
||||
...localBookedOut.filter(flight => {
|
||||
const createdDate = flight.created_dt.split('T')[0];
|
||||
return createdDate === today;
|
||||
return isTodayRecord(flight, ['created_dt', 'etd']);
|
||||
}).map(flight => ({
|
||||
...flight,
|
||||
isLocalFlight: true
|
||||
})),
|
||||
...depBookedOut.filter(flight => {
|
||||
const createdDate = flight.created_dt.split('T')[0];
|
||||
return createdDate === today;
|
||||
return isTodayRecord(flight, ['created_dt', 'etd']);
|
||||
}).map(flight => ({
|
||||
...flight,
|
||||
isDeparture: true
|
||||
@@ -1848,4 +1946,4 @@
|
||||
Please contact James Pattinson if you have any ideas about or problems with this system
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+3
-4
@@ -966,8 +966,7 @@
|
||||
if (/^[0-9]{4}$/.test(timeValue)) {
|
||||
timeValue = timeValue.slice(0, 2) + ':' + timeValue.slice(2);
|
||||
}
|
||||
const datetime = new Date(`${today}T${timeValue}:00`);
|
||||
data[field] = datetime.toISOString();
|
||||
data[field] = `${today}T${timeValue}:00Z`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1031,8 +1030,8 @@
|
||||
function setDefaultTimes() {
|
||||
const now = new Date();
|
||||
const futureTime = new Date(now.getTime() + 10 * 60000); // 10 minutes from now
|
||||
const futureHours = String(futureTime.getHours()).padStart(2, '0');
|
||||
const futureMinutes = String(futureTime.getMinutes()).padStart(2, '0');
|
||||
const futureHours = String(futureTime.getUTCHours()).padStart(2, '0');
|
||||
const futureMinutes = String(futureTime.getUTCMinutes()).padStart(2, '0');
|
||||
const futureTimeValue = `${futureHours}:${futureMinutes}`;
|
||||
|
||||
const etdFieldIds = ['localETD', 'circuitETD', 'depETD', 'arrETA'];
|
||||
|
||||
+1051
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,333 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Drone Flight Request</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #f5f5f5;
|
||||
color: #263645;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin: 2rem auto;
|
||||
max-width: 900px;
|
||||
padding: 2rem;
|
||||
}
|
||||
.header {
|
||||
border-bottom: 2px solid #3498db;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.header h1 { color: #2c3e50; margin-bottom: 0.4rem; }
|
||||
.status {
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
}
|
||||
.status-NEW { background: #3498db; }
|
||||
.status-APPROVED { background: #27ae60; }
|
||||
.status-DENIED { background: #c0392b; }
|
||||
.status-CANCELED { background: #7f8c8d; }
|
||||
.status-INFLIGHT { background: #8e44ad; }
|
||||
.status-COMPLETED { background: #2c3e50; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.full { grid-column: 1 / -1; }
|
||||
label {
|
||||
color: #555;
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
input, textarea {
|
||||
border: 1px solid #d6dce2;
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
padding: 0.65rem;
|
||||
width: 100%;
|
||||
}
|
||||
textarea { min-height: 110px; resize: vertical; }
|
||||
.actions {
|
||||
border-top: 1px solid #e6e9ec;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 0.8rem 1.4rem;
|
||||
}
|
||||
.btn-primary { background: #3498db; color: white; }
|
||||
.btn-danger { background: #e74c3c; color: white; }
|
||||
.btn-secondary { background: #95a5a6; color: white; }
|
||||
.message {
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.message.ok { background: #d4edda; color: #155724; display: block; }
|
||||
.message.error { background: #f8d7da; color: #721c24; display: block; }
|
||||
.read-only {
|
||||
background: #eef1f4;
|
||||
border-radius: 5px;
|
||||
color: #607080;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.container { margin: 0; min-height: 100vh; border-radius: 0; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.full { grid-column: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<div class="header">
|
||||
<h1>Drone Flight Request</h1>
|
||||
<p id="summary">Loading request...</p>
|
||||
<span id="status" class="status status-NEW" style="display: none;"></span>
|
||||
</div>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
<div id="locked" class="read-only" style="display: none;">This request can be viewed, but can no longer be edited or cancelled.</div>
|
||||
|
||||
<form id="request-form" style="display: none;">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="operator_name">Operator name</label>
|
||||
<input id="operator_name" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="operator_id">Operator ID</label>
|
||||
<input id="operator_id">
|
||||
</div>
|
||||
<div>
|
||||
<label for="flyer_name">Flyer name</label>
|
||||
<input id="flyer_name">
|
||||
</div>
|
||||
<div>
|
||||
<label for="flyer_id">Flyer ID</label>
|
||||
<input id="flyer_id">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="phone">Phone</label>
|
||||
<input id="phone">
|
||||
</div>
|
||||
<div>
|
||||
<label for="estimated_takeoff_at">Estimated takeoff</label>
|
||||
<input id="estimated_takeoff_at" type="datetime-local" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="estimated_completion_at">Estimated completion</label>
|
||||
<input id="estimated_completion_at" type="datetime-local" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="maximum_elevation_ft_agl">Maximum elevation ft AGL</label>
|
||||
<input id="maximum_elevation_ft_agl" type="number" min="0" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="location_inside_frz">Inside FRZ</label>
|
||||
<input id="location_inside_frz" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label for="location_latitude">Latitude</label>
|
||||
<input id="location_latitude" type="number" step="0.000001" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="location_longitude">Longitude</label>
|
||||
<input id="location_longitude" type="number" step="0.000001" required>
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="location_description">Location description</label>
|
||||
<input id="location_description">
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes"></textarea>
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="operator_comments">Airport comments</label>
|
||||
<textarea id="operator_comments" readonly></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="save-btn" type="submit">Save Changes</button>
|
||||
<button class="btn btn-danger" id="cancel-btn" type="button" onclick="cancelRequest()">Cancel Request</button>
|
||||
<button class="btn btn-secondary" type="button" onclick="loadRequest()">Reload</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get('token');
|
||||
let currentRequest = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('request-form').addEventListener('submit', saveRequest);
|
||||
if (!token) {
|
||||
showMessage('Missing secure request token.', true);
|
||||
return;
|
||||
}
|
||||
loadRequest();
|
||||
});
|
||||
|
||||
async function loadRequest() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/drone-requests/public/edit/${encodeURIComponent(token)}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Unable to load request');
|
||||
currentRequest = data;
|
||||
populateForm(data);
|
||||
showMessage('', false, true);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function populateForm(request) {
|
||||
document.getElementById('summary').textContent = `${request.reference_number} - ${request.operator_name}`;
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = request.status;
|
||||
status.className = `status status-${request.status}`;
|
||||
status.style.display = 'inline-flex';
|
||||
|
||||
setValue('operator_name', request.operator_name);
|
||||
setValue('operator_id', request.operator_id);
|
||||
setValue('flyer_name', request.flyer_name);
|
||||
setValue('flyer_id', request.flyer_id);
|
||||
setValue('email', request.email);
|
||||
setValue('phone', request.phone);
|
||||
setValue('estimated_takeoff_at', toLocalInputValue(request.estimated_takeoff_at));
|
||||
setValue('estimated_completion_at', toLocalInputValue(request.estimated_completion_at));
|
||||
setValue('maximum_elevation_ft_agl', request.maximum_elevation_ft_agl);
|
||||
setValue('location_inside_frz', request.location_inside_frz ? 'Yes' : 'No');
|
||||
setValue('location_latitude', request.location_latitude);
|
||||
setValue('location_longitude', request.location_longitude);
|
||||
setValue('location_description', request.location_description);
|
||||
setValue('notes', request.notes);
|
||||
setValue('operator_comments', request.operator_comments);
|
||||
|
||||
const locked = !['NEW', 'APPROVED'].includes(request.status);
|
||||
document.getElementById('locked').style.display = locked ? 'block' : 'none';
|
||||
document.getElementById('save-btn').disabled = locked;
|
||||
document.getElementById('cancel-btn').disabled = locked;
|
||||
document.querySelectorAll('#request-form input, #request-form textarea').forEach(input => {
|
||||
if (input.id !== 'operator_comments' && input.id !== 'location_inside_frz') {
|
||||
input.readOnly = locked;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('request-form').style.display = 'block';
|
||||
}
|
||||
|
||||
async function saveRequest(event) {
|
||||
event.preventDefault();
|
||||
const payload = {
|
||||
operator_name: value('operator_name'),
|
||||
operator_id: value('operator_id') || null,
|
||||
flyer_name: value('flyer_name') || null,
|
||||
flyer_id: value('flyer_id') || null,
|
||||
email: value('email'),
|
||||
phone: value('phone') || null,
|
||||
estimated_takeoff_at: fromLocalInputValue(value('estimated_takeoff_at')),
|
||||
estimated_completion_at: fromLocalInputValue(value('estimated_completion_at')),
|
||||
maximum_elevation_ft_agl: Number(value('maximum_elevation_ft_agl')),
|
||||
location_latitude: Number(value('location_latitude')),
|
||||
location_longitude: Number(value('location_longitude')),
|
||||
location_description: value('location_description') || null,
|
||||
notes: value('notes') || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/drone-requests/public/edit/${encodeURIComponent(token)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Unable to save request');
|
||||
currentRequest = data;
|
||||
populateForm(data);
|
||||
showMessage('Request updated.');
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRequest() {
|
||||
if (!confirm('Cancel this drone flight request?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/drone-requests/public/cancel/${encodeURIComponent(token)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Unable to cancel request');
|
||||
currentRequest = data;
|
||||
populateForm(data);
|
||||
showMessage('Request cancelled.');
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function setValue(id, value) {
|
||||
document.getElementById(id).value = value == null ? '' : value;
|
||||
}
|
||||
|
||||
function value(id) {
|
||||
return document.getElementById(id).value.trim();
|
||||
}
|
||||
|
||||
function toLocalInputValue(value) {
|
||||
if (!value) return '';
|
||||
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
|
||||
const date = new Date(normalized.endsWith('Z') ? normalized : `${normalized}Z`);
|
||||
return date.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function fromLocalInputValue(value) {
|
||||
return `${value}:00Z`;
|
||||
}
|
||||
|
||||
function showMessage(message, isError = false, clear = false) {
|
||||
const element = document.getElementById('message');
|
||||
if (clear || !message) {
|
||||
element.textContent = '';
|
||||
element.className = 'message';
|
||||
return;
|
||||
}
|
||||
element.textContent = message;
|
||||
element.className = `message ${isError ? 'error' : 'ok'}`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
+29
-12
@@ -437,12 +437,12 @@
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const etaDate = new Date(utcDateStr);
|
||||
const etaDateStr = etaDate.toISOString().split('T')[0];
|
||||
const etaTimeStr = etaDate.toISOString().slice(11, 16);
|
||||
const etaDateStr = formatLocalDateInput(etaDate);
|
||||
const etaTimeStr = formatLocalTimeInput(etaDate);
|
||||
document.getElementById('eta-date').value = etaDateStr;
|
||||
document.getElementById('eta-time').value = etaTimeStr;
|
||||
}
|
||||
@@ -457,12 +457,12 @@
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const etdDate = new Date(utcDateStr);
|
||||
const etdDateStr = etdDate.toISOString().split('T')[0];
|
||||
const etdTimeStr = etdDate.toISOString().slice(11, 16);
|
||||
const etdDateStr = formatLocalDateInput(etdDate);
|
||||
const etdTimeStr = formatLocalTimeInput(etdDate);
|
||||
document.getElementById('etd-date').value = etdDateStr;
|
||||
document.getElementById('etd-time').value = etdTimeStr;
|
||||
}
|
||||
@@ -471,15 +471,35 @@
|
||||
document.getElementById('email').value = ppr.email || '';
|
||||
document.getElementById('phone').value = ppr.phone || '';
|
||||
document.getElementById('notes').value = ppr.notes || '';
|
||||
|
||||
if (['CANCELED', 'DELETED', 'LANDED', 'LOCAL', 'DEPARTED'].includes(ppr.status)) {
|
||||
document.getElementById('update-btn').disabled = true;
|
||||
document.getElementById('cancel-btn').disabled = true;
|
||||
showNotification('This PPR can no longer be edited or cancelled online.', true);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to load PPR data');
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Failed to load PPR data');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PPR:', error);
|
||||
showNotification('Error loading PPR data', true);
|
||||
showNotification(`Error loading PPR: ${error.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
function formatLocalDateInput(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatLocalTimeInput(date) {
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Aircraft lookup (same as submit form)
|
||||
let aircraftLookupTimeout;
|
||||
async function handleAircraftLookup(registration) {
|
||||
@@ -692,9 +712,6 @@
|
||||
document.getElementById('ppr-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Auto-save any unsaved aircraft types
|
||||
await autoSaveUnsavedAircraft(this);
|
||||
|
||||
const formData = new FormData(this);
|
||||
const pprData = {};
|
||||
|
||||
@@ -792,4 +809,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+14
-8
@@ -386,10 +386,12 @@
|
||||
<body>
|
||||
<header>
|
||||
<img src="assets/logo.png" alt="EGFH Logo" class="left-image">
|
||||
<!-- Booking QR hidden until the Book Out flow is ready.
|
||||
<div class="qr-code-container">
|
||||
<img id="bookingQR" alt="Scan to book a flight" title="Scan to book a flight">
|
||||
<div class="qr-label">Book Out</div>
|
||||
</div>
|
||||
-->
|
||||
<h1>Flight Information</h1>
|
||||
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
|
||||
</header>
|
||||
@@ -629,8 +631,15 @@
|
||||
if (!utcDateTimeString) return '';
|
||||
|
||||
try {
|
||||
// Parse the ISO datetime string
|
||||
const date = new Date(utcDateTimeString);
|
||||
// API datetimes are UTC, but DB-backed values may arrive without a timezone suffix.
|
||||
let normalizedDateTime = String(utcDateTimeString).trim();
|
||||
if (!normalizedDateTime.includes('T')) {
|
||||
normalizedDateTime = normalizedDateTime.replace(' ', 'T');
|
||||
}
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(normalizedDateTime)) {
|
||||
normalizedDateTime += 'Z';
|
||||
}
|
||||
const date = new Date(normalizedDateTime);
|
||||
|
||||
// Check if valid date
|
||||
if (isNaN(date.getTime())) {
|
||||
@@ -820,10 +829,10 @@
|
||||
const toDisplay = await getAirportName(departure.out_to || '');
|
||||
|
||||
let timeDisplay, sortTime;
|
||||
if (departure.status === 'DEPARTED' && departure.departed_dt) {
|
||||
const time = convertToLocalTime(departure.departed_dt);
|
||||
if (departure.status === 'DEPARTED' && departure.qsy_dt) {
|
||||
const time = convertToLocalTime(departure.qsy_dt);
|
||||
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
|
||||
sortTime = departure.departed_dt;
|
||||
sortTime = departure.qsy_dt;
|
||||
} else {
|
||||
timeDisplay = convertToLocalTime(departure.etd);
|
||||
sortTime = departure.etd;
|
||||
@@ -893,9 +902,6 @@
|
||||
// Initialize Christmas mode
|
||||
initChristmasMode();
|
||||
|
||||
// Load booking QR code
|
||||
generateBookingQR();
|
||||
|
||||
loadArrivals();
|
||||
loadDepartures();
|
||||
|
||||
|
||||
+8
-17
@@ -240,6 +240,7 @@
|
||||
background: #1976D2;
|
||||
}
|
||||
</style>
|
||||
<script src="topbar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
@@ -408,10 +409,10 @@
|
||||
function setDefaultDates() {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
|
||||
|
||||
document.getElementById('dateFrom').valueAsDate = thirtyDaysAgo;
|
||||
document.getElementById('dateTo').valueAsDate = today;
|
||||
document.getElementById('dateFrom').value = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
document.getElementById('dateTo').value = today.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
async function loadJournalEntries() {
|
||||
@@ -711,23 +712,13 @@
|
||||
}
|
||||
|
||||
function formatDateTime(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
const normalized = dateString.includes('T') ? dateString : dateString.replace(' ', 'T');
|
||||
const date = new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(normalized) ? normalized : `${normalized}Z`);
|
||||
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit'
|
||||
});
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
|
||||
+19
-2
@@ -168,7 +168,7 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
|
||||
// Format the aircraft registration
|
||||
const formatted = formatAircraftRegistration(searchTerm);
|
||||
const field = document.getElementById(fieldId);
|
||||
if (field) {
|
||||
if (field && fieldId !== 'aircraft-registration') {
|
||||
field.value = formatted;
|
||||
// Mark the form for auto-saving this aircraft
|
||||
const form = field.closest('form');
|
||||
@@ -194,7 +194,7 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
|
||||
|
||||
// Auto-populate the form fields
|
||||
const field = document.getElementById(fieldId);
|
||||
if (field) {
|
||||
if (field && fieldId !== 'aircraft-registration') {
|
||||
field.value = aircraft.registration;
|
||||
// Clear the unsaved aircraft flag since we found a match
|
||||
const form = field.closest('form');
|
||||
@@ -213,6 +213,8 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) {
|
||||
typeFieldId = 'book_in_type';
|
||||
} else if (fieldId === 'overflight_registration') {
|
||||
typeFieldId = 'overflight_type';
|
||||
} else if (fieldId === 'aircraft-registration') {
|
||||
typeFieldId = 'aircraft-type';
|
||||
}
|
||||
|
||||
if (typeFieldId) {
|
||||
@@ -330,6 +332,8 @@ const lookupManager = {
|
||||
}
|
||||
};
|
||||
|
||||
window.lookupManager = lookupManager;
|
||||
|
||||
// Initialize all lookups when page loads
|
||||
function initializeLookups() {
|
||||
// Create reusable lookup instances
|
||||
@@ -412,6 +416,14 @@ function initializeLookups() {
|
||||
{ isAirport: true, minLength: 2 }
|
||||
);
|
||||
lookupManager.register('overflight-destination', overflightDestinationLookup);
|
||||
|
||||
const bulkAircraftLookup = createLookup(
|
||||
'aircraft-registration',
|
||||
'bulk-aircraft-lookup-results',
|
||||
null,
|
||||
{ isAircraft: true, minLength: 4, debounceMs: 300 }
|
||||
);
|
||||
lookupManager.register('bulk-aircraft', bulkAircraftLookup);
|
||||
|
||||
// Attach keyboard handlers to airport input fields
|
||||
setTimeout(() => {
|
||||
@@ -459,6 +471,11 @@ function handleLocalAircraftLookup(value) {
|
||||
if (lookup) lookup.handle(value);
|
||||
}
|
||||
|
||||
function handleBulkAircraftLookup(value) {
|
||||
const lookup = lookupManager.lookups['bulk-aircraft'];
|
||||
if (lookup) lookup.handle(value);
|
||||
}
|
||||
|
||||
function clearArrivalAirportLookup() {
|
||||
const lookup = lookupManager.lookups['arrival-airport'];
|
||||
if (lookup) lookup.clear();
|
||||
|
||||
+24
-25
@@ -358,6 +358,7 @@
|
||||
min-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
<script src="topbar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
@@ -646,10 +647,10 @@
|
||||
// Set date range to this week (Monday to Sunday)
|
||||
function setDateRangeThisWeek() {
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
||||
const monday = new Date(now.setDate(diff));
|
||||
const sunday = new Date(now.setDate(diff + 6));
|
||||
const dayOfWeek = now.getUTCDay();
|
||||
const diff = now.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
||||
const monday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), diff));
|
||||
const sunday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), diff + 6));
|
||||
|
||||
document.getElementById('date-from').value = monday.toISOString().split('T')[0];
|
||||
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
|
||||
@@ -664,8 +665,8 @@
|
||||
// Set date range to this month
|
||||
function setDateRangeThisMonth() {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const firstDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const lastDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
|
||||
|
||||
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
|
||||
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
||||
@@ -811,21 +812,14 @@
|
||||
let dateRangeText = '';
|
||||
if (dateFrom && dateTo && dateFrom === dateTo) {
|
||||
// Single day
|
||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
||||
dateRangeText = `for ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
dateRangeText = `for ${formatDateOnly(dateFrom)}`;
|
||||
} else if (dateFrom && dateTo) {
|
||||
// Date range
|
||||
const fromDate = new Date(dateFrom + 'T00:00:00Z');
|
||||
const toDate = new Date(dateTo + 'T00:00:00Z');
|
||||
const fromText = fromDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const toText = toDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
dateRangeText = `for ${fromText} to ${toText}`;
|
||||
dateRangeText = `for ${formatDateOnly(dateFrom)} to ${formatDateOnly(dateTo)}`;
|
||||
} else if (dateFrom) {
|
||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
||||
dateRangeText = `from ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
dateRangeText = `from ${formatDateOnly(dateFrom)}`;
|
||||
} else if (dateTo) {
|
||||
const date = new Date(dateTo + 'T00:00:00Z');
|
||||
dateRangeText = `until ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
dateRangeText = `until ${formatDateOnly(dateTo)}`;
|
||||
}
|
||||
|
||||
// Update summary title with date range
|
||||
@@ -905,11 +899,11 @@
|
||||
const date = new Date(utcDateStr);
|
||||
|
||||
// Format as dd/mm/yy hh:mm
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getUTCFullYear()).slice(-2);
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
@@ -926,12 +920,17 @@
|
||||
const date = new Date(utcDateStr);
|
||||
|
||||
// Format as hh:mm only
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatDateOnly(dateStr) {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
// Clear filters
|
||||
function clearFilters() {
|
||||
document.getElementById('status-filter').value = '';
|
||||
@@ -1274,4 +1273,4 @@
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+395
-90
@@ -155,6 +155,14 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable-row:hover {
|
||||
background-color: #eef6ff;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #34495e;
|
||||
color: white;
|
||||
@@ -311,6 +319,98 @@
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
margin: 4% auto;
|
||||
width: min(920px, calc(100% - 2rem));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: #34495e;
|
||||
color: white;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.8rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-field {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
color: #667085;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.journal-section {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.journal-entry {
|
||||
border-left: 3px solid #3498db;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.journal-meta {
|
||||
font-size: 0.78rem;
|
||||
color: #667085;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
@@ -358,6 +458,7 @@
|
||||
min-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
<script src="topbar.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
@@ -473,7 +574,7 @@
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Departures</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="non-ppr-departures">0</div>
|
||||
</div>
|
||||
<div class="summary-item" style="padding: 0.4rem; cursor: pointer;" onclick="filterOtherFlights('OVERFLIGHT')">
|
||||
<div class="summary-item" style="padding: 0.4rem;">
|
||||
<div class="summary-item-label" style="font-size: 0.7rem; margin-bottom: 0.1rem;">Overflights</div>
|
||||
<div class="summary-item-value" style="font-size: 1.1rem;" id="overflights-count">0</div>
|
||||
</div>
|
||||
@@ -523,14 +624,12 @@
|
||||
<th>Callsign</th>
|
||||
<th>Captain</th>
|
||||
<th>From</th>
|
||||
<th>ETA</th>
|
||||
<th>POB In</th>
|
||||
<th>To</th>
|
||||
<th>ETD</th>
|
||||
<th>Takeoff</th>
|
||||
<th>Landing</th>
|
||||
<th>POB In</th>
|
||||
<th>POB Out</th>
|
||||
<th>Fuel</th>
|
||||
<th>Landed</th>
|
||||
<th>Departed</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Notes</th>
|
||||
@@ -581,8 +680,8 @@
|
||||
<th>Callsign</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>ETA / ETD / Called</th>
|
||||
<th>Landed / Departed / QSY</th>
|
||||
<th>Takeoff</th>
|
||||
<th>Landing</th>
|
||||
<th>Circuits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -599,6 +698,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reportDetailModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="report-detail-title">Details</h2>
|
||||
<button class="close" onclick="closeReportDetailModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="report-detail-body" class="detail-grid"></div>
|
||||
<div class="journal-section">
|
||||
<h3 style="margin: 0 0 0.5rem 0;">Journal</h3>
|
||||
<div id="report-detail-journal">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Notification -->
|
||||
<div id="notification" class="notification"></div>
|
||||
|
||||
@@ -698,10 +813,10 @@
|
||||
// Set date range to this week (Monday to Sunday)
|
||||
function setDateRangeThisWeek() {
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
||||
const monday = new Date(now.setDate(diff));
|
||||
const sunday = new Date(now.setDate(diff + 6));
|
||||
const dayOfWeek = now.getUTCDay();
|
||||
const diff = now.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
||||
const monday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), diff));
|
||||
const sunday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), diff + 6));
|
||||
|
||||
document.getElementById('date-from').value = monday.toISOString().split('T')[0];
|
||||
document.getElementById('date-to').value = sunday.toISOString().split('T')[0];
|
||||
@@ -716,8 +831,8 @@
|
||||
// Set date range to this month
|
||||
function setDateRangeThisMonth() {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const firstDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const lastDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
|
||||
|
||||
document.getElementById('date-from').value = firstDay.toISOString().split('T')[0];
|
||||
document.getElementById('date-to').value = lastDay.toISOString().split('T')[0];
|
||||
@@ -870,9 +985,12 @@
|
||||
const arrivals = await arrivalsResponse.json();
|
||||
otherFlights.push(...arrivals.map(f => ({
|
||||
...f,
|
||||
entityType: 'ARRIVAL',
|
||||
flightType: 'ARRIVAL',
|
||||
aircraft_type: f.type,
|
||||
timeField: f.eta || f.landed_dt,
|
||||
sortTime: f.landed_dt || f.eta || f.created_dt,
|
||||
takeoffTime: null,
|
||||
landingTime: f.landed_dt,
|
||||
fromField: f.in_from,
|
||||
toField: 'EGFH'
|
||||
})));
|
||||
@@ -882,9 +1000,12 @@
|
||||
const departures = await departuresResponse.json();
|
||||
otherFlights.push(...departures.map(f => ({
|
||||
...f,
|
||||
entityType: 'DEPARTURE',
|
||||
flightType: 'DEPARTURE',
|
||||
aircraft_type: f.type,
|
||||
timeField: f.etd || f.departed_dt,
|
||||
sortTime: f.takeoff_dt || f.departed_dt || f.etd || f.created_dt,
|
||||
takeoffTime: f.takeoff_dt || f.departed_dt,
|
||||
landingTime: null,
|
||||
fromField: 'EGFH',
|
||||
toField: f.out_to
|
||||
})));
|
||||
@@ -894,10 +1015,13 @@
|
||||
const localFlights = await localFlightsResponse.json();
|
||||
otherFlights.push(...localFlights.map(f => ({
|
||||
...f,
|
||||
entityType: 'LOCAL_FLIGHT',
|
||||
flightType: f.flight_type === 'CIRCUITS' ? 'CIRCUIT' : f.flight_type,
|
||||
aircraft_type: f.type,
|
||||
circuits: f.circuits,
|
||||
timeField: f.departed_dt,
|
||||
sortTime: f.takeoff_dt || f.departed_dt || f.landed_dt || f.etd || f.created_dt,
|
||||
takeoffTime: f.takeoff_dt || f.departed_dt,
|
||||
landingTime: f.landed_dt,
|
||||
fromField: 'EGFH',
|
||||
toField: 'EGFH'
|
||||
})));
|
||||
@@ -907,10 +1031,13 @@
|
||||
const overflights = await overflightsResponse.json();
|
||||
otherFlights.push(...overflights.map(f => ({
|
||||
...f,
|
||||
entityType: 'OVERFLIGHT',
|
||||
flightType: 'OVERFLIGHT',
|
||||
aircraft_type: f.type,
|
||||
circuits: null,
|
||||
timeField: f.call_dt,
|
||||
sortTime: f.call_dt,
|
||||
takeoffTime: null,
|
||||
landingTime: null,
|
||||
fromField: f.departure_airfield,
|
||||
toField: f.destination_airfield,
|
||||
callsign: f.registration
|
||||
@@ -958,21 +1085,14 @@
|
||||
let dateRangeText = '';
|
||||
if (dateFrom && dateTo && dateFrom === dateTo) {
|
||||
// Single day
|
||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
||||
dateRangeText = `for ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
dateRangeText = `for ${formatDateOnly(dateFrom)}`;
|
||||
} else if (dateFrom && dateTo) {
|
||||
// Date range
|
||||
const fromDate = new Date(dateFrom + 'T00:00:00Z');
|
||||
const toDate = new Date(dateTo + 'T00:00:00Z');
|
||||
const fromText = fromDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const toText = toDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
dateRangeText = `for ${fromText} to ${toText}`;
|
||||
dateRangeText = `for ${formatDateOnly(dateFrom)} to ${formatDateOnly(dateTo)}`;
|
||||
} else if (dateFrom) {
|
||||
const date = new Date(dateFrom + 'T00:00:00Z');
|
||||
dateRangeText = `from ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
dateRangeText = `from ${formatDateOnly(dateFrom)}`;
|
||||
} else if (dateTo) {
|
||||
const date = new Date(dateTo + 'T00:00:00Z');
|
||||
dateRangeText = `until ${date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
dateRangeText = `until ${formatDateOnly(dateTo)}`;
|
||||
}
|
||||
|
||||
// Update summary title with date range
|
||||
@@ -1044,11 +1164,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by ETA (ascending)
|
||||
// Sort by first actual movement time, then planned times as a fallback.
|
||||
pprs.sort((a, b) => {
|
||||
if (!a.eta) return 1;
|
||||
if (!b.eta) return -1;
|
||||
return new Date(a.eta) - new Date(b.eta);
|
||||
const aTime = getPPRSortTime(a);
|
||||
const bTime = getPPRSortTime(b);
|
||||
if (!aTime) return 1;
|
||||
if (!bTime) return -1;
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
@@ -1056,12 +1178,11 @@
|
||||
|
||||
for (const ppr of pprs) {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'clickable-row';
|
||||
row.onclick = () => openReportDetail('PPR', ppr.id);
|
||||
|
||||
// Format dates
|
||||
const eta = ppr.eta ? formatDateTime(ppr.eta) : '-';
|
||||
const etd = ppr.etd ? formatDateTime(ppr.etd) : '-';
|
||||
const landed = ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '-';
|
||||
const departed = ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '-';
|
||||
const takeoff = ppr.takeoff_dt ? formatDateTime(ppr.takeoff_dt) : '-';
|
||||
const landing = ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '-';
|
||||
const submitted = ppr.submitted_dt ? formatDateTime(ppr.submitted_dt) : '-';
|
||||
|
||||
// Status styling
|
||||
@@ -1075,14 +1196,12 @@
|
||||
<td>${ppr.ac_call || '-'}</td>
|
||||
<td>${ppr.captain}</td>
|
||||
<td>${ppr.in_from}</td>
|
||||
<td>${eta}</td>
|
||||
<td>${ppr.pob_in}</td>
|
||||
<td>${ppr.out_to || '-'}</td>
|
||||
<td>${etd}</td>
|
||||
<td>${takeoff}</td>
|
||||
<td>${landing}</td>
|
||||
<td>${ppr.pob_in}</td>
|
||||
<td>${ppr.pob_out || '-'}</td>
|
||||
<td>${ppr.fuel || '-'}</td>
|
||||
<td>${landed}</td>
|
||||
<td>${departed}</td>
|
||||
<td>${ppr.email || '-'}</td>
|
||||
<td>${ppr.phone || '-'}</td>
|
||||
<td>${ppr.notes || '-'}</td>
|
||||
@@ -1175,10 +1294,10 @@
|
||||
const tbody = document.getElementById('other-flights-table-body');
|
||||
const tableInfo = document.getElementById('other-flights-info');
|
||||
|
||||
// Apply filter if one is selected
|
||||
let filteredFlights = flights;
|
||||
// Overflights are counted in the summary but omitted from the detail table for now.
|
||||
let filteredFlights = flights.filter(flight => flight.flightType !== 'OVERFLIGHT');
|
||||
if (otherFlightsFilterType) {
|
||||
filteredFlights = flights.filter(flight => flight.flightType === otherFlightsFilterType);
|
||||
filteredFlights = filteredFlights.filter(flight => flight.flightType === otherFlightsFilterType);
|
||||
}
|
||||
|
||||
tableInfo.textContent = `${filteredFlights.length} flights found` + (otherFlightsFilterType ? ` (filtered by ${otherFlightsFilterType})` : '');
|
||||
@@ -1189,13 +1308,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by time field (ascending)
|
||||
// Sort by the first pertinent movement time.
|
||||
filteredFlights.sort((a, b) => {
|
||||
const aTime = a.timeField;
|
||||
const bTime = b.timeField;
|
||||
const aTime = a.sortTime;
|
||||
const bTime = b.sortTime;
|
||||
if (!aTime) return 1;
|
||||
if (!bTime) return -1;
|
||||
return new Date(aTime) - new Date(bTime);
|
||||
return parseUtcDate(aTime) - parseUtcDate(bTime);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
@@ -1204,6 +1323,8 @@
|
||||
|
||||
for (const flight of filteredFlights) {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'clickable-row';
|
||||
row.onclick = () => openReportDetail(flight.entityType, flight.id);
|
||||
|
||||
const typeLabel = flight.flightType;
|
||||
const registration = flight.registration || '-';
|
||||
@@ -1211,18 +1332,8 @@
|
||||
const callsign = flight.callsign || '-';
|
||||
const from = flight.fromField || '-';
|
||||
const to = flight.toField || '-';
|
||||
const timeDisplay = flight.timeField ? formatDateTime(flight.timeField) : '-';
|
||||
|
||||
// Different display for different flight types
|
||||
let actualDisplay = '-';
|
||||
if (flight.flightType === 'ARRIVAL') {
|
||||
actualDisplay = flight.landed_dt ? formatDateTime(flight.landed_dt) : '-';
|
||||
} else if (flight.flightType === 'OVERFLIGHT') {
|
||||
// For overflights, show qsy_dt (frequency change time)
|
||||
actualDisplay = flight.qsy_dt ? formatDateTime(flight.qsy_dt) : '-';
|
||||
} else {
|
||||
actualDisplay = flight.departed_dt ? formatDateTime(flight.departed_dt) : '-';
|
||||
}
|
||||
const takeoff = flight.takeoffTime ? formatDateTime(flight.takeoffTime) : '-';
|
||||
const landing = flight.landingTime ? formatDateTime(flight.landingTime) : '-';
|
||||
|
||||
const status = flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING');
|
||||
const circuits = (flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits > 0 ? flight.circuits : '-') : '-';
|
||||
@@ -1235,8 +1346,8 @@
|
||||
<td>${callsign}</td>
|
||||
<td>${from}</td>
|
||||
<td>${to}</td>
|
||||
<td>${timeDisplay}</td>
|
||||
<td>${actualDisplay}</td>
|
||||
<td>${takeoff}</td>
|
||||
<td>${landing}</td>
|
||||
<td>${circuits}</td>
|
||||
`;
|
||||
|
||||
@@ -1246,23 +1357,202 @@
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
let utcDateStr = dateStr;
|
||||
const date = parseUtcDate(dateStr);
|
||||
|
||||
// Format as dd/mm/yy hh:mm
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getUTCFullYear()).slice(-2);
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function parseUtcDate(dateStr) {
|
||||
let utcDateStr = String(dateStr).trim();
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const date = new Date(utcDateStr);
|
||||
|
||||
// Format as dd/mm/yy hh:mm
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
return new Date(utcDateStr);
|
||||
}
|
||||
|
||||
function getPPRSortTime(ppr) {
|
||||
return ppr.landed_dt || ppr.qsy_dt || ppr.takeoff_dt || ppr.eta || ppr.etd || ppr.submitted_dt;
|
||||
}
|
||||
|
||||
const detailConfig = {
|
||||
PPR: {
|
||||
endpoint: id => `/api/v1/pprs/${id}`,
|
||||
journalType: 'PPR',
|
||||
title: record => `PPR: ${record.ac_reg || '-'}`,
|
||||
fields: [
|
||||
['Status', r => r.status],
|
||||
['Aircraft', r => r.ac_reg],
|
||||
['Type', r => r.ac_type],
|
||||
['Callsign', r => r.ac_call],
|
||||
['Captain', r => r.captain],
|
||||
['From', r => r.in_from],
|
||||
['To', r => r.out_to],
|
||||
['Takeoff', r => formatOptionalDateTime(r.takeoff_dt)],
|
||||
['QSY', r => formatOptionalDateTime(r.qsy_dt)],
|
||||
['Landing', r => formatOptionalDateTime(r.landed_dt)],
|
||||
['ETA', r => formatOptionalDateTime(r.eta)],
|
||||
['ETD', r => formatOptionalDateTime(r.etd)],
|
||||
['POB In', r => r.pob_in],
|
||||
['POB Out', r => r.pob_out],
|
||||
['Fuel', r => r.fuel],
|
||||
['Email', r => r.email],
|
||||
['Phone', r => r.phone],
|
||||
['Submitted', r => formatOptionalDateTime(r.submitted_dt)],
|
||||
['Created By', r => r.created_by],
|
||||
['Notes', r => r.notes]
|
||||
]
|
||||
},
|
||||
LOCAL_FLIGHT: {
|
||||
endpoint: id => `/api/v1/local-flights/${id}`,
|
||||
journalType: 'LOCAL_FLIGHT',
|
||||
title: record => `${record.flight_type || 'LOCAL'}: ${record.registration || '-'}`,
|
||||
fields: [
|
||||
['Status', r => r.status],
|
||||
['Aircraft', r => r.registration],
|
||||
['Type', r => r.type],
|
||||
['Callsign', r => r.callsign],
|
||||
['Flight Type', r => r.flight_type],
|
||||
['From', () => 'EGFH'],
|
||||
['To', () => 'EGFH'],
|
||||
['Takeoff', r => formatOptionalDateTime(r.takeoff_dt || r.departed_dt)],
|
||||
['Landing', r => formatOptionalDateTime(r.landed_dt)],
|
||||
['ETD', r => formatOptionalDateTime(r.etd)],
|
||||
['POB', r => r.pob],
|
||||
['Duration', r => r.duration ? `${r.duration} min` : null],
|
||||
['Circuits', r => r.circuits],
|
||||
['Created', r => formatOptionalDateTime(r.created_dt)],
|
||||
['Created By', r => r.created_by],
|
||||
['Notes', r => r.notes]
|
||||
]
|
||||
},
|
||||
ARRIVAL: {
|
||||
endpoint: id => `/api/v1/arrivals/${id}`,
|
||||
journalType: 'ARRIVAL',
|
||||
title: record => `Arrival: ${record.registration || record.callsign || '-'}`,
|
||||
fields: [
|
||||
['Status', r => r.status],
|
||||
['Aircraft', r => r.registration],
|
||||
['Type', r => r.type],
|
||||
['Callsign', r => r.callsign],
|
||||
['From', r => r.in_from],
|
||||
['To', () => 'EGFH'],
|
||||
['Landing', r => formatOptionalDateTime(r.landed_dt)],
|
||||
['ETA', r => formatOptionalDateTime(r.eta)],
|
||||
['POB', r => r.pob],
|
||||
['Created', r => formatOptionalDateTime(r.created_dt)],
|
||||
['Created By', r => r.created_by],
|
||||
['Notes', r => r.notes]
|
||||
]
|
||||
},
|
||||
DEPARTURE: {
|
||||
endpoint: id => `/api/v1/departures/${id}`,
|
||||
journalType: 'DEPARTURE',
|
||||
title: record => `Departure: ${record.registration || '-'}`,
|
||||
fields: [
|
||||
['Status', r => r.status],
|
||||
['Aircraft', r => r.registration],
|
||||
['Type', r => r.type],
|
||||
['Callsign', r => r.callsign],
|
||||
['From', () => 'EGFH'],
|
||||
['To', r => r.out_to],
|
||||
['Takeoff', r => formatOptionalDateTime(r.takeoff_dt || r.departed_dt)],
|
||||
['ETD', r => formatOptionalDateTime(r.etd)],
|
||||
['POB', r => r.pob],
|
||||
['Created', r => formatOptionalDateTime(r.created_dt)],
|
||||
['Created By', r => r.created_by],
|
||||
['Notes', r => r.notes]
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
async function openReportDetail(type, id) {
|
||||
const config = detailConfig[type];
|
||||
if (!config) return;
|
||||
|
||||
document.getElementById('report-detail-title').textContent = 'Loading...';
|
||||
document.getElementById('report-detail-body').innerHTML = '';
|
||||
document.getElementById('report-detail-journal').textContent = 'Loading...';
|
||||
document.getElementById('reportDetailModal').style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(config.endpoint(id));
|
||||
if (!response.ok) throw new Error('Unable to load details');
|
||||
const record = await response.json();
|
||||
|
||||
document.getElementById('report-detail-title').textContent = config.title(record);
|
||||
document.getElementById('report-detail-body').innerHTML = config.fields
|
||||
.map(([label, getter]) => detailField(label, getter(record)))
|
||||
.join('');
|
||||
|
||||
await loadReportJournal(config.journalType, id);
|
||||
} catch (error) {
|
||||
console.error('Error loading report detail:', error);
|
||||
document.getElementById('report-detail-title').textContent = 'Unable to load details';
|
||||
document.getElementById('report-detail-body').innerHTML = `<div class="detail-field"><div class="detail-value">${escapeHtml(error.message)}</div></div>`;
|
||||
document.getElementById('report-detail-journal').textContent = '-';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReportJournal(entityType, entityId) {
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/v1/journal/${entityType}/${entityId}`);
|
||||
if (!response.ok) throw new Error('Unable to load journal');
|
||||
const data = await response.json();
|
||||
const entries = data.entries || [];
|
||||
document.getElementById('report-detail-journal').innerHTML = entries.length
|
||||
? entries.map(entry => `
|
||||
<div class="journal-entry">
|
||||
<div class="journal-meta">${formatOptionalDateTime(entry.entry_dt)} by ${escapeHtml(entry.user || '-')}</div>
|
||||
<div>${escapeHtml(entry.entry || '-')}</div>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<p>No journal entries yet.</p>';
|
||||
} catch (error) {
|
||||
document.getElementById('report-detail-journal').textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function closeReportDetailModal() {
|
||||
document.getElementById('reportDetailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function detailField(label, value) {
|
||||
const displayValue = value === null || value === undefined || value === '' ? '-' : value;
|
||||
return `
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">${escapeHtml(label)}</div>
|
||||
<div class="detail-value">${escapeHtml(displayValue)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatOptionalDateTime(value) {
|
||||
return value ? formatDateTime(value) : '-';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, char => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function formatDateOnly(dateStr) {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
// Clear filters
|
||||
@@ -1282,8 +1572,8 @@
|
||||
|
||||
const headers = [
|
||||
'ID', 'Status', 'Aircraft Reg', 'Aircraft Type', 'Callsign', 'Captain',
|
||||
'From', 'ETA', 'POB In', 'To', 'ETD', 'POB Out', 'Fuel',
|
||||
'Landed', 'Departed', 'Email', 'Phone', 'Notes', 'Submitted', 'Created By'
|
||||
'From', 'To', 'Takeoff', 'QSY', 'Landing', 'POB In', 'POB Out', 'Fuel',
|
||||
'Email', 'Phone', 'Notes', 'Submitted', 'Created By'
|
||||
];
|
||||
|
||||
const csvData = currentPPRs.map(ppr => [
|
||||
@@ -1294,14 +1584,13 @@
|
||||
ppr.ac_call || '',
|
||||
ppr.captain,
|
||||
ppr.in_from,
|
||||
ppr.eta ? formatDateTime(ppr.eta) : '',
|
||||
ppr.pob_in,
|
||||
ppr.out_to || '',
|
||||
ppr.etd ? formatDateTime(ppr.etd) : '',
|
||||
ppr.takeoff_dt ? formatDateTime(ppr.takeoff_dt) : '',
|
||||
ppr.qsy_dt ? formatDateTime(ppr.qsy_dt) : '',
|
||||
ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '',
|
||||
ppr.pob_in,
|
||||
ppr.pob_out || '',
|
||||
ppr.fuel || '',
|
||||
ppr.landed_dt ? formatDateTime(ppr.landed_dt) : '',
|
||||
ppr.departed_dt ? formatDateTime(ppr.departed_dt) : '',
|
||||
ppr.email || '',
|
||||
ppr.phone || '',
|
||||
ppr.notes || '',
|
||||
@@ -1318,22 +1607,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const exportFlights = currentOtherFlights.filter(flight => flight.flightType !== 'OVERFLIGHT');
|
||||
if (exportFlights.length === 0) {
|
||||
showNotification('No table data to export', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'Flight Type', 'Aircraft Registration', 'Aircraft Type', 'Callsign', 'From', 'To',
|
||||
'ETA/ETD', 'Landed/Departed', 'Status', 'Circuits'
|
||||
'Takeoff', 'Landing', 'Status', 'Circuits'
|
||||
];
|
||||
|
||||
const csvData = currentOtherFlights.map(flight => [
|
||||
const csvData = exportFlights.map(flight => [
|
||||
flight.flightType,
|
||||
flight.registration || '',
|
||||
flight.aircraft_type || '',
|
||||
flight.callsign || '',
|
||||
flight.fromField || '',
|
||||
flight.toField || '',
|
||||
flight.timeField ? formatDateTime(flight.timeField) : '',
|
||||
flight.flightType === 'ARRIVAL'
|
||||
? (flight.landed_dt ? formatDateTime(flight.landed_dt) : '')
|
||||
: (flight.departed_dt ? formatDateTime(flight.departed_dt) : ''),
|
||||
flight.takeoffTime ? formatDateTime(flight.takeoffTime) : '',
|
||||
flight.landingTime ? formatDateTime(flight.landingTime) : '',
|
||||
flight.status || (flight.flightType === 'CIRCUIT' ? 'COMPLETED' : 'PENDING'),
|
||||
(flight.flightType === 'CIRCUIT' || flight.flightType === 'LOCAL') ? (flight.circuits || '') : ''
|
||||
]);
|
||||
@@ -1382,8 +1675,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && document.getElementById('reportDetailModal').style.display === 'block') {
|
||||
closeReportDetailModal();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('click', function(e) {
|
||||
if (e.target === document.getElementById('reportDetailModal')) {
|
||||
closeReportDetailModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<div id="arrival-airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eta">ETA (Local Time) *</label>
|
||||
<label for="eta">ETA (UTC) *</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="eta-date" name="eta-date" required style="flex: 1;">
|
||||
<select id="eta-time" name="eta-time" required style="flex: 1;">
|
||||
@@ -95,7 +95,7 @@
|
||||
<div id="departure-airport-lookup-results"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="etd">ETD (Local Time)</label>
|
||||
<label for="etd">ETD (UTC)</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="date" id="etd-date" name="etd-date" tabindex="-1" style="flex: 1;">
|
||||
<select id="etd-time" name="etd-time" tabindex="-1" style="flex: 1;">
|
||||
|
||||
+134
-62
@@ -128,6 +128,13 @@
|
||||
loadArrivals();
|
||||
showNotification('Arrival updated');
|
||||
}
|
||||
|
||||
if (data.type && data.type.startsWith('drone_request_')) {
|
||||
if (typeof window.refreshDroneRequestBadge === 'function') {
|
||||
window.refreshDroneRequestBadge();
|
||||
}
|
||||
showNotification('Drone request updated');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
@@ -513,6 +520,9 @@
|
||||
await updateUserRole(); // Update role-based UI
|
||||
startSessionExpiryCheck(); // Start monitoring session expiry
|
||||
connectWebSocket(); // Connect WebSocket for real-time updates
|
||||
if (typeof window.refreshDroneRequestBadge === 'function') {
|
||||
window.refreshDroneRequestBadge();
|
||||
}
|
||||
loadPPRs();
|
||||
} else {
|
||||
throw new Error(data.detail || 'Authentication failed');
|
||||
@@ -580,33 +590,104 @@
|
||||
return response;
|
||||
}
|
||||
|
||||
// Load PPR records - now loads all tables
|
||||
function formatTimeOnly(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
// Ensure the datetime string is treated as UTC
|
||||
let utcDateStr = dateStr;
|
||||
function normalizeUtcDateString(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
let utcDateStr = String(dateStr).trim();
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(utcDateStr)) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const date = new Date(utcDateStr);
|
||||
return utcDateStr;
|
||||
}
|
||||
|
||||
function parseUtcDate(dateStr) {
|
||||
const normalized = normalizeUtcDateString(dateStr);
|
||||
return normalized ? new Date(normalized) : null;
|
||||
}
|
||||
|
||||
function utcDateOnly(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
return date && !Number.isNaN(date.getTime()) ? date.toISOString().slice(0, 10) : '';
|
||||
}
|
||||
|
||||
function formatUtcDateInput(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatUtcTimeInput(date) {
|
||||
return date.toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
function formatUtcDayMonth(dateStr) {
|
||||
const isoDate = utcDateOnly(dateStr);
|
||||
return isoDate ? `${isoDate.slice(8, 10)}/${isoDate.slice(5, 7)}` : '-';
|
||||
}
|
||||
|
||||
function formatUtcWeekdayDayMonth(dateStr) {
|
||||
const date = parseUtcDate(dateStr);
|
||||
if (!date || Number.isNaN(date.getTime())) return '-';
|
||||
const dayName = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getUTCDay()];
|
||||
return `${dayName} ${formatUtcDayMonth(dateStr)}`;
|
||||
}
|
||||
|
||||
function combineUtcDateTimeInput(dateStr, timeStr) {
|
||||
return `${dateStr}T${timeStr}:00Z`;
|
||||
}
|
||||
|
||||
async function autoSaveUnsavedAircraft(form) {
|
||||
if (!form || !form.hasAttribute('data-unsaved-aircraft') || !accessToken) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const registration = (
|
||||
formData.get('ac_reg') ||
|
||||
formData.get('registration') ||
|
||||
formData.get('local_registration') ||
|
||||
formData.get('book_in_registration') ||
|
||||
formData.get('overflight_registration') ||
|
||||
''
|
||||
).trim();
|
||||
const typeCode = (
|
||||
formData.get('ac_type') ||
|
||||
formData.get('type') ||
|
||||
formData.get('local_type') ||
|
||||
formData.get('book_in_type') ||
|
||||
formData.get('overflight_type') ||
|
||||
''
|
||||
).trim();
|
||||
|
||||
if (!registration || !typeCode) return;
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/v1/aircraft/user-aircraft', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
registration,
|
||||
type_code: typeCode
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
form.removeAttribute('data-unsaved-aircraft');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not save user aircraft type:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load PPR records - now loads all tables
|
||||
function formatTimeOnly(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const date = parseUtcDate(dateStr);
|
||||
return date && !Number.isNaN(date.getTime()) ? formatUtcTimeInput(date) : '-';
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
// Ensure the datetime string is treated as UTC
|
||||
let utcDateStr = dateStr;
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const date = new Date(utcDateStr);
|
||||
return date.toISOString().slice(0, 10) + ' ' + date.toISOString().slice(11, 16);
|
||||
const date = parseUtcDate(dateStr);
|
||||
return date && !Number.isNaN(date.getTime()) ? `${formatUtcDateInput(date)} ${formatUtcTimeInput(date)}` : '-';
|
||||
}
|
||||
|
||||
// Modal functions
|
||||
@@ -630,24 +711,10 @@
|
||||
const eta = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour
|
||||
const etd = new Date(now.getTime() + 2 * 60 * 60 * 1000); // +2 hours
|
||||
|
||||
// Format date and time for separate inputs
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(Math.ceil(date.getMinutes() / 15) * 15 % 60).padStart(2, '0'); // Round up to next 15-minute interval
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
document.getElementById('eta-date').value = formatDate(eta);
|
||||
document.getElementById('eta-time').value = formatTime(eta);
|
||||
document.getElementById('etd-date').value = formatDate(etd);
|
||||
document.getElementById('etd-time').value = formatTime(etd);
|
||||
document.getElementById('eta-date').value = formatUtcDateInput(eta);
|
||||
document.getElementById('eta-time').value = formatUtcTimeInput(eta);
|
||||
document.getElementById('etd-date').value = formatUtcDateInput(etd);
|
||||
document.getElementById('etd-time').value = formatUtcTimeInput(etd);
|
||||
|
||||
// Clear aircraft lookup results
|
||||
clearAircraftLookup();
|
||||
@@ -673,15 +740,14 @@
|
||||
const etaTime = document.getElementById('eta-time').value;
|
||||
|
||||
if (etaDate && etaTime) {
|
||||
// Parse ETA
|
||||
const eta = new Date(`${etaDate}T${etaTime}`);
|
||||
const eta = parseUtcDate(combineUtcDateTimeInput(etaDate, etaTime));
|
||||
|
||||
// Calculate ETD (2 hours after ETA)
|
||||
const etd = new Date(eta.getTime() + 2 * 60 * 60 * 1000);
|
||||
|
||||
// Format ETD
|
||||
const etdDateStr = `${etd.getFullYear()}-${String(etd.getMonth() + 1).padStart(2, '0')}-${String(etd.getDate()).padStart(2, '0')}`;
|
||||
const etdTimeStr = `${String(etd.getHours()).padStart(2, '0')}:${String(etd.getMinutes()).padStart(2, '0')}`;
|
||||
const etdDateStr = formatUtcDateInput(etd);
|
||||
const etdTimeStr = formatUtcTimeInput(etd);
|
||||
|
||||
// Update ETD fields
|
||||
document.getElementById('etd-date').value = etdDateStr;
|
||||
@@ -711,6 +777,9 @@
|
||||
|
||||
const ppr = await response.json();
|
||||
populateForm(ppr);
|
||||
const departedBtn = document.getElementById('btn-departed');
|
||||
departedBtn.textContent = '🛫 Depart';
|
||||
departedBtn.setAttribute('onclick', "showTimestampModal('DEPARTED')");
|
||||
|
||||
// Show/hide quick action buttons based on current status
|
||||
if (ppr.status === 'NEW') {
|
||||
@@ -723,8 +792,16 @@
|
||||
document.getElementById('btn-cancel').style.display = 'inline-block';
|
||||
} else if (ppr.status === 'LANDED') {
|
||||
document.getElementById('btn-landed').style.display = 'none';
|
||||
document.getElementById('btn-departed').style.display = 'inline-block';
|
||||
departedBtn.style.display = 'inline-block';
|
||||
departedBtn.textContent = '🛫 Take Off';
|
||||
departedBtn.setAttribute('onclick', "showTimestampModal('LOCAL')");
|
||||
document.getElementById('btn-cancel').style.display = 'inline-block';
|
||||
} else if (ppr.status === 'LOCAL') {
|
||||
document.getElementById('btn-landed').style.display = 'none';
|
||||
departedBtn.style.display = 'inline-block';
|
||||
departedBtn.textContent = 'QSY';
|
||||
departedBtn.setAttribute('onclick', "showTimestampModal('DEPARTED')");
|
||||
document.getElementById('btn-cancel').style.display = 'none';
|
||||
} else {
|
||||
// DEPARTED, CANCELED, DELETED - hide all quick actions and cancel button
|
||||
document.querySelector('.quick-actions').style.display = 'none';
|
||||
@@ -744,31 +821,19 @@
|
||||
Object.keys(ppr).forEach(key => {
|
||||
if (key === 'eta' || key === 'etd') {
|
||||
if (ppr[key]) {
|
||||
// ppr[key] is UTC datetime string from API (naive, assume UTC)
|
||||
let utcDateStr = ppr[key];
|
||||
if (!utcDateStr.includes('T')) {
|
||||
utcDateStr = utcDateStr.replace(' ', 'T');
|
||||
}
|
||||
if (!utcDateStr.includes('Z')) {
|
||||
utcDateStr += 'Z';
|
||||
}
|
||||
const date = new Date(utcDateStr); // Now correctly parsed as UTC
|
||||
const date = parseUtcDate(ppr[key]);
|
||||
|
||||
// Split into date and time components for separate inputs
|
||||
const dateField = document.getElementById(`${key}-date`);
|
||||
const timeField = document.getElementById(`${key}-time`);
|
||||
|
||||
if (dateField && timeField) {
|
||||
// Format date
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const dateValue = `${year}-${month}-${day}`;
|
||||
const dateValue = formatUtcDateInput(date);
|
||||
dateField.value = dateValue;
|
||||
|
||||
// Format time (round to nearest 15-minute interval)
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const rawMinutes = date.getMinutes();
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const rawMinutes = date.getUTCMinutes();
|
||||
const roundedMinutes = Math.round(rawMinutes / 15) * 15 % 60;
|
||||
const minutes = String(roundedMinutes).padStart(2, '0');
|
||||
const timeValue = `${hours}:${minutes}`;
|
||||
@@ -904,6 +969,9 @@
|
||||
|
||||
// Circuit modal functions
|
||||
function showCircuitModal(localFlightId = null, arrivalId = null) {
|
||||
localFlightId = localFlightId || currentLocalFlightId;
|
||||
arrivalId = arrivalId || currentArrivalId;
|
||||
|
||||
if (!localFlightId && !arrivalId) return;
|
||||
|
||||
// Set the current IDs
|
||||
@@ -1076,12 +1144,12 @@
|
||||
// Combine date and time for ETA
|
||||
const dateStr = formData.get('eta-date');
|
||||
const timeStr = formData.get('eta-time');
|
||||
pprData.eta = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||
pprData.eta = combineUtcDateTimeInput(dateStr, timeStr);
|
||||
} else if (key === 'etd-date' && formData.get('etd-time')) {
|
||||
// Combine date and time for ETD
|
||||
const dateStr = formData.get('etd-date');
|
||||
const timeStr = formData.get('etd-time');
|
||||
pprData.etd = new Date(`${dateStr}T${timeStr}`).toISOString();
|
||||
pprData.etd = combineUtcDateTimeInput(dateStr, timeStr);
|
||||
} else if (key !== 'eta-time' && key !== 'etd-time') {
|
||||
// Skip the time fields as they're handled above
|
||||
pprData[key] = value;
|
||||
@@ -1843,13 +1911,13 @@
|
||||
|
||||
// Parse and populate call_dt
|
||||
if (overflight.call_dt) {
|
||||
const callDt = new Date(overflight.call_dt);
|
||||
const callDt = parseUtcDate(overflight.call_dt);
|
||||
document.getElementById('overflight_edit_call_dt').value = callDt.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
// Parse and populate qsy_dt if exists
|
||||
if (overflight.qsy_dt) {
|
||||
const qsyDt = new Date(overflight.qsy_dt);
|
||||
const qsyDt = parseUtcDate(overflight.qsy_dt);
|
||||
document.getElementById('overflight_edit_qsy_dt').value = qsyDt.toISOString().slice(0, 16);
|
||||
} else {
|
||||
document.getElementById('overflight_edit_qsy_dt').value = '';
|
||||
@@ -2593,11 +2661,15 @@
|
||||
const tableHelpTexts = {
|
||||
arrivals: {
|
||||
title: "Today's Pending Arrivals",
|
||||
text: "Displays aircraft that are expected to arrive at Swansea today. These are flights that have filed PPRs or have been booked in as arriving. Aircraft in this list are actively planning to land today."
|
||||
text: "Displays aircraft that are expected to arrive at Swansea today. These are PPR arrivals and aircraft booked in as arrivals."
|
||||
},
|
||||
departures: {
|
||||
title: "Today's Pending Departures",
|
||||
text: "Displays aircraft that are ready to depart from Swansea today. This includes flights with approved PPRs awaiting departure, local flights that have been booked out, and departures to other airfields. These aircraft will depart today."
|
||||
text: "Displays visiting aircraft and airport departures that are ready to leave Swansea today. Local flights are shown in their own table."
|
||||
},
|
||||
"local-flights": {
|
||||
title: "Local Traffic",
|
||||
text: "Displays local traffic booked out today, including local flights, circuits, and PPR departures that are airborne locally before QSY."
|
||||
},
|
||||
overflights: {
|
||||
title: "Active Overflights",
|
||||
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
(function () {
|
||||
const actionMap = {
|
||||
'new-ppr': "openNewPPRModal",
|
||||
'book-out': "openLocalFlightModal",
|
||||
'book-in': "openBookInModal",
|
||||
overflight: "openOverflightModal",
|
||||
'user-aircraft': "openUserAircraftModal",
|
||||
'user-management': "openUserManagementModal"
|
||||
};
|
||||
|
||||
function injectTopbarStyles() {
|
||||
if (document.getElementById('shared-topbar-styles')) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'shared-topbar-styles';
|
||||
style.textContent = `
|
||||
.top-bar {
|
||||
background: linear-gradient(135deg, #2c3e50, #3498db);
|
||||
color: white;
|
||||
padding: 0.5rem 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
.top-bar .title { order: 2; flex: 1; text-align: center; }
|
||||
.top-bar .title h1 { margin: 0; font-size: 1.5rem; }
|
||||
.top-bar .menu-buttons {
|
||||
order: 1;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.top-bar .user-info {
|
||||
order: 3;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.top-bar .btn {
|
||||
padding: 0.7rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.top-bar .btn-success { background-color: #27ae60; color: white; }
|
||||
.top-bar .btn-warning { background-color: #f39c12; color: white; }
|
||||
.dropdown { position: relative; display: inline-block; }
|
||||
.dropdown-toggle { white-space: nowrap; }
|
||||
.dropdown-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
min-width: 220px;
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||
border-radius: 5px;
|
||||
z-index: 1000;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
.dropdown-menu.active { display: block; }
|
||||
.dropdown-menu a {
|
||||
color: #333;
|
||||
padding: 0.75rem 1.2rem;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dropdown-menu a:hover { background-color: #f5f5f5; }
|
||||
.dropdown-menu a:first-child { border-radius: 5px 5px 0 0; }
|
||||
.dropdown-menu a:last-child { border-radius: 0 0 5px 5px; }
|
||||
.shortcut { font-size: 0.8rem; color: #999; margin-left: 1rem; }
|
||||
.notification-badge {
|
||||
min-width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.notification-badge[hidden] { display: none; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function actionLink(label, action, shortcut, id = '') {
|
||||
const shortcutText = shortcut ? `<span class="shortcut">(${shortcut})</span>` : '';
|
||||
const idAttr = id ? ` id="${id}"` : '';
|
||||
const hidden = id === 'user-management-dropdown' ? ' style="display: none;"' : '';
|
||||
return `<a href="#" data-topbar-action="${action}"${idAttr}${hidden}>${label} ${shortcutText}</a>`;
|
||||
}
|
||||
|
||||
function navLink(label, href, id = '') {
|
||||
const idAttr = id ? ` id="${id}"` : '';
|
||||
return `<a href="${href}"${idAttr}>${label}</a>`;
|
||||
}
|
||||
|
||||
function normalizeTopbar() {
|
||||
const topbar = document.querySelector('.top-bar');
|
||||
if (!topbar || topbar.dataset.sharedTopbar === 'true') return;
|
||||
|
||||
injectTopbarStyles();
|
||||
|
||||
const existingTitle = topbar.querySelector('#tower-title, .title h1, h1');
|
||||
const path = window.location.pathname.replace(/\/$/, '') || '/';
|
||||
const titleByPath = {
|
||||
'/admin': '✈️ Swansea Tower',
|
||||
'/atc': '✈️ Swansea Tower - ATC View',
|
||||
'/reports': '📊 PPR Reports',
|
||||
'/movements': '📈 Movements',
|
||||
'/drone-requests': 'Drone Requests',
|
||||
'/bulk-log': '🧾 Bulk Flight Log',
|
||||
'/journal': '📔 Journal Log'
|
||||
};
|
||||
const titleText = titleByPath[path] || (existingTitle ? existingTitle.textContent.trim() : 'Tower Ops');
|
||||
const existingUser = topbar.querySelector('#current-user');
|
||||
const username = existingUser ? existingUser.textContent.trim() : 'Loading...';
|
||||
|
||||
topbar.dataset.sharedTopbar = 'true';
|
||||
topbar.innerHTML = `
|
||||
<div class="title">
|
||||
<h1 id="tower-title">${titleText}</h1>
|
||||
</div>
|
||||
<div class="menu-buttons">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-success dropdown-toggle" id="actionsDropdownBtn">📋 Actions</button>
|
||||
<div class="dropdown-menu" id="actionsDropdownMenu">
|
||||
${actionLink('➕ New PPR', 'new-ppr', 'N')}
|
||||
${actionLink('🛫 Book Out', 'book-out', 'L')}
|
||||
${actionLink('🛬 Book In', 'book-in', 'I')}
|
||||
${actionLink('🔄 Overflight', 'overflight', 'O')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-warning dropdown-toggle" id="adminDropdownBtn">
|
||||
⚙️ Menu <span class="notification-badge" id="drone-request-menu-badge" hidden>0</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" id="adminDropdownMenu">
|
||||
${navLink('🏠 Home', '/admin')}
|
||||
${navLink('🎛️ ATC View', '/atc')}
|
||||
${navLink('📊 Reports', '/reports')}
|
||||
${navLink('🛸 Drone Requests <span class="notification-badge" id="drone-request-badge" hidden>0</span>', '/drone-requests')}
|
||||
${navLink('📔 Journal Log', '/journal')}
|
||||
${actionLink('✈️ User Aircraft', 'user-aircraft', '', 'user-aircraft-dropdown')}
|
||||
${actionLink('👥 User Management', 'user-management', '', 'user-management-dropdown')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
Logged in as: <span id="current-user">${username || 'Loading...'}</span> |
|
||||
<a href="#" data-topbar-logout style="color: white;">Logout</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function closeDropdowns(except = null) {
|
||||
document.querySelectorAll('.dropdown-menu.active').forEach(menu => {
|
||||
if (menu !== except) menu.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
function runAction(action) {
|
||||
const fnName = actionMap[action];
|
||||
if (fnName && typeof window[fnName] === 'function') {
|
||||
if (action === 'book-out') {
|
||||
window[fnName]('LOCAL');
|
||||
} else {
|
||||
window[fnName]();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/admin?action=${encodeURIComponent(action)}`;
|
||||
}
|
||||
|
||||
function handleDeferredAction() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const action = params.get('action');
|
||||
if (!action) return;
|
||||
|
||||
window.setTimeout(() => {
|
||||
runAction(action);
|
||||
const cleanUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, document.title, cleanUrl);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function updateRoleVisibility() {
|
||||
const token = localStorage.getItem('ppr_access_token');
|
||||
const userManagement = document.getElementById('user-management-dropdown');
|
||||
const userAircraft = document.getElementById('user-aircraft-dropdown');
|
||||
|
||||
if (!token || (!userManagement && !userAircraft)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/test-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
|
||||
const user = await response.json();
|
||||
const role = (user.role || '').toUpperCase();
|
||||
if (role === 'ADMINISTRATOR') {
|
||||
if (userManagement) userManagement.style.display = 'flex';
|
||||
if (userAircraft) userAircraft.style.display = 'flex';
|
||||
} else if (role === 'OPERATOR') {
|
||||
if (userManagement) userManagement.style.display = 'none';
|
||||
if (userAircraft) userAircraft.style.display = 'flex';
|
||||
} else {
|
||||
if (userManagement) userManagement.style.display = 'none';
|
||||
if (userAircraft) userAircraft.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
if (userManagement) userManagement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDroneRequestBadge() {
|
||||
const badges = [
|
||||
document.getElementById('drone-request-badge'),
|
||||
document.getElementById('drone-request-menu-badge'),
|
||||
].filter(Boolean);
|
||||
if (!badges.length) return;
|
||||
|
||||
const token = localStorage.getItem('ppr_access_token');
|
||||
if (!token) {
|
||||
badges.forEach(badge => {
|
||||
badge.hidden = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/drone-requests/?status=NEW&limit=100', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) {
|
||||
badges.forEach(badge => {
|
||||
badge.hidden = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requests = await response.json();
|
||||
const count = Array.isArray(requests) ? requests.length : 0;
|
||||
const title = count === 1
|
||||
? '1 drone request waiting for approval'
|
||||
: `${count} drone requests waiting for approval`;
|
||||
badges.forEach(badge => {
|
||||
badge.textContent = count > 99 ? '99+' : String(count);
|
||||
badge.hidden = count === 0;
|
||||
badge.title = title;
|
||||
});
|
||||
} catch (error) {
|
||||
badges.forEach(badge => {
|
||||
badge.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.refreshDroneRequestBadge = refreshDroneRequestBadge;
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const toggle = event.target.closest('.dropdown-toggle');
|
||||
const action = event.target.closest('[data-topbar-action]');
|
||||
const logout = event.target.closest('[data-topbar-logout]');
|
||||
|
||||
if (toggle) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const menu = toggle.parentElement.querySelector('.dropdown-menu');
|
||||
const willOpen = !menu.classList.contains('active');
|
||||
closeDropdowns(menu);
|
||||
menu.classList.toggle('active', willOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
closeDropdowns();
|
||||
runAction(action.dataset.topbarAction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (logout) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (typeof window.logout === 'function') {
|
||||
window.logout();
|
||||
} else {
|
||||
localStorage.removeItem('ppr_access_token');
|
||||
localStorage.removeItem('ppr_username');
|
||||
localStorage.removeItem('ppr_token_expiry');
|
||||
window.location.href = '/admin';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.target.closest('.dropdown')) {
|
||||
closeDropdowns();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
normalizeTopbar();
|
||||
updateRoleVisibility();
|
||||
refreshDroneRequestBadge();
|
||||
handleDeferredAction();
|
||||
window.setInterval(refreshDroneRequestBadge, 60000);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user