Initial commit
This commit is contained in:
commit
3355b9d716
15 changed files with 1635 additions and 0 deletions
88
src/api.py
Normal file
88
src/api.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
|
||||
from .models.authentication import (
|
||||
User, UserRead,
|
||||
Role, RoleRead,
|
||||
UserRoleLink
|
||||
)
|
||||
from .models.category import (
|
||||
CategoryGroup, CategoryModelType,
|
||||
Category, CategoryRead
|
||||
)
|
||||
from .database import get_db_session, pandas_query
|
||||
from .security import (
|
||||
User, Token,
|
||||
authenticate_user, get_current_active_user, create_access_token,
|
||||
)
|
||||
from .config import conf
|
||||
|
||||
api = FastAPI()
|
||||
|
||||
|
||||
@api.get("/nothing")
|
||||
async def get_nothing() -> str:
|
||||
return ''
|
||||
|
||||
@api.post("/token", response_model=Token)
|
||||
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
user = await authenticate_user(form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token_expires = timedelta(
|
||||
minutes=conf.security['access_token_expire_minutes'])
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username},
|
||||
expires_delta=access_token_expires)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
@api.get("/users")
|
||||
async def get_users(
|
||||
*, db_session: AsyncSession = Depends(get_db_session)
|
||||
) -> list[UserRead]:
|
||||
query = select(User).options(selectinload(User.roles))
|
||||
data = await db_session.exec(query)
|
||||
return data.all()
|
||||
|
||||
@api.get("/roles")
|
||||
async def get_roles(
|
||||
*, db_session: AsyncSession = Depends(get_db_session)
|
||||
) -> list[RoleRead]:
|
||||
query = select(Role).options(selectinload(Role.users))
|
||||
data = await db_session.exec(query)
|
||||
return data.all()
|
||||
|
||||
@api.get("/categories")
|
||||
async def get_categories(
|
||||
*, db_session: AsyncSession = Depends(get_db_session)
|
||||
) -> list[CategoryRead]:
|
||||
query = select(Category)
|
||||
data = await db_session.exec(query)
|
||||
return data.all()
|
||||
|
||||
|
||||
@api.get("/categories_p")
|
||||
async def get_categories_p(
|
||||
*, db_session: AsyncSession = Depends(get_db_session)
|
||||
) -> list[CategoryRead]:
|
||||
query = select(Category)
|
||||
df = await db_session.run_sync(pandas_query, query)
|
||||
return df.to_dict(orient="records")
|
||||
|
||||
# @api.get("/user-role")
|
||||
# async def get_user_role_relation(
|
||||
# *, db_session: AsyncSession = Depends(get_db_session)
|
||||
# ) -> list[UserRoleLink]:
|
||||
# roles = await db_session.exec(select(UserRoleLink))
|
||||
# return roles.all()
|
5
src/application.py
Normal file
5
src/application.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from fastapi import Depends, FastAPI
|
||||
from .api import api
|
||||
|
||||
app = FastAPI()
|
||||
app.mount('/v2', api)
|
69
src/config.py
Normal file
69
src/config.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
from os import environ
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
from sqlalchemy.ext.asyncio.engine import AsyncEngine
|
||||
from sqlalchemy.orm.session import sessionmaker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ENV = environ.get('env', 'prod')
|
||||
|
||||
|
||||
class Config:
|
||||
app: dict
|
||||
postgres: dict
|
||||
storage: dict
|
||||
map: dict
|
||||
security: dict
|
||||
version: str
|
||||
engine: AsyncEngine
|
||||
session_maker: sessionmaker
|
||||
|
||||
def __init__(self) -> None:
|
||||
from ._version import __version__
|
||||
self.version = __version__
|
||||
|
||||
|
||||
conf = Config()
|
||||
|
||||
|
||||
def set_app_config(app) -> None:
|
||||
raw_configs = []
|
||||
with open(Path(__file__).parent / 'defaults.yml') as cf:
|
||||
raw_configs.append(cf.read())
|
||||
for cf_path in (
|
||||
Path(Path.cwd().root) / 'etc' / 'gisaf' / ENV,
|
||||
Path.home() / '.local' / 'gisaf' / ENV
|
||||
):
|
||||
try:
|
||||
with open(cf_path.with_suffix('.yml')) as cf:
|
||||
raw_configs.append(cf.read())
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
yaml_config = yaml.safe_load('\n'.join(raw_configs))
|
||||
|
||||
conf.app = yaml_config['app']
|
||||
conf.postgres = yaml_config['postgres']
|
||||
conf.storage = yaml_config['storage']
|
||||
conf.map = yaml_config['map']
|
||||
conf.security = yaml_config['security']
|
||||
# create_dirs()
|
||||
|
||||
|
||||
# def create_dirs():
|
||||
# """
|
||||
# Create the directories needed for a proper functioning of the app
|
||||
# """
|
||||
# ## Avoid circular imports
|
||||
# from treetrail.api_v1 import attachment_types
|
||||
# for type in attachment_types:
|
||||
# base_dir = Path(conf.storage['root_attachment_path']) / type
|
||||
# base_dir.mkdir(parents=True, exist_ok=True)
|
||||
# logger.info(f'Cache dir: {get_cache_dir()}')
|
||||
# get_cache_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_cache_dir() -> Path:
|
||||
return Path(conf.storage['root_cache_path'])
|
16
src/database.py
Normal file
16
src/database.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
import pandas as pd
|
||||
|
||||
echo = False
|
||||
pg_url = "postgresql+asyncpg://avgis@localhost/avgis"
|
||||
|
||||
engine = create_async_engine(pg_url, echo=echo)
|
||||
|
||||
async def get_db_session():
|
||||
async with AsyncSession(engine) as session:
|
||||
yield session
|
||||
|
||||
def pandas_query(session, query):
|
||||
return pd.read_sql_query(query, session.connection())
|
60
src/models/authentication.py
Normal file
60
src/models/authentication.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
from sqlmodel import Field, SQLModel, MetaData, Relationship
|
||||
|
||||
schema = 'gisaf_admin'
|
||||
metadata = MetaData(schema=schema)
|
||||
|
||||
class UserRoleLink(SQLModel, table=True):
|
||||
metadata = metadata
|
||||
__tablename__: str = 'roles_users'
|
||||
user_id: int | None = Field(
|
||||
default=None, foreign_key="user.id", primary_key=True
|
||||
)
|
||||
role_id: int | None = Field(
|
||||
default=None, foreign_key="role.id", primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class UserBase(SQLModel):
|
||||
username: str
|
||||
email: str
|
||||
|
||||
|
||||
class User(UserBase, table=True):
|
||||
metadata = metadata
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
roles: list["Role"] = Relationship(back_populates="users",
|
||||
link_model=UserRoleLink)
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class RoleBase(SQLModel):
|
||||
name: str = Field(unique=True)
|
||||
|
||||
class RoleWithDescription(RoleBase):
|
||||
description: str | None
|
||||
|
||||
class Role(RoleWithDescription, table=True):
|
||||
metadata = metadata
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
users: list[User] = Relationship(back_populates="roles",
|
||||
link_model=UserRoleLink)
|
||||
|
||||
|
||||
class UserReadNoRoles(UserBase):
|
||||
id: int
|
||||
email: str | None
|
||||
|
||||
|
||||
class RoleRead(RoleBase):
|
||||
id: int
|
||||
users: list[UserReadNoRoles] = []
|
||||
|
||||
|
||||
class RoleReadNoUsers(RoleBase):
|
||||
id: int
|
||||
|
||||
|
||||
class UserRead(UserBase):
|
||||
id: int
|
||||
email: str | None
|
||||
roles: list[RoleReadNoUsers] = []
|
103
src/models/category.py
Normal file
103
src/models/category.py
Normal file
|
@ -0,0 +1,103 @@
|
|||
from typing import Any
|
||||
from sqlmodel import Field, SQLModel, MetaData, JSON, TEXT, Relationship, Column
|
||||
|
||||
schema = 'gisaf_survey'
|
||||
metadata = MetaData(schema=schema)
|
||||
|
||||
mapbox_type_mapping = {
|
||||
'Point': 'symbol',
|
||||
'Line': 'line',
|
||||
'Polygon': 'fill',
|
||||
}
|
||||
|
||||
class CategoryGroup(SQLModel, table=True):
|
||||
metadata = metadata
|
||||
name: str = Field(min_length=4, max_length=4,
|
||||
default=None, primary_key=True)
|
||||
major: str
|
||||
long_name: str
|
||||
|
||||
class Admin:
|
||||
menu = 'Other'
|
||||
flask_admin_model_view = 'CategoryGroupModelView'
|
||||
|
||||
|
||||
class CategoryModelType(SQLModel, table=True):
|
||||
metadata = metadata
|
||||
name: str = Field(default=None, primary_key=True)
|
||||
|
||||
class Admin:
|
||||
menu = 'Other'
|
||||
flask_admin_model_view = 'MyModelViewWithPrimaryKey'
|
||||
|
||||
|
||||
class CategoryBase(SQLModel):
|
||||
metadata = metadata
|
||||
|
||||
class Admin:
|
||||
menu = 'Other'
|
||||
flask_admin_model_view = 'CategoryModelView'
|
||||
|
||||
name: str | None = Field(default=None, primary_key=True)
|
||||
description: str | None
|
||||
group: str = Field(min_length=4, max_length=4,
|
||||
foreign_key="CategoryGroup.name", index=True)
|
||||
#group_: CategoryGroup = Relationship()
|
||||
minor_group_1: str = Field(min_length=4, max_length=4, default='----')
|
||||
minor_group_2: str = Field(min_length=4, max_length=4, default='----')
|
||||
status: str = Field(min_length=1, max_length=1)
|
||||
custom: bool | None
|
||||
auto_import: bool = True
|
||||
model_type: str = Field(max_length=50,
|
||||
foreign_key='CategoryModelType.name', default='Point')
|
||||
long_name: str | None = Field(max_length=50)
|
||||
style: str | None = Field(sa_column=Column(TEXT))
|
||||
symbol: str | None = Field(max_length=1)
|
||||
mapbox_type_custom: str | None = Field(max_length=32)
|
||||
mapbox_paint: dict[str, Any] | None = Field(sa_column=Column(JSON, none_as_null=True))
|
||||
mapbox_layout: dict[str, Any] | None = Field(sa_column=Column(JSON, none_as_null=True))
|
||||
viewable_role: str | None
|
||||
extra: dict[str, Any] | None = Field(sa_column=Column(JSON, none_as_null=True))
|
||||
|
||||
|
||||
class Category(CategoryBase, table=True):
|
||||
name: str = Field(default=None, primary_key=True)
|
||||
|
||||
|
||||
class CategoryRead(CategoryBase):
|
||||
name: str
|
||||
domain = 'V' # Survey
|
||||
|
||||
@property
|
||||
def layer_name(self):
|
||||
"""
|
||||
ISO compliant layer name (see ISO 13567)
|
||||
:return: str
|
||||
"""
|
||||
return '{self.domain}-{self.group:4s}-{self.minor_group_1:4s}-{self.minor_group_2:4s}-{self.status:1s}'.format(self=self)
|
||||
|
||||
@property
|
||||
def table_name(self):
|
||||
"""
|
||||
Table name
|
||||
:return:
|
||||
"""
|
||||
if self.minor_group_2 == '----':
|
||||
return '{self.domain}_{self.group:4s}_{self.minor_group_1:4s}'.format(self=self)
|
||||
else:
|
||||
return '{self.domain}_{self.group:4s}_{self.minor_group_1:4s}_{self.minor_group_2:4s}'.format(self=self)
|
||||
|
||||
@property
|
||||
def raw_survey_table_name(self):
|
||||
"""
|
||||
Table name
|
||||
:return:
|
||||
"""
|
||||
if self.minor_group_2 == '----':
|
||||
return 'RAW_{self.domain}_{self.group:4s}_{self.minor_group_1:4s}'.format(self=self)
|
||||
else:
|
||||
return 'RAW_{self.domain}_{self.group:4s}_{self.minor_group_1:4s}_{self.minor_group_2:4s}'.format(self=self)
|
||||
|
||||
@property
|
||||
def mapbox_type(self):
|
||||
return self.mapbox_type_custom or mapbox_type_mapping[self.model_type]
|
138
src/security.py
Normal file
138
src/security.py
Normal file
|
@ -0,0 +1,138 @@
|
|||
from datetime import datetime, timedelta
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from pydantic import BaseModel
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from .config import conf
|
||||
from .models.authentication import User as UserInDB
|
||||
|
||||
|
||||
# openssl rand -hex 32
|
||||
# import secrets
|
||||
# SECRET_KEY = secrets.token_hex(32)
|
||||
ALGORITHM: str = "HS256"
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: str | None = None
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
username: str
|
||||
email: str | None = None
|
||||
full_name: str | None = None
|
||||
disabled: bool | None = None
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def get_password_hash(password: str):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
async def delete_user(username):
|
||||
async with conf.session_maker.begin() as session:
|
||||
user_in_db: UserInDB | None = await get_user(username)
|
||||
if user_in_db is None:
|
||||
raise SystemExit(f'User {username} does not exist in the database')
|
||||
await session.delete(user_in_db)
|
||||
|
||||
|
||||
async def enable_user(username, enable=True):
|
||||
async with conf.session_maker.begin() as session:
|
||||
user_in_db: UserInDB | None = await get_user(username)
|
||||
if user_in_db is None:
|
||||
raise SystemExit(f'User {username} does not exist in the database')
|
||||
user_in_db.disabled = not enable # type: ignore
|
||||
session.add(user_in_db)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def create_user(username: str, password: str, full_name: str,
|
||||
email: str, **kwargs):
|
||||
async with conf.session_maker.begin() as session:
|
||||
user_in_db: UserInDB | None = await get_user(username)
|
||||
if user_in_db is None:
|
||||
user = UserInDB(
|
||||
username=username,
|
||||
password=get_password_hash(password),
|
||||
full_name=full_name,
|
||||
email=email,
|
||||
disabled=False
|
||||
)
|
||||
session.add(user)
|
||||
else:
|
||||
user_in_db.full_name = full_name # type: ignore
|
||||
user_in_db.email = email # type: ignore
|
||||
user_in_db.password = get_password_hash(password) # type: ignore
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_user(username: str) -> (UserInDB | None):
|
||||
async with conf.session_maker.begin() as session:
|
||||
req = await session.execute(select(UserInDB).where(UserInDB.username==username))
|
||||
return req.scalar()
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, conf.security['secret_key'], algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub", '')
|
||||
if username == '':
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
user = await get_user(username=token_data.username) # type: ignore
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return User(username=user.username, # type: ignore
|
||||
email=user.email, # type: ignore
|
||||
full_name=user.full_name) # type: ignore
|
||||
|
||||
|
||||
async def authenticate_user(username: str, password: str):
|
||||
user = await get_user(username)
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(password, user.password):
|
||||
return False
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(current_user: User = Depends(get_current_user)):
|
||||
if current_user.disabled:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode,
|
||||
conf.security['secret_key'],
|
||||
algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
Loading…
Add table
Add a link
Reference in a new issue