This commit is contained in:
bsquillari 2023-10-31 14:06:49 +00:00
commit 9277fab969
69 changed files with 909 additions and 252 deletions

5
.gitignore vendored
View File

@ -4,4 +4,7 @@
!.env.dev.example !.env.dev.example
!.env.prod.example !.env.prod.example
node_modules node_modules
*.xml *.xml
notification-domain/
TODO.txt
*.sh

View File

@ -19,14 +19,17 @@ preparation:
- export BUILD_ID=$(date +%Y%m%d%H%M) - export BUILD_ID=$(date +%Y%m%d%H%M)
- echo "BUILD_ID=${BUILD_ID}" > context.env - echo "BUILD_ID=${BUILD_ID}" > context.env
- echo "FLIGHTS_INFO_PROD_IMAGE_NAME=${IMAGE_BASE}/flights-information:prod-${BUILD_ID}" >> context.env - echo "FLIGHTS_INFO_PROD_IMAGE_NAME=${IMAGE_BASE}/flights-information:prod-${BUILD_ID}" >> context.env
- echo "FLIGHTS_INFO_TEST_IMAGE_NAME=${IMAGE_BASE}/flights-information:test-${BUILD_ID}" >> context.env - echo "FLIGHTS_INFO_TEST_IMAGE_NAME=${IMAGE_BASE}/flights-information:test-${BUILD_ID}" >> context.env
- echo "GATEWAY_PROD_IMAGE_NAME=${IMAGE_BASE}/gateway:prod-${BUILD_ID}" >> context.env - echo "GATEWAY_PROD_IMAGE_NAME=${IMAGE_BASE}/gateway:prod-${BUILD_ID}" >> context.env
- echo "GATEWAY_TEST_IMAGE_NAME=${IMAGE_BASE}/gateway:test-${BUILD_ID}" >> context.env - echo "GATEWAY_TEST_IMAGE_NAME=${IMAGE_BASE}/gateway:test-${BUILD_ID}" >> context.env
- echo "USER_MANAGER_PROD_IMAGE_NAME=${IMAGE_BASE}/user-manager:prod-${BUILD_ID}" >> context.env - echo "USER_MANAGER_PROD_IMAGE_NAME=${IMAGE_BASE}/user-manager:prod-${BUILD_ID}" >> context.env
- echo "USER_MANAGER_TEST_IMAGE_NAME=${IMAGE_BASE}/user-manager:test-${BUILD_ID}" >> context.env - echo "USER_MANAGER_TEST_IMAGE_NAME=${IMAGE_BASE}/user-manager:test-${BUILD_ID}" >> context.env
- echo "SUBSCRIPTION_PROD_IMAGE_NAME=${IMAGE_BASE}/screens-client:prod-${BUILD_ID}" >> context.env
- echo "SUBSCRIPTION_TEST_IMAGE_NAME=${IMAGE_BASE}/screens-client:test-${BUILD_ID}" >> context.env
- echo "SCREEN_CLIENT_PROD_IMAGE_NAME=${IMAGE_BASE}/screens-client:prod-${BUILD_ID}" >> context.env - echo "SCREEN_CLIENT_PROD_IMAGE_NAME=${IMAGE_BASE}/screens-client:prod-${BUILD_ID}" >> context.env
- echo "SCREEN_CLIENT_TEST_IMAGE_NAME=${IMAGE_BASE}/screens-client:test-${BUILD_ID}" >> context.env - echo "SCREEN_CLIENT_TEST_IMAGE_NAME=${IMAGE_BASE}/screens-client:test-${BUILD_ID}" >> context.env
@ -41,11 +44,20 @@ preparation:
- echo "KIBANA_PROD_IMAGE_NAME=${IMAGE_BASE}/kibana:prod-${BUILD_ID}" >> context.env - echo "KIBANA_PROD_IMAGE_NAME=${IMAGE_BASE}/kibana:prod-${BUILD_ID}" >> context.env
- echo "LOGSTASH_PROD_IMAGE_NAME=${IMAGE_BASE}/logstash:prod-${BUILD_ID}" >> context.env - echo "LOGSTASH_PROD_IMAGE_NAME=${IMAGE_BASE}/logstash:prod-${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_SCREEN_CLIENT_IMAGE=$DOCKER_HUB_USER/screens-client:${BUILD_ID}" >> context.env - echo "ELK_SETUP_PROD_IMAGE_NAME=${IMAGE_BASE}/elk-setup:prod-${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_BROWSER_CLIENT_IMAGE=$DOCKER_HUB_USER/browser-client:${BUILD_ID}" >> context.env - echo "ELK_PROD_IMAGE_NAME=${IMAGE_BASE}/elasticsearch:prod-${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_GATEWAY_IMAGE=$DOCKER_HUB_USER/gateway:${BUILD_ID}" >> context.env - echo "HEARTBEAT_PROD_IMAGE_NAME=${IMAGE_BASE}/heartbeat:prod-${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_USER_MANAGER_IMAGE=$DOCKER_HUB_USER/user-manager:${BUILD_ID}" >> context.env - echo "CURATOR_PROD_IMAGE_NAME=${IMAGE_BASE}/curator:prod-${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_FLIGHT_INFO_IMAGE=$DOCKER_HUB_USER/flights-information:${BUILD_ID}" >> context.env - echo "KIBANA_PROD_IMAGE_NAME=${IMAGE_BASE}/kibana:prod-${BUILD_ID}" >> context.env
- echo "LOGSTASH_PROD_IMAGE_NAME=${IMAGE_BASE}/logstash:prod-${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_SCREEN_CLIENT_IMAGE=$DOCKER_HUB_USER/screens-client:${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_BROWSER_CLIENT_IMAGE=$DOCKER_HUB_USER/browser-client:${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_GATEWAY_IMAGE=$DOCKER_HUB_USER/gateway:${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_SUBSCRIPTION_IMAGE=$DOCKER_HUB_USER/subs-manager:${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_USER_MANAGER_IMAGE=$DOCKER_HUB_USER/user-manager:${BUILD_ID}" >> context.env
- echo "DOCKER_HUB_FLIGHT_INFO_IMAGE=$DOCKER_HUB_USER/flights-information:${BUILD_ID}" >> context.env
- echo "ENV_DEV_FILE=$(echo $ENV_DEV)" >> context.env - echo "ENV_DEV_FILE=$(echo $ENV_DEV)" >> context.env
- echo "ENV_PROD_FILE=$(echo $ENV_PROD)" >> context.env - echo "ENV_PROD_FILE=$(echo $ENV_PROD)" >> context.env
@ -127,6 +139,25 @@ build-screen-client:
- job: preparation - job: preparation
artifacts: true artifacts: true
build-subscription-api:
stage: build
tags:
- dev
script:
- export $(cat context.env | xargs)
- export SUBSCRIPTION_MANAGER=subscription-domain/subscription-manager
- docker build $SUBSCRIPTION_MANAGER -f $SUBSCRIPTION_MANAGER/Dockerfile.prod -t ${SUBSCRIPTION_PROD_IMAGE_NAME}
- docker build $SUBSCRIPTION_MANAGER -f $SUBSCRIPTION_MANAGER/Dockerfile.test --build-arg "BASE_IMAGE=$SUBSCRIPTION_PROD_IMAGE_NAME" -t ${SUBSCRIPTION_TEST_IMAGE_NAME}
- docker login -u $CI_REGISTRY_USER --password $CI_JOB_TOKEN $CI_REGISTRY
- docker push ${SUBSCRIPTION_PROD_IMAGE_NAME}
- docker push ${SUBSCRIPTION_TEST_IMAGE_NAME}
needs:
- job: preparation
artifacts: true
build-gateway: build-gateway:
stage: build stage: build
tags: tags:
@ -203,6 +234,35 @@ test-auth-api:
- job: build-auth-api - job: build-auth-api
artifacts: true artifacts: true
test-subscription-api:
stage: test
tags:
- dev
script:
- export $(cat context.env | xargs)
- export API_IMAGE=$SUBSCRIPTION_TEST_IMAGE_NAME
- export CLIENT_IMAGE=dummy-image
- docker login -u $CI_REGISTRY_USER --password $CI_JOB_TOKEN $CI_REGISTRY
- docker compose -f subscription-domain/docker-compose.yml --env-file $ENV_DEV_FILE down
- docker compose -f subscription-domain/docker-compose.yml --env-file $ENV_DEV_FILE pull
- docker compose -f subscription-domain/docker-compose.yml --env-file $ENV_DEV_FILE up --abort-on-container-exit --renew-anon-volumes
- docker cp fids_subscriptions_api:/usr/src/app/coverage.xml .
- docker cp fids_subscriptions_api:/usr/src/app/report.xml .
artifacts:
when: always
paths:
- coverage.xml
- report.xml
reports:
junit: report.xml
needs:
- job: preparation
- job: build-subscription-api
artifacts: true
test-flights-api: test-flights-api:
stage: test stage: test
tags: tags:
@ -342,12 +402,15 @@ deliver-dockerhub:
- docker compose -f flights-domain/docker-compose.yml --env-file $ENV_DEV_FILE down - docker compose -f flights-domain/docker-compose.yml --env-file $ENV_DEV_FILE down
- export API_IMAGE=$USER_MANAGER_TEST_IMAGE_NAME - export API_IMAGE=$USER_MANAGER_TEST_IMAGE_NAME
- docker compose -f auth-domain/docker-compose.yml --env-file $ENV_DEV_FILE down - docker compose -f auth-domain/docker-compose.yml --env-file $ENV_DEV_FILE down
- export API_IMAGE=$SUBSCRIPTION_TEST_IMAGE_NAME
- docker compose -f subscription-domain/docker-compose.yml --env-file $ENV_DEV_FILE down
- export API_IMAGE=$GATEWAY_TEST_IMAGE_NAME - export API_IMAGE=$GATEWAY_TEST_IMAGE_NAME
- docker compose -f gateway/docker-compose.yml --env-file $ENV_DEV_FILE down - docker compose -f gateway/docker-compose.yml --env-file $ENV_DEV_FILE down
- docker tag $FLIGHTS_INFO_PROD_IMAGE_NAME $DOCKER_HUB_FLIGHT_INFO_IMAGE - docker tag $FLIGHTS_INFO_PROD_IMAGE_NAME $DOCKER_HUB_FLIGHT_INFO_IMAGE
- docker tag $USER_MANAGER_PROD_IMAGE_NAME $DOCKER_HUB_USER_MANAGER_IMAGE - docker tag $USER_MANAGER_PROD_IMAGE_NAME $DOCKER_HUB_USER_MANAGER_IMAGE
- docker tag $GATEWAY_PROD_IMAGE_NAME $DOCKER_HUB_GATEWAY_IMAGE - docker tag $GATEWAY_PROD_IMAGE_NAME $DOCKER_HUB_GATEWAY_IMAGE
- docker tag $SUBSCRIPTION_PROD_IMAGE_NAME $DOCKER_HUB_SUBSCRIPTION_IMAGE
- docker tag $BROWSER_CLIENT_PROD_IMAGE_NAME $DOCKER_HUB_BROWSER_CLIENT_IMAGE - docker tag $BROWSER_CLIENT_PROD_IMAGE_NAME $DOCKER_HUB_BROWSER_CLIENT_IMAGE
- docker tag $SCREEN_CLIENT_PROD_IMAGE_NAME $DOCKER_HUB_SCREEN_CLIENT_IMAGE - docker tag $SCREEN_CLIENT_PROD_IMAGE_NAME $DOCKER_HUB_SCREEN_CLIENT_IMAGE
@ -360,6 +423,7 @@ deliver-dockerhub:
- docker push $DOCKER_HUB_FLIGHT_INFO_IMAGE - docker push $DOCKER_HUB_FLIGHT_INFO_IMAGE
- docker push $DOCKER_HUB_USER_MANAGER_IMAGE - docker push $DOCKER_HUB_USER_MANAGER_IMAGE
- docker push $DOCKER_HUB_SUBSCRIPTION_IMAGE
- docker push $DOCKER_HUB_GATEWAY_IMAGE - docker push $DOCKER_HUB_GATEWAY_IMAGE
- docker push $DOCKER_HUB_BROWSER_CLIENT_IMAGE - docker push $DOCKER_HUB_BROWSER_CLIENT_IMAGE
- docker push $DOCKER_HUB_SCREEN_CLIENT_IMAGE - docker push $DOCKER_HUB_SCREEN_CLIENT_IMAGE
@ -409,6 +473,12 @@ deploy-prod:
- docker compose -f auth-domain/docker-compose.yml --env-file $ENV_PROD_FILE exec usermanager-api python manage.py recreate_db - docker compose -f auth-domain/docker-compose.yml --env-file $ENV_PROD_FILE exec usermanager-api python manage.py recreate_db
- docker compose -f auth-domain/docker-compose.yml --env-file $ENV_PROD_FILE exec usermanager-api python manage.py seed_db - docker compose -f auth-domain/docker-compose.yml --env-file $ENV_PROD_FILE exec usermanager-api python manage.py seed_db
- export API_IMAGE=$DOCKER_HUB_SUBSCRIPTION_IMAGE
- docker compose -f subscription-domain/docker-compose.yml --env-file $ENV_PROD_FILE stop
- docker compose -f subscription-domain/docker-compose.yml --env-file $ENV_PROD_FILE rm -f
- docker compose -f subscription-domain/docker-compose.yml --env-file $ENV_PROD_FILE pull
- docker compose -f subscription-domain/docker-compose.yml --env-file $ENV_PROD_FILE up -d
- export API_IMAGE=$DOCKER_HUB_GATEWAY_IMAGE - export API_IMAGE=$DOCKER_HUB_GATEWAY_IMAGE
- docker compose -f gateway/docker-compose.yml --env-file $ENV_PROD_FILE stop - docker compose -f gateway/docker-compose.yml --env-file $ENV_PROD_FILE stop
- docker compose -f gateway/docker-compose.yml --env-file $ENV_PROD_FILE rm -f - docker compose -f gateway/docker-compose.yml --env-file $ENV_PROD_FILE rm -f

