Compare commits

...

6 Commits

Author SHA1 Message Date
Lino Mallevaey
70ae9654e0 feat: Addition of authentication and user management systems 2025-08-19 23:17:06 +02:00
Lino Mallevaey
954f73a0f5 Improved support for authentication cookies 2025-08-19 18:20:37 +02:00
Lino Mallevaey
cdbd58905d feat: Added SSL support for authentication cookies 2025-08-19 17:53:41 +02:00
Lino Mallevaey
c30ce3af56 fix: Correction of configuration and database models 2025-08-19 12:18:05 +02:00
Lino Mallevaey
756af47782 feat: Added database utility functions 2025-08-19 12:17:11 +02:00
Lino Mallevaey
660dcbe888 feat: Added the utils module and classes useful for managing task and event frequency. 2025-08-19 00:03:44 +02:00
18 changed files with 729 additions and 72 deletions

View File

@@ -28,7 +28,7 @@ DEBUG=True
APP_TITLE=MokPyo APP_TITLE=MokPyo
APP_VERSION=1.0.0 APP_VERSION=1.0.0
APP_DESCRIPTION=MokPyo APP_DESCRIPTION=MokPyo
APP_DOMAIN=localhost APP_DOMAIN=localhost # change in production with your domain (ex: mokpyo.com)
# ========================= # =========================
# SERVER CONFIGURATION # SERVER CONFIGURATION
@@ -49,6 +49,10 @@ ACCESS_TOKEN_EXPIRE_MINUTES=30
# Algorithme de chiffrement JWT # Algorithme de chiffrement JWT
ALGORITHM=HS256 ALGORITHM=HS256
# Use SSL (True/False)
# You need to set USE_SSL=True if you use HTTPS
USE_SSL=False
# ========================= # =========================
# EMAIL / VALIDATION # EMAIL / VALIDATION
# ========================= # =========================

View File

@@ -4,16 +4,76 @@ Entrypoint pour lancer l'application en dev.
Usage: Usage:
python3 -m app python3 -m app
""" """
import argparse
import uvicorn import uvicorn
from app.main import app from app.main import app
from app.core.config import settings from app.core.config import settings
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="MokPyo API",
)
# Option booléenne (type flag)
parser.add_argument(
"--init-db",
action="store_true",
help="Initialiser la base de données. Première exécution uniquement."
)
parser.add_argument(
"--reset",
action="store_true",
help="Vide les tables de la base de données (lignes uniquement). Pas besoin de --init-db au prochain lancement."
)
parser.add_argument(
"--clear",
action="store_true",
help="Vide la base de données (lignes et tables). besoin de --init-db au prochain lancement."
)
parser.add_argument(
"--host",
type=str,
default=settings.HOST,
help="Host sur lequel l'application va tourner. Override le host de configuration."
)
parser.add_argument(
"--port",
type=int,
default=settings.PORT,
help="Port sur lequel l'application va tourner. Override le port de configuration."
)
return parser
if __name__ == "__main__": if __name__ == "__main__":
parser = build_parser()
args = parser.parse_args()
if args.init_db:
from app.db.utils import init_db
init_db()
exit()
if args.reset:
from app.db.utils import reset_db
reset_db()
exit()
if args.clear:
from app.db.utils import clear_db
clear_db()
exit()
uvicorn.run( uvicorn.run(
"app.main:app", "app.main:app",
host=settings.HOST, host=args.host,
port=settings.PORT, port=args.port,
reload=settings.ENV == "dev", reload=settings.ENV == "dev",
log_level=settings.LOG_LEVEL.lower(), log_level=settings.LOG_LEVEL.lower(),
) )

View File

@@ -4,7 +4,6 @@ API v1 package for MokPyo application.
- Central router for version 1 of the API - Central router for version 1 of the API
""" """
from fastapi import APIRouter
from .endpoints import router as v1_router from .endpoints import router as v1_router
__version__ = "1.0.0" __version__ = "1.0.0"

