Compare commits

...

3 Commits

Author SHA1 Message Date
mcotton a3c530bac8 updated python in docker, fixed port mapping for workstation 2023-10-25 21:36:55 -05:00
Mark Cotton 693b6c1069 ignoring local db file 2023-10-05 21:21:45 -05:00
Mark Cotton e501a5b1b2 merging database branch 2023-10-05 21:18:39 -05:00
9 changed files with 879 additions and 30 deletions

4
.gitignore vendored
View File

@ -10,5 +10,7 @@ my_settings.py
flask_session/
git-version.txt
settings.py
*.db
videos/*
instance/project.db

View File

@ -1,5 +1,5 @@
FROM python:3.9-slim
FROM python:3.10-slim
COPY ./requirements.txt /app/requirements.txt

View File

@ -23,8 +23,22 @@ config = {
"server_port": "3333",
"server_path": "login_callback",
# preferences
"log_level": "INFO"
# how many days of history should be requested be default, more == slower API call
"days_of_history": 1,
"log_level": "INFO",
# determines directory where videos should be stored, see formating option below, don't include trailing slash
"video_dir": "videos",
# Folder structure that will be created, default is to save as:
# {video_dir}/{camera_device_id}/{start.year}/{start.month}/{start.day}/
# Setting this to False to save it as:
# {video_dir}/{start.year}/{start.month}/{start.day}/{camera_device_id}/
"path_esn_first": True,
# default is set at 4, but we feel the need for speed
"num_of_threads_in_pool": 4
}
```
@ -35,3 +49,39 @@ You can create your application and setup credentials at: [https://developerv3.e
## Ideas on how to use ##
I encluded an example Flask server in `app.py`. You can run it locally with `python flask run -p 3333 --debug`
## EagleEyev3 Version ##
Using EagleEyev3 version *0.0.24*
## Routes ##
### Command ###
`$ flask routes -s rule`
### Output ###
| Endpoint | Methods | Rule |
| --------------------- | ------- | -------------------------------------------- |
| index | GET | / |
| accounts | GET | /accounts |
| camera_live_preivew | GET | /camera/<esn>/preview |
| camera__preivew_image | GET | /camera/<esn>/preview_image |
| camera_video_player | GET | /camera/<esn>/video_player/<start_timestamp> |
| camera_list_of_videos | GET | /camera/<esn>/videos |
| cameras | GET | /cameras |
| landing | GET | /landing |
| login_callback | GET | /login_callback |
| logout | GET | /logout |
| static | GET | /static/<path:filename> |
| switch_account | GET | /switch_account |

130
app.py
View File

@ -9,7 +9,7 @@ from matplotlib.figure import Figure
import matplotlib.dates as mdates
import base64
from tqdm import tqdm
import datetime
from io import BytesIO
import logging
@ -35,12 +35,14 @@ if config:
if 'log_level' in config:
logger.setLevel(config['log_level'])
else:
logging.setLevel('INFO')
if 'client_secret' in config:
SECRET_KEY = config['client_secret']
else:
logging.setLevel(config['INFO'])
logging.setLevel('INFO')
@ -58,22 +60,20 @@ app.config.from_object(__name__)
Session(app)
# $ flask routes -s rule
# INFO:root:Using EagleEyev3 version 0.0.17
# Endpoint Methods Rule
# --------------------- ------- --------------------------------------------
# index GET /
# accounts GET /accounts
# camera_live_preivew GET /camera/<esn>/preview
# camera__preivew_image GET /camera/<esn>/preview_image
# camera_video_player GET /camera/<esn>/video_player/<start_timestamp>
# camera_list_of_videos GET /camera/<esn>/videos
# cameras GET /cameras
# landing GET /landing
# login_callback GET /login_callback
# logout GET /logout
# static GET /static/<path:filename>
# switch_account GET /switch_account
from flask_sqlalchemy import SQLAlchemy
from models import *
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///project.db"
db.init_app(app)
with app.app_context():
db.create_all()
def login_required(f):
@ -140,6 +140,8 @@ def index():
een.get_list_of_cameras()
een.get_list_of_accounts()
save_list_of_cameras(een=een)
if len(een.accounts) > 0 and een.active_account == None:
# they need to pick and account
@ -186,24 +188,78 @@ def login_callback():
logging.debug(oauth_object)
# let's try resaving this to see if it fixs the missing access_token on first request after login
session['een'] = een
een.get_current_user()
try:
user = User.query.filter(User.email == een.current_user['email']).first()
if user is None:
logging.debug(f"no user found with email {een.current_user['email']}, creating new user")
user = User(account_id=een.current_user['accountId'],\
user_id=een.current_user['id'],\
email=een.current_user['email'],\
timezone=een.current_user['timeZone']['timeZone'],\
access_token=een.access_token,\
refresh_token=een.refresh_token,\
last_login=datetime.now())
else:
logging.debug(f"found user with {een.current_user['email']}, updating")
user.last_login = datetime.now()
user.access_token = een.access_token
user.refresh_token = een.refresh_token
logging.debug(f"saving user object")
user.save()
except Exception as e:
logging.warn(e)
return redirect(url_for('index'))
@app.route('/logout')
def logout():
# logout isn't working APIv3 right now so don't wait for the call to fail before logging out
# if 'een' in session:
# een = session['een']
# een.logout()
if 'een' in session:
een = session['een']
logging.debug(f"calling logout { een.access_token = }")
een.logout()
session.pop('een')
return redirect(url_for('index'))
def save_list_of_cameras(een=None):
user = User.query.filter(User.email == een.current_user['email']).first()
if user:
user_id = user.id
else:
user_id = None
for c in een.cameras:
try:
check_camera = Camera.query.filter(Camera.device_id == c.id).first()
if check_camera is None:
logging.debug(f"no camera found for {c.id}, creating new camera")
check_camera = Camera(device_id=c.id,\
timezone=None, \
name=c.name,
user_id=user_id)
else:
logging.debug(f"found camera for {c.id}")
logging.debug(f"saving camera object")
check_camera.save()
except Exception as e:
logging.warn(e)
@app.route('/cameras')
@login_required
def cameras():
@ -213,6 +269,9 @@ def cameras():
logging.debug(een.get_list_of_cameras())
save_list_of_cameras(een=een)
values = {
"current_user": een.current_user,
"cameras": een.cameras,
@ -327,7 +386,30 @@ def camera_list_of_videos(esn=None):
een = session['een']
camera = een.get_camera_by_id(esn)
logging.debug(camera.get_list_of_videos(start_timestamp=een.time_before(hours=24), end_timestamp=een.time_now()))
logging.debug(camera.get_list_of_videos(start_timestamp=een.time_before(hours=24 * DAYS_OF_HISTORY), end_timestamp=een.time_now()))
db_camera = Camera.query.filter(Camera.device_id == esn).first()
if db_camera:
for v in camera.videos:
try:
check_video = Download.query.filter(Download.camera_id == db_camera.id)\
.filter(Download.start_timestamp == datetime.fromisoformat(v['startTimestamp']))\
.first()
if check_video is None:
logging.debug(f"creating a new record for {db_camera.id} {v['startTimestamp']}")
new_video = Download(url=v['mp4Url'],\
camera_id=db_camera.id,\
start_timestamp=datetime.fromisoformat(v['startTimestamp']),\
end_timestamp=datetime.fromisoformat(v['endTimestamp']),\
status='new',
error=None)
new_video.save()
except Exception as e:
logging.warn(f"Exception saving videos to db: {e}")
values = {
"current_user": een.current_user,
@ -348,4 +430,4 @@ def camera_list_of_videos(esn=None):
if __name__ == '__main__':
app.run(host=config.server_host, port=config.server_port)
app.run(host=config['server_host'], port=config['server_port'])

View File

@ -6,7 +6,7 @@ services:
volumes:
- .:/app
ports:
- "9400:3000"
- "3333:3000"
restart: always
tty: true
user: 1000:1000

172
download_worker.py Normal file
View File

@ -0,0 +1,172 @@
import requests
import json
import glob
import sys
import os
from multiprocessing import Process, Pool
import logging
logger = logging.getLogger()
logger.setLevel('DEBUG')
from EagleEyev3 import *
from settings import config
import sqlite3
con = sqlite3.connect('instance/project.db')
cur = con.cursor()
def run(args):
# iterate through the first argument, appending the second and third to each iteration
args = [([i], args[1], args[2]) for i in args[0]]
pool.map(download, args, 1)
def download(args):
# explode arguments into variable names
row, een_obj, cam = args
row = row[0]
camera_device_id = row[2]
start = row[3]
end = row[4]
start = datetime.fromisoformat(start)
video_dir = 'videos'
if 'video_dir' in config:
video_dir = config['video_dir']
path_esn_first = True
if 'path_esn_first' in config:
path_esn_first = config['path_esn_first']
if path_esn_first:
path = f"{video_dir}/{camera_device_id}/{start.year}/{start.month}/{start.day}/"
else:
path = f"{video_dir}/{camera_device_id}/{start.year}/{start.month}/{start.day}/{camera_device_id}"
os.makedirs(path, exist_ok=True)
fname = f"{path}/{camera_device_id}_{start}-{end}.mp4"
if os.path.isfile(fname) == False:
save_result = cam.save_video_to_file(url=row[1], filename=fname)
save_code = save_result['response_http_status']
match save_code:
case 200:
save_status = 'done'
case 400 | 404 | 409:
save_status = 'client failure'
case 401 | 403:
save_status = 'auth failure'
case 500 | 502 | 503 | 504:
save_status = 'cloud failure'
case _:
save_status = 'unknown failure'
else:
# file exists, don't do anything and mark as done
save_status = 'already exists'
save_code = None
new_cur = con.cursor()
new_cur.execute("update download set status = ?, error = ? where download.id == ?;", (save_status, save_code, row[0]))
con.commit()
return (save_code, row[0])
if __name__ == '__main__':
# the settings object to see how many threads we should run in the pool
num_of_threads_in_pool = 4
if 'num_of_threads_in_pool' in config:
num_of_threads_in_pool = config['num_of_threads_in_pool']
pool = Pool(num_of_threads_in_pool)
print("starting up...")
een = EagleEyev3(config)
logging.info(f"EagleEyev3 version: {een.__version__}")
results = cur.execute('select email from user')
list_of_users = [r[0] for r in results]
for user_email in list_of_users:
if een:
result = cur.execute("select user.refresh_token from user where user.email = ?;", (user_email,))
for row in result:
een.refresh_token = row[0]
else:
logging.error('een object is None')
een.login_tokens(code=None, cascade=True, refresh_token=een.refresh_token)
if een and een.current_user and 'email' in een.current_user:
result = cur.execute("select user.refresh_token, user.id from user where user.email = ?;", (een.current_user['email'],))
for row in result:
print(f"found user {een.current_user['email']}, updating refresh_token")
print(row)
print(een.refresh_token)
print(f"update user set refresh_token = {een.refresh_token} where id == {row[1]};")
cur.execute("update user set refresh_token = ? where id == ?;", (een.refresh_token, row[1]))
con.commit()
een.get_list_of_cameras()
# iterate through all the cameras this user has access to
for current_camera in een.cameras:
sql = '''SELECT
download.id,
download.url,
camera.device_id,
download.start_timestamp,
download.end_timestamp,
download.status
FROM
camera
JOIN download ON download.camera_id = camera.id
JOIN USER ON camera.user_id = user.id
WHERE
download.status == ?
AND camera.device_id == ?
ORDER BY download.start_timestamp
LIMIT 10000;'''
results = cur.execute(sql, ('new', current_camera.id))
# Here is where we send the list of files to be run in the multiprocessing pool
run([results, een, current_camera])
else:
logging.info(f"failed to login for {user_email}")
pool.close()
print("Shutting down...")

202
models.py Normal file
View File

@ -0,0 +1,202 @@
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import exc
from settings import *
db = SQLAlchemy()
__all__ = ['db', 'save_row', 'delete_row', 'User', 'Camera', 'Download' ]
#
# helper function to reduce repeated code
#
def commit_or_rollback(db, obj):
try:
db.session.add(obj)
db.session.commit()
except exc.IntegrityError as e:
print(f"caught an exception {e}")
db.session.rollback()
except exc.OperationalError as e:
print(f"caught an exception {e}")
db.session.rollback()
def delete_or_rollback(db, obj):
try:
db.session.delete(obj)
db.session.commit()
except exc.IntegrityError as e:
print(f"caught an exception {e}")
db.session.rollback()
except exc.OperationalError as e:
print(f"caught an exception {e}")
db.session.rollback()
def save_row(obj):
commit_or_rollback(db, obj)
def delete_row(obj):
delete_or_rollback(db, obj)
# trying out making my own BaseModel, follow this blogpost
# https://dev.to/chidioguejiofor/making-sqlalchemy-models-simpler-by-creating-a-basemodel-3m9c
class BaseModel(db.Model):
__abstract__ = True
id = db.Column(db.Integer, primary_key=True)
created = db.Column(db.DateTime, server_default=db.func.now())
updated = db.Column(db.DateTime, server_default=db.func.now(), server_onupdate=db.func.now())
def before_save(self, *args, **kwargs):
pass
def after_save(self, *args, **kwargs):
pass
def save(self, commit=True):
self.before_save()
db.session.add(self)
if commit:
try:
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
self.after_save()
def before_update(self, *args, **kwargs):
pass
def after_update(self, *args, **kwargs):
pass
def update(self, *args, **kwargs):
self.before_update(*args, **kwargs)
db.session.commit()
self.after_update(*args, **kwargs)
def delete(self, commit=True):
db.session.delete(self)
if commit:
db.session.commit()
@classmethod
def eager(cls, *args):
cols = [orm.joinedload(arg) for arg in args]
return cls.query.options(*cols)
@classmethod
def before_bulk_create(cls, iterable, *args, **kwargs):
pass
@classmethod
def after_bulk_create(cls, model_objs, *args, **kwargs):
pass
@classmethod
def bulk_create(cls, iterable, *args, **kwargs):
cls.before_bulk_create(iterable, *args, **kwargs)
model_objs = []
for data in iterable:
if not isinstance(data, cls):
data = cls(**data)
model_objs.append(data)
db.session.bulk_save_objects(model_objs)
if kwargs.get('commit', True) is True:
db.session.commit()
cls.after_bulk_create(model_objs, *args, **kwargs)
return model_objs
@classmethod
def bulk_create_or_none(cls, iterable, *args, **kwargs):
try:
return cls.bulk_create(iterable, *args, **kwargs)
except exc.IntegrityError as e:
db.session.rollback()
return None
class User(BaseModel):
'''
The User object stores basic data from EagleEye API along with cameras they have access to, and stores the access/refresh tokens.
'''
account_id = db.Column(db.String(8), nullable=True)
user_id = db.Column(db.String(8), nullable=True)
email = db.Column(db.String(), nullable=True)
timezone = db.Column(db.String(), nullable=True)
access_token = db.Column(db.String(), nullable=True)
refresh_token = db.Column(db.String(), nullable=True)
last_login = db.Column(db.DateTime, nullable=True)
cameras = db.relationship('Camera', backref=db.backref('user', lazy=True))
def __repr__(self):
return f"[User] {email}"
def to_dict(self):
return {
"account_id": self.account_id,
"user_id": self.user_id,
"email": self.email,
"timezone": self.timezone,
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"last_login": self.last_login,
"cameras": self.cameras
}
class Camera(BaseModel):
'''
The Camera object holds the list of recordings (Downloads) and some basics about the device
'''
device_id = db.Column(db.String(8), nullable=True)
timezone = db.Column(db.String(), nullable=True)
name = db.Column(db.String(), nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
downloads = db.relationship('Download', backref=db.backref('camera', lazy=True))
def __repr__(self):
return f"[Camera] {device_id} {name}"
def get_newest_download(self, status=None):
return None
def get_oldest_download(self, status=None):
return None
class Download(BaseModel):
'''
Downloads are the actual recording that should be downloaded.
It keeps its own state/progress/errors so it can be reported on later.
Downloads belong to a camera which by extension belongs to a user
'''
url = db.Column(db.String(), nullable=True)
camera_id = db.Column(db.Integer, db.ForeignKey('camera.id'), nullable=True)
start_timestamp = db.Column(db.DateTime, nullable=True)
end_timestamp = db.Column(db.DateTime, nullable=True)
status = db.Column(db.String(), nullable=True)
error = db.Column(db.String(), nullable=True)
def __repr__(self):
return f"[Download] {id} {camera_id} {status} {error}"

339
playground.ipynb Normal file
View File

@ -0,0 +1,339 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "b276f988-0f92-4ac0-b440-523252f841e9",
"metadata": {},
"source": [
"# Playing around with Machine to Machine API access"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "0aac543d-e9f0-4a10-b4c2-35466085f2c2",
"metadata": {},
"outputs": [],
"source": [
"import requests\n",
"from tqdm import tqdm"
]
},
{
"cell_type": "markdown",
"id": "5efb08d1-b0f6-4f64-b87a-f2f5afd4c050",
"metadata": {},
"source": [
"## Let's do some EagleEye setup"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8f557a22-7ffe-4c0d-910d-9dee63b40832",
"metadata": {},
"outputs": [],
"source": [
"import logging\n",
"logger = logging.getLogger()\n",
"logger.setLevel('INFO')"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d8ced614-480a-4341-bc40-6c66e16b54cd",
"metadata": {},
"outputs": [],
"source": [
"from EagleEyev3 import *\n",
"from settings import config"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "091f1c2f-6bff-40e2-a0e8-d22d5f0ccd31",
"metadata": {},
"outputs": [],
"source": [
"een = EagleEyev3(config)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "23864933-181c-402e-b2f5-e47a9f1871c8",
"metadata": {},
"outputs": [],
"source": [
"een.__version__"
]
},
{
"cell_type": "markdown",
"id": "2aaf14a9-f206-4909-a85f-fc736e964592",
"metadata": {},
"source": [
"## Let's do some DB setup"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "12364fa5-fa06-419a-9d1c-b44f02c9ac63",
"metadata": {},
"outputs": [],
"source": [
"import sqlite3"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c960ddb6-316f-4737-bd88-d796cdd7897b",
"metadata": {},
"outputs": [],
"source": [
"sqlite3.sqlite_version"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d8ca6f35-ee80-4463-aab1-3c83e72068f0",
"metadata": {},
"outputs": [],
"source": [
"con = sqlite3.connect('instance/project.db')"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a8c9679c-37e1-4091-998d-08fc83f9c53b",
"metadata": {},
"outputs": [],
"source": [
"cur = con.cursor()"
]
},
{
"cell_type": "markdown",
"id": "80f58426-168f-47e1-b4f6-164cd5c6840a",
"metadata": {},
"source": [
"## Login as a user with their refresh_token and query their cameras"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "954c5d7b-db09-483e-a5f0-a59fb5733c34",
"metadata": {},
"outputs": [],
"source": [
"if een:\n",
" if een.refresh_token == None or een.refresh_token == '':\n",
" # if you get out of sync, pull the refresh_token from the db to get the loop started\n",
" result = cur.execute(\"select user.refresh_token from user where user.email = ?;\", (\"mcotton@mcottondesign.com\",))\n",
" \n",
" for row in result:\n",
" een.refresh_token = row[0]\n",
" else:\n",
" # een object and refresh_token appear to be good\n",
" pass\n",
"else:\n",
" logging.error('een object is None')"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a3cd64b9-3e3c-4f02-971c-1b4436a776ea",
"metadata": {},
"outputs": [],
"source": [
"_ = een.login_tokens(code=None, cascade=True, refresh_token=een.refresh_token)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a6a4aaac-64b0-424b-b5a1-50a3630e3b6f",
"metadata": {},
"outputs": [],
"source": [
"een.refresh_token"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "205b1c7b-0845-4412-a77e-98e6506d36de",
"metadata": {},
"outputs": [],
"source": [
"een.current_user"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e640fd2d-05eb-42c1-bcf6-3183afa065ef",
"metadata": {},
"outputs": [],
"source": [
"result = cur.execute(\"select user.refresh_token, user.id from user where user.email = ?;\", (een.current_user['email'],))\n",
"\n",
"for row in result:\n",
" print(f\"found user {een.current_user['email']}, updating refresh_token\")\n",
" print(row)\n",
" print(een.refresh_token)\n",
" print(f\"update user set refresh_token = {een.refresh_token} where id == {row[1]};\")\n",
" cur.execute(\"update user set refresh_token = ? where id == ?;\", (een.refresh_token, row[1]))\n",
" con.commit()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "bdfde2f6-28b1-4623-8528-1492de00b83d",
"metadata": {},
"outputs": [],
"source": [
"_ = een.get_list_of_cameras()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e85f28ee-165d-442b-896e-05d74e29e644",
"metadata": {},
"outputs": [],
"source": [
"een.cameras"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "17806b22-5bad-497d-977d-b1c31ed48304",
"metadata": {},
"outputs": [],
"source": [
"r2d2 = een.cameras[3]"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a6dc31ee-6041-44f7-8967-67856033a6a7",
"metadata": {
"scrolled": true
},
"outputs": [],
"source": [
"_ = r2d2.get_list_of_videos(start_timestamp=een.time_before(hours=24*1), end_timestamp=een.time_now())"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3395164b-307d-473e-a090-fd782d0c1dc1",
"metadata": {},
"outputs": [],
"source": [
"len(r2d2.videos)"
]
},
{
"cell_type": "markdown",
"id": "a924b74d-7a77-45b1-90a5-b9b0c3ab8d7d",
"metadata": {},
"source": [
"## Download all the videos ##"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "74378ae5-0ade-4d8a-9636-2bd2800e59a3",
"metadata": {},
"outputs": [],
"source": [
"sql = '''SELECT\n",
"\tdownload.id,\n",
"\tdownload.url,\n",
"\tcamera.device_id,\n",
"\tdownload.start_timestamp,\n",
"\tdownload.end_timestamp,\n",
"\tdownload.status\n",
"FROM\n",
"\tcamera\n",
"\tJOIN download ON download.camera_id = camera.id\n",
"\tJOIN USER ON camera.user_id = user.id\n",
"WHERE\n",
"\tdownload.status == ?\n",
"\tAND camera.id == ?;'''"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8b61db76-e925-4ccb-bead-39239615daae",
"metadata": {
"scrolled": true
},
"outputs": [],
"source": [
"results = cur.execute(sql, ('new', 4))\n",
"\n",
"for row in tqdm(results):\n",
" fname = f\"videos/{row[2]}/{row[2]}_{row[3]}-{row[4]}.mp4\"\n",
" print(f\"{row[0]} - {row[5]}\")\n",
" r2d2.save_video_to_file(url=row[1], filename=fname)\n",
" new_cur = con.cursor()\n",
" new_cur.execute(\"update download set status = ? where download.id == ?;\", ('done', row[0]))\n",
" \n",
" # less efficient to commit every iteration but it means I can watch the updates at the DB level\n",
" con.commit()"
]
},
{
"cell_type": "raw",
"id": "4f728219-12e2-4b60-b0af-0320efec95fb",
"metadata": {},
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "7a72cb7f-a6c5-49c1-8dca-38fe4a4981bb",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "flask2",
"language": "python",
"name": "flask2"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.3"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -14,8 +14,10 @@ Werkzeug==2.3.6
gunicorn==20.1.0
cachelib==0.10.2
Flask-Session==0.5.0
EagleEyev3>=0.0.18
tqdm
pandas
numpy
matplotlib
Flask-SQLAlchemy==3.0.5
Flask-Migrate==4.0.4
EagleEyev3>=0.0.20