mirror of
https://github.com/DarrylNixon/ghostforge
synced 2024-04-22 06:27:20 -07:00
Layout work. Learning how to Alembic.
This commit is contained in:
parent
1fa3d8a372
commit
7598e2c8fc
37 changed files with 806 additions and 173 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
15
.dockerignore
Normal file
15
.dockerignore
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
.gitignore
|
||||||
|
.git/
|
||||||
|
.env
|
||||||
|
.env.sample
|
||||||
|
.flake8
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
build/
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.md
|
||||||
|
**/*.pyc
|
||||||
|
**/*.pyo
|
||||||
|
**/*.mo
|
||||||
|
*.egg-info/
|
|
@ -1,11 +1,13 @@
|
||||||
# DATABASE_* variables are used both to initially configure the
|
# POSTGRES_* variables are used both to initially configure the
|
||||||
# ghostforge postgresql database and to later access the data from
|
# ghostforge postgresql database and to later access the data from
|
||||||
# ghostforge execution.
|
# ghostforge execution.
|
||||||
DATABASE_CONTAINER_NAME=ghostforge-db
|
#
|
||||||
DATABASE_USER=ghost
|
# POSTGRES_PASSWORD is used for database authentication and should
|
||||||
DATABASE_NAME=ghostforge
|
# be secure or randomized (optionally using the method in README.md).
|
||||||
DATABASE_PORT=5432
|
POSTGRES_CONTAINER=ghostforge-db
|
||||||
DATABASE_PASSWORD=
|
POSTGRES_USER=ghost
|
||||||
|
POSTGRES_DB=ghostforge
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
|
||||||
# GHOSTFORGE_*_WEB_PORT variables are used to determine what
|
# GHOSTFORGE_*_WEB_PORT variables are used to determine what
|
||||||
# port the web interface is served on within the container (INTERNAL)
|
# port the web interface is served on within the container (INTERNAL)
|
||||||
|
@ -25,6 +27,6 @@ GHOSTFORGE_ENV=prod
|
||||||
# GHOSTFORGE_DATA_DIR is created within the ghostforge container.
|
# GHOSTFORGE_DATA_DIR is created within the ghostforge container.
|
||||||
GHOSTFORGE_DATA_DIR=/data
|
GHOSTFORGE_DATA_DIR=/data
|
||||||
|
|
||||||
# JWT_SECRET is used for authentication purposes and should be
|
# JWT_SECRET is used for authentication purposes and can easily be
|
||||||
# randomized using the method in README.md.
|
# securely randomized using the method in README.md.
|
||||||
GHOSTFORGE_JWT_SECRET=
|
GHOSTFORGE_JWT_SECRET=
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -120,7 +120,7 @@ celerybeat.pid
|
||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
# .env
|
.env
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
|
|
37
Dockerfile
37
Dockerfile
|
@ -1,18 +1,37 @@
|
||||||
FROM python:3.11-alpine
|
FROM python:3.11-alpine
|
||||||
|
|
||||||
ENV DATABASE_PASSWORD ""
|
# Enforcement to ensure passwords environment variables are not left blank.
|
||||||
RUN if [ -z "${DATABASE_PASSWORD}" ]; then echo "ghostforge build error: Set DATABASE_PASSWORD in .env."; exit 1; fi
|
# This won't stop bad passwords from being used, but at least won't cause
|
||||||
RUN if [ -z "${GHOSTFORGE_JWT_SECRET}" ]; then echo "ghostforge build error: Set GHOSTFORGE_JWT_SECRET in .env."; exit 1; fi
|
# errors or, worse, weaker crypt.
|
||||||
|
ARG POSTGRES_PASSWORD
|
||||||
|
ENV ENV_POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
ARG GHOSTFORGE_JWT_SECRET
|
||||||
|
ENV ENV_GHOSTFORGE_JWT_SECRET=${GHOSTFORGE_JWT_SECRET}
|
||||||
|
RUN [ ! -z "${ENV_POSTGRES_PASSWORD}" ] || { echo "ghostforge build error: Set POSTGRES_PASSWORD in .env."; exit 1; }
|
||||||
|
RUN [ ! -z "${ENV_GHOSTFORGE_JWT_SECRET}" ] || { echo "ghostforge build error: Set GHOSTFORGE_JWT_SECRET in .env."; exit 1; }
|
||||||
|
|
||||||
|
# Create the data directory specified using the environment variables.
|
||||||
|
# This is redundant for mapped volumes, but necessary if the data
|
||||||
|
# directory is specified but not mapped.
|
||||||
|
ARG GHOSTFORGE_DATA_DIR
|
||||||
|
ENV ENV_GHOSTFORGE_DATA_DIR=${GHOSTFORGE_DATA_DIR}
|
||||||
|
RUN mkdir -p "${ENV_GHOSTFORGE_DATA_DIR}"
|
||||||
|
|
||||||
|
# Copy project into Docker image, skipping entries in .dockerignore.
|
||||||
WORKDIR /ghostforge
|
WORKDIR /ghostforge
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN rm .env
|
|
||||||
RUN mkdir -p "${GHOSTFORGE_DATA_DIR}"
|
|
||||||
|
|
||||||
|
# Install ghostforge from the work directory.
|
||||||
RUN pip install .
|
RUN pip install .
|
||||||
|
|
||||||
ENV GHOSTFORGE_INTERNAL_WEB_PORT=8080
|
# Expose the web "serve" port specific in the environment variables.
|
||||||
ENV PYTHONPATH=/ghostforge/ghostforge
|
ARG GHOSTFORGE_INTERNAL_WEB_PORT
|
||||||
|
ENV ENV_GHOSTFORGE_INTERNAL_WEB_PORT=${GHOSTFORGE_INTERNAL_WEB_PORT}
|
||||||
|
EXPOSE ${ENV_GHOSTFORGE_INTERNAL_WEB_PORT}
|
||||||
|
|
||||||
EXPOSE ${GHOSTFORGE_INTERNAL_WEB_PORT}
|
# TODO: Is this line necessary?
|
||||||
CMD [ "ghostforge_serve" ]
|
# ENV PYTHONPATH=/ghostforge/ghostforge
|
||||||
|
|
||||||
|
# TODO: Replace with ghostforge_serve when it works.
|
||||||
|
# This currently just keeps the container running for development.
|
||||||
|
CMD [ "python3", "-m", "http.server" ]
|
||||||
|
|
|
@ -23,7 +23,8 @@ You'll need `docker-compose` installed or you can convert the contents of `docke
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/darrylnixon/ghostforge.git && \
|
git clone https://github.com/darrylnixon/ghostforge.git && \
|
||||||
cd ghostforge && \
|
cd ghostforge && \
|
||||||
PW=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^DATABASE_PASSWORD=.*/DATABASE_PASSWORD=$PW/" .env' && \
|
cp .env.sample .env && \
|
||||||
|
PW=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$PW/" .env' && \
|
||||||
JWT=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^GHOSTFORGE_JWT_SECRET=.*/GHOSTFORGE_JWT_SECRET=$JWT/" .env';
|
JWT=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^GHOSTFORGE_JWT_SECRET=.*/GHOSTFORGE_JWT_SECRET=$JWT/" .env';
|
||||||
docker-compose up --detach --build;
|
docker-compose up --detach --build;
|
||||||
docker exec --interactive --tty ghostforge ghostforge_adduser;
|
docker exec --interactive --tty ghostforge ghostforge_adduser;
|
||||||
|
|
108
alembic.ini
Normal file
108
alembic.ini
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = migrations
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python-dateutil library that can be
|
||||||
|
# installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to dateutil.tz.gettz()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to migrations/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
sqlalchemy.url = postgresql+asyncpg://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_CONTAINER)s:5432/%(POSTGRES_DB)s
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
Binary file not shown.
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 43 KiB |
|
@ -6,16 +6,29 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ghostforge-db-data:/var/lib/postgresql/data
|
- ghostforge-db-data:/var/lib/postgresql/data
|
||||||
|
ports: [ "5432:5432" ]
|
||||||
|
env_file: [ .env ]
|
||||||
ghostforge:
|
ghostforge:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args: [GHOSTFORGE_ENV=prod]
|
args:
|
||||||
ports: ["${GHOSTFORGE_HOST_WEB_PORT}:${GHOSTFORGE_WEB_PORT}"]
|
- GHOSTFORGE_ENV=${GHOSTFORGE_ENV}
|
||||||
|
- GHOSTFORGE_DATA_DIR=${GHOSTFORGE_DATA_DIR}
|
||||||
|
- GHOSTFORGE_JWT_SECRET=${GHOSTFORGE_JWT_SECRET}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- GHOSTFORGE_INTERNAL_WEB_PORT=${GHOSTFORGE_INTERNAL_WEB_PORT}
|
||||||
|
ports:
|
||||||
|
[
|
||||||
|
"${GHOSTFORGE_HOST_WEB_PORT}:${GHOSTFORGE_INTERNAL_WEB_PORT}"
|
||||||
|
]
|
||||||
image: ghostforge:latest
|
image: ghostforge:latest
|
||||||
container_name: ghostforge
|
container_name: ghostforge
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on: [ghostforge-db]
|
depends_on: [ ghostforge-db ]
|
||||||
|
env_file: [ .env ]
|
||||||
|
volumes:
|
||||||
|
- ./migrations/versions:/ghostforge/migrations/versions
|
||||||
volumes:
|
volumes:
|
||||||
ghostforge-db-data:
|
ghostforge-db-data:
|
||||||
name: ghostforge-db-data
|
name: ghostforge-db-data
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
from fastapi import Body
|
|
||||||
from fastapi import Depends
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from ghostforge.auth import check_user
|
|
||||||
from ghostforge.auth.bearer import JWTBearer
|
|
||||||
from ghostforge.auth.handler import signJWT
|
|
||||||
from ghostforge.models import UserLoginSchema
|
|
||||||
|
|
||||||
# define("port", default=os.environ.get("GHOSTFORGE_INTERNAL_WEB_PORT", 1337), help="Webserver port", type=int)
|
|
||||||
# define("db_host", default=os.environ.get("DATABASE_CONTAINER_NAME", "localhost"), help="Host/IP of db")
|
|
||||||
# define("db_port", default=os.environ.get("DATABASE_PORT", 5432), help="Port of db", type=int)
|
|
||||||
# define("db_database", default=os.environ.get("DATABASE_NAME", "ghostforge"), help="Name of db")
|
|
||||||
# define("db_user", default=os.environ.get("DATABASE_USER", "ghostforge"), help="User with access to db")
|
|
||||||
# define("db_password", default=os.environ.get("DATABASE_PASSWORD", "secure"), help="Password for db user")
|
|
||||||
# define("secret_key", default=os.environ.get("DATABASE_PASSWORD", "secure"), help="Password for db user")
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", tags=["root"])
|
|
||||||
async def read_root() -> dict:
|
|
||||||
return {"message": "Welcome!"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/login", tags=["user"])
|
|
||||||
async def user_login(user: UserLoginSchema = Body(...)):
|
|
||||||
if check_user(user):
|
|
||||||
return signJWT(user.name)
|
|
||||||
return {"error": "Wrong login details!"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/auth_check", dependencies=[Depends(JWTBearer())], tags=["auth_check"])
|
|
||||||
async def auth_check(post) -> dict:
|
|
||||||
return {"data": "post added."}
|
|
|
@ -1,11 +0,0 @@
|
||||||
from ghostforge.models import UserLoginSchema
|
|
||||||
|
|
||||||
users = [{"name": "test", "password": "pw"}]
|
|
||||||
|
|
||||||
|
|
||||||
def check_user(data: UserLoginSchema):
|
|
||||||
print(data)
|
|
||||||
for user in users:
|
|
||||||
if user["name"] == data.name and user["password"] == data.password:
|
|
||||||
return True
|
|
||||||
return False
|
|
|
@ -1,35 +0,0 @@
|
||||||
from fastapi import HTTPException
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
|
||||||
from fastapi.security import HTTPBearer
|
|
||||||
|
|
||||||
from ghostforge.auth.handler import decodeJWT
|
|
||||||
|
|
||||||
# from https://github.com/testdrivenio/fastapi-jwt/
|
|
||||||
|
|
||||||
|
|
||||||
class JWTBearer(HTTPBearer):
|
|
||||||
def __init__(self, auto_error: bool = True):
|
|
||||||
super(JWTBearer, self).__init__(auto_error=auto_error)
|
|
||||||
|
|
||||||
async def __call__(self, request: Request):
|
|
||||||
credentials: HTTPAuthorizationCredentials = await super(JWTBearer, self).__call__(request)
|
|
||||||
if credentials:
|
|
||||||
if not credentials.scheme == "Bearer":
|
|
||||||
raise HTTPException(status_code=403, detail="Invalid authentication scheme.")
|
|
||||||
if not self.verify_jwt(credentials.credentials):
|
|
||||||
raise HTTPException(status_code=403, detail="Invalid token or expired token.")
|
|
||||||
return credentials.credentials
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=403, detail="Invalid authorization code.")
|
|
||||||
|
|
||||||
def verify_jwt(self, jwtoken: str) -> bool:
|
|
||||||
isTokenValid: bool = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = decodeJWT(jwtoken)
|
|
||||||
except Exception:
|
|
||||||
payload = None
|
|
||||||
if payload:
|
|
||||||
isTokenValid = True
|
|
||||||
return isTokenValid
|
|
|
@ -1,28 +0,0 @@
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
|
|
||||||
# Based on handler from https://github.com/testdrivenio/fastapi-jwt/
|
|
||||||
|
|
||||||
JWT_SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET")
|
|
||||||
|
|
||||||
|
|
||||||
def token_response(token: str):
|
|
||||||
return {"access_token": token}
|
|
||||||
|
|
||||||
|
|
||||||
def signJWT(user: str) -> Dict[str, str]:
|
|
||||||
payload = {"user_id": user, "expires": time.time() + 600}
|
|
||||||
token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
|
||||||
|
|
||||||
return token_response(token)
|
|
||||||
|
|
||||||
|
|
||||||
def decodeJWT(token: str) -> dict:
|
|
||||||
try:
|
|
||||||
decoded_token = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
|
||||||
return decoded_token if decoded_token["expires"] >= time.time() else None
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
|
@ -1,9 +0,0 @@
|
||||||
import os
|
|
||||||
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
|
|
||||||
def start_api() -> None:
|
|
||||||
uvicorn.run(
|
|
||||||
"ghostforge.api:app", host="0.0.0.0", port=os.environ.get("GHOSTFORGE_INTERNAL_WEB_PORT", 1337), reload=True
|
|
||||||
)
|
|
20
ghostforge/db.py
Normal file
20
ghostforge/db.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
database_url = (
|
||||||
|
f'postgresql+asyncpg://{os.environ.get("POSTGRES_USER")}:'
|
||||||
|
+ f'{os.environ.get("POSTGRES_PASSWORD")}@'
|
||||||
|
+ os.environ.get("POSTGRES_CONTAINER")
|
||||||
|
+ f':5432/{os.environ.get("POSTGRES_DB")}'
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_async_engine(database_url, echo=True, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncSession:
|
||||||
|
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with async_session() as session:
|
||||||
|
yield session
|
|
@ -1,27 +1,8 @@
|
||||||
import datetime
|
from sqlmodel import Field
|
||||||
|
from sqlmodel import SQLModel
|
||||||
from pydantic import BaseModel
|
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
|
|
||||||
class UserSchema(BaseModel):
|
class User(SQLModel, table=True):
|
||||||
name: str = Field(...)
|
id: int = Field(default=None, primary_key=True)
|
||||||
password: str = Field(...)
|
name: str = Field(unique=True)
|
||||||
created: datetime.datetime = datetime.datetime
|
password: str
|
||||||
|
|
||||||
class Config:
|
|
||||||
schema_extra = {
|
|
||||||
"example": {
|
|
||||||
"name": "Jeremy Tootsieroll",
|
|
||||||
"password": "notarealpassword",
|
|
||||||
"created": "2021-03-05T08:21:00.000Z",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class UserLoginSchema(BaseModel):
|
|
||||||
name: str = Field(...)
|
|
||||||
password: str = Field(...)
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
schema_extra = {"example": {"name": "Jeremy Tootsieroll", "password": "notarealpassword"}}
|
|
||||||
|
|
11
ghostforge/serve.py
Normal file
11
ghostforge/serve.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from users import gf as gf_users
|
||||||
|
|
||||||
|
# from ghostforge.models import User
|
||||||
|
|
||||||
|
gf = FastAPI()
|
||||||
|
gf.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
gf.include_router(gf_users)
|
53
ghostforge/static/css/ghostforge.css
Normal file
53
ghostforge/static/css/ghostforge.css
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
.nav-menus {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 0 .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menus .nav-item {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menus .nav-item.header {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-nav {
|
||||||
|
padding-left: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-string {
|
||||||
|
background: #303742;
|
||||||
|
border-radius: .1rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: .7rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
padding: .1rem .2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.brand {
|
||||||
|
margin: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.version-size {
|
||||||
|
height: .7rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.left-nav {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.left-nav::before {
|
||||||
|
margin-left: 1ch;
|
||||||
|
display: inline-block;
|
||||||
|
transition: 0.2s;
|
||||||
|
content: '\203A';
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] summary.left-nav::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
1
ghostforge/static/css/spectre-exp.min.css
vendored
Normal file
1
ghostforge/static/css/spectre-exp.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ghostforge/static/css/spectre-icons.min.css
vendored
Normal file
1
ghostforge/static/css/spectre-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ghostforge/static/css/spectre.min.css
vendored
Normal file
1
ghostforge/static/css/spectre.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
ghostforge/static/img/default-avatar.png
Normal file
BIN
ghostforge/static/img/default-avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
ghostforge/static/img/favicon.ico
Normal file
BIN
ghostforge/static/img/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 239 KiB |
BIN
ghostforge/static/img/ghostforge-sidebar.png
Normal file
BIN
ghostforge/static/img/ghostforge-sidebar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.6 KiB |
BIN
ghostforge/static/img/github-inverted.png
Normal file
BIN
ghostforge/static/img/github-inverted.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 885 B |
104
ghostforge/static/js/ghostforge.js
Normal file
104
ghostforge/static/js/ghostforge.js
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
// Save and restore navigation menu state from local storage.
|
||||||
|
|
||||||
|
function saveMenuState() {
|
||||||
|
var leftNavigation = document.getElementById("left-navigation");
|
||||||
|
if (!leftNavigation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rootMenuItems = leftNavigation.getElementsByTagName("details");
|
||||||
|
console.log(rootMenuItems);
|
||||||
|
var rootMenuStates = {};
|
||||||
|
for (var i = 0; i < rootMenuItems.length; i++) {
|
||||||
|
var item = rootMenuItems[i];
|
||||||
|
var id = item.getAttribute("id");
|
||||||
|
var isOpen = item.hasAttribute("open");
|
||||||
|
rootMenuStates[id] = isOpen;
|
||||||
|
}
|
||||||
|
var jsonMenuStates = JSON.stringify(rootMenuStates);
|
||||||
|
localStorage.setItem("menuState", jsonMenuStates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreMenuState() {
|
||||||
|
var leftNavigation = document.getElementById("left-navigation");
|
||||||
|
if (!leftNavigation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var jsonMenuStates = localStorage.getItem("menuState");
|
||||||
|
var rootMenuStates = JSON.parse(jsonMenuStates) || {};
|
||||||
|
var rootMenuItems = leftNavigation.getElementsByTagName("details");
|
||||||
|
for (var i = 0; i < rootMenuItems.length; i++) {
|
||||||
|
var item = rootMenuItems[i];
|
||||||
|
var id = item.getAttribute("id");
|
||||||
|
var isOpen = rootMenuStates[id];
|
||||||
|
if (isOpen === undefined) {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
item.setAttribute("open", "");
|
||||||
|
} else {
|
||||||
|
item.removeAttribute("open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trap tab-targeting for ADA-compliant use
|
||||||
|
// https://scribe.bus-hit.me/how-to-trap-focus-inside-modal-to-make-it-ada-compliant-6a50f9a70700
|
||||||
|
function modal_focus(modal_name) {
|
||||||
|
const focusableElements =
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
const modal = document.querySelector(modal_name);
|
||||||
|
|
||||||
|
const firstFocusableElement = modal.querySelectorAll(focusableElements)[0];
|
||||||
|
const focusableContent = modal.querySelectorAll(focusableElements);
|
||||||
|
const lastFocusableElement = focusableContent[focusableContent.length - 1];
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
let isTabPressed = e.key === 'Tab' || e.keyCode === 9;
|
||||||
|
|
||||||
|
if (!isTabPressed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstFocusableElement) {
|
||||||
|
lastFocusableElement.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastFocusableElement) {
|
||||||
|
firstFocusableElement.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
firstFocusableElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hotkeys
|
||||||
|
document.addEventListener("keydown", function (event) {
|
||||||
|
// Ctrl/Cmd+G for search modal
|
||||||
|
if (event.key == "g") {
|
||||||
|
const nav_search_button = document.getElementById("nav_search_button");
|
||||||
|
const search_box = document.getElementById("search_string");
|
||||||
|
console.log(search_box);
|
||||||
|
if (event.ctrlKey) {
|
||||||
|
event.preventDefault(); // prevent default Ctrl+G behavior (find in page)
|
||||||
|
nav_search_button.click();
|
||||||
|
search_box.focus();
|
||||||
|
}
|
||||||
|
// Add support for Apple+G on Macs
|
||||||
|
if (event.metaKey) {
|
||||||
|
event.preventDefault(); // prevent default Apple+G behavior (bookmark)
|
||||||
|
nav_search_button.click();
|
||||||
|
search_box.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run various onload/unload functions since script is loaded before elements are
|
||||||
|
window.onload = function () {
|
||||||
|
restoreMenuState();
|
||||||
|
modal_focus("#search-modal");
|
||||||
|
};
|
||||||
|
window.onbeforeunload = saveMenuState;
|
45
ghostforge/templates.py
Normal file
45
ghostforge/templates.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import importlib.metadata
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# Inject version string and Github URL into every template for navbar display.
|
||||||
|
try:
|
||||||
|
gf_version = importlib.metadata.version(__package__)
|
||||||
|
except ValueError:
|
||||||
|
gf_version = "Local"
|
||||||
|
templates.env.globals["gf_version"] = f"v{gf_version}"
|
||||||
|
templates.env.globals["gf_repository_url"] = "https://github.com/DarrylNixon/ghostforge"
|
||||||
|
|
||||||
|
# Same, but build the navbar from an ordered dictionary for centralization.
|
||||||
|
# Since 3.7 (we require >= 3.9), dicts are guaranteed ordered as inserted.
|
||||||
|
templates.env.globals["gf_navbar"] = {
|
||||||
|
"Management": {
|
||||||
|
"Dashboard": "/user/0",
|
||||||
|
"New Ghost": "/user/0",
|
||||||
|
"Active Ghosts": "/user/0",
|
||||||
|
"Archived Ghosts": "/user/0",
|
||||||
|
},
|
||||||
|
"Research": {
|
||||||
|
"Guidebook": "/user/0",
|
||||||
|
"Cheat Sheet": "/user/0",
|
||||||
|
},
|
||||||
|
"Settings": {
|
||||||
|
"Your Profile": "/user/0",
|
||||||
|
"Configuration": "/user/0",
|
||||||
|
"Integrations": "/user/0",
|
||||||
|
"Manage Users": "/user/0",
|
||||||
|
},
|
||||||
|
"Meta": {
|
||||||
|
"About GhostForge": "/user/0",
|
||||||
|
"System Logs": "/user/0",
|
||||||
|
"Logout": "/user/0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.env.globals["avatar_menu"] = {
|
||||||
|
"Dashboard": "/user/0",
|
||||||
|
"Your Profile": "/user/0",
|
||||||
|
"Logout": "/user/0",
|
||||||
|
}
|
29
ghostforge/templates/base.html
Normal file
29
ghostforge/templates/base.html
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='/img/favicon.ico') }}">
|
||||||
|
<title>{% block title %}{{ title }}{% endblock title %}</title>
|
||||||
|
<link href="{{ url_for('static', path='/css/spectre.min.css') }}" rel="stylesheet">
|
||||||
|
<link href="{{ url_for('static', path='/css/spectre-exp.min.css') }}" rel="stylesheet">
|
||||||
|
<link href="{{ url_for('static', path='/css/spectre-icons.min.css') }}" rel="stylesheet">
|
||||||
|
<link href="{{ url_for('static', path='/css/ghostforge.css') }}" rel="stylesheet">
|
||||||
|
<script src="{{ url_for('static', path='/js/ghostforge.js') }}"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="off-canvas off-canvas-sidebar-show d-flex">
|
||||||
|
{% include "navigation/side.html" %}
|
||||||
|
<div class="off-canvas-content">
|
||||||
|
<div class="container">
|
||||||
|
{% include "navigation/top.html" %}
|
||||||
|
{% block content %}
|
||||||
|
{% endblock content %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
40
ghostforge/templates/modals/search.html
Normal file
40
ghostforge/templates/modals/search.html
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<div align="center">
|
||||||
|
<a class="btn btn-primary btn-sm" href="#search-modal" id="nav_search_button">
|
||||||
|
<i class="icon icon-search"></i>
|
||||||
|
⌘-g
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="modal modal-sm" id="search-modal"><a class="modal-overlay" href="#modals-sizes" aria-label="Close"></a>
|
||||||
|
<div class="modal-container" role="document">
|
||||||
|
<div class="modal-header"><a class="btn btn-clear float-right" href="#modals-sizes" aria-label="Close"></a>
|
||||||
|
<div class="modal-title h5">Search Ghosts</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="content">
|
||||||
|
<form>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="search_string">
|
||||||
|
<h6>Search Term</h6>
|
||||||
|
</label>
|
||||||
|
<input class="form-input" id="search_string" type="text" placeholder="Type a string here">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
<h6>Scope</h6>
|
||||||
|
</label>
|
||||||
|
<label class="form-radio">
|
||||||
|
<input type="radio" name="scope"><i class="form-icon"></i> All text fields
|
||||||
|
</label>
|
||||||
|
<label class="form-radio">
|
||||||
|
<input type="radio" name="scope" checked=""><i class="form-icon"></i> Name fields only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-primary">Search</button>
|
||||||
|
<a class="btn btn-link" href="#modals-sizes" aria-label="Close">Close</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
40
ghostforge/templates/navigation/side.html
Normal file
40
ghostforge/templates/navigation/side.html
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<a class="off-canvas-toggle btn btn-primary btn-action" href="#left-navigation"><i class="icon icon-menu"></i></a>
|
||||||
|
<div id="left-navigation" class="off-canvas-sidebar">
|
||||||
|
<div class="navigation-menu">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
<img src="{{ url_for('static', path='/img/ghostforge-sidebar.png') }}" class="brand">
|
||||||
|
</a>
|
||||||
|
<div>{% include "modals/search.html" %}</div>
|
||||||
|
<div class="nav-menus">
|
||||||
|
{% for key, value in gf_navbar.items() %}
|
||||||
|
<details id="{{ key }}" , class="accordion">
|
||||||
|
<summary class="accordion-header c-hand left-nav">
|
||||||
|
<span class="nav-item header">{{ key }}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="accordion-body">
|
||||||
|
<ul class="menu menu-nav">
|
||||||
|
{% for subkey, subvalue in value.items() %}
|
||||||
|
<li class="menu-item">
|
||||||
|
<a href="{{ subvalue }}" class="nav-item">{{
|
||||||
|
subkey
|
||||||
|
}}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
<!-- ETC -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div align="right">
|
||||||
|
<kbd>
|
||||||
|
{{ gf_version }}
|
||||||
|
<a href="{{ gf_repository_url }}">
|
||||||
|
<img src="{{ url_for('static', path='/img/github-inverted.png') }}" class="version-size">
|
||||||
|
</a>
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="off-canvas-overlay" href="#close"></a>
|
55
ghostforge/templates/navigation/top.html
Normal file
55
ghostforge/templates/navigation/top.html
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<header class="navbar">
|
||||||
|
<section class="navbar-section">
|
||||||
|
{% if crumbs %}
|
||||||
|
<ul class="breadcrumb">
|
||||||
|
{% for name, url in crumbs %}
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
{% if url %}
|
||||||
|
<a href="{{ url }}">{{ name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ name }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
<section class="navbar-section">
|
||||||
|
<div class="dropdown dropdown-right">
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="#" class="dropdown-toggle" tabindex="0">
|
||||||
|
<figure class="avatar avatar-lg badge" data-badge="3">
|
||||||
|
<img src="{{ url_for('static', path='/img/default-avatar.png') }}">
|
||||||
|
</figure>
|
||||||
|
</a>
|
||||||
|
<ul class="menu">
|
||||||
|
<li class="menu-item">
|
||||||
|
<div class="tile tile-centered">
|
||||||
|
<div class="tile-icon">
|
||||||
|
<img src="{{ url_for('static', path='/img/default-avatar.png') }}" class="avatar"
|
||||||
|
alt="Avatar">
|
||||||
|
</div>
|
||||||
|
<div class="tile-content">
|
||||||
|
Steve Rogers
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="divider"></li>
|
||||||
|
{% for key, value in avatar_menu.items() %}
|
||||||
|
<li class="menu-item">
|
||||||
|
{% if value is mapping and "quantity" in value and value["quantity"] > 0 %}
|
||||||
|
<div class="menu-badge">
|
||||||
|
<label class="label label-primary">{{ value["quantity"] }}</label>
|
||||||
|
</div>
|
||||||
|
{% set value = value["path"] %}
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ value }}">
|
||||||
|
{{ key }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</header>
|
62
ghostforge/templates/user.html
Normal file
62
ghostforge/templates/user.html
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column col-12">
|
||||||
|
<h1>Words!</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column col-3"></div>
|
||||||
|
<div class="column col-6">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header text-center">
|
||||||
|
<figure class="avatar avatar-lg"><img src="../img/avatar-2.png" alt="Avatar"></figure>
|
||||||
|
<div class="panel-title h5 mt-10">Bruce Banner</div>
|
||||||
|
<div class="panel-subtitle">THE HULK</div>
|
||||||
|
</div>
|
||||||
|
<nav class="panel-nav">
|
||||||
|
<ul class="tab tab-block">
|
||||||
|
<li class="tab-item active"><a href="#panels">Profile</a></li>
|
||||||
|
<li class="tab-item"><a href="#panels">Files</a></li>
|
||||||
|
<li class="tab-item"><a href="#panels">Tasks</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="tile tile-centered">
|
||||||
|
<div class="tile-content">
|
||||||
|
<div class="tile-title text-bold">E-mail</div>
|
||||||
|
<div class="tile-subtitle">bruce.banner@hulk.com</div>
|
||||||
|
</div>
|
||||||
|
<div class="tile-action">
|
||||||
|
<button class="btn btn-link btn-action btn-lg tooltip tooltip-left"
|
||||||
|
data-tooltip="Edit E-mail"><i class="icon icon-edit"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tile tile-centered">
|
||||||
|
<div class="tile-content">
|
||||||
|
<div class="tile-title text-bold">Skype</div>
|
||||||
|
<div class="tile-subtitle">bruce.banner</div>
|
||||||
|
</div>
|
||||||
|
<div class="tile-action">
|
||||||
|
<button class="btn btn-link btn-action btn-lg"><i class="icon icon-edit"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tile tile-centered">
|
||||||
|
<div class="tile-content">
|
||||||
|
<div class="tile-title text-bold">Location</div>
|
||||||
|
<div class="tile-subtitle">Dayton, Ohio</div>
|
||||||
|
</div>
|
||||||
|
<div class="tile-action">
|
||||||
|
<button class="btn btn-link btn-action btn-lg"><i class="icon icon-edit"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-footer">
|
||||||
|
<button class="btn btn-primary btn-block">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column col-3"></div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
15
ghostforge/users.py
Normal file
15
ghostforge/users.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from templates import templates
|
||||||
|
|
||||||
|
gf = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@gf.get("/user/{id}", response_class=HTMLResponse)
|
||||||
|
async def get_user(request: Request, id: str):
|
||||||
|
crumbs = [("settings", False), ("users", "/users"), (id, False)]
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"user.html", {"request": request, "crumbs": crumbs, "user": "test", "id": id, "title": f"User {id}"}
|
||||||
|
)
|
1
migrations/README
Normal file
1
migrations/README
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Generic single-database configuration with an async dbapi.
|
101
migrations/env.py
Normal file
101
migrations/env.py
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
# from ghostforge.models import User
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
section = config.config_ini_section
|
||||||
|
|
||||||
|
# this is ghostforge-specific, and loads the POSTGRES_*
|
||||||
|
# environment variables from .env into the alembic context
|
||||||
|
# for use in dynamically building the postgres URL string.
|
||||||
|
for var in os.environ:
|
||||||
|
if var.startswith("POSTGRES_"):
|
||||||
|
config.set_section_option(section, var, os.environ.get(var))
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = SQLModel.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
"""In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
25
migrations/script.py.mako
Normal file
25
migrations/script.py.mako
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
|
@ -11,19 +11,27 @@ readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.95.2",
|
"fastapi==0.95.2",
|
||||||
"uvicorn>=0.22.0",
|
"uvicorn==0.22.0",
|
||||||
"loguru>=0.7.0",
|
"loguru==0.7.0",
|
||||||
"passlib>=1.7.4",
|
"passlib==1.7.4",
|
||||||
"pydantic>=1.10.8",
|
"pydantic==1.10.8",
|
||||||
|
"alembic==1.11.1",
|
||||||
|
"asyncpg==0.27.0",
|
||||||
|
"sqlmodel==0.0.8",
|
||||||
|
"greenlet==2.0.2",
|
||||||
|
"jinja2==3.1.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ghostforge_serve = "ghostforge.cli:start_api"
|
ghostforge_serve = "ghostforge.serve:start"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
"Homepage" = "https://github.com/DarrylNixon/ghostforge"
|
homepage = "https://github.com/DarrylNixon/ghostforge"
|
||||||
"Bug Tracker" = "https://github.com/DarrylNixon/ghostforge/issues"
|
repository = "https://github.com/DarrylNixon/ghostforge"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = ["ghostforge"]
|
||||||
|
|
||||||
[tool.bandit]
|
[tool.bandit]
|
||||||
exclude_dirs = ["/doc", "/build"]
|
exclude_dirs = ["/doc", "/build"]
|
||||||
|
|
Loading…
Reference in a new issue