First go at it
This commit is contained in:
parent
13af3b78eb
commit
9ae5ee3ef5
8 changed files with 223 additions and 11 deletions
28
.gitea/workflows/build-api-stable.yml
Normal file
28
.gitea/workflows/build-api-stable.yml
Normal file
|
@ -0,0 +1,28 @@
|
|||
name: Build image - Stable
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-app-stable:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PACKAGE_SERVER: git.bigun.dev
|
||||
PACKAGE_USER: evan
|
||||
PACKAGE_REPO: evan/AtmoAssistant
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build & Deploy
|
||||
run: |
|
||||
version=$(echo ${{ github.ref }} | awk -F "v" '{print $2}')
|
||||
cd app
|
||||
echo ${{ secrets.PACKAGE_TOKEN }} | docker login -u ${{ env.PACKAGE_USER }} --password-stdin ${{ env.PACKAGE_SERVER }}
|
||||
docker build -t ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:stable .
|
||||
docker build -t ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:$version .
|
||||
docker push ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:stable
|
||||
docker push ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:$version
|
||||
|
25
.gitea/workflows/build-api-testing.yml
Normal file
25
.gitea/workflows/build-api-testing.yml
Normal file
|
@ -0,0 +1,25 @@
|
|||
name: Build image - Testing
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.gitea/workflows/build-api-testing.yml'
|
||||
|
||||
jobs:
|
||||
build-api-testing:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PACKAGE_SERVER: git.bigun.dev
|
||||
PACKAGE_USER: evan
|
||||
PACKAGE_REPO: evan/AtmoAssistant
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build & Deploy
|
||||
run: |
|
||||
cd api
|
||||
echo ${{ secrets.PACKAGE_TOKEN }} | docker login -u ${{ env.PACKAGE_USER }} --password-stdin ${{ env.PACKAGE_SERVER }}
|
||||
docker build -t ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:testing .
|
||||
docker push ${{ env.PACKAGE_SERVER }}/${{ env.PACKAGE_REPO }}:testing
|
|
@ -8,7 +8,7 @@ ENV PROMETHEUS_MULTIPROC_DIR=/dev/shm
|
|||
ENV PAPERSIZE=letter
|
||||
|
||||
# Defining working directory and adding source code
|
||||
WORKDIR /template
|
||||
WORKDIR /AtmoAssistant
|
||||
COPY . .
|
||||
|
||||
# Install requirements
|
||||
|
|
|
@ -4,6 +4,9 @@ import tempfile
|
|||
env_DEBUG = os.environ.get("DEBUG", "").lower() == "true"
|
||||
|
||||
env_SECURE = os.environ.get("SECURE", "").lower() == "true"
|
||||
env_OWM_KEY = os.environ.get("OWM_API_KEY", "")
|
||||
env_OWM_UNITS = os.environ.get("OWM_UNITS", "")
|
||||
env_AUTHORIZED_CALLERS = list(os.environ.get("AUTHORIZED_CALLERS", ""))
|
||||
env_SECRET_KEY = os.environ.get("SECRET_KEY", os.urandom(24))
|
||||
if not env_SECRET_KEY:
|
||||
env_SECRET_KEY = os.urandom(24)
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
services:
|
||||
template:
|
||||
container_name: template
|
||||
image: git.bigun.dev/evan/template:stable
|
||||
AtmoAssistant:
|
||||
container_name: AtmoAssistant
|
||||
image: git.bigun.dev/evan/AtmoAssistant:stable
|
||||
ports:
|
||||
- 80:5000 # API
|
||||
- 9200:9200 # Prometheus
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime
|
||||
- ./database:/template/instance
|
||||
- ./database:/AtmoAssistant/instance
|
||||
environment:
|
||||
- DEBUG=FALSE # Enables debug route and Flask's debug mode
|
||||
- SECRET_KEY="" # Should be a long random value, randomly regenerated every launch if not specified
|
||||
- SECRET_KEY= # Should be a long random value, randomly regenerated every launch if not specified
|
||||
- SECURE=FALSE # Set to True when using HTTPS
|
||||
- OWM_API_KEY= # API key from OpenWeatherMap (One Call 3.0 and Geocoding)
|
||||
- OWM_UNITS= # Units for OpenWeatherMap (Standard, Metric, Imperial)
|
||||
- AUTHORIZED_CALLERS= # Comma seperated list of authorized phone numbers, eg +13365550916,+13365553721
|
||||
|
|
|
@ -2,3 +2,4 @@ Flask==3.1.0
|
|||
flask_sqlalchemy==3.1.1
|
||||
gunicorn==23.0.0
|
||||
prometheus-flask-exporter==0.23.1
|
||||
twilio==9.4.3
|
|
@ -1,10 +1,99 @@
|
|||
from flask import jsonify, request
|
||||
from twilio.twiml.voice_response import VoiceResponse, Gather
|
||||
|
||||
from utils import (
|
||||
logger,
|
||||
validate_data_presence,
|
||||
)
|
||||
from utils import logger, validate_data_presence, _get_weather, _get_cords
|
||||
|
||||
# from config import
|
||||
from . import routes as app
|
||||
from . import by_path_counter
|
||||
|
||||
|
||||
@app.route("/voice", methods=["GET", "POST"])
|
||||
@by_path_counter
|
||||
def voice():
|
||||
"""Respond to incoming phone calls with a menu of options"""
|
||||
# Start our TwiML response
|
||||
resp = VoiceResponse()
|
||||
|
||||
from_number = request.form["From"]
|
||||
if from_number not in env_AUTHORIZED_CALLERS:
|
||||
resp.say("You are calling from an unauthorized number. Goodbye.")
|
||||
return str(resp)
|
||||
|
||||
resp.say("Welcome to Atmo Assistant.")
|
||||
# Start our <Gather> verb
|
||||
gather = Gather(num_digits=1, action="/gather")
|
||||
gather.say(
|
||||
"Main menu. Enter one for Asheboro. Two for Lynchburg. Three for Cullowhee. Zero for another zipcode."
|
||||
)
|
||||
resp.append(gather)
|
||||
|
||||
# If the user doesn't select an option, redirect them into a loop
|
||||
resp.redirect("/voice")
|
||||
|
||||
return str(resp)
|
||||
|
||||
|
||||
@app.route("/gather", methods=["GET", "POST"])
|
||||
@by_path_counter
|
||||
def gather():
|
||||
"""Processes results from the <Gather> prompt in /voice"""
|
||||
# Start our TwiML response
|
||||
resp = VoiceResponse()
|
||||
|
||||
# If Twilio's request to our app included already gathered digits,
|
||||
# process them
|
||||
if "Digits" in request.values:
|
||||
# Get which digit the caller chose
|
||||
choice = request.values["Digits"]
|
||||
|
||||
# <Say> a different message depending on the caller's choice
|
||||
if choice == "1":
|
||||
weather = _get_weather(35.6396, -79.8509)
|
||||
resp.say(weather)
|
||||
elif choice == "2":
|
||||
weather = _get_weather(37.3490, -79.1787)
|
||||
resp.say(weather)
|
||||
elif choice == "3":
|
||||
weather = _get_weather(35.3087, -83.1861)
|
||||
resp.say(weather)
|
||||
elif choice == "0":
|
||||
custom = Gather(num_digits=5, action="/custom")
|
||||
custom.say("Enter a zipcode for its weather.")
|
||||
resp.append(custom)
|
||||
else:
|
||||
# If the caller didn't choose 1 or 2, apologize and ask them again
|
||||
resp.say("Sorry, I don't understand that choice.")
|
||||
|
||||
# If the user didn't choose 1 or 2 (or anything), send them back to /voice
|
||||
resp.redirect("/voice")
|
||||
|
||||
return str(resp)
|
||||
|
||||
|
||||
@app.route("/custom", methods=["GET", "POST"])
|
||||
@by_path_counter
|
||||
def custom():
|
||||
"""Processes results from the <Gather> prompt in /gather"""
|
||||
# Start our TwiML response
|
||||
resp = VoiceResponse()
|
||||
|
||||
# If Twilio's request to our app included already gathered digits,
|
||||
# process them
|
||||
if "Digits" in request.values:
|
||||
# Get which digit the caller chose
|
||||
choice = request.values["Digits"]
|
||||
|
||||
# <Say> a different message depending on the caller's choice
|
||||
if len(choice) == 5:
|
||||
weather = _get_weather(_get_cords(choice))
|
||||
resp.say(weather)
|
||||
return str(resp)
|
||||
else:
|
||||
# If the caller didn't choose 1 or 2, apologize and ask them again
|
||||
resp.say("Sorry, I don't understand that choice.")
|
||||
|
||||
# If the user didn't choose 1 or 2 (or anything), send them back to /voice
|
||||
resp.redirect("/voice")
|
||||
|
||||
return str(resp)
|
||||
|
|
63
app/utils.py
63
app/utils.py
|
@ -4,10 +4,12 @@ from urllib import parse
|
|||
import logging
|
||||
import re
|
||||
import typing as t
|
||||
import requests
|
||||
|
||||
import models
|
||||
|
||||
logger = logging.getLogger("gunicorn.error")
|
||||
weather_template = "The current temperature is {0} and feels like {1}. The high today is {2} with a low of {3}. The current humidity is {4} percent. The summary for today is: {5}."
|
||||
|
||||
|
||||
def str_none(x):
|
||||
|
@ -47,3 +49,64 @@ def validate_data_presence(data: t.Dict[str, t.Any], keys: list[str]) -> bool:
|
|||
if key not in data:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_weather(lat, long):
|
||||
try:
|
||||
if lat is None or long is None:
|
||||
return "An error has occured and the provided zipcode could not be understood."
|
||||
|
||||
weather_json = _get_weather_json(lat, long)
|
||||
if weather_json is None:
|
||||
return "An error has occured and the weather could not be retrieved."
|
||||
|
||||
weather = weather_template.format(
|
||||
weather_json.current.temp,
|
||||
weather_json.current.feels_like,
|
||||
weather_json.daily[0].temp.max,
|
||||
weather_json.daily[0].temp.min,
|
||||
weather_json.current.humidity,
|
||||
weather_json.daily[0].summary,
|
||||
)
|
||||
return weather
|
||||
except Exception as e:
|
||||
logger.error("Error in _get_weather: " + e)
|
||||
return "An error has occured and the weather could not be retrieved."
|
||||
|
||||
|
||||
def _get_weather_json(lat, long):
|
||||
url = "https://api.openweathermap.org/data/3.0/onecall?lat={0}&lon={1}&exclude=alerts,minutely,hourly&units={2}&appid={3}".format(
|
||||
lat, long, env_OWM_UNITS, env_OWM_KEY
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
weather = response.json()
|
||||
return weather
|
||||
else:
|
||||
logger.error("Error in _get_weather_json: " + str(response.status_code))
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Error in _get_weather_json: " + e)
|
||||
return None
|
||||
|
||||
|
||||
def _get_cords(zipcode):
|
||||
url = "http://api.openweathermap.org/geo/1.0/zip?zip={0},US&appid={1}".format(
|
||||
zipcode, env_OWM_KEY
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
zipcode = response.json()
|
||||
return zipcode.lat, zipcode.long
|
||||
else:
|
||||
logger.error("Error in _get_cords: " + str(response.status_code))
|
||||
return None, None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Error in _get_cords: " + e)
|
||||
return None, None
|
||||
|
|
Loading…
Reference in a new issue