This commit is contained in:
jose-rZM
2025-12-15 11:42:17 +01:00
commit c3f7af7379
33 changed files with 5004 additions and 0 deletions

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.venv
__pycache__
.pytest_cache
.ruff_cache
*.pyc
.env
.env.*

8
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__
.venv
.env
*.pyc
*.log
.pytest_cache/
.ruff_cache/
*.py[cod]

38
backend/Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM python:3.11-slim AS base
# ---- Build args (desde Jenkins) ----
ARG APP_VERSION=dev
ARG GIT_COMMIT=local
ARG COMMIT_AUTHOR=local
ARG BUILD_NUMBER=
# ---- Runtime env ----
ENV APP_VERSION=${APP_VERSION} \
GIT_COMMIT=${GIT_COMMIT} \
COMMIT_AUTHOR=${COMMIT_AUTHOR} \
BUILD_NUMBER=${BUILD_NUMBER} \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100
LABEL app.version="${APP_VERSION}" \
git.commit="${GIT_COMMIT}" \
git.author="${COMMIT_AUTHOR}" \
build.number="${BUILD_NUMBER}"
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

View File

@@ -0,0 +1,68 @@
{
"starters": [
"Ensalada de pimiento asado",
"Ensalada de yogurt con pavo",
"Ensalada mixta",
"Macarrones con salsa de tomate y queso",
"Ensaladilla rusa",
"Paella de verdura",
"Pasta con tomate",
"Ensalada César"
],
"mains": {
"fish": [
"Pescado al vapor",
"Gallo san Pedro",
"Pescado al limón",
"Pescado al vapor"
],
"others": [
"Arroz salteado con verduras",
"Pollo asado",
"San jacobo de pavo",
"Cinta de lomo adobata con pimientos",
"Croquetas de bacalao",
"Flamenquín de pollo",
"Albóndigas con tomate"
],
"garnish": [
"Patatas panaderas",
"Patatas fritas",
"Arroz salteado con verdura",
"Brocoli al vapor",
"Patatas gajo"
]
},
"desserts": [
"Natilla con galleta",
"\"Frutas\" varias",
"Café",
"Plátano",
"Mousse de limón",
"Mousse de café",
"Compota (¿alguien se ha pedido esto alguna vez?)"
],
"notes": [
"Aquí al lado tienes la BP, los bocatas están muy bien.",
"¿No has pensado en comer en Ciencias?",
"O un bocata de estos fríos y lacios de la máquina..."
],
"espetos_tips": [
"El bocata pollo completo en la BP son solo 3.9 €.",
"El menú de ciencias de hoy tiene muy buena pinta, solo digo eso.",
"Aquí cerca hay un sitio que vende tuppers muy buenos."
],
"alternatives": {
"title": "Alternativa para el almuerzo",
"items": [
"Pizza (congelada)",
"¿Campero? No sé, dos panes con algo en medio"
],
"price": 5
},
"university_deal": {
"old_price": 4.5,
"current_price": 5.5,
"note": "jaja ya no tienes descuento de estudiante por los recortes de la UMA"
}
}

View File

@@ -0,0 +1,20 @@
{
"items": {
"cafe_solo": [
1.0,
1.4
],
"cafe_con_leche": [
1.2,
1.8
],
"pitufo_bacon_queso": [
1.5,
2.8
],
"zumo_naranja": [
2.0,
2.9
]
}
}

57
backend/app/main.py Normal file
View File

@@ -0,0 +1,57 @@
import time
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.services.menu import build_menu
from app.services.prices import prices_payload, random_price
from app.settings import settings
START_TIME = time.time()
def uptime() -> int:
return int(time.time() - START_TIME)
app = FastAPI(
title="Cafetería API",
description="Devuelve precios y el menú del día.",
version="0.1.0",
)
# Frontend and API will likely run on different ports; allow everything to keep the workshop simple.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
return {
"status": "ok",
"version": settings.app_version,
"commit": settings.git_commit,
"build": settings.build_number,
"author": settings.commit_author,
"uptime_seconds": uptime()
}
@app.get("/menu")
def menu():
return build_menu()
@app.get("/prices")
def prices():
return prices_payload()
@app.get("/prices/{item}")
def price_for_item(item: str):
return random_price(item)

View File

