add project base

This commit is contained in:
Julien Aldon
2026-02-09 17:39:09 +01:00
commit 145f3f632e
43 changed files with 1045 additions and 0 deletions

9
backend/LICENSE.txt Normal file
View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

21
backend/README.md Normal file
View File

@@ -0,0 +1,21 @@
# backend
[![PyPI - Version](https://img.shields.io/pypi/v/backend.svg)](https://pypi.org/project/backend)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/backend.svg)](https://pypi.org/project/backend)
-----
## Table of Contents
- [Installation](#installation)
- [License](#license)
## Installation
```console
pip install backend
```
## License
`backend` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.

65
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,65 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "backend"
dynamic = ["version"]
description = ''
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
keywords = []
authors = [
{ name = "Julien Aldon", email = "julien.aldon@wanadoo.fr" },
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = [
"fastapi",
"sqlmodel",
"psycopg2",
"PyJWT",
"cryptography",
"requests"
]
[project.urls]
Documentation = "https://github.com/Julien Aldon/backend#readme"
Issues = "https://github.com/Julien Aldon/backend/issues"
Source = "https://github.com/Julien Aldon/backend"
[tool.hatch.version]
path = "src/__about__.py"
[tool.hatch.envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {args:src/backend tests}"
[tool.coverage.run]
source_pkgs = ["backend", "tests"]
branch = true
parallel = true
omit = [
"src/__about__.py",
]
[tool.coverage.paths]
backend = ["src", "*/backend/src/"]
tests = ["tests", "*/backend/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]

4
backend/src/__about__.py Normal file
View File

@@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.1"

3
backend/src/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
#
# SPDX-License-Identifier: MIT

80
backend/src/auth/auth.py Normal file
View File

@@ -0,0 +1,80 @@
from fastapi import APIRouter, Security, HTTPException
from fastapi.responses import RedirectResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from src.secrets import CLIENT_ID, REDIRECT_URI, AUTH_URL, CLIENT_SECRET, TOKEN_URL, JWKS_URL, ISSUER
import secrets
import jwt
from jwt import PyJWKClient
import requests
router = APIRouter(prefix="/auth")
jwk_client = PyJWKClient(JWKS_URL)
security = HTTPBearer()
@router.get('/login')
def login():
state = secrets.token_urlsafe(16)
params = {
"client_id": CLIENT_ID,
"response_type": "code",
"scope": "openid",
"redirect_uri": REDIRECT_URI,
"state": state,
}
request_url = requests.Request('GET', AUTH_URL, params=params).prepare().url
return RedirectResponse(request_url)
@router.get("/callback")
def callback(code: str):
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
}
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
response = requests.post(TOKEN_URL, data=data, headers=headers)
if response.status_code != 200:
return JSONResponse(
{"error": "Failed to get token"},
status_code=400
)
token_data = response.json()
return {
"access_token": token_data["access_token"],
"id_token": token_data["id_token"],
"refresh_token": token_data["refresh_token"],
}
def verify_token(token: str):
try:
signing_key = jwk_client.get_signing_key_from_jwt(token)
decoded = jwt.decode(token, options={"verify_signature": False})
print(decoded, ISSUER)
print(decoded["exp"])
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=CLIENT_ID,
issuer=ISSUER,
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
def get_current_user(
credentials: HTTPAuthorizationCredentials = Security(security)
):
return verify_token(credentials.credentials)

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
#
# SPDX-License-Identifier: MIT

View File

@@ -0,0 +1,3 @@
from fastapi import APIRouter
router = APIRouter(prefix='/contracts')

9
backend/src/database.py Normal file
View File

@@ -0,0 +1,9 @@
from sqlmodel import create_engine, SQLModel, Session
from src.secrets import dbname, dbhost, dbuser, dbpass
engine = create_engine(f'postgresql://{dbuser}:{dbpass}@{dbhost}:54321/{dbname}')
# SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
#
# SPDX-License-Identifier: MIT

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter
router = APIRouter(prefix='/forms')
@router.get('/')
def get_forms():
return []
@router.post('/')
def create_form():
return {}
@router.put('/')
def update_form():
return {}
@router.delete('/')
def delete_form():
return {}

View File

@@ -0,0 +1,10 @@
from sqlmodel import Field, SQLModel
form src.productors.model import Productor
class Form(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
productor_id: int | None = Field(default=None, foreign_key="productor.id")
shipment_number: int
season: str

36
backend/src/main.py Normal file
View File

@@ -0,0 +1,36 @@
from sqlmodel import SQLModel
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.templates.templates import router as template_router
from src.contracts.contracts import router as contracts_router
from src.forms.forms import router as forms_router
from src.productors.productors import router as productors_router
from src.products.products import router as products_router
from src.users.users import router as users_router
from src.auth.auth import router as auth_router
from src.secrets import origins
from src.database import engine
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=['x-nbpage']
)
app.include_router(template_router)
app.include_router(contracts_router)
app.include_router(forms_router)
app.include_router(productors_router)
app.include_router(products_router)
app.include_router(users_router)
app.include_router(auth_router)
SQLModel.metadata.create_all(engine)

1
backend/src/messages.py Normal file
View File

@@ -0,0 +1 @@
notfound = "Resource was not found."

100
backend/src/models.py Normal file
View File

@@ -0,0 +1,100 @@
from sqlmodel import Field, SQLModel, Relationship
from enum import Enum
from typing import Optional
import datetime
class Unit(Enum):
GRAMS = 1
KILO = 2
class ProductBase(SQLModel):
name: str
unit: Unit
price: float
price_kg: float | None
weight: float
productor_id: int | None = Field(default=None, foreign_key="productor.id")
class ProductPublic(ProductBase):
id: int
class Product(ProductBase, table=True):
id: int | None = Field(default=None, primary_key=True)
productor: Optional['Productor'] = Relationship(back_populates="products")
class ProductUpdate(SQLModel):
name: str | None
unit: Unit | None
price: float | None
price_kg: float | None
weight: float | None
productor_id: int | None = Field(default=None, foreign_key="productor.id")
class ProductCreate(ProductBase):
pass
class ProductorBase(SQLModel):
name: str
address: str
payment: str
class ProductorPublic(ProductorBase):
id: int
products: list[Product] = []
class Productor(ProductorBase, table=True):
id: int | None = Field(default=None, primary_key=True)
products: list[Product] = Relationship(back_populates='productor')
class ProductorUpdate(SQLModel):
name: str | None
address: str | None
payment: str | None
class ProductorCreate(ProductorBase):
pass
class FormBase(SQLModel):
productor_id: int | None = Field(default=None, foreign_key="productor.id")
referer_id: int | None = Field(default=None, foreign_key="referer.id")
season: str
shipments: int
start: datetime.date
end: datetime.date
class FormPublic(FormBase):
id: int
class Form(FormBase, table=True):
id: int | None = Field(default=None, primary_key=True)
class FormUpdate(SQLModel):
productor_id: int | None = Field(default=None, foreign_key="productor.id")
referer_id: int | None = Field(default=None, foreign_key="user.id")
season: str | None
shipments: int | None
start: datetime.date | None
end: datetime.date | None
class FormCreate(FormBase):
pass
class UserBase(SQLModel):
name: str
email: str
class UserPublic(UserBase):
id: int
class User(UserBase, table=True):
id: int | None = Field(default=None, primary_key=True)
class UserUpdate(SQLModel):
name: str | None
email: str | None
class UserCreate(UserBase):
pass

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
#
# SPDX-License-Identifier: MIT

View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, HTTPException, Depends
import src.messages as messages
import src.models as models
from src.database import get_session
from sqlmodel import Session
import src.productors.service as service
router = APIRouter(prefix='/productors')
@router.get('/', response_model=list[models.ProductorPublic])
def get_productors(session: Session = Depends(get_session)):
return service.get_all(session)
@router.get('/{id}', response_model=models.ProductorPublic)
def get_productors(id: int, session: Session = Depends(get_session)):
result = service.get_one(session, id)
if result is None:
raise HTTPException(status_code=404, detail=messages.notfound)
return result
@router.post('/', response_model=models.ProductorPublic)
def create_productor(productor: models.ProductorCreate, session: Session = Depends(get_session)):
return service.create_one(session, productor)
@router.put('/{id}', response_model=models.ProductorPublic)
def update_productor(id: int, productor: models.ProductorUpdate, session: Session = Depends(get_session)):
result = service.update_one(session, id, productor)
if result is None:
raise HTTPException(status_code=404, detail=messages.notfound)
return result
@router.delete('/{id}', response_model=models.ProductorPublic)
def delete_productor(id: int, session: Session = Depends(get_session)):
result = service.delete_one(session, id)
if result is None:
raise HTTPException(status_code=404, detail=messages.notfound)
return result

View File

@@ -0,0 +1,41 @@
from sqlmodel import Session, select
from src.models import Productor
def get_all(session: Session) -> list[Productor]:
statement = select(Productor)
return session.exec(statement).all()
def get_one(session: Session, productor_id: int) -> Productor:
return session.get(Productor, productor_id)
def create_one(session: Session, productor: Productor) -> Productor:
productor_create = productor.model_dump(exclude_unset=True)
new_productor = Productor(**productor_create)
session.add(new_productor)
session.commit()
session.refresh(new_productor)
return new_productor
def update_one(session: Session, id: int, productor: Productor) -> Productor:
statement = select(Productor).where(Productor.id == id)
result = session.exec(statement)
new_productor = result.first()
if not new_productor:
return None
productor_updates = productor.model_dump(exclude_unset=True)
for key, value in productor_updates.items():
setattr(new_productor, key, value)
session.add(new_productor)
session.commit()
session.refresh(new_productor)
return new_productor
def delete_one(session: Session, id: int) -> Productor:
statement = select(Productor).where(Productor.id == id)
result = session.exec(statement)
productor = result.first()
if not productor:
return None
session.delete(productor)
session.commit()
return productor

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
#
# SPDX-License-Identifier: MIT

View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, HTTPException, Depends
import src.messages as messages
import src.models as models
from src.database import get_session
from sqlmodel import Session
import src.products.service as service
from src.auth.auth import get_current_user
router = APIRouter(prefix='/products')
@router.get('/', response_model=list[models.ProductPublic], )
def get_products(session: Session = Depends(get_session), user=Depends(get_current_user)):
return service.get_all(session)
@router.get('/{id}', response_model=models.ProductPublic)
def get_product(id: int, session: Session = Depends(get_session)):
result = service.get_one(session, id)
if result is None:
raise HTTPException(status_code=404, detail=messages.notfound)
return result
@router.post('/', response_model=models.ProductPublic)
def create_product(product: models.ProductCreate, session: Session = Depends(get_session)):
return service.create_one(session, product)
@router.put('/{id}', response_model=models.ProductPublic)
def update_product(id: int, product: models.ProductUpdate, session: Session = Depends(get_session)):
result = service.update_one(session, id, product)
if result is None:
raise HTTPException(status_code=404, detail=messages.notfound)
return result
@router.delete('/{id}', response_model=models.ProductPublic)
def delete_product(id: int, session: Session = Depends(get_session)):
result = service.delete_one(session, id)
if result is None:
raise HTTPException(status_code=404, detail=messages.notfound)
return result

View File

@@ -0,0 +1,41 @@
from sqlmodel import Session, select
from src.models import Product
def get_all(session: Session) -> list[Product]:
statement = select(Product)
return session.exec(statement).all()
def get_one(session: Session, product_id: int) -> Product:
return session.get(Product, product_id)
def create_one(session: Session, product: Product) -> Product:
product_create = product.model_dump(exclude_unset=True)
new_product = Product(**product_create)
session.add(new_product)
session.commit()
session.refresh(new_product)
return new_product
def update_one(session: Session, id: int, product: Product) -> Product:
statement = select(Product).where(Product.id == id)
result = session.exec(statement)
new_product = result.first()
if not new_product:
return None
product_updates = product.model_dump(exclude_unset=True)
for key, value in product_updates.items():
setattr(new_product, key, value)
session.add(new_product)
session.commit()
session.refresh(new_product)
return new_product
def delete_one(session: Session, id: int) -> Product:
statement = select(Product).where(Product.id == id)
result = session.exec(statement)
product = result.first()
if not product:
return None
session.delete(product)
session.commit()
return product

29
backend/src/secrets.py Normal file
View File

@@ -0,0 +1,29 @@
import os
origins = [
os.environ.get('SERVICE_ORIGIN') or 'http://localhost'
]
dbhost = os.environ.get('DB_HOST') or 'localhost'
dbuser = os.environ.get('PGSQL_USER') or 'postgres'
dbpass = os.environ.get('PGSQL_PASSWORD') or 'postgres'
dbname = os.environ.get('PGSQL_DATABASE') or 'amap'
# openssl rand -hex 32
SECRET_KEY = os.environ.get('SERVICE_SECRET_KEY') or 'test'
ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_MINUTES = 600
KEYCLOAK_SERVER = ""
REALM = ""
CLIENT_ID = ""
CLIENT_SECRET = ""
REDIRECT_URI = "http://localhost:8000/auth/callback"
AUTH_URL = f"{KEYCLOAK_SERVER}/realms/{REALM}/protocol/openid-connect/auth"
TOKEN_URL = f"{KEYCLOAK_SERVER}/realms/{REALM}/protocol/openid-connect/token"
ISSUER = f"{KEYCLOAK_SERVER}/realms/{REALM}"
JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs"

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
#
# SPDX-License-Identifier: MIT

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter
router = APIRouter(prefix='/templates')
@router.get('/')
def get_templates():
return []
@router.post('/')
def create_template():
return {}
@router.put('/')
def update_template():
return {}
@router.delete('/')
def delete_template():
return {}

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter
router = APIRouter(prefix='/users')
@router.get('/')
def get_users():
return []
@router.post('/')
def create_user():
return {}
@router.put('/')
def update_user():
return {}
@router.delete('/')
def delete_user():
return {}

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
#
# SPDX-License-Identifier: MIT