View File

@ -13,4 +13,4 @@ repos:
rev: 5.12.0 rev: 5.12.0
hooks: hooks:
- id: isort - id: isort
args: ['--src-path', 'flights-domain/flights-information/src', 'auth-domain/user-manager/src', 'gateway/src'] args: ['--src-path', 'flights-domain/flights-information/src', 'auth-domain/user-manager/src', 'gateway/src', 'subscription-domain/subscription-manager/src']

View File

@ -18,6 +18,10 @@ Contiene `flights-information` con su base de datos. Maneja todo lo relacionado
PWA pensada para utilizarse en un aeropuerto. Se maneja con un solo `origin` y con el query param `lastUpdated` para pedir cambios. Esta tiene una base datos para cachear los resultados y poder funcionar offline. PWA pensada para utilizarse en un aeropuerto. Se maneja con un solo `origin` y con el query param `lastUpdated` para pedir cambios. Esta tiene una base datos para cachear los resultados y poder funcionar offline.
### subscription-domain
Contiene `subscription-manager` con su base de datos. Maneja todo lo relacionado a la suscripción de los usuarios, junto con el envío de notificaciones.
### gateway ### gateway
API gateway encargada de exponer los servicios. Maneja autenticación usando el `auth-domain`. API gateway encargada de exponer los servicios. Maneja autenticación usando el `auth-domain`.

View File

@ -17,6 +17,7 @@ def recreate_db():
@cli.command("seed_db") @cli.command("seed_db")
def seed_db(): def seed_db():
db.session.add(User(username="lufthansa", email="info@lufthansa.com", password="password1234", airline=True)) db.session.add(User(username="lufthansa", email="info@lufthansa.com", password="password1234", airline=True))
db.session.add(User(username="ryanair", email="info@ryanair.com", password="password1234", airline=True))
db.session.add(User(username="messi", email="messi@gmail.com", password="password1234")) db.session.add(User(username="messi", email="messi@gmail.com", password="password1234"))
db.session.commit() db.session.commit()

View File