@@ -0,0 +1,73 @@
import json
import random
from datetime import datetime
from pathlib import Path
from typing import Dict, List
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
ITEMS_PER_SECTION = 3
def _load_json(filename: str) -> Dict:
path = DATA_DIR / filename
with open(path, encoding="utf-8") as file:
return json.load(file)
MENU_SOURCE = _load_json("menu_items.json")
def _pick_items(options: List[str], count: int) -> List[str]:
if count >= len(options):
return list(options)
return random.sample(options, count)
def _pick_mains(count: int = ITEMS_PER_SECTION) -> List[str]:
fish_options = MENU_SOURCE["mains"]["fish"]
other_options = MENU_SOURCE["mains"]["others"]
fish_choice = random.choice(fish_options)
remaining_needed = max(count - 1, 0)
pool = [item for item in fish_options if item != fish_choice] + other_options
if remaining_needed > len(pool):
remaining_needed = len(pool)
mains = [fish_choice] + _pick_items(pool, remaining_needed)
random.shuffle(mains)
return mains
def _pick_garnish() -> List[str]:
garnish_options = MENU_SOURCE["mains"]["garnish"]
return _pick_items(garnish_options, 2)
def _build_alternative() -> Dict:
alternative = MENU_SOURCE.get("alternatives", {})
return {
"title": alternative.get("title", "Alternativa"),
"items": alternative.get("items", []),
"price": alternative.get("price"),
"note": alternative.get("note", ""),
}
def build_menu(items_per_section: int = ITEMS_PER_SECTION) -> Dict:
today = datetime.now()
return {
"day": today.strftime("%A").capitalize(),
"starters": _pick_items(MENU_SOURCE["starters"], items_per_section),
"mains": _pick_mains(items_per_section),
"garnish": _pick_garnish(),
"desserts": _pick_items(MENU_SOURCE["desserts"], items_per_section),
"notes": _pick_items(MENU_SOURCE["notes"], 3),
"menu_price": MENU_SOURCE["university_deal"]["current_price"],
"university_deal": MENU_SOURCE["university_deal"],
"espetos_tip": random.choice(MENU_SOURCE["espetos_tips"]),
"alternative": _build_alternative(),
"availability": {
"last_updated": today.isoformat(timespec="seconds"),
},
}

View File

@@ -0,0 +1,34 @@
import json
import random
from datetime import datetime
from pathlib import Path
from typing import Dict
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def _load_json(filename: str) -> Dict:
path = DATA_DIR / filename
with open(path, encoding="utf-8") as file:
return json.load(file)
PRICE_RANGES = _load_json("price_ranges.json")["items"]
def random_price(item: str) -> Dict:
low, high = PRICE_RANGES.get(item, (1.0, 3.0))
price = min(round(random.uniform(low, high), 2), 3.0)
return {
"item": item,
"price": price,
"currency": "EUR",
"generated_at": datetime.now().isoformat(timespec="seconds"),
}
def prices_payload() -> Dict:
return {
"items": [random_price(item) for item in PRICE_RANGES.keys()],
"disclaimer": "Depende de como pilles al de cafete.",
}

13
backend/app/settings.py Normal file
View File

@@ -0,0 +1,13 @@
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class RuntimeConfig:
app_version: str = os.getenv("APP_VERSION", "dev")
git_commit: str = os.getenv("GIT_COMMIT", "local")
build_number: str = os.getenv("BUILD_NUMBER", "-")
commit_author: str = os.getenv("COMMIT_AUTHOR", "local")
settings = RuntimeConfig()

7
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,7 @@
[tool.ruff]
line-length = 100
target-version = "py311"
select = ["E", "F", "I"]
[tool.pytest.ini_options]
pythonpath = ["."]

View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.3
ruff==0.7.1

2
backend/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1

36
backend/tests/test_api.py Normal file
View File

@@ -0,0 +1,36 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
def test_menu_contains_fish():
response = client.get("/menu")
assert response.status_code == 200
body = response.json()
mains = [main.lower() for main in body["mains"]]
assert any("pescado" in main for main in mains)
assert body["menu_price"] == 5.5
deal = body["university_deal"]
assert deal["old_price"] == 4.5
assert deal["current_price"] == 5.5
def test_prices_random_list():
response = client.get("/prices")
assert response.status_code == 200
body = response.json()
items = body["items"]
assert isinstance(items, list)
assert len(items) >= 1
first = items[0]
assert "item" in first and "price" in first and "currency" in first
assert all(item["price"] <= 3 for item in items)