Commit initial
This commit is contained in:
commit
baa0c9fabe
12 changed files with 423 additions and 0 deletions
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Brassage
|
||||||
|
|
||||||
|
Application web de brassage multi-opérateurs.
|
1
brassage/__init__.py
Normal file
1
brassage/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
|
28
brassage/__main__.py
Normal file
28
brassage/__main__.py
Normal 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
33
brassage/config.py
Normal 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
111
brassage/static/style.css
Normal 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
11
brassage/switch.py
Normal 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: ...
|
23
brassage/templates/base.html.j2
Normal file
23
brassage/templates/base.html.j2
Normal 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>
|
30
brassage/templates/room.html.j2
Normal file
30
brassage/templates/room.html.j2
Normal 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 : {{ room.switch }}</li>
|
||||||
|
<li>Port : {{ room.port }}</li>
|
||||||
|
<li>Brassage : {{ 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 %}
|
12
brassage/templates/rooms.html.j2
Normal file
12
brassage/templates/rooms.html.j2
Normal 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
85
brassage/web.py
Normal 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
33
examples/config.toml
Normal 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
53
pyproject.toml
Normal 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",
|
||||||
|
]
|
Loading…
Reference in a new issue