diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index d9f7c8d..925e2e6 --- a/.gitignore +++ b/.gitignore @@ -1,33 +1,108 @@ -# These are some examples of commonly ignored file patterns. -# You should customize this list as applicable to your project. -# Learn more about .gitignore: -# https://www.atlassian.com/git/tutorials/saving-changes/gitignore - -# Node artifact files -node_modules/ -dist/ - -# Compiled Java class files -*.class - -# Compiled Python bytecode +# Byte-compiled / optimized / DLL files +__pycache__/ *.py[cod] +*$py.class -# Log files -*.log +# C extensions +*.so -# Package files -*.jar - -# Maven -target/ +# Distribution / packaging +.Python +build/ +develop-eggs/ dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST -# JetBrains IDE -.idea/ +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec -# Unit test reports -TEST*.xml +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +environment/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ # Generated by MacOS .DS_Store @@ -35,15 +110,23 @@ TEST*.xml # Generated by Windows Thumbs.db -# Applications -*.app -*.exe -*.war - -# Large media files -*.mp4 -*.tiff -*.avi -*.flv -*.mov -*.wmv +# Added by Scott Idem +# https://github.com/github/gitignore +*.sock +*.csv +*.xlsx +#*.pdf +*.cfg +*.ini +*.bak +*.pid +flask_config.py +config.py +#config.cfg +#users.cfg +.directory +tmp/ +temp/ +development/ +myapp/files/ +myapp/file_distribution/ diff --git a/admin/documentation/run locally.txt b/admin/documentation/run locally.txt new file mode 100644 index 0000000..cb116e1 --- /dev/null +++ b/admin/documentation/run locally.txt @@ -0,0 +1,44 @@ +# Go to root of application +cd ~/path/to/directory/my_application/ + +# Create new application environment +virtualenv environment + +# Activate application environment +source environment/bin/activate + +# Install application requirements +pip install -r admin/requirements.txt + +# Start application +uvicorn app.main:app --host 0.0.0.0 --port 5005 --reload + +# View app +http://localhost:5005 + +# Deactivate environment when done +deactivate + + +# Use git +# Go to root of application +cd ~/path/to/directory/my_application/ + +git init + +git remote add origin https://scott_idem@bitbucket.org/oneskyit/one-sky-it-api.git +git add . +git commit -m 'Initial commit' + +git push -u origin master +git push -u origin development +git push -u origin new-branch-name + +# List branches +git branch -a + +# Create new branch +git branch new-branch-name + +# Switch branch +git switch new-branch-name diff --git a/admin/requirements.txt b/admin/requirements.txt new file mode 100644 index 0000000..2dd3ffc --- /dev/null +++ b/admin/requirements.txt @@ -0,0 +1,4 @@ +uvicorn +fastapi[all] +SQLAlchemy +mysqlclient diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py.default b/app/config.py.default new file mode 100644 index 0000000..b621900 --- /dev/null +++ b/app/config.py.default @@ -0,0 +1,18 @@ +import secrets +from pydantic import AnyHttpUrl, BaseSettings, EmailStr, HttpUrl, PostgresDsn, validator +from typing import Any, Dict, List, Optional, Union + + +class Settings(BaseSettings): + APP_NAME: str = "Aether API" + ADMIN_EMAIL: EmailStr = 'example@example.com' + + AETHER_DB_SERVER = 'xxx' + AETHER_DB_PORT = '3306' # default = 3306 + AETHER_DB_NAME = 'xxx' + AETHER_DB_USERNAME = 'xxx' + AETHER_DB_PASSWORD = 'xxx' + SQLALCHEMY_DATABASE_URI = 'mysql://'+AETHER_DB_USERNAME+':'+AETHER_DB_PASSWORD+'@'+AETHER_DB_SERVER+'/'+AETHER_DB_NAME + + +settings = Settings() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..091effb --- /dev/null +++ b/app/db.py @@ -0,0 +1,171 @@ +from app.config import settings + +from sqlalchemy import create_engine, text +from sqlalchemy.exc import IntegrityError, OperationalError +#from app import db + +AMS_DB_SERVER = 'linode.oneskyit.com' +AMS_DB_PORT = '3306' # default = 3306 +AMS_DB_NAME = 'aether_dev' #onesky_ams_dev +AMS_DB_USERNAME = 'onesky_aether' +AMS_DB_PASSWORD = '$onesky.Aether.2020' + + +connection_string = 'mysql://'+AMS_DB_USERNAME+':'+AMS_DB_PASSWORD+'@'+AMS_DB_SERVER+'/'+AMS_DB_NAME +engine = create_engine(name_or_url=connection_string, pool_size=10, pool_recycle=120, pool_pre_ping=True, echo=True, echo_pool=True, isolation_level='READ COMMITTED') +# NOTE: The default isolation_level is 'REPEATABLE READ'. This can sometimes not show updated data. + +db = engine.connect() + + +# Insert a new record with values given. +def sql_insert(table_name=None, record=None, sql=None, data=None): + print('** sql_insert() ***') + + if table_name and record: + fields = [] + values = [] + for key, value in record.items(): + if key != 'id': # A special exception for the id auto increment field. + fields.append('`'+str(key)+'`') + values.append(':'+str(key)) + fields_string = ', '.join(fields) + values_string = ', '.join(values) + + sql_insert = text( + """ + INSERT INTO `"""+table_name+"""` ("""+fields_string+""") VALUES ("""+values_string+"""); + """ + ) + elif table_name: + sql_insert = text( + """ + INSERT INTO `"""+table_name+"""` () VALUES (); + """ + ) + elif sql: + sql_insert = text(sql) + else: + print('One or more required fields are missing') + return False + + + trans = db.begin() + try: + if record: + result_insert = db.execute(sql_insert, record) + else: + result_insert = db.execute(sql_insert) + trans.commit() + except OperationalError as e: + trans.rollback() + print('*** An exception happened: OperationalError ***') + print('* This is likely because a field that does not exist. *') + print(repr(e)) + print('***') + print(str(e)) + print('^^^ exception ^^^') + return False + except IntegrityError as e: + trans.rollback() + print('*** An exception happened: IntegrityError ***') + print('* This is likely because of a duplicate entry for a primary or unique field. *') + print(repr(e)) + print('***') + print(str(e)) + print('^^^ exception ^^^') + return True # NOTE: This is returning True even though there was an exception + except Exception as e: + trans.rollback() + print('*** An exception happened: catch all ***') + print(repr(e)) + print('***') + print(str(e)) + print('^^^ exception ^^^') + return False + else: + record_id = result_insert.lastrowid + if record_id == 0: + #print('******') + #print(dir(result_insert)) + #print('******') + #print(vars(result_insert)) + #print('******') + return True + else: + return record_id + + +# NOTE: Select records using custom SQL SELECT statements. +def sql_select(sql=None, data=None, table_name=None, record_id=None, record_id_random=None, field_name=None, field_value=None, as_list=False): + print('*** sql_select() ***') + + if record_id and table_name: + sql = text( + """ + SELECT * + FROM `"""+table_name+"""` + WHERE `"""+table_name+"""`.id = :record_id + """ + ) + elif record_id_random and table_name: + sql = text( + """ + SELECT * + FROM `"""+table_name+"""` + WHERE `"""+table_name+"""`.id_random = :record_id_random + """ + ) + elif field_name and field_value and table_name: + sql = text( + """ + SELECT * + FROM `"""+table_name+"""` + WHERE `"""+table_name+"""`."""+field_name+""" = :field_value + """ + ) + elif table_name: + sql = text( + """ + SELECT * + FROM `"""+table_name+"""` + """ + ) + elif sql: + print('SQL found') + sql = text(sql) + else: + print('One or more required fields are missing') + return False + + try: + #if record_id or record_id_random: + #result = db.execute(sql, record_id=record_id, record_id_random=record_id_random) + #elif field_name and field_value: + #result = db.execute(sql, field_value=field_value) + #elif sql and data: + #result = db.execute(sql, data) + print('Executing SQL...') + result = db.execute(sql, data=data, record_id=record_id, record_id_random=record_id_random, table_name=table_name, field_name=field_name, field_value=field_value) + except Exception as e: + print('*** An exception happened. ***') + print(repr(e)) + print('***') + print(str(e)) + print('^^^ exception ^^^') + return False + else: + if result.rowcount == 1 and as_list: + print('Single as list') + record = result.fetchall() + return record + elif result.rowcount == 1 and not as_list: + print('Single as single') + record = result.fetchone() + return record + elif result.rowcount > 1: + print('List as list') + records = result.fetchall() + return records + else: + return False diff --git a/app/lib_general.py b/app/lib_general.py new file mode 100644 index 0000000..4db2659 --- /dev/null +++ b/app/lib_general.py @@ -0,0 +1,52 @@ +from datetime import datetime, time, timedelta +from fastapi import APIRouter, Depends, Header, HTTPException, status +from pydantic import BaseModel, EmailStr, Field +from typing import Dict, List, Optional, Set, Union + + +#router = APIRouter() + +#import app + +async def get_token_header(x_token: str = Header(...)): + if x_token != 'fake-super-secret-token': + raise HTTPException(status_code=400, detail='X-Token header invalid') + + +async def get_account_header(x_account_id: str = Header(...)): + print('get_account_header(): '+x_account_id) + return x_account_id + + +#Add the processing time to the response header. +#@app.middleware('http') +#async def add_process_time_header(request: Request, call_next): + #import time + #start_time = time.time() + #response = await call_next(request) + #process_time = time.time() - start_time + #response.headers['X-Process-Time'] = str(process_time) + #return response + + +#async def get_token_header(x_token: str = Header(...)): + #if x_token != 'fake-super-secret-token': + #raise HTTPException(status_code=400, detail='X-Token header invalid') + + +#async def get_account_header(x_account_id: str = Header(...)): +#@app.middleware("http") +#async def get_account_header(x_account_id: str = Header(...)): + #return x_account_id + #x_account_id: str = Header(...) + #x_account_id = 'static random ID...' + #response = await call_next(request) + + #print(x_account_id) + + #return x_account_id + + +#async def get_account_header(x_account_id: str = Header(...)): + #print('get_account_header(): '+x_account_id+'z9999z') + #return x_account_id+'z9999z' diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..ef4ccba --- /dev/null +++ b/app/main.py @@ -0,0 +1,93 @@ +import logging + +from datetime import datetime, time, timedelta +from enum import Enum +from fastapi import Body, Cookie, Depends, FastAPI, File, Form, Header, HTTPException, Path, Query, Request, status, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse +from fastapi.staticfiles import StaticFiles +from functools import lru_cache +from pydantic import BaseModel, EmailStr, Field +from typing import Dict, List, Optional, Set, Union + +from sqlalchemy import create_engine, text +from sqlalchemy.exc import IntegrityError, OperationalError + +from . import config +from .lib_general import * +from .routers import items, users, websockets + + +app = FastAPI() + + +@lru_cache() +def get_settings(): + return config.Settings() + + +app.mount('/static', StaticFiles(directory='static'), name='static') + + +app.include_router( + users.router, + prefix='/user', + tags=['Users'], + #dependencies=[Depends(get_token_header)], + #dependencies=[Depends(get_account_header)], + #responses={404: {'description': 'Not found'}}, +) +app.include_router( + items.router, + prefix='/item', + tags=['Items'], + #dependencies=[Depends(get_token_header)], + #responses={404: {'description': 'Not found'}}, +) +app.include_router( + websockets.router, + #prefix='/item', + tags=['Websockets'], + #dependencies=[Depends(get_token_header)], + #responses={404: {'description': 'Not found'}}, +) + + +# BEGIN: CORS +origins = [ + 'http://fastapi.localhost', + 'http://localhost', + 'http://localhost:5000', + 'http://fastapi.localhost:5000', + 'https://example.org', +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_origin_regex='https://.*\.oneskyit\.com', + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], + #expose_headers=[], + #max_age=600, +) +# END: CORS + + +#Add the processing time to the response header. +@app.middleware('http') +async def add_process_time_header(request: Request, call_next): + import time + start_time = time.time() + response = await call_next(request) + process_time = time.time() - start_time + response.headers['X-Process-Time'] = str(process_time) + return response + + +@app.get('/', tags=['Default']) +async def get_root(): + print(config.settings.APP_NAME) + + return {'hello': 'This is the Aether API using FastAPI.'} diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/items.py b/app/routers/items.py new file mode 100644 index 0000000..1579489 --- /dev/null +++ b/app/routers/items.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, EmailStr, Field +from typing import Dict, List, Optional, Set, Union + +router = APIRouter() + + +class Image(BaseModel): + url: str + name: str + + +class Item(BaseModel): + name: str + description: Optional[str] = Field(None, example='A very nice Item') + price: float + tax: Optional[float] = None + is_offer: Optional[bool] = None + #tags: List[str] = [] # not unique tags + tags: Set[str] = set() # unique tags + image: Optional[Image] = None # one image + images: Optional[List[Image]] = None # or as a list of images + + +@router.get('/') +async def read_items(): + return [{'name': 'Item Foo'}, {'name': 'item Bar'}] + + +@router.get('/{item_id}') +async def read_item(item_id: str): + return {'name': 'Fake Specific Item', 'item_id': item_id} + + +@router.put( + '/{item_id}', + tags=['Extra Tag'], + responses={403: {'description': 'Operation forbidden'}}, +) +async def update_item(item_id: str): + if item_id != 'foo': + raise HTTPException(status_code=403, detail='You can only update the item: foo') + return {'item_id': item_id, 'name': 'The Fighters'} diff --git a/app/routers/items_basic.py b/app/routers/items_basic.py new file mode 100644 index 0000000..de5d9b6 --- /dev/null +++ b/app/routers/items_basic.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, HTTPException + +router = APIRouter() + + +@router.get("/") +async def read_items(): + return [{"name": "Item Foo"}, {"name": "item Bar"}] + + +@router.get("/{item_id}") +async def read_item(item_id: str): + return {"name": "Fake Specific Item", "item_id": item_id} + + +@router.put( + "/{item_id}", + tags=["custom"], + responses={403: {"description": "Operation forbidden"}}, +) +async def update_item(item_id: str): + if item_id != "foo": + raise HTTPException(status_code=403, detail="You can only update the item: foo") + return {"item_id": item_id, "name": "The Fighters"} diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..4838bfc --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,153 @@ +from datetime import datetime, time, timedelta +from fastapi import APIRouter, Depends, Header, HTTPException, status +from pydantic import BaseModel, EmailStr, Field +from typing import Dict, List, Optional, Set, Union + +from ..lib_general import * +from app.config import settings + +from app.db import * + + +#import logging + +router = APIRouter() + + +class UserBase(BaseModel): + id_random: str = None # This should not be None. It is required. + account_id_random: str = None # This should not be None. It is required. + username: str = Field(None, example='New.User', min_length=3, max_length=100) + name: Optional[str] = None + email: EmailStr + email_verified: Optional[bool] = None + enable: Optional[bool] = None + enable_from: Optional[datetime] = None + enable_to: Optional[datetime] = None + super: Optional[bool] = None + manager: Optional[bool] = None + administrator: Optional[bool] = None + verified: Optional[bool] = None + + +class UserIn(UserBase): + password: str = Field(None, example='My Difficult Password!', min_length=10) + + +class UserOut(UserBase): + password_set_on: Optional[datetime] = None + password_reset_token: Optional[str] = None + password_reset_expire_on: Optional[datetime] = None + logged_in_on: Optional[datetime] = None + last_activity_on: Optional[datetime] = None + created_on: datetime + update_on: Optional[datetime] = None + + +class UserInDB(UserBase): + hashed_password: str + password_set_on: Optional[datetime] = None + password_reset_token: Optional[str] = None + password_reset_expire_on: Optional[datetime] = None + logged_in_on: Optional[datetime] = None + last_activity_on: Optional[datetime] = None + + +@router.post( + "/", + response_model=UserOut, + summary='Create a new user account', + status_code=status.HTTP_201_CREATED +) +async def create_user(user: UserIn, x_account_id: str = Header(...)): + """ + Create a new user account + """ + + user = {} + user['account_id_random'] = x_account_id + user['username'] = 'Scott.Idem' + user['name'] = 'Scott Idem' + user['email'] = 'Scott.Idem@oneskyit.com' + + return user + + +#@router.patch('/{id_random}', response_model=UserOut, dependencies=[Depends(get_account_header)]) +#async def update_user(id_random: str, user: UserIn, x_account_id: str = Header(...)): +#async def update_user(id_random: str, user: UserIn): +@router.patch( + '/{id_random}', + response_model=UserOut, + summary='Update a user account' + ) +async def update_user(id_random: str, user: UserIn, x_account_id: str = Depends(get_account_header)): + """ + Update a user account + """ + + user = {} + user['id_random'] = id_random + user['account_id_random'] = x_account_id + user['username'] = 'Scott.Idem' + user['name'] = 'Scott Idem' + user['email'] = 'Scott.Idem@oneskyit.com' + user['created_on'] = datetime.now() + user['super'] = True + + return user + + +@router.delete('/{id_random}', response_model=bool) +async def delete_user(id_random: str, x_account_id: str = Depends(get_account_header)): + """ + Delete a user account + """ + + return True + return False + + +@router.get('/', response_model=List[UserOut]) +@router.get('/list_all', response_model=List[UserOut]) +async def list_users(): + """ + Get a list of users + """ + + print(settings.APP_NAME) + + users = [{'username': 'test.user.1'}, {'username': 'test.user.2'}, {'username': 'Scott.Idem'}] + + + print('Getting all users...') + + sql = """ + SELECT * + FROM `user` + WHERE id=1 + """ + + records = sql_select(sql=sql, as_list=True) + + #records = sql_select(table_name='user') + + + if records: + print('Got the user list') + return records + else: + print('No user records found') + raise HTTPException(status_code=404) + + +@router.get('/{username}') +async def get_user_username(username: str, x_account_id: str = Header(...)): + return {'username': username} + + +#@router.get('/me') +#async def get_user_current(): + #user_out: UserOut + + #return {'username': 'test.user'} diff --git a/app/routers/users_basic.py b/app/routers/users_basic.py new file mode 100644 index 0000000..e88b20c --- /dev/null +++ b/app/routers/users_basic.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/users/", tags=["users"]) +async def read_users(): + return [{"username": "Foo"}, {"username": "Bar"}] + + +@router.get("/users/me", tags=["users"]) +async def read_user_me(): + return {"username": "fakecurrentuser"} + + +@router.get("/users/{username}", tags=["users"]) +async def read_user(username: str): + return {"username": username} diff --git a/app/routers/websockets.py b/app/routers/websockets.py new file mode 100644 index 0000000..60b8552 --- /dev/null +++ b/app/routers/websockets.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse +from typing import List + +router = APIRouter() + + +html = """ + + +
+