Compare commits

...

2 Commits

Author SHA1 Message Date
b3166811e5 Started with area page 2025-10-29 10:31:09 +01:00
bd2f7b286e Add content to shop page 2025-10-29 09:39:42 +01:00
16 changed files with 244 additions and 56 deletions

0
dev-server.sh Normal file → Executable file
View File

View File

@@ -0,0 +1,23 @@
from fastapi import APIRouter, Request
from allmende_payment_system.api.dependencies import SessionDep, UserDep
from allmende_payment_system.database import ensure_user
from allmende_payment_system.tools import get_jinja_renderer
root_router = APIRouter()
templates = get_jinja_renderer()
@root_router.get("/")
async def landing_page(request: Request, user_info: UserDep, session: SessionDep):
user = ensure_user(user_info, session)
print(f"User {user.username} ({user.display_name}) accessed landing page")
transactions = []
for account in user.accounts:
transactions += account.transactions
transactions = sorted(transactions, key=lambda t: t.timestamp)
return templates.TemplateResponse(
"index.html.jinja",
context={"request": request, "user": user, "transactions": transactions},
)

View File

@@ -0,0 +1,33 @@
import os
from typing import Annotated
from fastapi import Depends, HTTPException, Request
from sqlalchemy.orm import Session
from allmende_payment_system.database import SessionLocal
async def get_user(request: Request) -> dict:
if username := os.environ.get("APS_username", None):
return {
"username": username,
"display_name": os.environ.get("APS_display_name", "Missing Display Name"),
}
if "ynh_user" not in request.headers:
raise HTTPException(status_code=401, detail="Missing ynh_user header")
return {"username": request.headers["ynh_user"]}
UserDep = Annotated[dict, Depends(get_user)]
def get_session() -> Session:
db = SessionLocal()
try:
yield db
finally:
db.close()
SessionDep = Annotated[Session, Depends(get_session)]

View File

@@ -0,0 +1,30 @@
from fastapi import APIRouter, Request
from sqlalchemy import select
from allmende_payment_system.api import SessionDep
from allmende_payment_system.models import Area
from allmende_payment_system.tools import get_jinja_renderer
shop_router = APIRouter()
templates = get_jinja_renderer()
@shop_router.get("/shop")
async def get_shop(request: Request, session: SessionDep):
query = select(Area)
areas = session.scalars(query).all()
return templates.TemplateResponse(
"shop.html.jinja",
context={"request": request, "areas": areas},
)
@shop_router.get("/shop/area/{area_id}")
async def get_shop(request: Request, session: SessionDep, area_id: int):
query = select(Area).where(Area.id == area_id)
area = session.scalars(query).one()
return templates.TemplateResponse(
"area.html.jinja",
context={"request": request, "area": area},
)

View File

