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 && \ RUN uv run --project /app pybabel compile -d translations && \
chown -R app:app /app /tmp/uv-cache 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 USER app
EXPOSE 5000 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 up -d --build` | Avvia servizi in sviluppo (build incluso) |
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container | | `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 server` | Segui log server in tempo reale |
| `docker compose logs -f client` | Segui log client in tempo reale | | `docker compose logs -f client` | Segui log client in tempo reale |
| `docker compose ps` | Stato di tutti i servizi | | `docker compose ps` | Stato di tutti i servizi |
+3
View File
@@ -75,6 +75,9 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.frontend dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client container_name: tmflow-client
restart: unless-stopped restart: unless-stopped
env_file: env_file:
+3
View File
@@ -89,6 +89,9 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.frontend dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client container_name: tmflow-client
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@@ -22,24 +22,44 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
op.create_table( # Idempotent on purpose (#71): a database born from create_all has no
'api_keys', # `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), # MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
sa.Column('user_id', sa.Integer(), nullable=False), # created and `alembic_version` still at 014. Every step checks first.
sa.Column('key_hash', sa.String(64), nullable=False), insp = sa.inspect(op.get_bind())
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'])
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: with op.batch_alter_table('users') as batch:
batch.drop_index('ix_users_api_key') if 'ix_users_api_key' in user_idx:
batch.drop_constraint('uq_users_api_key', type_='unique') 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') batch.drop_column('api_key')
+67
View File
@@ -13,7 +13,10 @@ from pathlib import Path
import pytest import pytest
from alembic import command from alembic import command
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from alembic.config import Config from alembic.config import Config
import sqlalchemy as sa
from sqlalchemy import create_engine, inspect as sa_inspect, text from sqlalchemy import create_engine, inspect as sa_inspect, text
from src.backend.config import Settings from src.backend.config import Settings
@@ -132,6 +135,70 @@ class TestApiKeyHashMigration:
command.upgrade(cfg, "head") # and up again command.upgrade(cfg, "head") # and up again
assert "api_keys" in _inspect(db_path).get_table_names() 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: class TestVisionRecipeDirMigration:
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46).""" """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"), "company_logo": session.get("company_logo"),
"auto_logout_minutes": session.get("auto_logout_minutes"), "auto_logout_minutes": session.get("auto_logout_minutes"),
"current_year": date.today().year, "current_year": date.today().year,
# Versione/commit nel piede di pagina (#70)
"app_version": Config.APP_VERSION,
"app_commit": Config.APP_COMMIT,
} }
return app return app
+27
View File
@@ -1,5 +1,7 @@
"""TieMeasureFlow Client Configuration.""" """TieMeasureFlow Client Configuration."""
import os import os
import tomllib
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env')) 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" 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: class Config:
"""Flask client configuration.""" """Flask client configuration."""
@@ -17,6 +38,12 @@ class Config:
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY) SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1" 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 connection
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000") API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
@@ -157,7 +157,7 @@
<!-- Footer --> <!-- Footer -->
<div class="text-center text-xs text-[var(--text-muted)]"> <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> </div>
</div> </div>
+1 -1
View File
@@ -107,7 +107,7 @@
<!-- Footer --> <!-- Footer -->
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300"> <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"> <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> </p>
</footer> </footer>
{% endblock %} {% 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