diff --git a/.gitignore b/.gitignore index 628a4dc..31072e4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ !.env.dev.example !.env.prod.example node_modules -*.xml \ No newline at end of file +*.xml +notification-domain/ +TODO.txt 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/flights-domain/flights-information/src/api/routes/flights.py b/flights-domain/flights-information/src/api/routes/flights.py index 262ee84..bf7c310 100644 --- a/flights-domain/flights-information/src/api/routes/flights.py +++ b/flights-domain/flights-information/src/api/routes/flights.py @@ -25,7 +25,9 @@ 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) + db_flight = flight_crud.update_flight_status(db=db, id=id, status=status.status) + # push to queue with BackgroundTasks + return db_flight @router.get("", response_model=list[Flight]) 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 43c4f7a..64c8007 100644 --- a/gateway/docker-compose.yml +++ b/gateway/docker-compose.yml @@ -21,6 +21,7 @@ services: - auth - flights - gateway + - subscriptions networks: auth: @@ -29,5 +30,8 @@ networks: flights: name: flights-domain_flights external: true + subscriptions: + name: subscription-domain_subscriptions + external: true gateway: driver: bridge \ No newline at end of file diff --git a/gateway/src/api/config.py b/gateway/src/api/config.py index 1f68bc3..80a6744 100644 --- a/gateway/src/api/config.py +++ b/gateway/src/api/config.py @@ -1,3 +1,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" +API_SUBSCRIPTIONS = "http://fids_subscriptions_api:5000/subscriptions" +API_NOTIFICATIONS = "http://fids_subscriptions_api:5000/notifications" +API_MESSAGES = "http://fids_subscriptions_api:5000/messages" \ No newline at end of file diff --git a/gateway/src/api/main.py b/gateway/src/api/main.py index ed0b3a9..eac7982 100644 --- a/gateway/src/api/main.py +++ b/gateway/src/api/main.py @@ -1,13 +1,16 @@ 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) app = FastAPI(title="Flights Information API") 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/flights.py b/gateway/src/api/routes/flights.py index 85d1fb4..63fb601 100644 --- a/gateway/src/api/routes/flights.py +++ b/gateway/src/api/routes/flights.py @@ -2,7 +2,7 @@ from typing import Annotated, Optional from fastapi import APIRouter, Header, HTTPException -from src.api.config import API_FLIGHTS +from src.api.config import API_FLIGHTS, API_MESSAGES 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 @@ -41,6 +41,14 @@ async def update_flight( (response, status, _) = await request( f"{API_FLIGHTS}/{id}", "PATCH", json=status_update.model_dump() ) + if status < 200 or status > 204: + raise HTTPException(status_code=status, detail=response) + # TODO: move to flights-domain + msg = response + msg["id"] = id + (response, status, _) = await request( + f"{API_MESSAGES}", "POST", json=msg + ) if status < 200 or status > 204: raise HTTPException(status_code=status, detail=response) return response diff --git a/gateway/src/api/routes/notifications.py b/gateway/src/api/routes/notifications.py new file mode 100644 index 0000000..0006580 --- /dev/null +++ b/gateway/src/api/routes/notifications.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Header, HTTPException + +from src.api.config import (API_FLIGHTS, API_NOTIFICATIONS, API_SUBSCRIPTIONS, + API_USERS) +from src.api.schemas.notification import Update as Message +from src.api.utils.network import request + +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..73a2bc1 --- /dev/null +++ b/gateway/src/api/routes/subscriptions.py @@ -0,0 +1,26 @@ +from typing import Annotated + +from fastapi import APIRouter, Header, HTTPException + +from src.api.config import (API_FLIGHTS, API_NOTIFICATIONS, API_SUBSCRIPTIONS, + API_USERS) +from src.api.routes.auth import status as checkAuth +from src.api.schemas.subscriptions import Subscription +from src.api.utils.network import request + +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/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 index c7e7274..ed30b45 100644 --- a/gateway/src/api/utils/network.py +++ b/gateway/src/api/utils/network.py @@ -3,7 +3,7 @@ from typing import Optional import aiohttp import async_timeout from aiohttp import ClientConnectorError, ContentTypeError, JsonPayload -from fastapi import HTTPException +from fastapi import HTTPException, Response async def make_request( @@ -20,7 +20,10 @@ async def make_request( async with session.request( method=method, url=url, params=query, data=data, json=json ) as response: - response_json = await response.json() + if response.status == 204: + response_json = Response(status_code=204) + else: + response_json = await response.json() decoded_json = response_json return decoded_json, response.status, response.headers diff --git a/run.sh b/run.sh index 6913deb..b271d5d 100755 --- a/run.sh +++ b/run.sh @@ -104,12 +104,14 @@ elif [ -n "$domain" ] && [ -z "$down" ]; then *) exit 1 ;; esac elif [ -n "$down" ]; then + 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 @@ -121,6 +123,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 @@ -133,6 +137,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/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..c81e8c4 --- /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 +aiohttp==3.8.6 \ 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..39722c0 --- /dev/null +++ b/subscription-domain/subscription-manager/src/.cicd/test.sh @@ -0,0 +1,21 @@ +#!/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 + + ## Coverage + python -m pytest "src/tests" -p no:warnings --cov="src" --cov-report 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..12343ca --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/config.py @@ -0,0 +1 @@ +API_FLIGHTS = "http://fids_flights_api:5000/flights" \ No newline at end of file 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..ca74576 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/routes/notifications.py @@ -0,0 +1,61 @@ +import re + +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, FlightData, Update +from src.api.utils import telegram +from src.api.utils.messages import get_flight_message +from src.api.utils.network import request + +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=f"You sent an invalid option. Sorry!" + 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]) + print(flight_id) + (response, status, _) = await request(f"{API_FLIGHTS}/{flight_id}", "GET") + print(response) + if status < 200 or status > 204: + msg=f"Could not get flight '{flight_id}'. Sorry!" + msg = get_flight_message(response) + print(msg) + background_tasks.add_task(telegram.send_message, chat_id, msg) + + return Response(status_code=204) + + +# @router.put("/{user_id}") +# async def send_notification(user_id: int, data: FlightData, db: Session = Depends(get_db)): +# chat_id = notif_crud.get_chat_id(db=db, user_id=user_id) +# if chat_id is None: +# raise HTTPException() +# telegram.send_message(chat_id=chat_id, message=data.model_dump()) 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..e4e59b9 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/utils/messages.py @@ -0,0 +1,27 @@ +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!" + ) diff --git a/subscription-domain/subscription-manager/src/api/utils/network.py b/subscription-domain/subscription-manager/src/api/utils/network.py new file mode 100644 index 0000000..c7e7274 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/utils/network.py @@ -0,0 +1,37 @@ +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/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..1077c62 --- /dev/null +++ b/subscription-domain/subscription-manager/src/api/utils/telegram.py @@ -0,0 +1,13 @@ +import os + +from src.api.utils.network 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" + 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