@@ -1,32 +1,16 @@
import os import locale
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Request from fastapi import Depends, FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from allmende_payment_system.database import SessionLocal, ensure_user
async def get_user(request: Request) -> dict:
if username := os.environ.get("APS_username", None):
return {
"username": username,
"display_name": os.environ.get("APS_display_name", "Missing Display Name"),
}
if "ynh_user" not in request.headers:
raise HTTPException(status_code=401, detail="Missing ynh_user header")
return {"username": request.headers["ynh_user"]}
UserDep = Annotated[dict, Depends(get_user)]
from allmende_payment_system.api import root_router
from allmende_payment_system.api.dependencies import get_user
from allmende_payment_system.api.shop import shop_router
locale.setlocale(locale.LC_ALL, "de_DE.UTF-8")
app = FastAPI(dependencies=[Depends(get_user)]) app = FastAPI(dependencies=[Depends(get_user)])
templates = Jinja2Templates(directory="src/allmende_payment_system/templates")
app.mount( app.mount(
"/static", "/static",
StaticFiles(directory="src/allmende_payment_system/static"), StaticFiles(directory="src/allmende_payment_system/static"),
@@ -34,26 +18,5 @@ app.mount(
) )
def get_session() -> Session: app.include_router(root_router)
db = SessionLocal() app.include_router(shop_router)
try:
yield db
finally:
db.close()
SessionDep = Annotated[Session, Depends(get_session)]
@app.get("/")
async def landing_page(request: Request, user_info: UserDep, session: SessionDep):
user = ensure_user(user_info, session)
print(f"User {user.username} ({user.display_name}) accessed landing page")
transactions = []
for account in user.accounts:
transactions += account.transactions
transactions = sorted(transactions, key=lambda t: t.timestamp)
return templates.TemplateResponse(
"index.html.jinja",
context={"request": request, "user": user, "transactions": transactions},
)

View File

@@ -52,6 +52,17 @@ class Area(Base):
__tablename__ = TABLE_PREFIX + "area" __tablename__ = TABLE_PREFIX + "area"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False, unique=True) name: Mapped[str] = mapped_column(nullable=False, unique=True)
description: Mapped[str] = mapped_column(nullable=True)
image_path: Mapped[str] = mapped_column(nullable=True)
products: Mapped[list["Product"]] = relationship("Product")
UnitsOfMeasure = typing.Literal[
"g",
"kg",
"piece",
]
class Product(Base): class Product(Base):
@@ -60,11 +71,14 @@ class Product(Base):
name: Mapped[str] = mapped_column(nullable=False, unique=True) name: Mapped[str] = mapped_column(nullable=False, unique=True)
price: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2)) price: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
unit_of_measure: Mapped[UnitsOfMeasure] = mapped_column(nullable=False)
# TODO: limit this to actually used vat rates? # TODO: limit this to actually used vat rates?
vat_rate: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2)) vat_rate: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
area_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "area.id")) area_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "area.id"))
area: Mapped["Area"] = relationship("Area") area: Mapped["Area"] = relationship("Area", back_populates="products")
image_path: Mapped[str] = mapped_column(nullable=True)
TransactionTypes = typing.Literal[ TransactionTypes = typing.Literal[

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -0,0 +1,36 @@
{% extends "base.html.jinja" %}
{% block content %}
<!-- Area Header -->
<div class="mb-4">
<h2 class="h4 mb-3">{{ area.name }}</h2>
<p class="text-muted">{{ area.description or ''}} </p>
</div>
<!-- Products Grid -->
<div class="row g-3">
{% for product in area.products %}
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<a href="#" class="text-decoration-none text-dark">
<!-- Product Image -->
<img
src="/static/img/{{ product.image_path if product.image_path else 'placeholder.jpg' }}"
alt="{{ product.name }}"
class="card-img-top img-fluid rounded-top"
style="height: 100px; object-fit: cover;"
>
<!-- Product Details -->
<div class="card-body">
<h5 class="card-title mb-2">{{ product.name }}</h5>
<p class="card-text text-muted small mb-3">{{ product.description }}</p>
<div class="d-flex justify-content-between align-items-center">
<span class="fw-bold">{{ product.price|format_number }} € pro {{ product.unit_of_measure|units_of_measure_de }}</span>
<button class="btn btn-sm btn-outline-primary">Add to Cart</button>
</div>
</div>
</a>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -21,22 +21,22 @@
<ul class="nav nav-pills flex-column mb-auto"> <ul class="nav nav-pills flex-column mb-auto">
<li class="nav-item"> <li class="nav-item">
<a href="#" class="nav-link active"> <a href="#" class="nav-link active">
Dashboard Übersicht
</a>
</li>
<li class="nav-item">
<a href="/shop" class="nav-link">
Einkaufen
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a href="#" class="nav-link"> <a href="#" class="nav-link">
Projects Lorem
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a href="#" class="nav-link"> <a href="#" class="nav-link">
Reports Ipsum
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link">
Settings
</a> </a>
</li> </li>
</ul> </ul>

View File

@@ -45,7 +45,7 @@
{% for transaction in transactions[:10] %} {% for transaction in transactions[:10] %}
<div class="list-group-item d-flex justify-content-between align-items-start py-3"> <div class="list-group-item d-flex justify-content-between align-items-start py-3">
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="fw-semibold">{{ transaction.product.name }}</div> <div class="fw-semibold">{{ transaction.product.name or transaction.type|transaction_type_de }}</div>
<small class="text-muted"> <small class="text-muted">
{{ transaction.timestamp }} {{ transaction.timestamp }}
</small> </small>

View File

@@ -0,0 +1,61 @@
{% extends "base.html.jinja" %}
{% block content %}
<!-- Shop Landing Page Header -->
<div class="mb-4">
<h2 class="h4 mb-3">Shop</h2>
<p class="text-muted">In welchem Bereich möchtest du einkaufen?</p>
</div>
<div class="row g-3">
{% for area in areas %}
<div class="col-md-6 col-lg-4">
<a href="/shop/area/{{ area.id }}" class="text-decoration-none">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body d-flex align-items-center p-3">
<!-- Image on the left -->
<div class="me-3" style="width: 120px; flex-shrink: 0;">
<img
src="/static/img/{{ area.image_path if area.image_path !='' else 'placeholder.png'}}" }}"
alt="{{ area.name }}"
class="img-fluid rounded"
style="max-height: 100px; width: 100%; object-fit: contain;"
>
</div>
<!-- Title and description on the right -->
<div class="flex-grow-1">
<h5 class="card-title mb-1">{{ area.name }}</h5>
<p class="card-text text-muted small mb-0">{{ area.description or '' }}</p>
</div>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
<!-- Optional: Featured Products Section -->
{# <div class="mt-5">#}
{# <div class="d-flex justify-content-between align-items-center mb-3">#}
{# <h2 class="h4 mb-0">Featured Products</h2>#}
{# <a href="#" class="btn btn-outline-primary btn-sm">View All</a>#}
{# </div>#}
{# <div class="alert alert-info">#}
{# Featured products will appear here#}
{# </div>#}
{# </div>#}
{% endblock %}
{% block styles %}
<style>
/* Hover effect for shop tiles */
.hover-shadow:hover {
transform: translateY(-3px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
transition: all 0.3s ease;
}
.card {
border: none;
transition: all 0.3s ease;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,28 @@
import locale
import numbers
from starlette.templating import Jinja2Templates
TRANSACTION_TYPE_DE = {
"deposit": "Einzahlung",
"withdrawal": "Auszahlung",
"expense": "Auslage",
"product": "Einkauf",
}
UNITS_OF_MEASURE = {"piece": "Stück"}
def format_number(value: float):
try:
return f"{value:n}"
except TypeError:
return value
def get_jinja_renderer() -> Jinja2Templates:
renderer = Jinja2Templates(directory="src/allmende_payment_system/templates")
renderer.env.filters["transaction_type_de"] = lambda x: TRANSACTION_TYPE_DE[x]
renderer.env.filters["units_of_measure_de"] = lambda x: UNITS_OF_MEASURE.get(x, x)
renderer.env.filters["format_number"] = format_number
return renderer