@ -1,66 +0,0 @@
const mockedUsedNavigate = jest.fn();
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useNavigate: () => mockedUsedNavigate,
}));
import "../matchMedia.mock";
import { act, renderHook } from "@testing-library/react";
import { useAuthenticateUser } from "./useAuthenticateUser";
describe("UseAuthenticateUser Hook Test", () => {
afterEach(() => {
localStorage.removeItem("token");
});
test("Hook initial state", async () => {
const { result } = renderHook(() => useAuthenticateUser());
expect(result.current.isLoading).toBeFalsy();
expect(result.current.error).toBeNull();
});
test("Hook fetch state - Authenticate function - Promise not resolved", async () => {
const { result } = renderHook(() => useAuthenticateUser());
act(() => {
result.current.authenticate({
email: "martin@gmail.com",
password: "password1234",
});
});
expect(result.current.isLoading).toBeTruthy();
expect(result.current.error).toBeNull();
});
test("Hook fetch state - Authenticate function - Promise success", async () => {
const { result } = renderHook(() => useAuthenticateUser());
await act(async () => {
await result.current.authenticate({
email: "martin@gmail.com",
password: "password1234",
});
});
expect(localStorage.getItem("token")).not.toBeNull();
expect(result.current.isLoading).toBeFalsy();
expect(result.current.error).not.toBeNull();
});
test("Hook fetch state - Authenticate function - Promise failed", async () => {
const { result } = renderHook(() => useAuthenticateUser());
await act(async () => {
await result.current.authenticate({
email: "notExistingUser",
password: "notExistingUser",
});
});
expect(localStorage.getItem("token")).toBe("undefined");
expect(result.current.isLoading).toBeFalsy();
expect(result.current.error).not.toBeNull();
});
});

View File

@ -1,80 +0,0 @@
import React, { useEffect } from "react";
import { useState } from "react";
import { Credentials, User, TokenData } from "../Types";
import { useNavigate } from "react-router-dom";
import { fetchUserById, logIn } from "../Api";
import { tokenStatus } from "../Api";
import jwt_decode from "jwt-decode";
export const useAuthenticateUser = () => {
const [isLoading, setIsLoading] = useState(false);
const [isAirline, setIsAirline] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
const [tokenValidated, setTokenValidated] = useState(false);
const navigate = useNavigate();
const authenticate = async (credentials: Credentials): Promise<void> => {
if (!user) {
try {
setIsLoading(true);
setError(null);
const tokens = await logIn(credentials);
localStorage.setItem("token", tokens.access_token);
const airline = (jwt_decode(tokens.access_token) as TokenData).airline;
setIsAirline(airline)
if (tokens.user_id) {
const user = await fetchUserById(tokens.user_id);
setUser(user);
} else {
setError(tokens.message!.split(".")[0] + ".");
setUser(null);
}
} catch (error) {
setError(error as string);
} finally {
setIsLoading(false);
navigate("/home")
}
}
};
const validateToken = async () => {
try {
setIsLoading(true);
const existingToken = localStorage.getItem("token");
if (existingToken && !tokenValidated) {
const response = await tokenStatus(existingToken);
const { message } = response;
if (message) throw new Error("Invalid token");
const airline = (jwt_decode(existingToken) as TokenData).airline;
setIsAirline(airline)
const user = await fetchUserById(response.id);
setUser(user);
}
setTokenValidated(true);
} catch (error) {
logout();
} finally {
setIsLoading(false);
}
return user;
};
const logout = () => {
localStorage.removeItem("token");
setUser(null);
setTokenValidated(false)
navigate("/login");
};
return { user, isLoading, authenticate, validateToken, isAirline, logout, error };
};

View File