View File

@@ -1,15 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core import settings, get_current_user, hash_password, verify_password
from app.db import get_db
from pathlib import Path
router = APIRouter()
# =========================
# TEST / PING
# =========================
@router.get("/ping", tags=["Test"])
def ping():
return {"message": "pong"}

View File

@@ -0,0 +1,11 @@
from fastapi import APIRouter
router = APIRouter()
from .user import router as user_router
from .auth import router as auth_router
router.include_router(user_router, prefix="/users")
router.include_router(auth_router, prefix="/auth")

View File

@@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core import settings
from app.core.security import (
verify_password,
create_access_token,
set_auth_cookie,
)
from app.db import get_async_db
from app.db.models import User
from app.api.v1.schemas import TokenRequest, TokenResponse
router = APIRouter()
# ------------------------
# Authentication
# ------------------------
@router.post(
"/login",
response_model=TokenResponse,
status_code=status.HTTP_200_OK,
tags=["Authentication"]
)
async def get_token(payload: TokenRequest, response: Response, db: Session = Depends(get_async_db)):
result = await db.execute(select(User).where(User.username == payload.username))
user = result.scalar_one_or_none()
if not user or not verify_password(payload.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid crendentials",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(
data = {"sub": str(user.id)}
)
set_auth_cookie(
response,
token=access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
return {"access_token": access_token}
@router.post(
"/logout",
status_code=status.HTTP_200_OK,
tags=["Authentication"],
)
def logout(response: Response) -> dict:
response.delete_cookie(
key="access_token",
path="/", # même chemin que lors du set_cookie
httponly=True,
samesite="lax" if settings.ENV == "dev" else "strict",
secure=settings.USE_SSL, # mettre True en prod sur HTTPS
)
return {"detail": "Logged out successfully"}

View File

@@ -0,0 +1,194 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core import settings
from app.core.security import (
hash_password,
get_token_payload,
get_current_user,
get_cookie_token,
verify_password,
create_access_token,
set_auth_cookie,
)
from app.db import get_async_db
from app.db.models import User
from app.api.v1.schemas import UserCreate, UserRead
router = APIRouter()
# ------------------------
# User
# ------------------------
@router.post(
"/new",
response_model=UserRead,
status_code=status.HTTP_201_CREATED,
tags=["Users"]
)
async def register_user(user_in: UserCreate, response: Response, db: Session = Depends(get_async_db)):
result = await db.execute(select(User).where(User.email == user_in.email))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
result = await db.execute(select(User).where(User.username == user_in.username))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hash_password(user_in.password),
stats = "{}"
)
db.add(user)
await db.commit()
await db.refresh(user)
access_token = create_access_token(
data = {"sub": str(user.id)}
)
set_auth_cookie(
response,
token=access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
return UserRead.model_validate(user)
@router.get(
"/me",
response_model=UserRead,
status_code=status.HTTP_200_OK,
tags=["Users"]
)
async def get_user(payload: dict = Depends(get_token_payload), db: Session = Depends(get_async_db)):
user_id = payload.get('sub')
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserRead.model_validate(user)
@router.put(
"/me",
response_model=UserRead,
status_code=status.HTTP_200_OK,
tags=["Users"]
)
async def update_user(user_in: UserCreate, payload: dict = Depends(get_token_payload), db: Session = Depends(get_async_db)):
user_id = payload.get('sub')
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if user_in.email != user.email:
result = await db.execute(select(User).where(
(User.email == user_in.email)
))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email or username already registered"
)
if user_in.username != user.username:
result = await db.execute(select(User).where(
(User.username == user_in.username)
))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email or username already registered"
)
user.username = user_in.username
user.email = user_in.email
user.hashed_password = hash_password(user_in.password)
await db.commit()
await db.refresh(user)
return UserRead.model_validate(user)
@router.delete(
"/me",
status_code=status.HTTP_200_OK,
tags=['Users']
)
async def delete_user(payload: dict = Depends(get_token_payload), db: Session = Depends(get_async_db)):
user_id = payload.get('sub')
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
await db.delete(user)
await db.commit()
response = Response()
response.delete_cookie(
key="access_token",
path="/", # mesmo chemin que lors du set_cookie
httponly=True,
samesite="lax" if settings.ENV == "dev" else "strict",
secure=settings.USE_SSL, # mettre True en prod sur HTTPS
)
return response

