Commit initial

This commit is contained in:
jeltz 2025-08-15 14:37:10 +02:00
commit baa0c9fabe
Signed by: jeltz
GPG key ID: 800882B66C0C3326
12 changed files with 423 additions and 0 deletions

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# Brassage
Application web de brassage multi-opérateurs.

1
brassage/__init__.py Normal file
View file

@ -0,0 +1 @@

28
brassage/__main__.py Normal file
View file

@ -0,0 +1,28 @@
from argparse import ArgumentParser, BooleanOptionalAction
from pathlib import Path
from uvicorn import run
from brassage.config import read_config
from brassage.web import make_app
def main() -> None:
parser = ArgumentParser("brassage")
parser.add_argument("-c", "--config", type=Path, default="config.toml")
parser.add_argument(
"-d", "--debug", action=BooleanOptionalAction, default=False
)
args = parser.parse_args()
config = read_config(args.config)
run(
make_app(config, args.debug),
port=5000,
log_level="debug" if args.debug else "info",
)
if __name__ == "__main__":
main()

33
brassage/config.py Normal file
View file

@ -0,0 +1,33 @@
from pathlib import Path
from tomllib import load
from pydantic import BaseModel
class Switch(BaseModel):
url: str
user: str
password: str
class State(BaseModel):
untag: int | None = None
tag: set[int] = []
class Room(BaseModel):
switch: str
port: str
labels: list[str]
states: dict[str, State] = {}
class Config(BaseModel):
switchs: dict[str, Switch]
rooms: dict[str, Room]
def read_config(path: Path) -> Config:
with path.open("rb") as f:
config = load(f)
return Config.model_validate(config)

111
brassage/static/style.css Normal file
View file

@ -0,0 +1,111 @@
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font: inherit;
}
p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}
p {
text-wrap: pretty;
}
h1, h2, h3, h4, h5, h6 {
text-wrap: balance;
}
html {
font-family: system-ui, sans-serif;
}
body {
display: grid;
grid-template-columns:
1fr
[start] min(980px, 100vw)
[end] 1fr;
grid-template-rows:
[header] auto
[main] 1fr
[footer] auto;
min-height: 100vh;
width: 100vw;
line-height: 1.5;
}
header {
grid-column: start / end;
grid-row: header;
display: flex;
align-items: center;
padding: 2rem 0;
border-bottom: 1px solid grey;
a.title {
flex: 1;
font-size: 1.5rem;
font-weight: normal;
text-decoration: none;
color: inherit;
}
nav a {
text-decoration: none;
color: inherit;
padding: .5rem 1rem;
background-color: lightgrey;
&:hover {
color: white;
background-color: grey;
}
}
}
main {
grid-column: start / end;
grid-row: main;
padding: 2rem 0;
h1 {
margin: 0 0 1rem 0;
font-size: 2rem;
font-weight: bold;
}
}
footer {
grid-column: start / end;
grid-row: footer;
padding: 2rem 0;
border-top: 1px solid grey;
}
nav.rooms {
display: flex;
flex-direction: column;
a {
text-decoration: none;
font-size: 1.25rem;
padding: .5rem;
color: blue;
&:hover:before {
content: "> ";
}
}
}

11
brassage/switch.py Normal file
View file

@ -0,0 +1,11 @@
from brassage.config import State, Switch
class Interface:
def __init__(self, switch: Switch) -> None:
self._switch = switch
async def fetch(self, port: str, timeout: int = 2) -> State | None:
return State(untag=102, tag=[120, 404, 505])
async def update(self, port: str, state: State) -> None: ...

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}"/>
</head>
<body>
<header>
<a class="title" href="{{ url_for('rooms') }}">Brassage</a>
<nav>
<a href="#">Déconnexion</a>
</nav>
</header>
<main>
{% block main %}
{% endblock %}
</main>
<footer>
<p>Brassage</p>
</footer>
</body>
</html>

View file

