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