View File

@@ -0,0 +1,8 @@
"""
API v1 schemas package for MokPyo application.
- Central access point for v1 API schemas
"""
from .user import UserCreate, UserRead
from .token import TokenRequest, TokenResponse

View File

@@ -0,0 +1,21 @@
from pydantic import BaseModel, Field, field_validator
# ----------------------------------------------------------------------
# Token
# ----------------------------------------------------------------------
class TokenRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=30)
password: str
@field_validator("username")
def all_allowed_chars(cls, v):
for v_char in v:
if not v_char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_":
raise ValueError("username can only contain a-z, A-Z, 0-9 and _")
return v
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"

View File

@@ -0,0 +1,27 @@
from pydantic import BaseModel, Field, EmailStr, field_validator
# ----------------------------------------------------------------------
# User
# ----------------------------------------------------------------------
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=30)
email: EmailStr
password: str = Field(..., min_length=8)
@field_validator("username")
def all_allowed_chars(cls, v):
for v_char in v:
if not v_char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_":
raise ValueError("username can only contain a-z, A-Z, 0-9 and _")
return v
class UserRead(BaseModel):
id: int
username: str
email: EmailStr
stats: str
class Config:
from_attributes = True

View File

@@ -15,7 +15,6 @@ class Settings(BaseSettings):
DATABASE_USER: str DATABASE_USER: str
DATABASE_PASSWORD: str DATABASE_PASSWORD: str
DATABASE_NAME: str DATABASE_NAME: str
DATABASE_DRIVER: str
# ========================= # =========================
# FASTAPI # FASTAPI
@@ -40,6 +39,7 @@ class Settings(BaseSettings):
SECRET_KEY: str SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
USE_SSL: bool = False
# ========================= # =========================
# EMAIL (optionnel) # EMAIL (optionnel)
@@ -70,6 +70,10 @@ class Settings(BaseSettings):
def database_url(self) -> str: def database_url(self) -> str:
return f"mysql+<driver>://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}" return f"mysql+<driver>://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}"
@property
def app_url(self) -> str:
return f"{'https' if settings.USE_SSL else 'http'}://{settings.APP_DOMAIN}{':' + str(settings.PORT) if (settings.PORT != 80 and settings.ENV == 'dev') else ''}"
@property @property
def access_token_expire(self) -> timedelta: def access_token_expire(self) -> timedelta:
return timedelta(minutes=self.ACCESS_TOKEN_EXPIRE_MINUTES) return timedelta(minutes=self.ACCESS_TOKEN_EXPIRE_MINUTES)

View File