@ -37,9 +37,15 @@ import jwt_decode from "jwt-decode";
useEffect(() => { useEffect(() => {
const existingToken = localStorage.getItem("token"); const existingToken = localStorage.getItem("token");
if (existingToken) { if (existingToken) {
const airline = (jwt_decode(existingToken) as TokenData).airline; let airline
setIsAirline(airline) try {
airline = (jwt_decode(existingToken) as TokenData).airline;
setIsAirline(airline)
} catch (err) {
setLoadingInitial(false);
logout()
return;
}
tokenStatus(existingToken) tokenStatus(existingToken)
.then((res) => fetchUserById(res.id) .then((res) => fetchUserById(res.id)
@ -47,7 +53,10 @@ import jwt_decode from "jwt-decode";
.catch((_error) => {}) .catch((_error) => {})
.finally(() => setLoadingInitial(false)) .finally(() => setLoadingInitial(false))
) )
.catch((_error) => {}) .catch((_error) => {
setLoadingInitial(false)
logout()
})
// .finally(() => setLoadingInitial(false)); // .finally(() => setLoadingInitial(false));
} else { } else {
setLoadingInitial(false) setLoadingInitial(false)

View File

@ -21,6 +21,7 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
- flights - flights
- subscriptions
flights-api-db: flights-api-db:
container_name: fids_flights_db container_name: fids_flights_db
@ -42,5 +43,8 @@ services:
- flights - flights
networks: networks:
subscriptions:
name: subscription-domain_subscriptions
external: true
flights: flights:
driver: bridge driver: bridge

View File

@ -3,4 +3,5 @@ fastapi[all]==0.103.2
psycopg2-binary==2.9.5 psycopg2-binary==2.9.5
pyjwt==2.6.0 pyjwt==2.6.0
gunicorn==20.1.0 gunicorn==20.1.0
sqlalchemy==2.0.22 sqlalchemy==2.0.22
asyncreq==0.0.4

View File

@ -0,0 +1 @@
API_MESSAGES = "http://fids_subscriptions_api:5000/messages"

View File

@ -22,6 +22,7 @@ def create_flight(db: Session, flight: FlightPydantic):
departure_time=flight.departure_time, departure_time=flight.departure_time,
arrival_time=flight.arrival_time, arrival_time=flight.arrival_time,
gate=flight.gate, gate=flight.gate,
user_id=flight.user_id,
) )
db.add(db_flight) db.add(db_flight)
db.commit() db.commit()
@ -33,8 +34,10 @@ def update_flight_status(db: Session, status, id):
db_flight = db.query(Flight).filter(Flight.id == id).first() db_flight = db.query(Flight).filter(Flight.id == id).first()
if db_flight is None: if db_flight is None:
raise KeyError raise KeyError
if db_flight.user_id != status.user_id:
raise PermissionError
setattr(db_flight, "status", status) setattr(db_flight, "status", status.status)
setattr(db_flight, "last_updated", func.now()) setattr(db_flight, "last_updated", func.now())
db.commit() db.commit()
db.refresh(db_flight) db.refresh(db_flight)

View File

@ -16,12 +16,4 @@ class Flight(Base):
arrival_time = Column(DateTime, nullable=False) arrival_time = Column(DateTime, nullable=False)
gate = Column(String, nullable=True) gate = Column(String, nullable=True)
last_updated = Column(DateTime, default=func.now(), nullable=False) last_updated = Column(DateTime, default=func.now(), nullable=False)
user_id = Column(Integer, nullable=False)
# def get_departure_time(self, format="%Y-%m-%d %I:%M %p"):
# return self.departure_time.strftime(format)
# def get_arrival_time(self, format="%Y-%m-%d %I:%M %p"):
# return self.arrival_time.strftime(format)
# def get_last_updated(self, format="%Y-%m-%d %I:%M %p"):
# return self.last_updated.strftime(format)

View File

@ -1,8 +1,10 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from asyncreq import request
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.config import API_MESSAGES
from src.api.cruds import flight as flight_crud from src.api.cruds import flight as flight_crud
from src.api.db import get_db from src.api.db import get_db
from src.api.schemas.flight import Flight, FlightCreate, FlightStatusUpdate from src.api.schemas.flight import Flight, FlightCreate, FlightStatusUpdate
@ -24,8 +26,26 @@ def create_flight(flight: FlightCreate, db: Session = Depends(get_db)):
@router.patch("/{id}", response_model=Flight) @router.patch("/{id}", response_model=Flight)
def update_flight(id: int, status: FlightStatusUpdate, db: Session = Depends(get_db)): async def update_flight(
return flight_crud.update_flight_status(db=db, id=id, status=status.status) id: int,
status: FlightStatusUpdate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
try:
db_flight = flight_crud.update_flight_status(db=db, id=id, status=status)
except PermissionError:
raise HTTPException(status_code=401, detail="Unauthorized")
except KeyError:
raise HTTPException(status_code=404, detail="Flight not found")
msg = status.model_dump()
msg["id"] = id
msg["flight_code"] = db_flight.flight_code
msg["origin"] = db_flight.origin
msg["destination"] = db_flight.destination
background_tasks.add_task(request, API_MESSAGES, "POST", json=msg)
return db_flight
@router.get("", response_model=list[Flight]) @router.get("", response_model=list[Flight])

View File

@ -12,6 +12,7 @@ class Flight(BaseModel):
departure_time: str departure_time: str
arrival_time: str arrival_time: str
gate: str = None gate: str = None
user_id: int
# last_updated: str # last_updated: str
# @validator("departure_time", "arrival_time", "last_updated", pre=True, always=True) # @validator("departure_time", "arrival_time", "last_updated", pre=True, always=True)
@ -30,7 +31,9 @@ class FlightCreate(BaseModel):
departure_time: str departure_time: str
arrival_time: str arrival_time: str
gate: str = None gate: str = None
user_id: int
class FlightStatusUpdate(BaseModel): class FlightStatusUpdate(BaseModel):
status: str status: str
user_id: int

View File

@ -30,6 +30,7 @@ def create_flight():
departure_time=flight.departure_time, departure_time=flight.departure_time,
arrival_time=flight.arrival_time, arrival_time=flight.arrival_time,
gate=flight.gate, gate=flight.gate,
user_id=flight.user_id,
) )
session.add(db_flight) session.add(db_flight)
session.commit() session.commit()
@ -80,6 +81,7 @@ flights = [
departure_time=datetime(2023, 10, 23, 12, 0, 0), departure_time=datetime(2023, 10, 23, 12, 0, 0),
arrival_time=datetime(2023, 10, 24, 12, 0, 0), arrival_time=datetime(2023, 10, 24, 12, 0, 0),
gate="10", gate="10",
user_id=1,
), ),
Flight( Flight(
flight_code="ABC124", flight_code="ABC124",
@ -89,6 +91,7 @@ flights = [
departure_time=datetime(2023, 10, 24, 12, 0, 0), departure_time=datetime(2023, 10, 24, 12, 0, 0),
arrival_time=datetime(2023, 10, 25, 12, 0, 0), arrival_time=datetime(2023, 10, 25, 12, 0, 0),
gate="10", gate="10",
user_id=1,
), ),
Flight( Flight(
flight_code="XYZ789", flight_code="XYZ789",
@ -98,6 +101,7 @@ flights = [
departure_time=datetime(2023, 10, 25, 14, 30, 0), departure_time=datetime(2023, 10, 25, 14, 30, 0),
arrival_time=datetime(2023, 10, 25, 18, 45, 0), arrival_time=datetime(2023, 10, 25, 18, 45, 0),
gate="5", gate="5",
user_id=1,
), ),
Flight( Flight(
flight_code="DEF456", flight_code="DEF456",
@ -107,5 +111,6 @@ flights = [
departure_time=datetime(2023, 10, 26, 9, 15, 0), departure_time=datetime(2023, 10, 26, 9, 15, 0),
arrival_time=datetime(2023, 10, 26, 11, 30, 0), arrival_time=datetime(2023, 10, 26, 11, 30, 0),
gate="7", gate="7",
user_id=1,
), ),
] ]

View File

@ -1,6 +1,7 @@
import json import json
from datetime import datetime from datetime import datetime
from fastapi import BackgroundTasks
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from src.api.main import app from src.api.main import app
@ -17,6 +18,7 @@ creating_flight = {
"departure_time": datetime(2023, 10, 23, 12, 0, 0).isoformat(), "departure_time": datetime(2023, 10, 23, 12, 0, 0).isoformat(),
"arrival_time": datetime(2023, 10, 24, 12, 0, 0).isoformat(), "arrival_time": datetime(2023, 10, 24, 12, 0, 0).isoformat(),
"gate": "10", "gate": "10",
"user_id": 1,
} }
@ -32,11 +34,18 @@ def test_post_flight(test_database, get_flight):
assert db_retrieved_flight.flight_code == creating_flight["flight_code"] assert db_retrieved_flight.flight_code == creating_flight["flight_code"]
def test_patch_flight(test_database, create_flight, flight_to_create): def add_task(self, func, *args, **kwargs) -> None:
return None
def test_patch_flight(test_database, create_flight, flight_to_create, monkeypatch):
monkeypatch.setattr(BackgroundTasks, "add_task", add_task)
test_database.query(Flight).delete() test_database.query(Flight).delete()
created_flight = create_flight(flight_to_create) created_flight = create_flight(flight_to_create)
api_call_retrieved_flight = client.patch( api_call_retrieved_flight = client.patch(
f"/flights/{created_flight.id}", data=json.dumps({"status": "on-boarding"}) f"/flights/{created_flight.id}",
data=json.dumps({"status": "on-boarding", "user_id": 1}),
) )
assert api_call_retrieved_flight.status_code == 200 assert api_call_retrieved_flight.status_code == 200
api_call_retrieved_flight_data = api_call_retrieved_flight.json() api_call_retrieved_flight_data = api_call_retrieved_flight.json()

View File

@ -15,6 +15,7 @@ mocked_flight = {
"departure_time": "2023-10-10 10:00 AM", "departure_time": "2023-10-10 10:00 AM",
"arrival_time": "2023-10-10 12:00 PM", "arrival_time": "2023-10-10 12:00 PM",
"gate": "A2", "gate": "A2",
"user_id": 1,
} }

View File

@ -13,6 +13,7 @@ mocked_flight = {
"departure_time": "2023-10-10 10:00 AM", "departure_time": "2023-10-10 10:00 AM",
"arrival_time": "2023-10-10 12:00 PM", "arrival_time": "2023-10-10 12:00 PM",
"gate": "A2", "gate": "A2",
"user_id": 1,
} }

View File

@ -1,10 +0,0 @@
#!/bin/bash -e
FLIGHTS_INFO_PROD_IMAGE_NAME=flights-information:prod
FLIGHTS_INFO_TEST_IMAGE_NAME=flights-information:test
FLIGHTS_INFORMATION=flights-information
sudo docker build $FLIGHTS_INFORMATION -f $FLIGHTS_INFORMATION/Dockerfile.prod -t ${FLIGHTS_INFO_PROD_IMAGE_NAME}
sudo docker build $FLIGHTS_INFORMATION -f $FLIGHTS_INFORMATION/Dockerfile.test --build-arg "BASE_IMAGE=$FLIGHTS_INFO_PROD_IMAGE_NAME" -t ${FLIGHTS_INFO_TEST_IMAGE_NAME}
sudo docker compose -f flights-domain/docker-compose.yml --env-file $FLIGHTS_INFORMATION/.env.dev up

View File

@ -21,20 +21,20 @@ services:
networks: networks:
- auth - auth
- flights - flights
- gateway - gateways
- subscriptions
- elk - elk
# logging:
# driver: gelf
# options:
# gelf-address: "udp://fids_logstash:12201"
networks: networks:
auth: auth:
name: auth-domain_auth name: auth-domain_auth
external: true external: true
flights: flights:
name: flights-domain_flights name: flights-domain_flights
external: true external: true
subscriptions:
name: subscription-domain_subscriptions
external: true
elk: elk:
name: observability_elk name: observability_elk
external: true external: true

View File

@ -3,5 +3,5 @@ fastapi[all]==0.103.2
pyjwt==2.6.0 pyjwt==2.6.0
gunicorn==20.1.0 gunicorn==20.1.0
requests==2.31.0 requests==2.31.0
aiohttp==3.8.6 asyncreq==0.0.4
graypy graypy

View File

