mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-11 18:33:11 -05:00
feature/profile-cards (#391)
* unify format * pass variables * remove namespace * rename * group-card init * shuffle + icons * remove console.logs * token CRUD * update changelog * add profile link * consolidate mealplan to profile dashboard * update docs * add query parameter to search page * update test routes * update python depts * basic token tests Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
@@ -8,7 +8,7 @@ from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag
|
||||
from mealie.db.models.settings import CustomPage, SiteSettings
|
||||
from mealie.db.models.sign_up import SignUp
|
||||
from mealie.db.models.theme import SiteThemeModel
|
||||
from mealie.db.models.users import User
|
||||
from mealie.db.models.users import LongLiveToken, User
|
||||
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
|
||||
from mealie.schema.events import Event as EventSchema
|
||||
from mealie.schema.meal import MealPlanInDB
|
||||
@@ -17,7 +17,7 @@ from mealie.schema.settings import CustomPageOut
|
||||
from mealie.schema.settings import SiteSettings as SiteSettingsSchema
|
||||
from mealie.schema.sign_up import SignUpOut
|
||||
from mealie.schema.theme import SiteTheme
|
||||
from mealie.schema.user import GroupInDB, UserInDB
|
||||
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, UserInDB
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
logger = getLogger()
|
||||
@@ -38,12 +38,16 @@ class _Recipes(BaseDocument):
|
||||
|
||||
def count_uncategorized(self, session: Session, count=True, override_schema=None) -> int:
|
||||
return self._countr_attribute(
|
||||
session, attribute_name=RecipeModel.recipe_category, attr_match=None, count=True, override_schema=None
|
||||
session,
|
||||
attribute_name=RecipeModel.recipe_category,
|
||||
attr_match=None,
|
||||
count=count,
|
||||
override_schema=override_schema,
|
||||
)
|
||||
|
||||
def count_untagged(self, session: Session, count=True, override_schema=None) -> int:
|
||||
return self._countr_attribute(
|
||||
session, attribute_name=RecipeModel.tags, attr_match=None, count=True, override_schema=None
|
||||
session, attribute_name=RecipeModel.tags, attr_match=None, count=count, override_schema=override_schema
|
||||
)
|
||||
|
||||
|
||||
@@ -102,6 +106,13 @@ class _Users(BaseDocument):
|
||||
return self.schema.from_orm(entry)
|
||||
|
||||
|
||||
class _LongLiveToken(BaseDocument):
|
||||
def __init__(self) -> None:
|
||||
self.primary_key = "id"
|
||||
self.sql_model = LongLiveToken
|
||||
self.schema = LongLiveTokenInDB
|
||||
|
||||
|
||||
class _Groups(BaseDocument):
|
||||
def __init__(self) -> None:
|
||||
self.primary_key = "id"
|
||||
@@ -154,6 +165,7 @@ class Database:
|
||||
self.categories = _Categories()
|
||||
self.tags = _Tags()
|
||||
self.users = _Users()
|
||||
self.api_tokens = _LongLiveToken()
|
||||
self.sign_ups = _SignUps()
|
||||
self.groups = _Groups()
|
||||
self.custom_pages = _CustomPages()
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import sqlalchemy as sa
|
||||
from mealie.db.models.model_base import SqlAlchemyBase
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
|
||||
|
||||
class RecipeIngredient(SqlAlchemyBase):
|
||||
__tablename__ = "recipes_ingredients"
|
||||
id = sa.Column(sa.Integer, primary_key=True)
|
||||
position = sa.Column(sa.Integer)
|
||||
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
|
||||
ingredient = sa.Column(sa.String)
|
||||
id = Column(Integer, primary_key=True)
|
||||
position = Column(Integer)
|
||||
parent_id = Column(Integer, ForeignKey("recipes.id"))
|
||||
# title = Column(String)
|
||||
ingredient = Column(String)
|
||||
|
||||
def update(self, ingredient):
|
||||
self.ingredient = ingredient
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import sqlalchemy as sa
|
||||
from mealie.db.models.model_base import SqlAlchemyBase
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
|
||||
|
||||
class RecipeInstruction(SqlAlchemyBase):
|
||||
__tablename__ = "recipe_instructions"
|
||||
id = sa.Column(sa.Integer, primary_key=True)
|
||||
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
|
||||
position = sa.Column(sa.Integer)
|
||||
type = sa.Column(sa.String, default="")
|
||||
text = sa.Column(sa.String)
|
||||
title = sa.Column(sa.String)
|
||||
id = Column(Integer, primary_key=True)
|
||||
parent_id = Column(Integer, ForeignKey("recipes.id"))
|
||||
position = Column(Integer)
|
||||
type = Column(String, default="")
|
||||
title = Column(String)
|
||||
text = Column(String)
|
||||
|
||||
@@ -93,7 +93,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
recipe_category: list[str] = None,
|
||||
tags: list[str] = None,
|
||||
date_added: datetime.date = None,
|
||||
date_updated: datetime.datetime = None,
|
||||
notes: list[dict] = None,
|
||||
rating: int = None,
|
||||
org_url: str = None,
|
||||
|
||||
@@ -3,11 +3,19 @@ from mealie.db.models.group import Group
|
||||
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||
|
||||
# I'm not sure this is necessasry, browser based settings may be sufficient
|
||||
# class UserSettings(SqlAlchemyBase, BaseMixins):
|
||||
# __tablename__ = "user_settings"
|
||||
# id = Column(Integer, primary_key=True, index=True)
|
||||
# parent_id = Column(String, ForeignKey("users.id"))
|
||||
|
||||
class LongLiveToken(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "long_live_tokens"
|
||||
id = Column(Integer, primary_key=True)
|
||||
parent_id = Column(Integer, ForeignKey("users.id"))
|
||||
name = Column(String, nullable=False)
|
||||
token = Column(String, nullable=False)
|
||||
user = orm.relationship("User")
|
||||
|
||||
def __init__(self, session, name, token, parent_id) -> None:
|
||||
self.name = name
|
||||
self.token = token
|
||||
self.user = User.get_ref(session, parent_id)
|
||||
|
||||
|
||||
class User(SqlAlchemyBase, BaseMixins):
|
||||
@@ -19,6 +27,9 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="users")
|
||||
admin = Column(Boolean, default=False)
|
||||
tokens: list[LongLiveToken] = orm.relationship(
|
||||
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -29,6 +40,8 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
group: str = settings.DEFAULT_GROUP,
|
||||
admin=False,
|
||||
id=None,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> None:
|
||||
|
||||
group = group or settings.DEFAULT_GROUP
|
||||
@@ -38,7 +51,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
self.admin = admin
|
||||
self.password = password
|
||||
|
||||
def update(self, full_name, email, group, admin, session=None, id=None, password=None):
|
||||
def update(self, full_name, email, group, admin, session=None, id=None, password=None, *args, **kwargs):
|
||||
self.full_name = full_name
|
||||
self.email = email
|
||||
self.group = Group.get_ref(session, group)
|
||||
@@ -49,3 +62,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
def update_password(self, password):
|
||||
self.password = password
|
||||
|
||||
@staticmethod
|
||||
def get_ref(session, id: str):
|
||||
return session.query(User).filter(User.id == id).one()
|
||||
|
||||
@@ -8,7 +8,8 @@ from mealie.core.config import settings
|
||||
from mealie.db.database import db
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.schema.auth import TokenData
|
||||
from mealie.schema.user import UserInDB
|
||||
from mealie.schema.user import LongLiveTokenInDB, UserInDB
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
ALGORITHM = "HS256"
|
||||
@@ -23,8 +24,14 @@ async def get_current_user(token: str = Depends(oauth2_scheme), session=Depends(
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
long_token: str = payload.get("long_token")
|
||||
|
||||
if long_token is not None:
|
||||
return validate_long_live_token(session, token, payload.get("id"))
|
||||
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
|
||||
token_data = TokenData(username=username)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
@@ -35,6 +42,16 @@ async def get_current_user(token: str = Depends(oauth2_scheme), session=Depends(
|
||||
return user
|
||||
|
||||
|
||||
def validate_long_live_token(session: Session, client_token: str, id: int) -> UserInDB:
|
||||
|
||||
tokens: list[LongLiveTokenInDB] = db.api_tokens.get(session, id, "parent_id", limit=9999)
|
||||
|
||||
for token in tokens:
|
||||
token: LongLiveTokenInDB
|
||||
if token.token == client_token:
|
||||
return token.user
|
||||
|
||||
|
||||
async def validate_file_token(token: Optional[str] = None) -> Path:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -75,7 +75,7 @@ def get_today(session: Session = Depends(generate_session), current_user: UserIn
|
||||
group_in_db: GroupInDB = db.groups.get(session, current_user.group, "name")
|
||||
recipe = get_todays_meal(session, group_in_db)
|
||||
if recipe:
|
||||
return recipe.slug
|
||||
return recipe
|
||||
|
||||
|
||||
@router.get("/today/image", tags=["Meal Plan"])
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import auth, crud, sign_up
|
||||
from . import api_tokens, auth, crud, sign_up
|
||||
|
||||
user_router = APIRouter()
|
||||
|
||||
user_router.include_router(auth.router)
|
||||
user_router.include_router(sign_up.router)
|
||||
user_router.include_router(crud.router)
|
||||
user_router.include_router(api_tokens.router)
|
||||
|
||||
56
mealie/routes/users/api_tokens.py
Normal file
56
mealie/routes/users/api_tokens.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.param_functions import Depends
|
||||
from mealie.core.security import create_access_token
|
||||
from mealie.db.database import db
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.deps import get_current_user
|
||||
from mealie.schema.user import CreateToken, LoingLiveTokenIn, LongLiveTokenInDB, UserInDB
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["User API Tokens"])
|
||||
|
||||
|
||||
@router.post("/api-tokens", status_code=status.HTTP_201_CREATED)
|
||||
async def create_api_token(
|
||||
token_name: LoingLiveTokenIn,
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
""" Create api_token in the Database """
|
||||
|
||||
token_data = {"long_token": True, "id": current_user.id}
|
||||
|
||||
five_years = timedelta(1825)
|
||||
token = create_access_token(token_data, five_years)
|
||||
|
||||
token_model = CreateToken(
|
||||
name=token_name.name,
|
||||
token=token,
|
||||
parent_id=current_user.id,
|
||||
)
|
||||
|
||||
new_token_in_db = db.api_tokens.create(session, token_model)
|
||||
|
||||
if new_token_in_db:
|
||||
return {"token": token}
|
||||
|
||||
|
||||
@router.delete("/api-tokens/{token_id}")
|
||||
async def delete_api_token(
|
||||
token_id: int,
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
""" Delete api_token from the Database """
|
||||
token: LongLiveTokenInDB = db.api_tokens.get(session, token_id)
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, f"Could not locate token with id '{token_id}' in database")
|
||||
|
||||
if token.user.email == current_user.email:
|
||||
deleted_token = db.api_tokens.delete(session, token_id)
|
||||
return {"token_delete": deleted_token.name}
|
||||
else:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from mealie.schema.category import CategoryBase
|
||||
from mealie.schema.category import CategoryBase, RecipeCategoryResponse
|
||||
from pydantic import validator
|
||||
from slugify import slugify
|
||||
|
||||
@@ -34,7 +34,7 @@ class CustomPageBase(CamelModel):
|
||||
name: str
|
||||
slug: Optional[str]
|
||||
position: int
|
||||
categories: list[CategoryBase] = []
|
||||
categories: list[RecipeCategoryResponse] = []
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@@ -10,6 +10,25 @@ from pydantic.types import constr
|
||||
from pydantic.utils import GetterDict
|
||||
|
||||
|
||||
class LoingLiveTokenIn(CamelModel):
|
||||
name: str
|
||||
|
||||
|
||||
class LongLiveTokenOut(LoingLiveTokenIn):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class CreateToken(LoingLiveTokenIn):
|
||||
parent_id: int
|
||||
token: str
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class ChangePassword(CamelModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
@@ -53,6 +72,7 @@ class UserIn(UserBase):
|
||||
class UserOut(UserBase):
|
||||
id: int
|
||||
group: str
|
||||
tokens: Optional[list[LongLiveTokenOut]]
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -96,3 +116,11 @@ class GroupInDB(UpdateGroup):
|
||||
**GetterDict(orm_model),
|
||||
"webhook_urls": [x.url for x in orm_model.webhook_urls if x],
|
||||
}
|
||||
|
||||
|
||||
class LongLiveTokenInDB(CreateToken):
|
||||
id: int
|
||||
user: UserInDB
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
Reference in New Issue
Block a user