diff --git a/backend/app/api/endpoints/arrivals.py b/backend/app/api/endpoints/arrivals.py index 924de95..3bb64e5 100644 --- a/backend/app/api/endpoints/arrivals.py +++ b/backend/app/api/endpoints/arrivals.py @@ -159,7 +159,8 @@ async def cancel_arrival( current_user: User = Depends(get_current_operator_user) ): """Cancel an arrival record""" - arrival = crud_arrival.cancel(db, arrival_id=arrival_id) + client_ip = get_client_ip(request) + arrival = crud_arrival.cancel(db, arrival_id=arrival_id, user=current_user.username, user_ip=client_ip) if not arrival: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index b747c5f..b834cae 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -87,7 +87,7 @@ async def create_user( status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered" ) - user = crud_user.create(db, obj_in=user_in) + user = crud_user.create(db, obj_in=user_in, admin_user=current_user.username) return user @@ -105,7 +105,7 @@ async def update_user( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) - user = crud_user.update(db, db_obj=user, obj_in=user_in) + user = crud_user.update(db, db_obj=user, obj_in=user_in, admin_user=current_user.username) return user @@ -123,5 +123,5 @@ async def change_user_password( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) - user = crud_user.change_password(db, db_obj=user, new_password=password_data.password) + user = crud_user.change_password(db, db_obj=user, new_password=password_data.password, admin_user=current_user.username) return user \ No newline at end of file diff --git a/backend/app/api/endpoints/circuits.py b/backend/app/api/endpoints/circuits.py index c3cc1d8..6fbefa6 100644 --- a/backend/app/api/endpoints/circuits.py +++ b/backend/app/api/endpoints/circuits.py @@ -64,7 +64,8 @@ async def create_circuit( detail="Cannot provide both local_flight_id and arrival_id" ) - circuit = crud_circuit.create(db, obj_in=circuit_in) + client_ip = get_client_ip(request) + circuit = crud_circuit.create(db, obj_in=circuit_in, user=current_user.username, user_ip=client_ip) # Send real-time update via WebSocket if hasattr(request.app.state, 'connection_manager'): @@ -116,6 +117,7 @@ async def update_circuit( @router.delete("/{circuit_id}") async def delete_circuit( + request: Request, circuit_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_operator_user) @@ -127,5 +129,6 @@ async def delete_circuit( status_code=status.HTTP_404_NOT_FOUND, detail="Circuit record not found" ) - crud_circuit.delete(db, circuit_id=circuit_id) + client_ip = get_client_ip(request) + crud_circuit.delete(db, circuit_id=circuit_id, user=current_user.username, user_ip=client_ip) return {"detail": "Circuit record deleted"} diff --git a/backend/app/api/endpoints/departures.py b/backend/app/api/endpoints/departures.py index 108df75..e690a80 100644 --- a/backend/app/api/endpoints/departures.py +++ b/backend/app/api/endpoints/departures.py @@ -159,7 +159,8 @@ async def cancel_departure( current_user: User = Depends(get_current_operator_user) ): """Cancel a departure record""" - departure = crud_departure.cancel(db, departure_id=departure_id) + client_ip = get_client_ip(request) + departure = crud_departure.cancel(db, departure_id=departure_id, user=current_user.username, user_ip=client_ip) if not departure: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/backend/app/api/endpoints/journal.py b/backend/app/api/endpoints/journal.py index b409e11..854d334 100644 --- a/backend/app/api/endpoints/journal.py +++ b/backend/app/api/endpoints/journal.py @@ -4,11 +4,79 @@ from app.api import deps from app.crud.crud_journal import journal from app.models.journal import EntityType from app.schemas.journal import JournalEntryResponse, EntityJournalResponse -from typing import List +from typing import List, Optional +from datetime import datetime, date router = APIRouter(tags=["journal"]) +@router.get("/search/all", response_model=List[JournalEntryResponse]) +async def search_journal( + date_from: Optional[date] = None, + date_to: Optional[date] = None, + entity_type: Optional[str] = None, + entity_id: Optional[int] = None, + user: Optional[str] = None, + limit: int = 500, + db: Session = Depends(deps.get_db), + current_user = Depends(deps.get_current_user) +): + """ + Search journal entries with optional filters. + + Parameters: + - date_from: Filter entries from this date (YYYY-MM-DD) + - date_to: Filter entries until this date (YYYY-MM-DD) + - entity_type: Filter by entity type (PPR, LOCAL_FLIGHT, ARRIVAL, DEPARTURE, OVERFLIGHT, CIRCUIT, USER) + - entity_id: Filter by specific entity ID + - user: Filter by user who created the entry + - limit: Maximum number of entries to return (default 500, max 5000) + + All filters are optional and can be combined. + Returns entries in reverse chronological order (newest first). + """ + if limit > 5000: + limit = 5000 + + # Validate entity_type if provided + if entity_type: + try: + EntityType[entity_type.upper()] + except KeyError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid entity_type. Must be one of: {', '.join([e.value for e in EntityType])}" + ) + + entries = journal.search_entries( + db, + date_from=date_from, + date_to=date_to, + entity_type=entity_type, + entity_id=entity_id, + user=user, + limit=limit + ) + + return entries + + +@router.get("/user/{username}", response_model=List[JournalEntryResponse]) +async def get_user_journal( + username: str, + limit: int = 100, + db: Session = Depends(deps.get_db), + current_user = Depends(deps.get_current_user) +): + """ + Get all journal entries created by a specific user. + + This endpoint is read-only and returns entries in reverse chronological order. + """ + entries = journal.get_user_journal(db, username, limit=limit) + return entries + + @router.get("/{entity_type}/{entity_id}", response_model=EntityJournalResponse) async def get_entity_journal( entity_type: str, @@ -45,19 +113,3 @@ async def get_entity_journal( entries=entries, total_entries=len(entries) ) - - -@router.get("/user/{username}", response_model=List[JournalEntryResponse]) -async def get_user_journal( - username: str, - limit: int = 100, - db: Session = Depends(deps.get_db), - current_user = Depends(deps.get_current_user) -): - """ - Get all journal entries created by a specific user. - - This endpoint is read-only and returns entries in reverse chronological order. - """ - entries = journal.get_user_journal(db, username, limit=limit) - return entries diff --git a/backend/app/api/endpoints/local_flights.py b/backend/app/api/endpoints/local_flights.py index c5d0607..70876e8 100644 --- a/backend/app/api/endpoints/local_flights.py +++ b/backend/app/api/endpoints/local_flights.py @@ -160,7 +160,8 @@ async def cancel_local_flight( current_user: User = Depends(get_current_operator_user) ): """Cancel a local flight record""" - flight = crud_local_flight.cancel(db, flight_id=flight_id) + client_ip = get_client_ip(request) + flight = crud_local_flight.cancel(db, flight_id=flight_id, user=current_user.username, user_ip=client_ip) if not flight: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/backend/app/api/endpoints/public_book.py b/backend/app/api/endpoints/public_book.py index 22d9a91..d394e28 100644 --- a/backend/app/api/endpoints/public_book.py +++ b/backend/app/api/endpoints/public_book.py @@ -56,7 +56,7 @@ async def public_book_local_flight( notes=flight_in.notes, ) - flight = crud_local_flight.create(db, obj_in=flight_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC") + flight = crud_local_flight.create(db, obj_in=flight_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None) # Update with submission source and pilot email db.query(type(flight)).filter(type(flight).id == flight.id).update({ @@ -98,7 +98,7 @@ async def public_record_circuit( circuit_timestamp=circuit_in.circuit_timestamp, ) - circuit = crud_circuit.create(db, obj_in=circuit_create) + circuit = crud_circuit.create(db, obj_in=circuit_create, user="PUBLIC_PILOT", user_ip=request.client.host if request.client else None) # Send real-time update via WebSocket if hasattr(request.app.state, 'connection_manager'): @@ -136,7 +136,7 @@ async def public_book_departure( notes=departure_in.notes, ) - departure = crud_departure.create(db, obj_in=departure_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC") + departure = crud_departure.create(db, obj_in=departure_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None) # Update with pilot email (submitted_via is already set in create method) db.query(type(departure)).filter(type(departure).id == departure.id).update({ @@ -181,7 +181,7 @@ async def public_book_arrival( notes=arrival_in.notes, ) - arrival = crud_arrival.create(db, obj_in=arrival_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC") + arrival = crud_arrival.create(db, obj_in=arrival_create, created_by="PUBLIC_PILOT", submitted_via="PUBLIC", user_ip=request.client.host if request.client else None) # Update with pilot email db.query(type(arrival)).filter(type(arrival).id == arrival.id).update({ diff --git a/backend/app/crud/crud_arrival.py b/backend/app/crud/crud_arrival.py index e420004..a0c8cac 100644 --- a/backend/app/crud/crud_arrival.py +++ b/backend/app/crud/crud_arrival.py @@ -58,7 +58,7 @@ class CRUDArrival: ) ).order_by(Arrival.created_dt).all() - def create(self, db: Session, obj_in: ArrivalCreate, created_by: str, submitted_via: str = "ADMIN") -> Arrival: + def create(self, db: Session, obj_in: ArrivalCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> Arrival: from app.models.arrival import SubmissionSource # Set initial status based on submission source @@ -76,6 +76,17 @@ class CRUDArrival: db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log creation in journal + journal.log_change( + db, + EntityType.ARRIVAL, + db_obj.id, + f"Arrival created: {db_obj.registration}", + created_by, + user_ip + ) + return db_obj def update(self, db: Session, db_obj: Arrival, obj_in: ArrivalUpdate, user: str = "system", user_ip: Optional[str] = None) -> Arrival: @@ -156,15 +167,27 @@ class CRUDArrival: return db_obj - def cancel(self, db: Session, arrival_id: int) -> Optional[Arrival]: + def cancel(self, db: Session, arrival_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Arrival]: db_obj = self.get(db, arrival_id) if not db_obj: return None + old_status = db_obj.status db_obj.status = ArrivalStatus.CANCELLED db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log cancellation in journal + journal.log_change( + db, + EntityType.ARRIVAL, + arrival_id, + f"Status changed from {old_status.value} to CANCELLED", + user, + user_ip + ) + return db_obj diff --git a/backend/app/crud/crud_circuit.py b/backend/app/crud/crud_circuit.py index 2f5879d..92fdfd8 100644 --- a/backend/app/crud/crud_circuit.py +++ b/backend/app/crud/crud_circuit.py @@ -4,6 +4,8 @@ from sqlalchemy import desc from datetime import datetime from app.models.circuit import Circuit from app.schemas.circuit import CircuitCreate, CircuitUpdate +from app.models.journal import EntityType +from app.crud.crud_journal import journal class CRUDCircuit: @@ -30,7 +32,7 @@ class CRUDCircuit: ) -> List[Circuit]: return db.query(Circuit).order_by(desc(Circuit.created_at)).offset(skip).limit(limit).all() - def create(self, db: Session, obj_in: CircuitCreate) -> Circuit: + def create(self, db: Session, obj_in: CircuitCreate, user: str = "system", user_ip: Optional[str] = None) -> Circuit: db_obj = Circuit( local_flight_id=obj_in.local_flight_id, arrival_id=obj_in.arrival_id, @@ -39,22 +41,74 @@ class CRUDCircuit: db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log circuit creation in journal + # Use LOCAL_FLIGHT entity type if local_flight_id exists, otherwise ARRIVAL + entity_type = EntityType.LOCAL_FLIGHT if obj_in.local_flight_id else EntityType.ARRIVAL + entity_id = obj_in.local_flight_id if obj_in.local_flight_id else obj_in.arrival_id + + journal.log_change( + db, + entity_type, + entity_id, + f"Circuit recorded at {obj_in.circuit_timestamp.isoformat()}", + user, + user_ip + ) + return db_obj - def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate) -> Circuit: + def update(self, db: Session, db_obj: Circuit, obj_in: CircuitUpdate, user: str = "system", user_ip: Optional[str] = None) -> Circuit: obj_data = obj_in.dict(exclude_unset=True) + changes = [] + for field, value in obj_data.items(): - setattr(db_obj, field, value) + 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) + db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log changes in journal if any were made + if changes: + entity_type = EntityType.LOCAL_FLIGHT if db_obj.local_flight_id else EntityType.ARRIVAL + entity_id = db_obj.local_flight_id if db_obj.local_flight_id else db_obj.arrival_id + + for change in changes: + journal.log_change( + db, + entity_type, + entity_id, + f"Circuit: {change}", + user, + user_ip + ) + return db_obj - def delete(self, db: Session, circuit_id: int) -> bool: + def delete(self, db: Session, circuit_id: int, user: str = "system", user_ip: Optional[str] = None) -> bool: circuit = self.get(db, circuit_id) if circuit: + # Determine which entity this circuit belongs to + entity_type = EntityType.LOCAL_FLIGHT if circuit.local_flight_id else EntityType.ARRIVAL + entity_id = circuit.local_flight_id if circuit.local_flight_id else circuit.arrival_id + db.delete(circuit) db.commit() + + # Log deletion in journal + journal.log_change( + db, + entity_type, + entity_id, + f"Circuit deleted (recorded at {circuit.circuit_timestamp.isoformat()})", + user, + user_ip + ) + return True return False diff --git a/backend/app/crud/crud_departure.py b/backend/app/crud/crud_departure.py index 50409d0..45dea9a 100644 --- a/backend/app/crud/crud_departure.py +++ b/backend/app/crud/crud_departure.py @@ -47,7 +47,7 @@ class CRUDDeparture: ) ).order_by(Departure.created_dt).all() - def create(self, db: Session, obj_in: DepartureCreate, created_by: str, submitted_via: str = "ADMIN") -> Departure: + def create(self, db: Session, obj_in: DepartureCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> Departure: from app.models.departure import SubmissionSource # Set initial status based on submission source @@ -68,6 +68,17 @@ class CRUDDeparture: db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log creation in journal + journal.log_change( + db, + EntityType.DEPARTURE, + db_obj.id, + f"Departure created: {db_obj.registration}", + created_by, + user_ip + ) + return db_obj def update(self, db: Session, db_obj: Departure, obj_in: DepartureUpdate, user: str = "system", user_ip: Optional[str] = None) -> Departure: @@ -150,15 +161,27 @@ class CRUDDeparture: return db_obj - def cancel(self, db: Session, departure_id: int) -> Optional[Departure]: + def cancel(self, db: Session, departure_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[Departure]: db_obj = self.get(db, departure_id) if not db_obj: return None + old_status = db_obj.status db_obj.status = DepartureStatus.CANCELLED db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log cancellation in journal + journal.log_change( + db, + EntityType.DEPARTURE, + departure_id, + f"Status changed from {old_status.value} to CANCELLED", + user, + user_ip + ) + return db_obj diff --git a/backend/app/crud/crud_journal.py b/backend/app/crud/crud_journal.py index 9d8c6d6..0e41496 100644 --- a/backend/app/crud/crud_journal.py +++ b/backend/app/crud/crud_journal.py @@ -1,7 +1,8 @@ from typing import List, Optional from sqlalchemy.orm import Session +from sqlalchemy import and_, or_, func, desc from app.models.journal import JournalEntry, EntityType -from datetime import datetime +from datetime import datetime, date class CRUDJournal: @@ -58,6 +59,41 @@ class CRUDJournal: JournalEntry.user == user ).order_by(JournalEntry.entry_dt.desc()).limit(limit).all() + def search_entries( + self, + db: Session, + date_from: Optional[date] = None, + date_to: Optional[date] = None, + entity_type: Optional[str] = None, + entity_id: Optional[int] = None, + user: Optional[str] = None, + limit: int = 500 + ) -> List[JournalEntry]: + """Search journal entries with optional filters.""" + query = db.query(JournalEntry) + + # Apply date filters + if date_from: + query = query.filter(func.date(JournalEntry.entry_dt) >= date_from) + + if date_to: + query = query.filter(func.date(JournalEntry.entry_dt) <= date_to) + + # Apply entity type filter + if entity_type: + query = query.filter(JournalEntry.entity_type == entity_type.upper()) + + # Apply entity ID filter + if entity_id: + query = query.filter(JournalEntry.entity_id == entity_id) + + # Apply user filter + if user: + query = query.filter(JournalEntry.user == user) + + # Order by date descending (newest first) and apply limit + return query.order_by(JournalEntry.entry_dt.desc()).limit(limit).all() + # Convenience methods for backward compatibility with PPR journal def log_ppr_change( self, diff --git a/backend/app/crud/crud_local_flight.py b/backend/app/crud/crud_local_flight.py index bd42771..fb4a3ab 100644 --- a/backend/app/crud/crud_local_flight.py +++ b/backend/app/crud/crud_local_flight.py @@ -84,7 +84,7 @@ class CRUDLocalFlight: ) ).order_by(LocalFlight.created_dt).all() - def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str, submitted_via: str = "ADMIN") -> LocalFlight: + def create(self, db: Session, obj_in: LocalFlightCreate, created_by: str, submitted_via: str = "ADMIN", user_ip: Optional[str] = None) -> LocalFlight: from app.models.local_flight import SubmissionSource # Set initial status based on submission source @@ -102,6 +102,17 @@ class CRUDLocalFlight: db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log creation in journal + journal.log_change( + db, + EntityType.LOCAL_FLIGHT, + db_obj.id, + f"Local flight created: {db_obj.registration} ({db_obj.flight_type.value})", + created_by, + user_ip + ) + return db_obj def update(self, db: Session, db_obj: LocalFlight, obj_in: LocalFlightUpdate, user: str = "system", user_ip: Optional[str] = None) -> LocalFlight: @@ -201,13 +212,24 @@ class CRUDLocalFlight: return db_obj - def cancel(self, db: Session, flight_id: int) -> Optional[LocalFlight]: + def cancel(self, db: Session, flight_id: int, user: str = "system", user_ip: Optional[str] = None) -> Optional[LocalFlight]: db_obj = self.get(db, flight_id) if db_obj: + old_status = db_obj.status db_obj.status = LocalFlightStatus.CANCELLED db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log cancellation in journal + journal.log_change( + db, + EntityType.LOCAL_FLIGHT, + flight_id, + f"Status changed from {old_status.value} to CANCELLED", + user, + user_ip + ) return db_obj diff --git a/backend/app/crud/crud_user.py b/backend/app/crud/crud_user.py index ff7d97e..a68fbdd 100644 --- a/backend/app/crud/crud_user.py +++ b/backend/app/crud/crud_user.py @@ -3,6 +3,8 @@ from sqlalchemy.orm import Session from app.models.ppr import User from app.schemas.ppr import UserCreate, UserUpdate from app.core.security import get_password_hash, verify_password +from app.models.journal import EntityType +from app.crud.crud_journal import journal class CRUDUser: @@ -15,7 +17,7 @@ class CRUDUser: def get_multi(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]: return db.query(User).offset(skip).limit(limit).all() - def create(self, db: Session, obj_in: UserCreate) -> User: + def create(self, db: Session, obj_in: UserCreate, admin_user: str = "system") -> User: hashed_password = get_password_hash(obj_in.password) db_obj = User( username=obj_in.username, @@ -25,17 +27,46 @@ class CRUDUser: db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log user creation in journal + journal.log_change( + db, + EntityType.USER, + db_obj.id, + f"User created: {obj_in.username} with role {obj_in.role}", + admin_user, + None + ) + return db_obj - def update(self, db: Session, db_obj: User, obj_in: UserUpdate) -> User: + def update(self, db: Session, db_obj: User, obj_in: UserUpdate, admin_user: str = "system") -> User: update_data = obj_in.dict(exclude_unset=True) + changes = [] if "password" in update_data: update_data["password"] = get_password_hash(update_data["password"]) + changes.append("password changed") for field, value in update_data.items(): - setattr(db_obj, field, value) + old_value = getattr(db_obj, field) + if field == "password" or old_value != value: + if field != "password": # Don't log actual password values + changes.append(f"{field} changed from '{old_value}' to '{value}'") + setattr(db_obj, field, value) db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log user update in journal + if changes: + journal.log_change( + db, + EntityType.USER, + db_obj.id, + "; ".join(changes), + admin_user, + None + ) + return db_obj def authenticate(self, db: Session, username: str, password: str) -> Optional[User]: @@ -50,13 +81,24 @@ class CRUDUser: # For future use if we add user status return True - def change_password(self, db: Session, db_obj: User, new_password: str) -> User: + def change_password(self, db: Session, db_obj: User, new_password: str, admin_user: str = "system") -> User: """Change a user's password (typically used by admins to reset another user's password)""" hashed_password = get_password_hash(new_password) db_obj.password = hashed_password db.add(db_obj) db.commit() db.refresh(db_obj) + + # Log password change in journal (security audit) + journal.log_change( + db, + EntityType.USER, + db_obj.id, + f"Password changed by {admin_user}", + admin_user, + None + ) + return db_obj diff --git a/backend/app/models/journal.py b/backend/app/models/journal.py index 6a74fae..279c001 100644 --- a/backend/app/models/journal.py +++ b/backend/app/models/journal.py @@ -11,6 +11,8 @@ class EntityType(str, PyEnum): ARRIVAL = "ARRIVAL" DEPARTURE = "DEPARTURE" OVERFLIGHT = "OVERFLIGHT" + CIRCUIT = "CIRCUIT" + USER = "USER" class JournalEntry(Base): diff --git a/web/admin.html b/web/admin.html index f129f88..fd73300 100644 --- a/web/admin.html +++ b/web/admin.html @@ -31,6 +31,7 @@
diff --git a/web/atc.html b/web/atc.html index ba3dd76..395532d 100644 --- a/web/atc.html +++ b/web/atc.html @@ -233,6 +233,7 @@ diff --git a/web/journal.html b/web/journal.html new file mode 100644 index 0000000..1925c3d --- /dev/null +++ b/web/journal.html @@ -0,0 +1,752 @@ + + + + + +