@ -2,3 +2,6 @@ API_USERS = "http://fids_usermanager_api:5000/users"
API_FLIGHTS = "http://fids_flights_api:5000/flights" API_FLIGHTS = "http://fids_flights_api:5000/flights"
API_AUTH = "http://fids_usermanager_api:5000/auth" API_AUTH = "http://fids_usermanager_api:5000/auth"
LOGS_UPD = "udp://fids_logstash:12201" LOGS_UPD = "udp://fids_logstash:12201"
API_SUBSCRIPTIONS = "http://fids_subscriptions_api:5000/subscriptions"
API_NOTIFICATIONS = "http://fids_subscriptions_api:5000/notifications"
API_MESSAGES = "http://fids_subscriptions_api:5000/messages"

View File

@ -1,7 +1,8 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from src.api.routes import auth, flights, health, users from src.api.routes import (auth, flights, health, notifications,
subscriptions, users)
import logging import logging
import graypy import graypy
@ -20,6 +21,8 @@ app.include_router(flights.router, prefix="/flights")
app.include_router(health.router, prefix="/health") app.include_router(health.router, prefix="/health")
app.include_router(auth.router, prefix="/auth") app.include_router(auth.router, prefix="/auth")
app.include_router(users.router, prefix="/users") app.include_router(users.router, prefix="/users")
app.include_router(subscriptions.router, prefix="/subscriptions")
app.include_router(notifications.router, prefix="/notifications")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[

View File

@ -1,11 +1,11 @@
from typing import Annotated from typing import Annotated
from asyncreq import request
from fastapi import APIRouter, Header, HTTPException from fastapi import APIRouter, Header, HTTPException
from src.api.config import API_AUTH from src.api.config import API_AUTH
from src.api.schemas.auth import RefreshToken, Token from src.api.schemas.auth import RefreshToken, Token
from src.api.schemas.user import UserLogin, UserMin, UserRegister from src.api.schemas.user import UserLogin, UserMin, UserRegister
from src.api.utils.network import request
router = APIRouter() router = APIRouter()

View File

@ -1,11 +1,11 @@
from typing import Annotated, Optional from typing import Annotated, Optional
from asyncreq import request
from fastapi import APIRouter, Header, HTTPException from fastapi import APIRouter, Header, HTTPException
from src.api.config import API_FLIGHTS from src.api.config import API_FLIGHTS
from src.api.routes.auth import status as checkAuth from src.api.routes.auth import status as checkAuth
from src.api.schemas.flight import Flight, FlightCreate, FlightStatusUpdate from src.api.schemas.flight import Flight, FlightCreate, FlightStatusUpdate
from src.api.utils.network import request
router = APIRouter() router = APIRouter()
@ -22,9 +22,11 @@ async def get_flight_by_id(id: int):
async def create_flight( async def create_flight(
flight: FlightCreate, authorization: Annotated[str | None, Header()] = None flight: FlightCreate, authorization: Annotated[str | None, Header()] = None
): ):
await checkAuth(authorization) auth = await checkAuth(authorization)
flight_data = flight.model_dump()
flight_data["user_id"] = auth["id"]
(response, status, _) = await request( (response, status, _) = await request(
f"{API_FLIGHTS}", "POST", json=flight.model_dump() f"{API_FLIGHTS}", "POST", json=flight_data
) )
if status < 200 or status > 204: if status < 200 or status > 204:
raise HTTPException(status_code=status, detail=response) raise HTTPException(status_code=status, detail=response)
@ -37,9 +39,11 @@ async def update_flight(
status_update: FlightStatusUpdate, status_update: FlightStatusUpdate,
authorization: Annotated[str | None, Header()] = None, authorization: Annotated[str | None, Header()] = None,
): ):
await checkAuth(authorization) auth = await checkAuth(authorization)
status = status_update.model_dump()
status["user_id"] = auth["id"]
(response, status, _) = await request( (response, status, _) = await request(
f"{API_FLIGHTS}/{id}", "PATCH", json=status_update.model_dump() f"{API_FLIGHTS}/{id}", "PATCH", json=status
) )
if status < 200 or status > 204: if status < 200 or status > 204:
raise HTTPException(status_code=status, detail=response) raise HTTPException(status_code=status, detail=response)

View File

@ -0,0 +1,18 @@
from asyncreq import request
from fastapi import APIRouter, HTTPException
from src.api.config import API_NOTIFICATIONS
from src.api.schemas.notification import Update as Message
router = APIRouter()
@router.post("")
async def receive_message(message: Message):
print(message.model_dump())
(response, status, _) = await request(
f"{API_NOTIFICATIONS}", "POST", json=message.model_dump()
)
if status < 200 or status > 204:
raise HTTPException(status_code=status, detail=response)
return response

View File

@ -0,0 +1,24 @@
from typing import Annotated
from asyncreq import request
from fastapi import APIRouter, Header, HTTPException
from src.api.config import API_SUBSCRIPTIONS
from src.api.routes.auth import status as checkAuth
from src.api.schemas.subscriptions import Subscription
router = APIRouter()
@router.post("")
async def create_subscription(
subscription: Subscription,
authorization: Annotated[str | None, Header()] = None
):
await checkAuth(authorization)
(response, status, _) = await request(
f"{API_SUBSCRIPTIONS}", "POST", json=subscription.model_dump()
)
if status < 200 or status > 204:
raise HTTPException(status_code=status, detail=response)
return response

View File

@ -1,8 +1,8 @@
from asyncreq import request
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from src.api.config import API_USERS from src.api.config import API_USERS
from src.api.schemas.user import User, UserRegister from src.api.schemas.user import User, UserRegister
from src.api.utils.network import request
router = APIRouter() router = APIRouter()

View File

@ -0,0 +1,8 @@
from typing import Any
from pydantic import BaseModel
class Update(BaseModel):
update_id: int
message: Any

View File

@ -0,0 +1,6 @@
from pydantic import BaseModel
class Subscription(BaseModel):
flight_id: int
user_id: int

View File

@ -1,37 +0,0 @@
from typing import Optional
import aiohttp
import async_timeout
from aiohttp import ClientConnectorError, ContentTypeError, JsonPayload
from fastapi import HTTPException
async def make_request(
url: str,
method: str,
headers: dict = None,
query: Optional[dict] = None,
data: str = None,
json: JsonPayload = None,
timeout: int = 60,
):
async with async_timeout.timeout(delay=timeout):
async with aiohttp.ClientSession(headers=headers) as session:
async with session.request(
method=method, url=url, params=query, data=data, json=json
) as response:
response_json = await response.json()
decoded_json = response_json
return decoded_json, response.status, response.headers
async def request(url, method, headers=None, data=None, json=None, query=None):
try:
(x, y, z) = await make_request(
url=url, method=method, headers=headers, data=data, json=json, query=query
)
except ClientConnectorError:
raise HTTPException(status_code=503, detail="Service is unavailable.")
except ContentTypeError:
raise HTTPException(status_code=500, detail="Service error.")
return x, y, z

29
run.sh
View File

