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.prod.example
node_modules
*.xml
*.xml
notification-domain/
TODO.txt

View File

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

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.
### 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`.

View File

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

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

View File

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

View File

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

View File

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

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

11
run.sh
View File

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

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