Compare commits

..

6 Commits

12 changed files with 187 additions and 19 deletions
+7
View File
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
RUN uv run --project /app pybabel compile -d translations && \
chown -R app:app /app /tmp/uv-cache
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
# piede mostra solo la versione. Uso:
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
ARG APP_COMMIT=""
ENV APP_COMMIT=${APP_COMMIT}
USER app
EXPOSE 5000
+1
View File
@@ -333,6 +333,7 @@ TieMeasureFlow/
|---|---|
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
| `docker compose logs -f server` | Segui log server in tempo reale |
| `docker compose logs -f client` | Segui log client in tempo reale |
| `docker compose ps` | Stato di tutti i servizi |
+3
View File
@@ -75,6 +75,9 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client
restart: unless-stopped
env_file:
+3
View File
@@ -89,6 +89,9 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client
restart: unless-stopped
env_file:
@@ -22,24 +22,44 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'api_keys',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('key_hash', sa.String(64), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
),
)
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
# Idempotent on purpose (#71): a database born from create_all has no
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
# created and `alembic_version` still at 014. Every step checks first.
insp = sa.inspect(op.get_bind())
if 'api_keys' not in insp.get_table_names():
op.create_table(
'api_keys',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('key_hash', sa.String(64), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
),
)
existing_idx: set[str] = set()
else:
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
if 'ix_api_keys_key_hash' not in existing_idx:
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
if 'ix_api_keys_user_id' not in existing_idx:
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
return # column already gone: nothing else to do
user_idx = {i['name'] for i in insp.get_indexes('users')}
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
with op.batch_alter_table('users') as batch:
batch.drop_index('ix_users_api_key')
batch.drop_constraint('uq_users_api_key', type_='unique')
if 'ix_users_api_key' in user_idx:
batch.drop_index('ix_users_api_key')
if 'uq_users_api_key' in user_uq:
batch.drop_constraint('uq_users_api_key', type_='unique')
batch.drop_column('api_key')
+67
View File
@@ -13,7 +13,10 @@ from pathlib import Path
import pytest
from alembic import command
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from alembic.config import Config
import sqlalchemy as sa
from sqlalchemy import create_engine, inspect as sa_inspect, text
from src.backend.config import Settings
@@ -132,6 +135,70 @@ class TestApiKeyHashMigration:
command.upgrade(cfg, "head") # and up again
assert "api_keys" in _inspect(db_path).get_table_names()
def test_015_on_create_all_schema(self, sqlite_database_url):
"""DB nato da create_all: users.api_key ha solo l'indice unique
ix_users_api_key, nessun vincolo uq_users_api_key (#71)."""
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "014_vision_fk_ondelete")
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
with ops.batch_alter_table("users") as batch:
batch.drop_index("ix_users_api_key")
batch.drop_constraint("uq_users_api_key", type_="unique")
ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True)
engine.dispose()
insp = _inspect(db_path)
assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in {
u["name"] for u in insp.get_unique_constraints("users")
}
command.upgrade(cfg, "head") # non deve sollevare
insp = _inspect(db_path)
assert "api_keys" in insp.get_table_names()
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
def test_015_resumes_after_partial_run(self, sqlite_database_url):
"""015 a meta' (MySQL, DDL non transazionale): api_keys creata e
ix_users_api_key tolto, alembic_version ancora 014 (#71)."""
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "014_vision_fk_ondelete")
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
ops = Operations(MigrationContext.configure(conn))
ops.create_table(
"api_keys",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("key_hash", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"],
name="fk_api_keys_user_id", ondelete="CASCADE",
),
)
ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
ops.drop_index("ix_users_api_key", table_name="users")
engine.dispose()
command.upgrade(cfg, "head") # non deve sollevare
insp = _inspect(db_path)
assert "api_keys" in insp.get_table_names()
assert {i["name"] for i in insp.get_indexes("api_keys")} >= {
"ix_api_keys_key_hash", "ix_api_keys_user_id",
}
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
class TestVisionRecipeDirMigration:
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
+3
View File
@@ -242,6 +242,9 @@ def create_app() -> Flask:
"company_logo": session.get("company_logo"),
"auto_logout_minutes": session.get("auto_logout_minutes"),
"current_year": date.today().year,
# Versione/commit nel piede di pagina (#70)
"app_version": Config.APP_VERSION,
"app_commit": Config.APP_COMMIT,
}
return app
+27
View File
@@ -1,5 +1,7 @@
"""TieMeasureFlow Client Configuration."""
import os
import tomllib
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
@@ -10,6 +12,25 @@ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
def _read_app_version() -> str:
"""Versione del prodotto = `[project] version` di pyproject.toml (#70).
Si risale dalla cartella del modulo: nel container il file sta in
/app/pyproject.toml (Dockerfile.frontend), nel repo alla radice. Se non
si trova o e' illeggibile torna "dev": il piede di pagina non deve mai
rompere l'avvio.
"""
for parent in Path(__file__).resolve().parents:
candidate = parent / "pyproject.toml"
if candidate.is_file():
try:
with candidate.open("rb") as fh:
return str(tomllib.load(fh)["project"]["version"])
except (OSError, tomllib.TOMLDecodeError, KeyError):
return "dev"
return "dev"
class Config:
"""Flask client configuration."""
@@ -17,6 +38,12 @@ class Config:
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
# Versione e commit mostrati nel piede di pagina (#70). Letti una volta
# all'import. Il commit arriva dal build arg APP_COMMIT (.git non e' nel
# contesto di build, vedi .dockerignore); facoltativo: vuoto = non mostrato.
APP_VERSION: str = _read_app_version()
APP_COMMIT: str = os.environ.get("APP_COMMIT", "")[:7]
# API Server connection
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
@@ -157,7 +157,7 @@
<!-- Footer -->
<div class="text-center text-xs text-[var(--text-muted)]">
<p>TieMeasureFlow &copy; {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
<p>TieMeasureFlow &copy; {{ current_year }} - {{ _('Sistema di misurazione industriale') }}{# versione e commit (#70) #} &middot; v{{ app_version }}{% if app_commit %} &middot; {{ app_commit }}{% endif %}</p>
</div>
</div>
</div>
+1 -1
View File
@@ -107,7 +107,7 @@
<!-- Footer -->
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic{# versione e commit (#70) #} &middot; v{{ app_version }}{% if app_commit %} &middot; {{ app_commit }}{% endif %}
</p>
</footer>
{% endblock %}
@@ -0,0 +1,37 @@
"""Versione e commit nel piede di pagina (#70)."""
import tomllib
from pathlib import Path
from config import Config
_PYPROJECT = Path(__file__).resolve().parents[4] / "pyproject.toml"
def _pyproject_version() -> str:
with _PYPROJECT.open("rb") as fh:
return tomllib.load(fh)["project"]["version"]
def test_app_version_matches_pyproject():
# La versione mostrata e' quella di pyproject.toml, non un valore a mano
assert Config.APP_VERSION == _pyproject_version()
def test_login_page_shows_version(client):
resp = client.get("/auth/login")
assert resp.status_code == 200
assert f"v{_pyproject_version()}" in resp.get_data(as_text=True)
def test_login_page_shows_commit_when_set(client, monkeypatch):
# Commit valorizzato (gia' troncato a 7 da Config): compare nel footer
monkeypatch.setattr(Config, "APP_COMMIT", "abc1234")
assert "abc1234" in client.get("/auth/login").get_data(as_text=True)
def test_login_page_hides_commit_when_empty(client, monkeypatch):
# Build senza APP_COMMIT: solo versione, nessun separatore orfano
monkeypatch.setattr(Config, "APP_COMMIT", "")
html = client.get("/auth/login").get_data(as_text=True)
assert f"v{_pyproject_version()}" in html
assert f"v{_pyproject_version()} &middot;" not in html