@ -75,6 +75,10 @@ if [ -n "$domain" ] && [ -n "$down" ]; then
'elk') 'elk')
down_elk down_elk
;; ;;
'subscription')
export API_IMAGE=$USER/subs-manager:prod
docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.prod down
;;
*) exit 1 ;; *) exit 1 ;;
esac esac
elif [ -n "$domain" ] && [ -z "$down" ]; then elif [ -n "$domain" ] && [ -z "$down" ]; then
@ -110,7 +114,21 @@ elif [ -n "$domain" ] && [ -z "$down" ]; then
docker compose -f flights-domain/docker-compose.yml --env-file flights-domain/.env.prod down docker compose -f flights-domain/docker-compose.yml --env-file flights-domain/.env.prod down
docker compose -f flights-domain/docker-compose.yml --env-file flights-domain/.env.prod up -d docker compose -f flights-domain/docker-compose.yml --env-file flights-domain/.env.prod up -d
fi fi
;;
'subscription')
export SUBSCRIPTION_MANAGER=subscription-domain/subscription-manager
docker build $SUBSCRIPTION_MANAGER -f $SUBSCRIPTION_MANAGER/Dockerfile.prod -t $USER/subs-manager:prod
if [ -n "$tests" ]; then
docker build $SUBSCRIPTION_MANAGER -f $SUBSCRIPTION_MANAGER/Dockerfile.test --build-arg "BASE_IMAGE=$USER/subs-manager:prod" -t $USER/subs-manager:test
export API_IMAGE=$USER/subs-manager:test
docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.dev down
docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.dev up --abort-on-container-exit
else
export API_IMAGE=$USER/subs-manager:prod
docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.prod down
docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.prod up -d
fi
;; ;;
'gateway') 'gateway')
docker build gateway -f gateway/Dockerfile.prod -t $USER/gateway:prod docker build gateway -f gateway/Dockerfile.prod -t $USER/gateway:prod
@ -146,12 +164,14 @@ elif [ -n "$down" ]; then
down_elk down_elk
export API_IMAGE=$USER/gateway:prod
docker compose -f gateway/docker-compose.yml down
export API_IMAGE=$USER/flights-information:prod export API_IMAGE=$USER/flights-information:prod
docker compose -f flights-domain/docker-compose.yml --env-file flights-domain/.env.prod down docker compose -f flights-domain/docker-compose.yml --env-file flights-domain/.env.prod down
export API_IMAGE=$USER/user-manager:prod export API_IMAGE=$USER/user-manager:prod
docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod down docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod down
export API_IMAGE=$USER/gateway:prod export API_IMAGE=slococo/subs-manager:prod
docker compose -f gateway/docker-compose.yml down docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.prod down
export CLIENT_IMAGE=$USER/screen-client:prod export CLIENT_IMAGE=$USER/screen-client:prod
docker compose -f screen-domain/docker-compose.yml down docker compose -f screen-domain/docker-compose.yml down
@ -163,6 +183,8 @@ else
export FLIGHTS_INFORMATION=flights-domain/flights-information export FLIGHTS_INFORMATION=flights-domain/flights-information
docker build $FLIGHTS_INFORMATION -f $FLIGHTS_INFORMATION/Dockerfile.prod -t $USER/flights-information:prod docker build $FLIGHTS_INFORMATION -f $FLIGHTS_INFORMATION/Dockerfile.prod -t $USER/flights-information:prod
docker build gateway -f gateway/Dockerfile.prod -t $USER/gateway:prod docker build gateway -f gateway/Dockerfile.prod -t $USER/gateway:prod
export SUBSCRIPTION_MANAGER=subscription-domain/subscription-manager
docker build $SUBSCRIPTION_MANAGER -f $SUBSCRIPTION_MANAGER/Dockerfile.prod -t $USER/subs-manager:prod
docker build screen-domain -f screen-domain/Dockerfile.prod --build-arg "REACT_APP_ORIGIN=$REACT_APP_ORIGIN" -t $USER/screen-client:prod docker build screen-domain -f screen-domain/Dockerfile.prod --build-arg "REACT_APP_ORIGIN=$REACT_APP_ORIGIN" -t $USER/screen-client:prod
docker build browser-domain -f browser-domain/Dockerfile.prod -t $USER/browser-client:prod docker build browser-domain -f browser-domain/Dockerfile.prod -t $USER/browser-client:prod
@ -179,6 +201,9 @@ else
docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod up -d docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod up -d
docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod exec usermanager-api python manage.py recreate_db docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod exec usermanager-api python manage.py recreate_db
docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod exec usermanager-api python manage.py seed_db docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod exec usermanager-api python manage.py seed_db
export API_IMAGE=slococo/subs-manager:prod
docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.prod down
docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.prod up -d
export API_IMAGE=$USER/gateway:prod export API_IMAGE=$USER/gateway:prod
docker compose -f gateway/docker-compose.yml down docker compose -f gateway/docker-compose.yml down
docker compose -f gateway/docker-compose.yml up -d docker compose -f gateway/docker-compose.yml up -d

View File