@@ -25,12 +25,12 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
# ========================= # =========================
# JWT TOKEN MANAGEMENT # JWT TOKEN MANAGEMENT
# ========================= # =========================
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token.""" """Create a JWT access token."""
to_encode = data.copy() to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) expire = datetime.now() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt return encoded_jwt
@@ -46,7 +46,7 @@ def decode_access_token(token: str) -> dict:
# ========================= # =========================
# DEPENDENCIES FASTAPI # DEPENDENCIES FASTAPI
# ========================= # =========================
async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict: def get_current_user(token: str) -> dict:
"""Get current user from JWT token.""" """Get current user from JWT token."""
payload = decode_access_token(token) payload = decode_access_token(token)
user_id: Any = payload.get("sub") user_id: Any = payload.get("sub")
@@ -54,6 +54,12 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
return payload return payload
async def get_token_payload(request: Request) -> dict:
token = get_cookie_token(request)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return get_current_user(token)
# ========================= # =========================
# COOKIE MANAGEMENT (optionnel) # COOKIE MANAGEMENT (optionnel)
# ========================= # =========================
@@ -64,8 +70,8 @@ def set_auth_cookie(response: Response, token: str, max_age: int = 3600):
value=f"Bearer {token}", value=f"Bearer {token}",
httponly=True, httponly=True,
max_age=max_age, max_age=max_age,
samesite="lax", samesite="lax" if settings.ENV == "dev" else "strict",
secure=False, # True si HTTPS secure=settings.USE_SSL, # True si HTTPS
) )
def get_cookie_token(request: Request) -> Optional[str]: def get_cookie_token(request: Request) -> Optional[str]:

View File

@@ -8,4 +8,4 @@ __version__ = "1.0.0"
from .engine import engine, async_engine from .engine import engine, async_engine
from .session import SessionLocal, AsyncSessionLocal, get_db, get_async_db from .session import SessionLocal, AsyncSessionLocal, get_db, get_async_db
from .models import Base from .models import *

View File

@@ -1,90 +1,134 @@
from datetime import date
from sqlalchemy import ( from sqlalchemy import (
sa, Column,
Column, Integer,
Integer, String,
String, Text,
Boolean, Boolean,
Date, Date,
DateTime,
Time,
Enum, Enum,
ForeignKey ForeignKey,
text,
) )
from sqlalchemy.orm import DeclarativeBase, relationship from sqlalchemy.orm import DeclarativeBase, relationship
# ----------------------------------------------------------------------
# Base class
# ----------------------------------------------------------------------
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
# ----------------------------------------------------------------------
# User
# ----------------------------------------------------------------------
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True, index=True)
email = Column(String, unique=True, index=True) email = Column(String(255), nullable=False, unique=True, index=True)
username = Column(String, unique=True, index=True) username = Column(String(150), nullable=False, unique=True, index=True)
hashed_password = Column(String) hashed_password = Column(String(255), nullable=False)
stats = Column(String)
is_active = Column(Boolean, default=True)
tasks = relationship("Item", back_populates="owner") # JSON est idéal pour stocker un dict, sinon utilise String + server_default
events = relationship("Event", back_populates="owner") stats = Column(Text, nullable=False)
goals = relationship("Goal", back_populates="owner")
sub_goals = relationship("SubGoal", back_populates="owner") # bool → TINYINT(1) avec server_default
notes = relationship("Note", back_populates="owner") is_active = Column(Boolean, nullable=False, server_default=text("1"))
# Relations
tasks = relationship("Task", back_populates="owner", cascade="all, delete-orphan")
events = relationship("Event", back_populates="owner", cascade="all, delete-orphan")
goals = relationship("Goal", back_populates="owner", cascade="all, delete-orphan")
sub_goals = relationship("SubGoal", back_populates="owner", cascade="all, delete-orphan")
notes = relationship("Note", back_populates="owner", cascade="all, delete-orphan")
# ----------------------------------------------------------------------
# Task
# ----------------------------------------------------------------------
class Task(Base): class Task(Base):
__tablename__ = "tasks" __tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True, index=True)
description = Column(String, index=True, nullable=False) description = Column(String(255), nullable=False, index=True)
frequency = Column(Integer, default=4223, nullable=False) frequency = Column(Integer, nullable=False,
server_default=text("4223")) # valeur par défaut côté serveur
reward = Column(Integer, nullable=True) reward = Column(Integer, nullable=True)
goal_id = Column(Integer, ForeignKey("goals.id"), default=None, nullable=True)
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
start_date = Column(Date, server_default=sa.text("CURRENT_DATE"), nullable=False) start_date = Column(Date, nullable=False)
next_date = Column(Date, nullable=True) next_date = Column(Date, nullable=True)
owner = relationship("User", back_populates="tasks") owner = relationship("User", back_populates="tasks")
goal = relationship("Goal", back_populates="tasks") goal = relationship("Goal", back_populates="tasks")
# ----------------------------------------------------------------------
# Event
# ----------------------------------------------------------------------
class Event(Base): class Event(Base):
__tablename__ = "events" __tablename__ = "events"
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True, index=True)
name = Column(String, index=True, nullable=False) name = Column(String(255), nullable=False, index=True) # texte court → String
description = Column(String, index=True, nullable=True) description = Column(String(255), nullable=True, index=True)
frequency = Column(Integer, default=4223, nullable=False) frequency = Column(Integer, nullable=False, server_default=text("4223"))
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) start_time = Column(Time, nullable=False)
end_time = Column(Time, nullable=False)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owner = relationship("User", back_populates="events") owner = relationship("User", back_populates="events")
# ----------------------------------------------------------------------
# Goal
# ----------------------------------------------------------------------
class Goal(Base): class Goal(Base):
__tablename__ = "goals" __tablename__ = "goals"
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True, index=True)
title = Column(String, index=True, nullable=False) title = Column(String(255), nullable=False, index=True)
description = Column(String, index=True, nullable=True) description = Column(Text, nullable=True)
state = Column(Enum("in_progress", "successful", "failed", "aborted"), default="in_progress", nullable=False)
# Enum avec nom explicite
state = Column(
Enum("in_progress", "successful", "failed", "aborted",
name="goal_state"),
nullable=False,
server_default=text("'in_progress'")
)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
tasks = relationship("Task", back_populates="goal") tasks = relationship("Task", back_populates="goal", cascade="all, delete-orphan")
notes = relationship("Note", back_populates="goal") notes = relationship("Note", back_populates="goal", cascade="all, delete-orphan")
sub_goals = relationship("SubGoal", back_populates="goal") sub_goals = relationship("SubGoal", back_populates="goal", cascade="all, delete-orphan")
owner = relationship("User", back_populates="goals") owner = relationship("User", back_populates="goals")
# ----------------------------------------------------------------------
# SubGoal
# ----------------------------------------------------------------------
class SubGoal(Base): class SubGoal(Base):
__tablename__ = "sub_goals" __tablename__ = "sub_goals"
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True, index=True)
title = Column(String, index=True, nullable=False) title = Column(String(255), nullable=False, index=True)
description = Column(String, index=True, nullable=True) description = Column(Text, nullable=True)
state = Column(Enum("in_progress", "successful", "failed", "aborted"), default="in_progress", nullable=False)
state = Column(
Enum("in_progress", "successful", "failed", "aborted",
name="subgoal_state"),
nullable=False,
server_default=text("'in_progress'")
)
reward = Column(Integer, nullable=True) reward = Column(Integer, nullable=True)
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False) goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
@@ -92,19 +136,26 @@ class SubGoal(Base):
owner = relationship("User", back_populates="sub_goals") owner = relationship("User", back_populates="sub_goals")
# ----------------------------------------------------------------------
# Note
# ----------------------------------------------------------------------
class Note(Base): class Note(Base):
__tablename__ = "notes" __tablename__ = "notes"
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True, index=True)
title = Column(String, index=True, nullable=False) title = Column(String(255), nullable=False, index=True)
description = Column(String, index=True, nullable=True) description = Column(Text, nullable=True)
content = Column(String, index=True, nullable=True) content = Column(Text, nullable=True)
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=True) goal_id = Column(Integer, ForeignKey("goals.id"), nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(Date, server_default=sa.text("CURRENT_DATE"), nullable=False) created_at = Column(DateTime, nullable=False)
updated_at = Column(Date, server_default=sa.text("CURRENT_DATE"), nullable=False) updated_at = Column(DateTime, nullable=False)
goal = relationship("Goal", back_populates="notes") goal = relationship("Goal", back_populates="notes")
owner = relationship("User", back_populates="notes") owner = relationship("User", back_populates="notes")
# --------------------------------------------------------------
__all__ = ["Base", "User", "Task", "Event", "Goal", "SubGoal", "Note"]

49
app/db/utils.py Normal file
View File

@@ -0,0 +1,49 @@
from app.db.engine import engine
from app.db.session import SessionLocal
from app.db.models import *
from app.core.config import settings
def init_db():
try:
if settings.DEBUG:
print("Creating database tables...")
Base.metadata.create_all(bind=engine)
if settings.DEBUG:
print("Database tables created.")
except Exception as e:
print(f"Error creating database tables: {e}")
raise
finally:
if settings.DEBUG:
print("Tables :", Base.metadata.tables.keys())
def clear_db():
try:
if settings.DEBUG:
print("Deleting database tables...")
Base.metadata.drop_all(bind=engine)
if settings.DEBUG:
print("Database tables deleted.")
except Exception as e:
print(f"Error deleting database tables: {e}")
raise
def reset_db():
session = SessionLocal()
try:
if settings.DEBUG:
print("Deleting database rows...")
for table in reversed(Base.metadata.sorted_tables):
session.execute(table.delete())
session.commit()
if settings.DEBUG:
print("Database rows deleted.")
except Exception as e:
print(f"Error deleting database rows: {e}")
raise
finally:
session.close()

View File

@@ -1,5 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import ORJSONResponse, JSONResponse, FileResponse from fastapi.responses import ORJSONResponse, JSONResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pathlib import Path from pathlib import Path
@@ -22,6 +23,14 @@ app = FastAPI(
redoc_url=None if settings.ENV == "prod" else "/redoc", redoc_url=None if settings.ENV == "prod" else "/redoc",
) )
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.app_url],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ========================= # =========================
# Static files # Static files
# ========================= # =========================

11
app/utils/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
"""
Utils package for MokPyo application.
- Expose utility classes for easy imports
"""
from .frequency import Frequency, Cron, DayInterval, WeekInterval, MonthInterval, Interval
__all__ = [
"Frequency", "Cron", "DayInterval", "WeekInterval", "MonthInterval", "Interval"
]

150
app/utils/frequency.py Normal file
View File

@@ -0,0 +1,150 @@
import re
class Interval:
def __init__(self, unit: str, value: int, exec_day: int = None):
if unit not in ["day", "week", "month"]:
raise ValueError("unit must be day, week or month")
if value < 1 or value > 2**17 - 1:
raise ValueError("value must be between 1 and 131071")
self.unit = unit
self.value = value
self.exec_day = exec_day
def copy(self):
return Interval(self.unit, self.value, self.exec_day)
def __repr__(self):
return f"<Interval unit={self.unit} value={self.value} exec_day={self.exec_day}>"
class DayInterval(Interval):
def __init__(self, value: int):
super().__init__("day", value)
class WeekInterval(Interval):
def __init__(self, value: int, exec_day: int = 1):
super().__init__("week", value)
if exec_day not in range(1, 8) and exec_day is not None:
raise ValueError("exec_day must be between 1 and 7 or None")
self.exec_day = exec_day
class MonthInterval(Interval):
def __init__(self, value: int, exec_day: int = 1):
super().__init__("month", value)
if exec_day not in range(1, 32) and exec_day is not None:
raise ValueError("exec_day must be between 1 and 31 or None")
self.exec_day = exec_day
class Cron:
def __init__(self, days: str, weeks: str, months: str):
assert re.fullmatch(r"^(?:\*|[1-7](?:,[1-7])*|(?:1-[2-7]|2-[3-7]|3-[4-7]|4-[5-7]|5-[6-7]|6-7))$", days)
assert re.fullmatch(r"^(?:\*|[1-5](?:,[1-5])*|(?:1-[2-5]|2-[3-5]|3-[4-5]|4-5))$", weeks)
assert re.fullmatch(r"^(?:\*|(?:[1-9]|1[0-2])(?:,(?:[1-9]|1[0-2]))*|(?:1-(?:[2-9]|1[0-2])|2-(?:[3-9]|1[0-2])|3-(?:[4-9]|1[0-2])|4-(?:[5-9]|1[0-2])|5-(?:[6-9]|1[0-2])|6-(?:[7-9]|1[0-2])|7-(?:[8-9]|1[0-2])|8-(?:9|1[0-2])|9-(1[0-2])|10-(11|12)|11-12))$", months)
self.days = days
self.weeks = weeks
self.months = months
def copy(self):
return Cron(self.days, self.weeks, self.months)
def __repr__(self):
return f"<Cron days={self.days} weeks={self.weeks} months={self.months}>"
def __str__(self):
return f"{self.days} {self.weeks} {self.months}"
class Frequency:
def __init__(self, frequency: Cron | Interval | int):
self.data = 0b0
self.__model = {"type": None, "instance": None}
if isinstance(frequency, Cron):
self.__model["type"] = Cron
self.__model["instance"] = frequency.copy()
self.data = 0b1 << 24
_bin_days = self.__cron_bin(frequency.days, 7) << 17
_bin_weeks = self.__cron_bin(frequency.weeks, 5) << 12
_bin_months = self.__cron_bin(frequency.months, 12)
self.data |= _bin_days | _bin_weeks | _bin_months
elif isinstance(frequency, Interval):
self.__model["type"] = Interval
self.__model["instance"] = frequency.copy()
_bin_unit = {"day": 1, "week": 2, "month": 3}[frequency.unit] << 22
_bin_value = frequency.value << 5
_bin_exec_day = frequency.exec_day if frequency.exec_day is not None else 0b0
self.data |= _bin_unit | _bin_value | _bin_exec_day
elif isinstance(frequency, int):
if frequency < 1 or frequency > 2**25 - 1:
raise ValueError("frequency must be between 1 and 2**25 - 1")
self.data = frequency
self.__model = self.__int_model(frequency)
else:
raise ValueError("frequency must be Cron, Interval or int")
def __cron_bin(self, cron: str, length: int) -> int:
_bin = 0b0
if cron == "*":
_bin |= (1 << length) - 1
elif '-' in cron:
start, end = map(int, cron.split("-"))
for i in range(start, end + 1):
_bin |= 1 << (i - 1)
else:
for i in cron.split(","):
_bin |= 1 << (int(i) - 1)
return _bin
def __int_model(self, _int: int):
if _int & (1 << 24): # Cron
_days = (_int >> 17) & 0b1111111
_weeks = (_int >> 12) & 0b11111
_months = _int & 0b111111111111
days = self.__bin_to_str(_days, 7)
weeks = self.__bin_to_str(_weeks, 5)
months = self.__bin_to_str(_months, 12)
return {"type": Cron, "instance": Cron(days, weeks, months)}
else: # Interval
_unit = (_int >> 22) & 0b11
_value = (_int >> 5) & ((1 << 17) - 1)
_exec_day = _int & 0b11111
unit = {1: "day", 2: "week", 3: "month"}[_unit]
IntervalClass = {"day": DayInterval, "week": WeekInterval, "month": MonthInterval}[unit]
return {"type": Interval, "instance": IntervalClass(_value, _exec_day or None)}
def __bin_to_str(self, value: int, length: int) -> str:
if value == (1 << length) - 1:
return "*"
nums = [str(i + 1) for i in range(length) if value & (1 << i)]
if not nums:
return "*"
return ",".join(nums)
def __int__(self):
return self.data
def __len__(self):
return len(bin(self.data)[2:])
def __repr__(self):
return str(bin(self.data)[2:].zfill(25))
__str__ = __repr__
def to_model(self) -> Cron | Interval:
return self.__model.get("instance")
def get_type(self):
return self.__model.get("type")