diff --git a/.gitignore b/.gitignore index 628a4dc..7c4f084 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ !.env.dev.example !.env.prod.example node_modules -*.xml \ No newline at end of file +*.xml +notification-domain/ +TODO.txt +*.sh \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 278541e..d08138b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,14 +19,17 @@ preparation: - export BUILD_ID=$(date +%Y%m%d%H%M) - 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_TEST_IMAGE_NAME=${IMAGE_BASE}/flights-information:test-${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 "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_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 "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_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 "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_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 "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_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 "ELK_SETUP_PROD_IMAGE_NAME=${IMAGE_BASE}/elk-setup:prod-${BUILD_ID}" >> context.env + - echo "ELK_PROD_IMAGE_NAME=${IMAGE_BASE}/elasticsearch:prod-${BUILD_ID}" >> context.env + - echo "HEARTBEAT_PROD_IMAGE_NAME=${IMAGE_BASE}/heartbeat:prod-${BUILD_ID}" >> context.env + - echo "CURATOR_PROD_IMAGE_NAME=${IMAGE_BASE}/curator: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 "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_PROD_FILE=$(echo $ENV_PROD)" >> context.env @@ -127,6 +139,25 @@ build-screen-client: - job: preparation 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: stage: build tags: @@ -203,6 +234,35 @@ test-auth-api: - job: build-auth-api 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: stage: test tags: @@ -342,12 +402,15 @@ deliver-dockerhub: - docker compose -f flights-domain/docker-compose.yml --env-file $ENV_DEV_FILE down - export API_IMAGE=$USER_MANAGER_TEST_IMAGE_NAME - 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 - 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 $USER_MANAGER_PROD_IMAGE_NAME $DOCKER_HUB_USER_MANAGER_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 $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_USER_MANAGER_IMAGE + - docker push $DOCKER_HUB_SUBSCRIPTION_IMAGE - docker push $DOCKER_HUB_GATEWAY_IMAGE - docker push $DOCKER_HUB_BROWSER_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 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 - 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6591b4e..97783b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,4 +13,4 @@ repos: rev: 5.12.0 hooks: - 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'] diff --git a/README.md b/README.md index 3c08ea9..9b9ffdf 100644 --- a/README.md +++ b/README.md @@ -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. +### 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 API gateway encargada de exponer los servicios. Maneja autenticación usando el `auth-domain`. diff --git a/auth-domain/user-manager/manage.py b/auth-domain/user-manager/manage.py index 7109b91..311468e 100644 --- a/auth-domain/user-manager/manage.py +++ b/auth-domain/user-manager/manage.py @@ -17,6 +17,7 @@ def recreate_db(): @cli.command("seed_db") def seed_db(): 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.commit() diff --git a/browser-domain/src/hooks/useAuthenticateUser.test.tsx b/browser-domain/src/hooks/useAuthenticateUser.test.tsx deleted file mode 100644 index 2bc7eb9..0000000 --- a/browser-domain/src/hooks/useAuthenticateUser.test.tsx +++ /dev/null @@ -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(); - }); -}); diff --git a/browser-domain/src/hooks/useAuthenticateUser.tsx b/browser-domain/src/hooks/useAuthenticateUser.tsx deleted file mode 100644 index f9ff993..0000000 --- a/browser-domain/src/hooks/useAuthenticateUser.tsx +++ /dev/null @@ -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(null); - const [error, setError] = useState(null); - const [tokenValidated, setTokenValidated] = useState(false); - - const navigate = useNavigate(); - - const authenticate = async (credentials: Credentials): Promise => { - 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 }; -}; diff --git a/browser-domain/src/useAuth.tsx b/browser-domain/src/useAuth.tsx index 6861fb1..9417c86 100644 --- a/browser-domain/src/useAuth.tsx +++ b/browser-domain/src/useAuth.tsx @@ -37,9 +37,15 @@ import jwt_decode from "jwt-decode"; useEffect(() => { const existingToken = localStorage.getItem("token"); if (existingToken) { - const airline = (jwt_decode(existingToken) as TokenData).airline; - setIsAirline(airline) - + let airline + try { + airline = (jwt_decode(existingToken) as TokenData).airline; + setIsAirline(airline) + } catch (err) { + setLoadingInitial(false); + logout() + return; + } tokenStatus(existingToken) .then((res) => fetchUserById(res.id) @@ -47,7 +53,10 @@ import jwt_decode from "jwt-decode"; .catch((_error) => {}) .finally(() => setLoadingInitial(false)) ) - .catch((_error) => {}) + .catch((_error) => { + setLoadingInitial(false) + logout() + }) // .finally(() => setLoadingInitial(false)); } else { setLoadingInitial(false) diff --git a/flights-domain/docker-compose.yml b/flights-domain/docker-compose.yml index 5722366..393bf2d 100644 --- a/flights-domain/docker-compose.yml +++ b/flights-domain/docker-compose.yml @@ -21,6 +21,7 @@ services: condition: service_healthy networks: - flights + - subscriptions flights-api-db: container_name: fids_flights_db @@ -42,5 +43,8 @@ services: - flights networks: + subscriptions: + name: subscription-domain_subscriptions + external: true flights: driver: bridge \ No newline at end of file diff --git a/flights-domain/flights-information/requirements.txt b/flights-domain/flights-information/requirements.txt index d396de1..eb2fac7 100644 --- a/flights-domain/flights-information/requirements.txt +++ b/flights-domain/flights-information/requirements.txt @@ -3,4 +3,5 @@ fastapi[all]==0.103.2 psycopg2-binary==2.9.5 pyjwt==2.6.0 gunicorn==20.1.0 -sqlalchemy==2.0.22 \ No newline at end of file +sqlalchemy==2.0.22 +asyncreq==0.0.4 \ No newline at end of file diff --git a/flights-domain/flights-information/src/api/config.py b/flights-domain/flights-information/src/api/config.py new file mode 100644 index 0000000..65f9891 --- /dev/null +++ b/flights-domain/flights-information/src/api/config.py @@ -0,0 +1 @@ +API_MESSAGES = "http://fids_subscriptions_api:5000/messages" diff --git a/flights-domain/flights-information/src/api/cruds/flight.py b/flights-domain/flights-information/src/api/cruds/flight.py index afbeb38..130be7c 100644 --- a/flights-domain/flights-information/src/api/cruds/flight.py +++ b/flights-domain/flights-information/src/api/cruds/flight.py @@ -22,6 +22,7 @@ def create_flight(db: Session, flight: FlightPydantic): departure_time=flight.departure_time, arrival_time=flight.arrival_time, gate=flight.gate, + user_id=flight.user_id, ) db.add(db_flight) db.commit() @@ -33,8 +34,10 @@ def update_flight_status(db: Session, status, id): db_flight = db.query(Flight).filter(Flight.id == id).first() if db_flight is None: 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()) db.commit() db.refresh(db_flight) diff --git a/flights-domain/flights-information/src/api/models/flight.py b/flights-domain/flights-information/src/api/models/flight.py index 4c7d999..3caa661 100644 --- a/flights-domain/flights-information/src/api/models/flight.py +++ b/flights-domain/flights-information/src/api/models/flight.py @@ -16,12 +16,4 @@ class Flight(Base): arrival_time = Column(DateTime, nullable=False) gate = Column(String, nullable=True) last_updated = Column(DateTime, default=func.now(), 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) + user_id = Column(Integer, nullable=False) diff --git a/flights-domain/flights-information/src/api/routes/flights.py b/flights-domain/flights-information/src/api/routes/flights.py index 262ee84..cb7c178 100644 --- a/flights-domain/flights-information/src/api/routes/flights.py +++ b/flights-domain/flights-information/src/api/routes/flights.py @@ -1,8 +1,10 @@ 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 src.api.config import API_MESSAGES from src.api.cruds import flight as flight_crud from src.api.db import get_db 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) -def update_flight(id: int, status: FlightStatusUpdate, db: Session = Depends(get_db)): - return flight_crud.update_flight_status(db=db, id=id, status=status.status) +async def update_flight( + 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]) diff --git a/flights-domain/flights-information/src/api/schemas/flight.py b/flights-domain/flights-information/src/api/schemas/flight.py index 10ca2b5..387a0be 100644 --- a/flights-domain/flights-information/src/api/schemas/flight.py +++ b/flights-domain/flights-information/src/api/schemas/flight.py @@ -12,6 +12,7 @@ class Flight(BaseModel): departure_time: str arrival_time: str gate: str = None + user_id: int # last_updated: str # @validator("departure_time", "arrival_time", "last_updated", pre=True, always=True) @@ -30,7 +31,9 @@ class FlightCreate(BaseModel): departure_time: str arrival_time: str gate: str = None + user_id: int class FlightStatusUpdate(BaseModel): status: str + user_id: int diff --git a/flights-domain/flights-information/src/tests/conftest.py b/flights-domain/flights-information/src/tests/conftest.py index f75c3a9..42da4c1 100644 --- a/flights-domain/flights-information/src/tests/conftest.py +++ b/flights-domain/flights-information/src/tests/conftest.py @@ -30,6 +30,7 @@ def create_flight(): departure_time=flight.departure_time, arrival_time=flight.arrival_time, gate=flight.gate, + user_id=flight.user_id, ) session.add(db_flight) session.commit() @@ -80,6 +81,7 @@ flights = [ departure_time=datetime(2023, 10, 23, 12, 0, 0), arrival_time=datetime(2023, 10, 24, 12, 0, 0), gate="10", + user_id=1, ), Flight( flight_code="ABC124", @@ -89,6 +91,7 @@ flights = [ departure_time=datetime(2023, 10, 24, 12, 0, 0), arrival_time=datetime(2023, 10, 25, 12, 0, 0), gate="10", + user_id=1, ), Flight( flight_code="XYZ789", @@ -98,6 +101,7 @@ flights = [ departure_time=datetime(2023, 10, 25, 14, 30, 0), arrival_time=datetime(2023, 10, 25, 18, 45, 0), gate="5", + user_id=1, ), Flight( flight_code="DEF456", @@ -107,5 +111,6 @@ flights = [ departure_time=datetime(2023, 10, 26, 9, 15, 0), arrival_time=datetime(2023, 10, 26, 11, 30, 0), gate="7", + user_id=1, ), ] diff --git a/flights-domain/flights-information/src/tests/functional/flights_functional_test.py b/flights-domain/flights-information/src/tests/functional/flights_functional_test.py index ba5d347..6e8e3e9 100644 --- a/flights-domain/flights-information/src/tests/functional/flights_functional_test.py +++ b/flights-domain/flights-information/src/tests/functional/flights_functional_test.py @@ -1,6 +1,7 @@ import json from datetime import datetime +from fastapi import BackgroundTasks from fastapi.testclient import TestClient from src.api.main import app @@ -17,6 +18,7 @@ creating_flight = { "departure_time": datetime(2023, 10, 23, 12, 0, 0).isoformat(), "arrival_time": datetime(2023, 10, 24, 12, 0, 0).isoformat(), "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"] -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() created_flight = create_flight(flight_to_create) 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 api_call_retrieved_flight_data = api_call_retrieved_flight.json() diff --git a/flights-domain/flights-information/src/tests/test_flights.py b/flights-domain/flights-information/src/tests/test_flights.py index bcb3162..f27308b 100644 --- a/flights-domain/flights-information/src/tests/test_flights.py +++ b/flights-domain/flights-information/src/tests/test_flights.py @@ -15,6 +15,7 @@ mocked_flight = { "departure_time": "2023-10-10 10:00 AM", "arrival_time": "2023-10-10 12:00 PM", "gate": "A2", + "user_id": 1, } diff --git a/flights-domain/flights-information/src/tests/unit/flights_unit_test.py b/flights-domain/flights-information/src/tests/unit/flights_unit_test.py index 62f0753..05cff7b 100644 --- a/flights-domain/flights-information/src/tests/unit/flights_unit_test.py +++ b/flights-domain/flights-information/src/tests/unit/flights_unit_test.py @@ -13,6 +13,7 @@ mocked_flight = { "departure_time": "2023-10-10 10:00 AM", "arrival_time": "2023-10-10 12:00 PM", "gate": "A2", + "user_id": 1, } diff --git a/flights-domain/run_tests.sh b/flights-domain/run_tests.sh deleted file mode 100755 index baad313..0000000 --- a/flights-domain/run_tests.sh +++ /dev/null @@ -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 - diff --git a/gateway/docker-compose.yml b/gateway/docker-compose.yml index 9dfe313..7ba24ef 100644 --- a/gateway/docker-compose.yml +++ b/gateway/docker-compose.yml @@ -21,20 +21,20 @@ services: networks: - auth - flights - - gateway + - gateways + - subscriptions - elk - # logging: - # driver: gelf - # options: - # gelf-address: "udp://fids_logstash:12201" -networks: +networks: auth: name: auth-domain_auth external: true flights: name: flights-domain_flights external: true + subscriptions: + name: subscription-domain_subscriptions + external: true elk: name: observability_elk external: true diff --git a/gateway/requirements.txt b/gateway/requirements.txt index ab34e30..813de4c 100644 --- a/gateway/requirements.txt +++ b/gateway/requirements.txt @@ -3,5 +3,5 @@ fastapi[all]==0.103.2 pyjwt==2.6.0 gunicorn==20.1.0 requests==2.31.0 -aiohttp==3.8.6 +asyncreq==0.0.4 graypy \ No newline at end of file diff --git a/gateway/src/api/config.py b/gateway/src/api/config.py index cbe42e8..65a0150 100644 --- a/gateway/src/api/config.py +++ b/gateway/src/api/config.py @@ -2,3 +2,6 @@ API_USERS = "http://fids_usermanager_api:5000/users" API_FLIGHTS = "http://fids_flights_api:5000/flights" API_AUTH = "http://fids_usermanager_api:5000/auth" 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" diff --git a/gateway/src/api/main.py b/gateway/src/api/main.py index 48dbe4e..fbe1f3e 100644 --- a/gateway/src/api/main.py +++ b/gateway/src/api/main.py @@ -1,7 +1,8 @@ from fastapi import FastAPI 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 graypy @@ -20,6 +21,8 @@ app.include_router(flights.router, prefix="/flights") app.include_router(health.router, prefix="/health") app.include_router(auth.router, prefix="/auth") app.include_router(users.router, prefix="/users") +app.include_router(subscriptions.router, prefix="/subscriptions") +app.include_router(notifications.router, prefix="/notifications") app.add_middleware( CORSMiddleware, allow_origins=[ diff --git a/gateway/src/api/routes/auth.py b/gateway/src/api/routes/auth.py index 3ab09cf..083fa99 100644 --- a/gateway/src/api/routes/auth.py +++ b/gateway/src/api/routes/auth.py @@ -1,11 +1,11 @@ from typing import Annotated +from asyncreq import request from fastapi import APIRouter, Header, HTTPException from src.api.config import API_AUTH from src.api.schemas.auth import RefreshToken, Token from src.api.schemas.user import UserLogin, UserMin, UserRegister -from src.api.utils.network import request router = APIRouter() diff --git a/gateway/src/api/routes/flights.py b/gateway/src/api/routes/flights.py index 85d1fb4..f1315c2 100644 --- a/gateway/src/api/routes/flights.py +++ b/gateway/src/api/routes/flights.py @@ -1,11 +1,11 @@ from typing import Annotated, Optional +from asyncreq import request from fastapi import APIRouter, Header, HTTPException from src.api.config import API_FLIGHTS from src.api.routes.auth import status as checkAuth from src.api.schemas.flight import Flight, FlightCreate, FlightStatusUpdate -from src.api.utils.network import request router = APIRouter() @@ -22,9 +22,11 @@ async def get_flight_by_id(id: int): async def create_flight( 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( - f"{API_FLIGHTS}", "POST", json=flight.model_dump() + f"{API_FLIGHTS}", "POST", json=flight_data ) if status < 200 or status > 204: raise HTTPException(status_code=status, detail=response) @@ -37,9 +39,11 @@ async def update_flight( status_update: FlightStatusUpdate, 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( - f"{API_FLIGHTS}/{id}", "PATCH", json=status_update.model_dump() + f"{API_FLIGHTS}/{id}", "PATCH", json=status ) if status < 200 or status > 204: raise HTTPException(status_code=status, detail=response) diff --git a/gateway/src/api/routes/notifications.py b/gateway/src/api/routes/notifications.py new file mode 100644 index 0000000..aa10d4f --- /dev/null +++ b/gateway/src/api/routes/notifications.py @@ -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 diff --git a/gateway/src/api/routes/subscriptions.py b/gateway/src/api/routes/subscriptions.py new file mode 100644 index 0000000..f55326d --- /dev/null +++ b/gateway/src/api/routes/subscriptions.py @@ -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 diff --git a/gateway/src/api/routes/users.py b/gateway/src/api/routes/users.py index 0313cb1..7ddab81 100644 --- a/gateway/src/api/routes/users.py +++ b/gateway/src/api/routes/users.py @@ -1,8 +1,8 @@ +from asyncreq import request from fastapi import APIRouter, HTTPException from src.api.config import API_USERS from src.api.schemas.user import User, UserRegister -from src.api.utils.network import request router = APIRouter() diff --git a/gateway/src/api/schemas/notification.py b/gateway/src/api/schemas/notification.py new file mode 100644 index 0000000..9650b51 --- /dev/null +++ b/gateway/src/api/schemas/notification.py @@ -0,0 +1,8 @@ +from typing import Any + +from pydantic import BaseModel + + +class Update(BaseModel): + update_id: int + message: Any diff --git a/gateway/src/api/schemas/subscriptions.py b/gateway/src/api/schemas/subscriptions.py new file mode 100644 index 0000000..2807f37 --- /dev/null +++ b/gateway/src/api/schemas/subscriptions.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class Subscription(BaseModel): + flight_id: int + user_id: int diff --git a/gateway/src/api/utils/network.py b/gateway/src/api/utils/network.py deleted file mode 100644 index c7e7274..0000000 --- a/gateway/src/api/utils/network.py +++ /dev/null @@ -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 diff --git a/run.sh b/run.sh index f683773..df5dcc4 100755 --- a/run.sh +++ b/run.sh @@ -75,6 +75,10 @@ if [ -n "$domain" ] && [ -n "$down" ]; then '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 ;; esac 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 up -d 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') docker build gateway -f gateway/Dockerfile.prod -t $USER/gateway:prod @@ -146,12 +164,14 @@ elif [ -n "$down" ]; then down_elk + export API_IMAGE=$USER/gateway:prod + docker compose -f gateway/docker-compose.yml down export API_IMAGE=$USER/flights-information:prod docker compose -f flights-domain/docker-compose.yml --env-file flights-domain/.env.prod down export API_IMAGE=$USER/user-manager:prod docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod down - export API_IMAGE=$USER/gateway:prod - docker compose -f gateway/docker-compose.yml down + export API_IMAGE=slococo/subs-manager:prod + docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.prod down export CLIENT_IMAGE=$USER/screen-client:prod docker compose -f screen-domain/docker-compose.yml down @@ -163,6 +183,8 @@ else export FLIGHTS_INFORMATION=flights-domain/flights-information 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 + 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 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 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 + 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 docker compose -f gateway/docker-compose.yml down docker compose -f gateway/docker-compose.yml up -d diff --git a/screen-domain/src/db.ts b/screen-domain/src/db.ts index 11f4b32..9a63554 100644 --- a/screen-domain/src/db.ts +++ b/screen-domain/src/db.ts @@ -60,7 +60,7 @@ export const addData = (storeName: string, data: T): Promise = }); }; -export const deleteData = (storeName: string, key: string): Promise => { +export const deleteData = (storeName: string, key: number): Promise => { return new Promise((resolve) => { request = indexedDB.open('myDB', version); @@ -80,7 +80,7 @@ export const deleteData = (storeName: string, key: string): Promise => }); }; -export const updateData = (storeName: string, key: string, data: T): Promise => { +export const updateData = (storeName: string, key: number, data: T): Promise => { return new Promise((resolve) => { request = indexedDB.open('myDB', version); diff --git a/screen-domain/src/hooks/useFetchZones.tsx b/screen-domain/src/hooks/useFetchZones.tsx index d8593d8..73cf036 100644 --- a/screen-domain/src/hooks/useFetchZones.tsx +++ b/screen-domain/src/hooks/useFetchZones.tsx @@ -7,6 +7,7 @@ import { Stores, addData, deleteData, getStoreData, updateData, initDB } from '. export const useFetchZones = () => { const [error, setError] = useState(null); const [zones, setZones] = useState([]); + let origin = process.env.REACT_APP_ORIGIN; useEffect(() => { @@ -22,10 +23,14 @@ export const useFetchZones = () => { fetchZones(origin, null) .then((data) => { localStorage.setItem('lastUpdated', newUpdate) - setZones(data); + let toAdd: Flight[] = [] data.map((u) => { - addData(Stores.Flight, u) + if (u.status != 'Deleted') { + addData(Stores.Flight, u) + toAdd.push(u) + } }) + setZones(toAdd); }) .catch((error) => {}); } @@ -42,20 +47,32 @@ export const useFetchZones = () => { .then((data) => { localStorage.setItem('lastUpdated', newUpdate) let toAdd: Flight[] = [] + let toRemove: Flight[] = [] zones.forEach((c, i) => { let index = data.findIndex(x => x.id === c.id) if (index >= 0) { - toAdd.push(data[index]); - console.log(",aria") - updateData(Stores.Flight, String(c.id), data[index]) + console.log(data[index].status) + if (data[index].status == 'Deleted') { + 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 { - toAdd.push(c); + if (c.status == 'Deleted') { + toRemove.push(c); + } else { + toAdd.push(c); + } } }); 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); filtered.forEach(c => { addData(Stores.Flight, c) diff --git a/subscription-domain/.env.dev.example b/subscription-domain/.env.dev.example new file mode 100644 index 0000000..84cc73e --- /dev/null +++ b/subscription-domain/.env.dev.example @@ -0,0 +1,5 @@ +POSTGRES_USER=user +POSTGRES_PASS=password +POSTGRES_DB=api_dev +APP_SETTINGS=src.config.DevelopmentConfig +TOKEN=3275588851:AT36AGy_BChQUuCq2M6d2UrY5CSWtZe45gV \ No newline at end of file diff --git a/subscription-domain/.env.prod.example b/subscription-domain/.env.prod.example new file mode 100644 index 0000000..d9c8136 --- /dev/null +++ b/subscription-domain/.env.prod.example @@ -0,0 +1,5 @@ +POSTGRES_USER=user +POSTGRES_PASS=password +POSTGRES_DB=api_prod +APP_SETTINGS=src.config.ProductionConfig +TOKEN=3275588851:AT36AGy_BChQUuCq2M6d2UrY5CSWtZe45gV \ No newline at end of file diff --git a/subscription-domain/db/Dockerfile b/subscription-domain/db/Dockerfile new file mode 100644 index 0000000..44b810d --- /dev/null +++ b/subscription-domain/db/Dockerfile @@ -0,0 +1,5 @@ +# pull official base image +FROM postgres:13.3 + +# run create.sql on init +ADD create.sql /docker-entrypoint-initdb.d \ No newline at end of file diff --git a/subscription-domain/db/create.sql b/subscription-domain/db/create.sql new file mode 100644 index 0000000..44a51ca --- /dev/null +++ b/subscription-domain/db/create.sql @@ -0,0 +1,3 @@ +CREATE DATABASE api_prod; +CREATE DATABASE api_dev; +CREATE DATABASE api_test; \ No newline at end of file diff --git a/subscription-domain/docker-compose.yml b/subscription-domain/docker-compose.yml new file mode 100644 index 0000000..2f1397e --- /dev/null +++ b/subscription-domain/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/subscription-domain/subscription-manager/.bandit.yml b/subscription-domain/subscription-manager/.bandit.yml new file mode 100644 index 0000000..96ed48e --- /dev/null +++ b/subscription-domain/subscription-manager/.bandit.yml @@ -0,0 +1,5 @@ + +exclude_dirs: + - src/tests +#tests: ['B201', 'B301'] +#skips: ['B101', 'B601'] \ No newline at end of file diff --git a/subscription-domain/subscription-manager/.coveragerc b/subscription-domain/subscription-manager/.coveragerc new file mode 100644 index 0000000..4e78546 --- /dev/null +++ b/subscription-domain/subscription-manager/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = src/tests/* +branch = True \ No newline at end of file diff --git a/subscription-domain/subscription-manager/.gitignore b/subscription-domain/subscription-manager/.gitignore new file mode 100644 index 0000000..4713fd7 --- /dev/null +++ b/subscription-domain/subscription-manager/.gitignore @@ -0,0 +1,7 @@ +**/__pycache__ +**/Pipfile.lock +.coverage +.pytest_cache +htmlcov +pact-nginx-ssl/nginx-selfsigned.* +src/tests/pacts \ No newline at end of file diff --git a/subscription-domain/subscription-manager/Dockerfile.prod b/subscription-domain/subscription-manager/Dockerfile.prod new file mode 100644 index 0000000..9e8ef04 --- /dev/null +++ b/subscription-domain/subscription-manager/Dockerfile.prod @@ -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"] diff --git a/subscription-domain/subscription-manager/Dockerfile.prod.dockerignore b/subscription-domain/subscription-manager/Dockerfile.prod.dockerignore new file mode 100644 index 0000000..243d713 --- /dev/null +++ b/subscription-domain/subscription-manager/Dockerfile.prod.dockerignore @@ -0,0 +1,9 @@ +env +.venv +Dockerfile.test +Dockerfile.prod +.coverage +.pytest_cache +htmlcov +src/tests +src/.cicd \ No newline at end of file diff --git a/subscription-domain/subscription-manager/Dockerfile.test b/subscription-domain/subscription-manager/Dockerfile.test new file mode 100644 index 0000000..a94f204 --- /dev/null +++ b/subscription-domain/subscription-manager/Dockerfile.test @@ -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"] diff --git a/subscription-domain/subscription-manager/Pipfile b/subscription-domain/subscription-manager/Pipfile new file mode 100644 index 0000000..2c45070 --- /dev/null +++ b/subscription-domain/subscription-manager/Pipfile @@ -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" diff --git a/subscription-domain/subscription-manager/entrypoint.sh b/subscription-domain/subscription-manager/entrypoint.sh new file mode 100755 index 0000000..6c6f67f --- /dev/null +++ b/subscription-domain/subscription-manager/entrypoint.sh @@ -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 diff --git a/subscription-domain/subscription-manager/requirements.test.txt b/subscription-domain/subscription-manager/requirements.test.txt new file mode 100644 index 0000000..b7b4a79 --- /dev/null +++ b/subscription-domain/subscription-manager/requirements.test.txt @@ -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 \ No newline at end of file diff --git a/subscription-domain/subscription-manager/requirements.txt b/subscription-domain/subscription-manager/requirements.txt new file mode 100644 index 0000000..eb2fac7 --- /dev/null +++ b/subscription-domain/subscription-manager/requirements.txt @@ -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 \ No newline at end of file diff --git a/subscription-domain/subscription-manager/setup.cfg b/subscription-domain/subscription-manager/setup.cfg new file mode 100644 index 0000000..ec4d2a5 --- /dev/null +++ b/subscription-domain/subscription-manager/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 119 \ No newline at end of file diff --git a/subscription-domain/subscription-manager/src/.cicd/test.sh b/subscription-domain/subscription-manager/src/.cicd/test.sh new file mode 100755 index 0000000..4306d82 --- /dev/null +++ b/subscription-domain/subscription-manager/src/.cicd/test.sh @@ -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 diff --git a/subscription-domain/subscription-manager/src/api/config.py b/subscription-domain/subscription-manager/src/api/config.py new file mode 100644 index 0000000..a9dc957 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/config.py @@ -0,0 +1 @@ +API_FLIGHTS = "http://fids_flights_api:5000/flights" diff --git a/subscription-domain/subscription-manager/src/api/cruds/chat.py b/subscription-domain/subscription-manager/src/api/cruds/chat.py new file mode 100644 index 0000000..148923c --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/cruds/chat.py @@ -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() diff --git a/subscription-domain/subscription-manager/src/api/cruds/subscription.py b/subscription-domain/subscription-manager/src/api/cruds/subscription.py new file mode 100644 index 0000000..f9624e0 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/cruds/subscription.py @@ -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() diff --git a/subscription-domain/subscription-manager/src/api/db.py b/subscription-domain/subscription-manager/src/api/db.py new file mode 100644 index 0000000..aa7110c --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/db.py @@ -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() diff --git a/subscription-domain/subscription-manager/src/api/main.py b/subscription-domain/subscription-manager/src/api/main.py new file mode 100644 index 0000000..2b89477 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/main.py @@ -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=["*"], +) diff --git a/subscription-domain/subscription-manager/src/api/models/chat.py b/subscription-domain/subscription-manager/src/api/models/chat.py new file mode 100644 index 0000000..1dee651 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/models/chat.py @@ -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) diff --git a/subscription-domain/subscription-manager/src/api/models/subscription.py b/subscription-domain/subscription-manager/src/api/models/subscription.py new file mode 100644 index 0000000..2f8fa98 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/models/subscription.py @@ -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) diff --git a/subscription-domain/subscription-manager/src/api/routes/health.py b/subscription-domain/subscription-manager/src/api/routes/health.py new file mode 100644 index 0000000..c3f059c --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/routes/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("", status_code=200) +async def get_health(): + return {"status": "OK"} diff --git a/subscription-domain/subscription-manager/src/api/routes/messages.py b/subscription-domain/subscription-manager/src/api/routes/messages.py new file mode 100644 index 0000000..381cf03 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/routes/messages.py @@ -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) diff --git a/subscription-domain/subscription-manager/src/api/routes/notifications.py b/subscription-domain/subscription-manager/src/api/routes/notifications.py new file mode 100644 index 0000000..5684129 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/routes/notifications.py @@ -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) diff --git a/subscription-domain/subscription-manager/src/api/routes/subscriptions.py b/subscription-domain/subscription-manager/src/api/routes/subscriptions.py new file mode 100644 index 0000000..af64453 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/routes/subscriptions.py @@ -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) diff --git a/subscription-domain/subscription-manager/src/api/schemas/chat.py b/subscription-domain/subscription-manager/src/api/schemas/chat.py new file mode 100644 index 0000000..b8bc5cc --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/schemas/chat.py @@ -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 diff --git a/subscription-domain/subscription-manager/src/api/schemas/subscription.py b/subscription-domain/subscription-manager/src/api/schemas/subscription.py new file mode 100644 index 0000000..f1d7550 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/schemas/subscription.py @@ -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 diff --git a/subscription-domain/subscription-manager/src/api/utils/messages.py b/subscription-domain/subscription-manager/src/api/utils/messages.py new file mode 100644 index 0000000..e131d63 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/utils/messages.py @@ -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." + ) diff --git a/subscription-domain/subscription-manager/src/api/utils/telegram.py b/subscription-domain/subscription-manager/src/api/utils/telegram.py new file mode 100644 index 0000000..7795b53 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/utils/telegram.py @@ -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' diff --git a/subscription-domain/subscription-manager/src/config.py b/subscription-domain/subscription-manager/src/config.py new file mode 100644 index 0000000..7387ace --- /dev/null +++ b/subscription-domain/subscription-manager/src/config.py @@ -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 diff --git a/subscription-domain/subscription-manager/src/tests/pytest.ini b/subscription-domain/subscription-manager/src/tests/pytest.ini new file mode 100644 index 0000000..e69de29