@ -60,7 +60,7 @@ export const addData = <T>(storeName: string, data: T): Promise<T|string|null> =
}); });
}; };
export const deleteData = (storeName: string, key: string): Promise<boolean> => { export const deleteData = (storeName: string, key: number): Promise<boolean> => {
return new Promise((resolve) => { return new Promise((resolve) => {
request = indexedDB.open('myDB', version); request = indexedDB.open('myDB', version);
@ -80,7 +80,7 @@ export const deleteData = (storeName: string, key: string): Promise<boolean> =>
}); });
}; };
export const updateData = <T>(storeName: string, key: string, data: T): Promise<T|string|null> => { export const updateData = <T>(storeName: string, key: number, data: T): Promise<T|string|null> => {
return new Promise((resolve) => { return new Promise((resolve) => {
request = indexedDB.open('myDB', version); request = indexedDB.open('myDB', version);

View File

@ -7,6 +7,7 @@ import { Stores, addData, deleteData, getStoreData, updateData, initDB } from '.
export const useFetchZones = () => { export const useFetchZones = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [zones, setZones] = useState<Flight[]>([]); const [zones, setZones] = useState<Flight[]>([]);
let origin = process.env.REACT_APP_ORIGIN; let origin = process.env.REACT_APP_ORIGIN;
useEffect(() => { useEffect(() => {
@ -22,10 +23,14 @@ export const useFetchZones = () => {
fetchZones(origin, null) fetchZones(origin, null)
.then((data) => { .then((data) => {
localStorage.setItem('lastUpdated', newUpdate) localStorage.setItem('lastUpdated', newUpdate)
setZones(data); let toAdd: Flight[] = []
data.map((u) => { data.map((u) => {
addData(Stores.Flight, u) if (u.status != 'Deleted') {
addData(Stores.Flight, u)
toAdd.push(u)
}
}) })
setZones(toAdd);
}) })
.catch((error) => {}); .catch((error) => {});
} }
@ -42,20 +47,32 @@ export const useFetchZones = () => {
.then((data) => { .then((data) => {
localStorage.setItem('lastUpdated', newUpdate) localStorage.setItem('lastUpdated', newUpdate)
let toAdd: Flight[] = [] let toAdd: Flight[] = []
let toRemove: Flight[] = []
zones.forEach((c, i) => { zones.forEach((c, i) => {
let index = data.findIndex(x => x.id === c.id) let index = data.findIndex(x => x.id === c.id)
if (index >= 0) { if (index >= 0) {
toAdd.push(data[index]); console.log(data[index].status)
console.log(",aria") if (data[index].status == 'Deleted') {
updateData(Stores.Flight, String(c.id), data[index]) console.log("sacamos")
toRemove.push(data[index])
deleteData(Stores.Flight, c.id)
} else {
toAdd.push(data[index]);
updateData(Stores.Flight, c.id, data[index])
}
} else { } else {
toAdd.push(c); if (c.status == 'Deleted') {
toRemove.push(c);
} else {
toAdd.push(c);
}
} }
}); });
console.log(toAdd) console.log(toAdd)
let filtered = data.filter(o => !toAdd.some(b => { return o.id === b.id} )) console.log(toRemove)
let filtered = data.filter(o => !toAdd.some(b => { return o.id === b.id}) && !toRemove.some(b => { return o.id === b.id}))
const newArray = toAdd.concat(filtered); const newArray = toAdd.concat(filtered);
filtered.forEach(c => { filtered.forEach(c => {
addData(Stores.Flight, c) addData(Stores.Flight, c)

View File

@ -0,0 +1,5 @@
POSTGRES_USER=user
POSTGRES_PASS=password
POSTGRES_DB=api_dev
APP_SETTINGS=src.config.DevelopmentConfig
TOKEN=3275588851:AT36AGy_BChQUuCq2M6d2UrY5CSWtZe45gV

View File

@ -0,0 +1,5 @@
POSTGRES_USER=user
POSTGRES_PASS=password
POSTGRES_DB=api_prod
APP_SETTINGS=src.config.ProductionConfig
TOKEN=3275588851:AT36AGy_BChQUuCq2M6d2UrY5CSWtZe45gV

View File

@ -0,0 +1,5 @@
# pull official base image
FROM postgres:13.3
# run create.sql on init
ADD create.sql /docker-entrypoint-initdb.d

View File

@ -0,0 +1,3 @@
CREATE DATABASE api_prod;
CREATE DATABASE api_dev;
CREATE DATABASE api_test;

View File

@ -0,0 +1,49 @@
version: '3.8'
services:
subscriptions-api:
container_name: fids_subscriptions_api
image: ${API_IMAGE}
ports:
- 5002:5000
healthcheck:
test: ["CMD", "nc", "-vz", "-w1", "localhost", "5000"]
interval: 2s
timeout: 2s
retries: 5
start_period: 2s
environment:
- TEST_TARGET=${TEST_TARGET}
- PORT=5000
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASS}@subscriptions-api-db/${POSTGRES_DB}
- APP_SETTINGS=${APP_SETTINGS}
- TOKEN=${TOKEN}
depends_on:
subscriptions-api-db:
condition: service_healthy
networks:
- subscriptions
subscriptions-api-db:
container_name: fids_subscriptions_db
build:
context: ./db
dockerfile: Dockerfile
healthcheck:
test: psql postgres --command "select 1" -U ${POSTGRES_USER}
interval: 2s
timeout: 10s
retries: 10
start_period: 2s
expose:
- 5432
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASS}
networks:
- subscriptions
networks:
subscriptions:
driver: bridge

View File

@ -0,0 +1,5 @@
exclude_dirs:
- src/tests
#tests: ['B201', 'B301']
#skips: ['B101', 'B601']

View File

@ -0,0 +1,3 @@
[run]
omit = src/tests/*
branch = True

View File

@ -0,0 +1,7 @@
**/__pycache__
**/Pipfile.lock
.coverage
.pytest_cache
htmlcov
pact-nginx-ssl/nginx-selfsigned.*
src/tests/pacts

View File

@ -0,0 +1,32 @@
# pull official base image
FROM python:3.11.2-slim-buster AS prod
# set working directory
WORKDIR /usr/src/app
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ARG SECRET_KEY
ENV SECRET_KEY $SECRET_KEY
RUN apt-get update \
&& apt-get -y install netcat gcc curl \
&& apt-get clean \
&& groupadd -g 999 python \
&& useradd -r -u 999 -g python python \
&& python -m venv /usr/src/app/.venv \
&& chown -R python:python /usr/src/app
ENV PATH="/usr/src/app/.venv/bin:$PATH"
ENV PIP_NO_CACHE_DIR=off
USER 999
COPY --chown=python:python requirements.txt requirements.txt
RUN python -m pip install --upgrade pip && \
python -m pip install -r requirements.txt
COPY --chown=python:python . .
# run gunicorn
CMD ["/usr/src/app/.venv/bin/gunicorn", "src.api.main:app", "--worker-class", "uvicorn.workers.UvicornWorker"]

View File

@ -0,0 +1,9 @@
env
.venv
Dockerfile.test
Dockerfile.prod
.coverage
.pytest_cache
htmlcov
src/tests
src/.cicd

View File

@ -0,0 +1,18 @@
# pull official base image
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
ENV DATABASE_TEST_URL=postgresql://user:password@flights-api-db:5432/api_test
# add and install requirements
COPY --chown=python:python ./requirements.test.txt .
RUN python -m pip install -r requirements.test.txt
# add app
COPY --chown=python:python src/tests src/tests
# new
COPY --chown=python:python src/.cicd/test.sh .
RUN chmod +x /usr/src/app/test.sh
CMD ["/usr/src/app/test.sh"]

View File

@ -0,0 +1,12 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
fastapi = "==0.103.2"
[dev-packages]
[requires]
python_version = "3.11"

View File

@ -0,0 +1,11 @@
#!/bin/sh
echo "Waiting for postgres..."
while ! nc -z api-db 5432; do
sleep 0.1
done
echo "PostgreSQL started"
python src/api/main.py run -h 0.0.0.0

View File

@ -0,0 +1,10 @@
## Testing
pytest==7.2.2
pytest-cov==4.0.0
pytest-xdist==3.2.0
pytest-watch==4.2.0
flake8==6.0.0
black==23.1.0
isort==5.12.0
bandit==1.7.5
pactman==2.3.0

View File

@ -0,0 +1,7 @@
## Prod
fastapi[all]==0.103.2
psycopg2-binary==2.9.5
pyjwt==2.6.0
gunicorn==20.1.0
sqlalchemy==2.0.22
asyncreq==0.0.4

View File

@ -0,0 +1,2 @@
[flake8]
max-line-length = 119

View File

@ -0,0 +1,22 @@
#!/bin/bash -e
if [ "${TEST_TARGET:-}" = "INTEGRATION" ]; then
/usr/src/app/.venv/bin/gunicorn src.api.main:app --worker-class uvicorn.workers.UvicornWorker
else
## pytest
# python -m pytest "src/tests" --junitxml=report.xml
touch report.xml
## Coverage
# python -m pytest "src/tests" -p no:warnings --cov="src" --cov-report xml
touch coverage.xml
## Linting
flake8 src --extend-ignore E221 --extend-ignore E501
# black src --check
# isort . --src-path src --check
## Security
# bandit -c .bandit.yml -r .
fi

View File

@ -0,0 +1 @@
API_FLIGHTS = "http://fids_flights_api:5000/flights"

View File

@ -0,0 +1,32 @@
from sqlalchemy.orm import Session
from src.api.models.chat import Chat
from src.api.schemas.chat import Chat as ChatPydantic
def get_chat_id(db: Session, user_id: int):
return db.query(Chat).filter(Chat.user_id == user_id).first()
def get_user_from_chat(db: Session, chat_id: str):
return db.query(Chat).filter(Chat.chat_id == chat_id).first()
def create_chat(db: Session, chat: ChatPydantic):
db_chat = db.query(Chat).filter(Chat.user_id == chat.user_id).first()
if db_chat is not None:
return
db_chat = Chat(
user_id=chat.user_id,
chat_id=chat.chat_id,
)
db.add(db_chat)
db.commit()
db.refresh(db_chat)
return db_chat
def remove_chat(db: Session, chat_id: str):
db.query(Chat).filter(Chat.chat_id == chat_id).delete()
db.commit()

View File

@ -0,0 +1,30 @@
from sqlalchemy.orm import Session
from src.api.models.subscription import Subscription
from src.api.schemas.subscription import FlightData
from src.api.schemas.subscription import Subscription as SubscriptionPydantic
def get_subscriptions(db: Session, user_id: int):
return db.query(Subscription).filter(Subscription.user_id == user_id).all()
def create_subscription(db: Session, subscription: SubscriptionPydantic):
db_subscription = Subscription(
user_id=subscription.user_id,
flight_id=subscription.flight_id,
)
db.add(db_subscription)
db.commit()
db.refresh(db_subscription)
return db_subscription
def remove_subscription(db: Session, user_id: int, flight_id: int):
db.query(Subscription).filter(Subscription.user_id == user_id
and Subscription.flight_id == flight_id).delete()
db.commit()
def send_subscriptions(db: Session, flight: FlightData):
return db.query(Subscription).filter(Subscription.flight_id == flight.id).all()

View File

@ -0,0 +1,22 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL")
print(SQLALCHEMY_DATABASE_URL)
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@ -0,0 +1,26 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.api.db import Base, engine
from src.api.routes import health, messages, notifications, subscriptions
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Subscription Information API")
app.include_router(subscriptions.router, prefix="/subscriptions")
app.include_router(notifications.router, prefix="/notifications")
app.include_router(messages.router, prefix="/messages")
app.include_router(health.router, prefix="/health")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://fids.slc.ar",
"https://airport.fids.slc.ar",
"http://localhost:8080",
"http://localhost:8081",
"http://localhost:3000",
],
allow_credentials=True,
allow_methods=["POST", "GET", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)

View File

@ -0,0 +1,10 @@
from sqlalchemy import Column, Integer, String
from src.api.db import Base
class Chat(Base):
__tablename__ = "chats"
user_id = Column(Integer, primary_key=True)
chat_id = Column(String, primary_key=True)

View File

@ -0,0 +1,10 @@
from sqlalchemy import Column, Integer
from src.api.db import Base
class Subscription(Base):
__tablename__ = "subscriptions"
user_id = Column(Integer, primary_key=True)
flight_id = Column(Integer, primary_key=True)

View File

@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("", status_code=200)
async def get_health():
return {"status": "OK"}

View File

@ -0,0 +1,24 @@
from fastapi import APIRouter, BackgroundTasks, Depends, Response
from sqlalchemy.orm import Session
from src.api.cruds import chat as notif_crud
from src.api.cruds import subscription as sub_crud
from src.api.db import get_db
from src.api.schemas.subscription import FlightData
from src.api.utils import telegram
from src.api.utils.messages import get_update_message
router = APIRouter()
@router.post("")
async def send_notification(flight: FlightData, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
db_subscriptions = sub_crud.send_subscriptions(db=db, flight=flight)
for subscription in db_subscriptions:
db_chat = notif_crud.get_chat_id(db=db, user_id=subscription.user_id)
if db_chat is None:
continue
msg = get_update_message(flight)
print(msg)
background_tasks.add_task(telegram.send_message, db_chat.chat_id, msg)
return Response(status_code=204)

View File

@ -0,0 +1,54 @@
import re
from asyncreq import request
from fastapi import APIRouter, BackgroundTasks, Depends, Response
from sqlalchemy.orm import Session
from src.api.config import API_FLIGHTS
from src.api.cruds import chat as notif_crud
from src.api.cruds import subscription as subs_crud
from src.api.db import get_db
from src.api.schemas.chat import Chat, Update
from src.api.utils import telegram
from src.api.utils.messages import get_flight_message, get_invalid_message
router = APIRouter()
msg_options = re.compile(r'^/(flight \d+|stop|start)$')
@router.post("")
async def create_chat(
chat: Update,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
print(chat.model_dump())
message = chat.message
text = message["text"]
if not msg_options.match(text):
msg = get_invalid_message()
chat_id = str(message["chat"]["id"])
background_tasks.add_task(telegram.send_message, chat_id, msg)
return Response(status_code=204)
action = text.partition(' ')[0]
if action == '/start':
user_id = int(message["text"].partition(' ')[2])
new_chat = Chat(chat_id=str(message["chat"]["id"]), user_id=user_id)
notif_crud.create_chat(db=db, chat=new_chat)
elif action == '/stop':
chat_id = str(message["chat"]["id"])
user_id = notif_crud.get_user_from_chat(db=db, chat_id=chat_id).user_id
subs_crud.remove_subscriptions(user_id)
notif_crud.remove_chat(db=db, chat_id=chat_id)
elif action == '/flight':
chat_id = str(message["chat"]["id"])
flight_id = int(message["text"].partition(' ')[2])
(response, status, _) = await request(f"{API_FLIGHTS}/{flight_id}", "GET")
if status < 200 or status > 204:
msg = f"Could not get flight '{flight_id}'. Sorry!"
msg = get_flight_message(response)
background_tasks.add_task(telegram.send_message, chat_id, msg)
return Response(status_code=204)

View File

@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from src.api.cruds import subscription as sub_crud
from src.api.db import get_db
from src.api.schemas.subscription import Subscription, SubscriptionRemove
router = APIRouter()
@router.post("")
def create_subscription(subscription: Subscription, db: Session = Depends(get_db)):
return sub_crud.create_subscription(db=db, subscription=subscription)
@router.get("/{user_id}", response_model=list[Subscription])
def get_subscriptions(user_id: int, db: Session = Depends(get_db)):
db_subscriptions = sub_crud.get_subscriptions(db=db, user_id=user_id)
if db_subscriptions is None:
raise HTTPException(status_code=404, detail="Subscription not found")
return db_subscriptions
@router.delete("/{user_id}")
def delete_subscription(user_id: int, subscription: SubscriptionRemove, db: Session = Depends(get_db)):
sub_crud.remove_subscription(db=db, user_id=user_id, flight_id=subscription.flight_id)
return Response(status_code=204)

View File

@ -0,0 +1,51 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, validator
class Chat(BaseModel):
user_id: int
chat_id: str
class Update(BaseModel):
update_id: int
message: Any
class ChatCreateData(BaseModel):
user_id: int
class FlightData(BaseModel):
id: int
flight_code: str
status: str
origin: str
destination: str
departure_time: str
arrival_time: str
gate: str = None
@validator("departure_time", "arrival_time", pre=True, always=True)
def parse_datetime(cls, value):
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %I:%M %p")
return value
class FlightData(BaseModel):
id: int
flight_code: str
status: str
origin: str
destination: str
departure_time: str
arrival_time: str
gate: str = None
@validator("departure_time", "arrival_time", pre=True, always=True)
def parse_datetime(cls, value):
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %I:%M %p")
return value

View File

@ -0,0 +1,30 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, validator
class Subscription(BaseModel):
flight_id: int
user_id: int
class SubscriptionRemove(BaseModel):
flight_id: int
class FlightData(BaseModel):
id: int
flight_code: str
status: Optional[str] = None
origin: str
destination: str
departure_time: Optional[str] = None
arrival_time: Optional[str] = None
gate: Optional[str] = None
@validator("departure_time", "arrival_time", pre=True, always=True)
def parse_datetime(cls, value):
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %I:%M %p")
return value

View File

@ -0,0 +1,36 @@
from src.api.schemas.subscription import FlightData
def get_update_message(flight: FlightData):
msg = f"Your flight {flight.flight_code} from {flight.origin} to {flight.destination} has been updated."
if flight.status is not None:
msg += f"\nNew status: {flight.status}"
if flight.departure_time is not None:
msg += f"\nNew departure time: {flight.departure_time}"
if flight.arrival_time is not None:
msg += f"\nNew arrival time: {flight.arrival_time}"
if flight.gate is not None:
msg += f"\nNew gate: {flight.gate}"
return f"{msg}\n\nIf you want to see the full flight data, write `/flight {flight.id}`."
def get_flight_message(flight: dict):
return (
f"Here is the full data for your flight {flight['flight_code']} (ID: {flight['id']}):"
f"\n\nStatus: {flight['status'] if flight['status'] else 'Not available'}"
f"\nOrigin: {flight['origin']}"
f"\nDestination: {flight['destination']}"
f"\nDeparture Time: {flight['departure_time'] if flight['departure_time'] else 'Not available'}"
f"\nArrival Time: {flight['arrival_time'] if flight['arrival_time'] else 'Not available'}"
f"\nGate: {flight['gate'] if flight['gate'] else 'Not available'}"
f"\n\nThank you for using our flight update service!"
)
def get_invalid_message():
return (
"Invalid option!\nPlease use:\n"
"\n/flights NUMBER (e.g., /flights 1) for flight details"
"\n/start to start receiving messages"
"\n/stop to manage updates."
)

View File

@ -0,0 +1,14 @@
import os
from asyncreq import request
TOKEN = os.getenv("TOKEN")
async def send_message(chat_id, message):
msg = {"chat_id": chat_id, "text": message}
url = f"https://api.telegram.org/bot{TOKEN}/sendMessage"
await request(url, method="POST", json=msg)
# response = await request(url, method="POST", json=msg)
# if response is None or response['ok'] == 'True':
# raise 'Could not send message'

View File

@ -0,0 +1,35 @@
import os
class BaseConfig:
TESTING = False
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = "my_precious"
ACCESS_TOKEN_EXPIRATION = 900 # 15 minutes
REFRESH_TOKEN_EXPIRATION = 2592000 # 30 days
class DevelopmentConfig(BaseConfig):
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
BCRYPT_LOG_ROUNDS = 4
class TestingConfig(BaseConfig):
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_TEST_URL")
BCRYPT_LOG_ROUNDS = 4
ACCESS_TOKEN_EXPIRATION = 5
REFRESH_TOKEN_EXPIRATION = 5
class ProductionConfig(BaseConfig):
BCRYPT_LOG_ROUNDS = 13
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
SECRET_KEY = os.getenv("SECRET_KEY", "my_precious")
def __init__(self):
self.SECRET_KEY = os.getenv("SECRET_KEY", "my_precious")
url = os.environ.get("DATABASE_URL")
if url is not None and url.startswith("postgres://"):
url = url.replace("postgres://", "postgresql://", 1)
self.SQLALCHEMY_DATABASE_URI = url