diff --git a/backend/alembic/versions/004_user_aircraft.py b/backend/alembic/versions/004_user_aircraft.py new file mode 100644 index 0000000..a02daad --- /dev/null +++ b/backend/alembic/versions/004_user_aircraft.py @@ -0,0 +1,50 @@ +"""Add user_aircraft table for user-defined aircraft types + +Revision ID: 004_user_aircraft +Revises: 003_public_booking +Create Date: 2026-03-23 12:00:00.000000 + +This migration adds a user_aircraft table to store aircraft types +that are manually entered by users when not found in the main aircraft database. + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '004_user_aircraft' +down_revision = '003_public_booking' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """ + Create user_aircraft table for storing user-defined aircraft types. + """ + op.create_table('user_aircraft', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('registration', sa.String(length=25), nullable=False), + sa.Column('type_code', sa.String(length=30), nullable=False), + sa.Column('clean_reg', sa.String(length=25), nullable=False), + sa.Column('created_by', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('registration') + ) + + # Create indexes + op.create_index('idx_user_aircraft_registration', 'user_aircraft', ['registration']) + op.create_index('idx_user_aircraft_clean_reg', 'user_aircraft', ['clean_reg']) + op.create_index('idx_user_aircraft_created_by', 'user_aircraft', ['created_by']) + + +def downgrade() -> None: + """ + Drop user_aircraft table. + """ + op.drop_index('idx_user_aircraft_created_by', table_name='user_aircraft') + op.drop_index('idx_user_aircraft_clean_reg', table_name='user_aircraft') + op.drop_index('idx_user_aircraft_registration', table_name='user_aircraft') + op.drop_table('user_aircraft') \ No newline at end of file diff --git a/backend/app/api/endpoints/aircraft.py b/backend/app/api/endpoints/aircraft.py index 0d702e9..7cbadfe 100644 --- a/backend/app/api/endpoints/aircraft.py +++ b/backend/app/api/endpoints/aircraft.py @@ -2,8 +2,8 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from app.api.deps import get_db, get_current_active_user -from app.models.ppr import Aircraft -from app.schemas.ppr import Aircraft as AircraftSchema +from app.models.ppr import Aircraft, UserAircraft +from app.schemas.ppr import Aircraft as AircraftSchema, UserAircraftCreate from app.models.ppr import User router = APIRouter() @@ -18,6 +18,7 @@ async def lookup_aircraft_by_registration( """ Lookup aircraft by registration (clean match). Removes non-alphanumeric characters from input for matching. + Checks user_aircraft table first, then aircraft table. """ # Clean the input registration (remove non-alphanumeric characters) clean_input = ''.join(c for c in registration if c.isalnum()).upper() @@ -25,7 +26,29 @@ async def lookup_aircraft_by_registration( if len(clean_input) < 4: return [] - # Query aircraft table using clean_reg column + # First check user_aircraft table + user_aircraft = db.query(UserAircraft).filter( + UserAircraft.clean_reg.like(f"{clean_input}%") + ).limit(10).all() + + if user_aircraft: + # Convert UserAircraft to Aircraft-like objects + result = [] + for ua in user_aircraft: + # Create a mock Aircraft object with the user data + result.append({ + 'id': ua.id, + 'registration': ua.registration, + 'type_code': ua.type_code, + 'clean_reg': ua.clean_reg, + 'icao24': None, + 'manufacturer_icao': None, + 'manufacturer_name': None, + 'model': None + }) + return result + + # If no user aircraft found, check main aircraft table aircraft_list = db.query(Aircraft).filter( Aircraft.clean_reg.like(f"{clean_input}%") ).limit(10).all() @@ -42,6 +65,7 @@ async def public_lookup_aircraft_by_registration( Public lookup aircraft by registration (clean match). Removes non-alphanumeric characters from input for matching. No authentication required. + Checks user_aircraft table first, then aircraft table. """ # Clean the input registration (remove non-alphanumeric characters) clean_input = ''.join(c for c in registration if c.isalnum()).upper() @@ -49,7 +73,28 @@ async def public_lookup_aircraft_by_registration( if len(clean_input) < 4: return [] - # Query aircraft table using clean_reg column + # First check user_aircraft table + user_aircraft = db.query(UserAircraft).filter( + UserAircraft.clean_reg.like(f"{clean_input}%") + ).limit(10).all() + + if user_aircraft: + # Convert UserAircraft to Aircraft-like objects + result = [] + for ua in user_aircraft: + result.append({ + 'id': ua.id, + 'registration': ua.registration, + 'type_code': ua.type_code, + 'clean_reg': ua.clean_reg, + 'icao24': None, + 'manufacturer_icao': None, + 'manufacturer_name': None, + 'model': None + }) + return result + + # If no user aircraft found, check main aircraft table aircraft_list = db.query(Aircraft).filter( Aircraft.clean_reg.like(f"{clean_input}%") ).limit(10).all() @@ -81,4 +126,39 @@ async def search_aircraft( (Aircraft.model.like(f"%{q}%")) ).limit(limit).all() - return aircraft_list \ No newline at end of file + return aircraft_list + + +@router.post("/user-aircraft", response_model=dict) +async def save_user_aircraft( + aircraft: UserAircraftCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """ + Save a user-defined aircraft type for a registration. + """ + # Clean the registration + clean_reg = ''.join(c for c in aircraft.registration if c.isalnum()).upper() + + # Check if already exists + existing = db.query(UserAircraft).filter( + UserAircraft.registration == aircraft.registration.upper() + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Aircraft registration already exists in user database") + + # Create new user aircraft + user_aircraft = UserAircraft( + registration=aircraft.registration.upper(), + type_code=aircraft.type_code.upper(), + clean_reg=clean_reg, + created_by=current_user.username + ) + + db.add(user_aircraft) + db.commit() + db.refresh(user_aircraft) + + return {"message": "Aircraft saved successfully", "id": user_aircraft.id} \ No newline at end of file diff --git a/backend/app/models/ppr.py b/backend/app/models/ppr.py index 9ccc08c..77f843a 100644 --- a/backend/app/models/ppr.py +++ b/backend/app/models/ppr.py @@ -88,4 +88,16 @@ class Aircraft(Base): model = Column(String(255), nullable=True) clean_reg = Column(String(25), nullable=True, 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()) + + +class UserAircraft(Base): + __tablename__ = "user_aircraft" + + id = Column(Integer, primary_key=True, autoincrement=True) + registration = Column(String(25), nullable=False, unique=True, index=True) + type_code = Column(String(30), nullable=False) + 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()) \ No newline at end of file diff --git a/backend/app/schemas/ppr.py b/backend/app/schemas/ppr.py index 1b73bdd..104c572 100644 --- a/backend/app/schemas/ppr.py +++ b/backend/app/schemas/ppr.py @@ -214,4 +214,24 @@ class Aircraft(AircraftBase): id: int class Config: - from_attributes = True \ No newline at end of file + from_attributes = True + + +# User Aircraft schemas +class UserAircraftBase(BaseModel): + registration: str + type_code: str + clean_reg: str + created_by: str + + +class UserAircraft(UserAircraftBase): + id: int + + class Config: + from_attributes = True + + +class UserAircraftCreate(BaseModel): + registration: str + type_code: str \ No newline at end of file diff --git a/web/admin.html b/web/admin.html index f9d3e8c..f75941a 100644 --- a/web/admin.html +++ b/web/admin.html @@ -1494,6 +1494,12 @@ openNewPPRModal(); } + // Press 'g' to book out local flight starting with G + if (e.key === 'g' || e.key === 'G') { + e.preventDefault(); + openLocalFlightModal('LOCAL', 'G'); + } + // Press 'l' to book out local flight (LOCAL type) if (e.key === 'l' || e.key === 'L') { e.preventDefault(); @@ -2624,6 +2630,9 @@ document.getElementById('ppr-form').reset(); document.getElementById('ppr-id').value = ''; + // Clear the unsaved aircraft flag + document.getElementById('ppr-form').removeAttribute('data-unsaved-aircraft'); + // Set default ETA and ETD const now = new Date(); const eta = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour @@ -3040,6 +3049,9 @@ if (!accessToken) return; + // Auto-save any unsaved aircraft types + await autoSaveUnsavedAircraft(this); + const formData = new FormData(this); const pprData = {}; @@ -3531,22 +3543,29 @@ }); // Local Flight (Book Out) Modal Functions - function openLocalFlightModal(flightType = 'LOCAL') { + function openLocalFlightModal(flightType = 'LOCAL', prefillReg = '') { document.getElementById('local-flight-form').reset(); document.getElementById('local-flight-id').value = ''; document.getElementById('local-flight-modal-title').textContent = 'Book Out'; document.getElementById('local_flight_type').value = flightType; document.getElementById('localFlightModal').style.display = 'block'; + // Clear the unsaved aircraft flag + document.getElementById('local-flight-form').removeAttribute('data-unsaved-aircraft'); + // Clear aircraft lookup results clearLocalAircraftLookup(); // Update destination field visibility based on flight type handleFlightTypeChange(flightType); - // Auto-focus on registration field + // Auto-focus on registration field and prefill if provided setTimeout(() => { - document.getElementById('local_registration').focus(); + const regField = document.getElementById('local_registration'); + regField.focus(); + if (prefillReg) { + regField.value = prefillReg; + } }, 100); } @@ -3555,6 +3574,9 @@ document.getElementById('book-in-id').value = ''; document.getElementById('bookInModal').style.display = 'block'; + // Clear the unsaved aircraft flag + document.getElementById('book-in-form').removeAttribute('data-unsaved-aircraft'); + // Clear aircraft lookup results clearBookInAircraftLookup(); clearBookInArrivalAirportLookup(); @@ -3573,6 +3595,9 @@ document.getElementById('overflight-id').value = ''; document.getElementById('overflightModal').style.display = 'block'; + // Clear the unsaved aircraft flag + document.getElementById('overflight-form').removeAttribute('data-unsaved-aircraft'); + // Clear aircraft lookup results clearOverflightAircraftLookup(); clearOverflightDepartureAirportLookup(); @@ -4502,6 +4527,9 @@ if (!accessToken) return; + // Auto-save any unsaved aircraft types + await autoSaveUnsavedAircraft(this); + const formData = new FormData(this); const flightType = formData.get('flight_type'); const flightData = {}; @@ -4585,6 +4613,9 @@ if (!accessToken) return; + // Auto-save any unsaved aircraft types + await autoSaveUnsavedAircraft(this); + const formData = new FormData(this); const arrivalData = {}; @@ -4664,6 +4695,9 @@ if (!accessToken) return; + // Auto-save any unsaved aircraft types + await autoSaveUnsavedAircraft(this); + const formData = new FormData(this); const overflightData = {}; diff --git a/web/edit.html b/web/edit.html index 9f714bf..cc6a715 100644 --- a/web/edit.html +++ b/web/edit.html @@ -692,6 +692,9 @@ 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 = {}; diff --git a/web/lookups.js b/web/lookups.js index 56f1d8d..818afe3 100644 --- a/web/lookups.js +++ b/web/lookups.js @@ -163,15 +163,26 @@ function createLookup(fieldId, resultsId, selectCallback, options = {}) { const resultsDiv = document.getElementById(resultsId); if (config.isAircraft) { - // Aircraft lookup: auto-populate on single match, format input on no match + // Aircraft lookup: auto-populate on single match, mark form for auto-save on no match if (!results || results.length === 0) { - // Format the aircraft registration and auto-populate + // Format the aircraft registration const formatted = formatAircraftRegistration(searchTerm); const field = document.getElementById(fieldId); if (field) { field.value = formatted; + // Mark the form for auto-saving this aircraft + const form = field.closest('form'); + if (form) { + form.setAttribute('data-unsaved-aircraft', fieldId); + } } - resultsDiv.innerHTML = ''; // Clear results, field is auto-populated + + // Show message that type will be saved + resultsDiv.innerHTML = ` +