Compare commits

..

3 Commits

Author SHA1 Message Date
81929cca21 Add first info to landing page 2025-10-23 12:35:52 +02:00
f6e69b1521 Add justfile
Only target so far is lint
2025-10-23 11:41:57 +02:00
e1c8b4ebeb Add authorization 2025-10-23 11:41:52 +02:00
13 changed files with 239 additions and 34 deletions

1
.gitignore vendored
View File

@@ -89,3 +89,4 @@ instance/
# uv specific # uv specific
.uvcache/ .uvcache/
/db_fixtures/

7
dev-server.sh Normal file
View File

@@ -0,0 +1,7 @@
if [ -z "${APS_username}" ]; then
export APS_username="testuser"
fi
if [ -z "${APS_display_name}" ]; then
export APS_display_name="Dr. T. Estuser"
fi
fastapi dev src/allmende_payment_system/app.py

8
justfile Normal file
View File

@@ -0,0 +1,8 @@
lint:
uv run isort test src
uv run black test src
reset_db:
rm -f aps_db.db
uv run python -c "from allmende_payment_system.database import create_tables; create_tables()"
for file in db_fixtures/*.sql; do sqlite3 aps_db.db < "$file"; done

View File

@@ -22,9 +22,10 @@ build-backend = "uv_build"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"black>=25.9.0", "black>=25.9.0",
"httpx>=0.28.1",
"isort>=7.0.0", "isort>=7.0.0",
"pytest>=8.4.2", "pytest>=8.4.2",
] ]
[tool.isort] [tool.isort]
profile = "black" profile = "black"

View File

@@ -1,9 +1,31 @@
# backend/app/main.py import os
from fastapi import FastAPI, Request from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates 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)]
app = FastAPI(dependencies=[Depends(get_user)])
app = FastAPI()
templates = Jinja2Templates(directory="src/allmende_payment_system/templates") templates = Jinja2Templates(directory="src/allmende_payment_system/templates")
app.mount( app.mount(
"/static", "/static",
@@ -12,6 +34,21 @@ app.mount(
) )
def get_session() -> Session:
db = SessionLocal()
try:
yield db
finally:
db.close()
SessionDep = Annotated[Session, Depends(get_session)]
@app.get("/") @app.get("/")
async def landing_page(request: Request): async def landing_page(request: Request, user_info: UserDep, session: SessionDep):
return templates.TemplateResponse("index.html.jinja", {"request": request}) user = ensure_user(user_info, session)
print(f"User {user.username} ({user.display_name}) accessed landing page")
return templates.TemplateResponse(
"index.html.jinja", context={"request": request, "user": user}
)

View File

@@ -1,6 +1,29 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import Session, sessionmaker
from allmende_payment_system.models import User
SQLALCHEMY_DATABASE_URL = "sqlite:///./aps_db.db" SQLALCHEMY_DATABASE_URL = "sqlite:///./aps_db.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL) engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def create_tables():
from allmende_payment_system.models import Base
Base.metadata.create_all(bind=engine)
def ensure_user(user_info: dict, session: Session) -> User:
statement = select(User).where(User.username == user_info["username"])
if user := session.scalars(statement).one_or_none():
return user
user = User(
username=user_info["username"], display_name=user_info.get("display_name")
)
session.add(user)
session.commit()
return user

View File

@@ -19,6 +19,10 @@ class Account(Base):
back_populates="accounts", back_populates="accounts",
) )
@property
def balance(self):
return 141.23
user_account_association = Table( user_account_association = Table(
TABLE_PREFIX + "user_account_association", TABLE_PREFIX + "user_account_association",

View File

@@ -1,7 +1,75 @@
{% extends "base.html.jinja" %} {% extends "base.html.jinja" %}
{% block content %} {% block content %}
<div class="p-3"> <!-- Account Balances Section -->
<h1>Welcome to My App</h1> <div class="mb-4">
<p>This is your landing page content. Replace this with your actual content.</p> <h2 class="h4 mb-3">Meine Konten</h2>
<div class="row g-3">
{% if user.accounts|length > 0 %}
{% for account in user.accounts %}
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">{{ account.name }}</h5>
<p class="h3 mb-2 {% if account.balance < 0 %}text-danger{% else %}text-success{% endif %}">
{{ '%.2f' | format(account.balance) }} €
</p>
<div class="d-flex gap-2 mt-3">
<a href="#" class="btn btn-sm btn-outline-primary">Transaktionen</a>
<a href="#" class="btn btn-sm btn-success">Aufladen</a>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="alert alert-info">
Keine Konten verfügbar
</div>
</div>
{% endif %}
</div>
</div>
<!-- Latest Transactions Section -->
<div class="transactions-section">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h3 mb-0">Latest Transactions</h2>
<a href="#" class="btn btn-outline-primary btn-sm">View All</a>
</div>
<div class="card">
<div class="list-group list-group-flush">
{% if transactions and transactions|length > 0 %}
{% for transaction in transactions[:5] %}
<div class="list-group-item d-flex justify-content-between align-items-start py-3">
<div class="flex-grow-1">
<div class="fw-semibold">{{ transaction.product_name | default('Transaction') }}</div>
<small class="text-muted">
{{ transaction.date | default('') }}
{% if transaction.description %}
· {{ transaction.description }}
{% endif %}
</small>
</div>
<div class="text-end ms-3">
<span class="fs-5 fw-bold {% if transaction.amount < 0 %}text-danger{% else %}text-success{% endif %}">
{{ '%+.2f' | format(transaction.amount | default(0)) }} €
</span>
{% if transaction.quantity %}
<div class="small text-muted">{{ transaction.quantity }} {{ transaction.unit | default('pcs') }}</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="list-group-item text-center py-5 text-muted">
<p class="mb-0">No transactions yet</p>
<small>Your transactions will appear here</small>
</div>
{% endif %}
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

35
test/conftest.py Normal file
View File

@@ -0,0 +1,35 @@
import os
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from allmende_payment_system.app import app
from allmende_payment_system.models import Base
@pytest.fixture(scope="session")
def client():
os.environ["APS_username"] = "test"
return TestClient(app)
@pytest.fixture(scope="session")
def unauthorized_client():
os.environ.pop("APS_username", None)
return TestClient(app)
@pytest.fixture
def test_db():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine) # Create tables
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Provide a session and the engine
db = TestingSessionLocal()
try:
yield db
finally:
db.close()

11
test/test_auth.py Normal file
View File

@@ -0,0 +1,11 @@
from allmende_payment_system.models import Account
def test_unauthorized_access(unauthorized_client):
response = unauthorized_client.get("/")
assert response.status_code == 401
def test_authorized_access(client):
response = client.get("/")
assert response.status_code == 200

19
test/test_database.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import func, select
from allmende_payment_system.database import ensure_user
from allmende_payment_system.models import User
def test_ensure_user(test_db):
user_info = {"username": "test", "display_name": "Test User"}
user = ensure_user(user_info, test_db)
assert user.username == "test"
test_db.commit()
assert test_db.scalar(select(func.count()).select_from(User)) == 1
user = ensure_user(user_info, test_db)
assert test_db.scalar(select(func.count()).select_from(User)) == 1

View File

@@ -1,27 +1,16 @@
# tests/conftest.py from allmende_payment_system.models import Account, User
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from allmende_payment_system.models import Base, User
# Create an in-memory SQLite database
@pytest.fixture
def in_memory_db():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine) # Create tables
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Provide a session and the engine
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
def test_create_user(in_memory_db): def test_user_model(test_db):
user = User(username="test", display_name="Test User") user = User(username="test", display_name="Test User")
in_memory_db.add(user) test_db.add(user)
in_memory_db.commit() test_db.commit()
assert user.id is not None assert user.id is not None
account = Account(name="Test Account")
account.users.append(user)
test_db.add(account)
test_db.commit()
assert len(user.accounts) == 1

2
uv.lock generated
View File

@@ -14,6 +14,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "black" }, { name = "black" },
{ name = "httpx" },
{ name = "isort" }, { name = "isort" },
{ name = "pytest" }, { name = "pytest" },
] ]
@@ -27,6 +28,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "black", specifier = ">=25.9.0" }, { name = "black", specifier = ">=25.9.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "isort", specifier = ">=7.0.0" }, { name = "isort", specifier = ">=7.0.0" },
{ name = "pytest", specifier = ">=8.4.2" }, { name = "pytest", specifier = ">=8.4.2" },
] ]