@ -0,0 +1,30 @@
{% extends "base.html.j2" %}
{% block title %}{{ room.labels | join(' > ') }}{% endblock %}
{% block main %}
<h1>{{ room.labels | join(' > ') }}</h1>
<h2>État</h2>
<ul>
<li>Switch&nbsp;: {{ room.switch }}</li>
<li>Port&nbsp;: {{ room.port }}</li>
<li>Brassage&nbsp;: {{ state }}</li>
</ul>
<h2>Modifier</h2>
<form method="POST">
<div class="row">
<label for="state">Brassage</label>
<select name="state">
{% for name in room.states.keys() %}
<option value="{{ name }}">{{ name }}</option>
{% endfor %}
</select>
</div>
<div class="row">
<input type="submit" value="Modifier"/>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "base.html.j2" %}
{% block title %}Logements{% endblock %}
{% block main %}
<h1>Logements</h1>
<nav class="rooms">
{% for name, room in request.app.state.config.rooms.items() %}
<a href="{{ url_for('room', name=name) }}">{{ room.labels | join(' > ') }}</a>
{% endfor %}
</nav>
{% endblock %}

85
brassage/web.py Normal file
View file

@ -0,0 +1,85 @@
from jinja2 import Environment, PackageLoader, select_autoescape
from starlette.applications import Starlette
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from starlette.templating import Jinja2Templates
from brassage.config import Config, State, read_config
from brassage.switch import Interface
templates = Jinja2Templates(
env=Environment(
loader=PackageLoader("brassage", "templates"),
autoescape=select_autoescape(),
)
)
def state_str(state: State | None, states: dict[str, State]) -> str:
def extract():
if state.untag is not None:
yield f"{state.untag}"
for tag in state.tag:
yield f"*{tag}"
if state is None:
return "Inconnu"
for name, target in states.items():
if state == target:
return name
return f"Invalide ({', '.join(extract())})"
async def rooms(request: Request):
return templates.TemplateResponse(request, "rooms.html.j2")
async def room(request: Request):
name = request.path_params["name"]
try:
room = request.app.state.config.rooms[name]
except KeyError:
raise HTTPException(status_code=404)
switch = request.app.state.config.switchs[room.switch]
interface = Interface(switch)
if request.method == "POST":
async with request.form() as form:
try:
target = room.states[form["state"]]
except KeyError:
raise HTTPException(status_code=400)
await interface.update(room.port, target)
state = await interface.fetch(room.port)
return templates.TemplateResponse(
request,
"room.html.j2",
{
"name": name,
"room": room,
"state": state_str(state, room.states),
},
)
def make_app(config: Config, debug: bool) -> Starlette:
static = StaticFiles(packages=[("brassage", "static")])
app = Starlette(
debug=debug,
routes=[
Route("/", rooms, name="rooms"),
Route(
"/room/{name:str}", room, name="room", methods=["GET", "POST"]
),
Mount("/static", app=static, name="static"),
],
)
app.state.config = config
return app

33
examples/config.toml Normal file
View file

@ -0,0 +1,33 @@
[switchs.gc-1]
url = "https://gc-1.acs.sw.infra.auro.re"
user = "brassage"
password = "password"
[switchs.gc-2]
url = "https://gc-2.acs.sw.infra.auro.re"
user = "brassage"
password = "password"
[rooms.gs-c-602]
switch = "gc-1"
port = "10"
labels = [ "George Sand", "Bâtiment C", "602" ]
[rooms.gs-c-602.states.Aurore]
untag = 1004
[rooms.gs-c-602.states.Crous]
untag = 4004
[rooms.gs-c-603]
switch = "gc-2"
port = "11"
labels = [ "George Sand", "Bâtiment C", "603" ]
[rooms.gs-c-603.states.Aurore]
untag = 1005
tag = [ 208, 209 ]
[rooms.gs-c-603.states.Crous]
untag = 4005
tag = [ 208, 209 ]

53
pyproject.toml Normal file
View file

@ -0,0 +1,53 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "brassage"
version = "0.0.1"
description = "Application web de brassage multi-opérateurs"
readme = "README.md"
requires-python = ">=3.12"
keywords = []
authors = [
{ name = "Jeltz", email = "jeltz@crans.org" },
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"starlette",
"aiohttp",
"pydantic>=2",
"uvicorn",
"jinja2",
"python-multipart",
]
[project.scripts]
brassage = "brassage.__main__:main"
[tool.ruff]
line-length = 79
target-version = "py312"
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D100",
"D101",
"D103",
"D104",
"T201",
"D203",
"D212",
"COM812",
"FBT001",
"ISC001",
"FIX",
"TD",
]