Add subscription-domain

Enables users to subscribe to any flight and receive updates via Telegram.
This commit is contained in:
Santiago Lo Coco 2023-10-27 14:45:48 -03:00
parent fcc2189674
commit 3e7515b9b1
50 changed files with 753 additions and 19 deletions

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -25,7 +25,9 @@ def create_flight(flight: FlightCreate, db: Session = Depends(get_db)):
@router.patch("/{id}", response_model=Flight) @router.patch("/{id}", response_model=Flight)
def update_flight(id: int, status: FlightStatusUpdate, db: Session = Depends(get_db)): 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]) @router.get("", response_model=list[Flight])

View File

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

View File

@ -21,6 +21,7 @@ services:
- auth - auth
- flights - flights
- gateway - gateway
- subscriptions
networks: networks:
auth: auth:
@ -29,5 +30,8 @@ networks:
flights: flights:
name: flights-domain_flights name: flights-domain_flights
external: true external: true
subscriptions:
name: subscription-domain_subscriptions
external: true
gateway: gateway:
driver: bridge driver: bridge

View File

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

View File

@ -1,13 +1,16 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from src.api.routes import auth, flights, health, users from src.api.routes import (auth, flights, health, notifications,
subscriptions, users)
app = FastAPI(title="Flights Information API") app = FastAPI(title="Flights Information API")
app.include_router(flights.router, prefix="/flights") app.include_router(flights.router, prefix="/flights")
app.include_router(health.router, prefix="/health") app.include_router(health.router, prefix="/health")
app.include_router(auth.router, prefix="/auth") app.include_router(auth.router, prefix="/auth")
app.include_router(users.router, prefix="/users") app.include_router(users.router, prefix="/users")
app.include_router(subscriptions.router, prefix="/subscriptions")
app.include_router(notifications.router, prefix="/notifications")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[

View File

@ -2,7 +2,7 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Header, HTTPException from fastapi import APIRouter, Header, HTTPException
from src.api.config import API_FLIGHTS from src.api.config import API_FLIGHTS, API_MESSAGES
from src.api.routes.auth import status as checkAuth from src.api.routes.auth import status as checkAuth
from src.api.schemas.flight import Flight, FlightCreate, FlightStatusUpdate from src.api.schemas.flight import Flight, FlightCreate, FlightStatusUpdate
from src.api.utils.network import request from src.api.utils.network import request
@ -41,6 +41,14 @@ async def update_flight(
(response, status, _) = await request( (response, status, _) = await request(
f"{API_FLIGHTS}/{id}", "PATCH", json=status_update.model_dump() f"{API_FLIGHTS}/{id}", "PATCH", json=status_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: if status < 200 or status > 204:
raise HTTPException(status_code=status, detail=response) raise HTTPException(status_code=status, detail=response)
return response return response

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ from typing import Optional
import aiohttp import aiohttp
import async_timeout import async_timeout
from aiohttp import ClientConnectorError, ContentTypeError, JsonPayload from aiohttp import ClientConnectorError, ContentTypeError, JsonPayload
from fastapi import HTTPException from fastapi import HTTPException, Response
async def make_request( async def make_request(
@ -20,7 +20,10 @@ async def make_request(
async with session.request( async with session.request(
method=method, url=url, params=query, data=data, json=json method=method, url=url, params=query, data=data, json=json
) as response: ) 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 decoded_json = response_json
return decoded_json, response.status, response.headers return decoded_json, response.status, response.headers

11
run.sh
View File

@ -104,12 +104,14 @@ elif [ -n "$domain" ] && [ -z "$down" ]; then
*) exit 1 ;; *) exit 1 ;;
esac esac
elif [ -n "$down" ]; then 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 export API_IMAGE=$USER/flights-information:prod
docker compose -f flights-domain/docker-compose.yml --env-file flights-domain/.env.prod down docker compose -f flights-domain/docker-compose.yml --env-file flights-domain/.env.prod down
export API_IMAGE=$USER/user-manager:prod export API_IMAGE=$USER/user-manager:prod
docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod down docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod down
export API_IMAGE=$USER/gateway:prod export API_IMAGE=slococo/subs-manager:prod
docker compose -f gateway/docker-compose.yml down docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.prod down
export CLIENT_IMAGE=$USER/screen-client:prod export CLIENT_IMAGE=$USER/screen-client:prod
docker compose -f screen-domain/docker-compose.yml down docker compose -f screen-domain/docker-compose.yml down
@ -121,6 +123,8 @@ else
export FLIGHTS_INFORMATION=flights-domain/flights-information export FLIGHTS_INFORMATION=flights-domain/flights-information
docker build $FLIGHTS_INFORMATION -f $FLIGHTS_INFORMATION/Dockerfile.prod -t $USER/flights-information:prod docker build $FLIGHTS_INFORMATION -f $FLIGHTS_INFORMATION/Dockerfile.prod -t $USER/flights-information:prod
docker build gateway -f gateway/Dockerfile.prod -t $USER/gateway:prod docker build gateway -f gateway/Dockerfile.prod -t $USER/gateway:prod
export SUBSCRIPTION_MANAGER=subscription-domain/subscription-manager
docker build $SUBSCRIPTION_MANAGER -f $SUBSCRIPTION_MANAGER/Dockerfile.prod -t $USER/subs-manager:prod
docker build screen-domain -f screen-domain/Dockerfile.prod --build-arg "REACT_APP_ORIGIN=$REACT_APP_ORIGIN" -t $USER/screen-client:prod docker build screen-domain -f screen-domain/Dockerfile.prod --build-arg "REACT_APP_ORIGIN=$REACT_APP_ORIGIN" -t $USER/screen-client:prod
docker build browser-domain -f browser-domain/Dockerfile.prod -t $USER/browser-client:prod docker build browser-domain -f browser-domain/Dockerfile.prod -t $USER/browser-client:prod
@ -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 up -d
docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod exec usermanager-api python manage.py recreate_db docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod exec usermanager-api python manage.py recreate_db
docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod exec usermanager-api python manage.py seed_db docker compose -f auth-domain/docker-compose.yml --env-file auth-domain/.env.prod exec usermanager-api python manage.py seed_db
export API_IMAGE=slococo/subs-manager:prod
docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.prod down
docker compose -f subscription-domain/docker-compose.yml --env-file subscription-domain/.env.prod up -d
export API_IMAGE=$USER/gateway:prod export API_IMAGE=$USER/gateway:prod
docker compose -f gateway/docker-compose.yml down docker compose -f gateway/docker-compose.yml down
docker compose -f gateway/docker-compose.yml up -d docker compose -f gateway/docker-compose.yml up -d

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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())

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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!"
)

View File

@ -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

View File

@ -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'

View File

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