This is an automated email from the ASF dual-hosted git repository.

tn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-atr-experiments.git


The following commit(s) were added to refs/heads/main by this push:
     new dd5203e  move database specific code to db module, add service module, 
use get_session() method, logout shall redirect to home, add debug / production 
config mechanism, rework server initialization code
dd5203e is described below

commit dd5203ef7a69a74e1db2439c7571ee8d85b1ec0e
Author: Thomas Neidhart <t...@apache.org>
AuthorDate: Mon Feb 17 21:35:57 2025 +0100

    move database specific code to db module, add service module, use 
get_session() method, logout shall redirect to home, add debug / production 
config mechanism, rework server initialization code
---
 .gitignore                                   |   2 +
 atr/blueprints/secret/__init__.py            |  13 +--
 atr/blueprints/secret/secret.py              |  32 +++---
 atr/config.py                                |  60 +++++++++++
 atr/{server.py => db/__init__.py}            |  81 ++-------------
 atr/{ => db}/models.py                       |   2 +-
 atr/db/service.py                            |  39 +++++++
 atr/routes.py                                | 147 +++++++++++----------------
 atr/server.py                                | 123 ++++++++--------------
 atr/templates/includes/sidebar.html          |   2 +-
 atr/templates/{ => secret}/data-browser.html |   0
 atr/templates/{ => secret}/update-pmcs.html  |   0
 atr/util.py                                  |  45 ++++++++
 poetry.lock                                  |  14 ++-
 pyproject.toml                               |   1 +
 uv.lock                                      |  15 ++-
 16 files changed, 302 insertions(+), 274 deletions(-)

diff --git a/.gitignore b/.gitignore
index ca93b00..4ff39cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@
 .vscode/
 __pycache__/
 state/
+
+apptoken.txt
diff --git a/atr/blueprints/secret/__init__.py 
b/atr/blueprints/secret/__init__.py
index df80c2d..dfd4942 100644
--- a/atr/blueprints/secret/__init__.py
+++ b/atr/blueprints/secret/__init__.py
@@ -21,19 +21,10 @@ from asfquart.auth import Requirements as R
 from asfquart.auth import require
 from asfquart.base import ASFQuartException
 from asfquart.session import read as session_read
+from atr.util import get_admin_users
 
 blueprint = Blueprint("secret_blueprint", __name__, url_prefix="/secret")
 
-ALLOWED_USERS = {
-    "cwells",
-    "fluxo",
-    "gmcdonald",
-    "humbedooh",
-    "sbp",
-    "tn",
-    "wave",
-}
-
 
 @blueprint.before_request
 async def before_request_func() -> None:
@@ -43,7 +34,7 @@ async def before_request_func() -> None:
         if session is None:
             raise ASFQuartException("Not authenticated", errorcode=401)
 
-        if session.uid not in ALLOWED_USERS:
+        if session.uid not in get_admin_users():
             raise ASFQuartException("You are not authorized to access the 
admin interface", errorcode=403)
 
     await check_logged_in()
diff --git a/atr/blueprints/secret/secret.py b/atr/blueprints/secret/secret.py
index d0daa5b..7ec78c5 100644
--- a/atr/blueprints/secret/secret.py
+++ b/atr/blueprints/secret/secret.py
@@ -22,8 +22,8 @@ from quart import current_app, render_template, request
 from sqlmodel import select
 
 from asfquart.base import ASFQuartException
-
-from ...models import (
+from atr.db import get_session
+from atr.db.models import (
     PMC,
     DistributionChannel,
     Package,
@@ -33,13 +33,15 @@ from ...models import (
     Release,
     VotePolicy,
 )
+from atr.db.service import get_pmcs
+
 from . import blueprint
 
 
 @blueprint.route("/data")
 @blueprint.route("/data/<model>")
 async def secret_data(model: str = "PMC") -> str:
-    "Browse all records in the database."
+    """Browse all records in the database."""
 
     # Map of model names to their classes
     models = {
@@ -54,11 +56,9 @@ async def secret_data(model: str = "PMC") -> str:
     }
 
     if model not in models:
-        # Default to PMC if invalid model specified
-        model = "PMC"
+        raise ASFQuartException(f"Model type '{model}' not found", 404)
 
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
+    async with get_session() as db_session:
         # Get all records for the selected model
         statement = select(models[model])
         records = (await db_session.execute(statement)).scalars().all()
@@ -79,12 +79,14 @@ async def secret_data(model: str = "PMC") -> str:
                         record_dict[key] = getattr(record, key)
             records_dict.append(record_dict)
 
-        return await render_template("data-browser.html", 
models=list(models.keys()), model=model, records=records_dict)
+        return await render_template(
+            "secret/data-browser.html", models=list(models.keys()), 
model=model, records=records_dict
+        )
 
 
 @blueprint.route("/pmcs/update", methods=["GET", "POST"])
 async def secret_pmcs_update() -> str:
-    "Update PMCs from remote, authoritative committee-info.json."
+    """Update PMCs from remote, authoritative committee-info.json."""
 
     if request.method == "POST":
         # Fetch committee-info.json from Whimsy
@@ -100,8 +102,7 @@ async def secret_pmcs_update() -> str:
         committees = data.get("committees", {})
         updated_count = 0
 
-        async_session = current_app.config["async_session"]
-        async with async_session() as db_session:
+        async with get_session() as db_session:
             async with db_session.begin():
                 for committee_id, info in committees.items():
                     # Skip non-PMC committees
@@ -147,14 +148,11 @@ async def secret_pmcs_update() -> str:
         return f"Successfully updated {updated_count} PMCs from Whimsy"
 
     # For GET requests, show the update form
-    return await render_template("update-pmcs.html")
+    return await render_template("secret/update-pmcs.html")
 
 
 @blueprint.route("/debug/database")
 async def secret_debug_database() -> str:
     """Debug information about the database."""
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
-        statement = select(PMC)
-        pmcs = (await db_session.execute(statement)).scalars().all()
-        return f"Database using {current_app.config['DATA_MODELS_FILE']} has 
{len(pmcs)} PMCs"
+    pmcs = await get_pmcs()
+    return f"Database using {current_app.config['DATA_MODELS_FILE']} has 
{len(pmcs)} PMCs"
diff --git a/atr/config.py b/atr/config.py
new file mode 100644
index 0000000..f2154ec
--- /dev/null
+++ b/atr/config.py
@@ -0,0 +1,60 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+
+from decouple import config
+
+from atr.db.models import __file__ as data_models_file
+
+
+class AppConfig:
+    # Get the project root directory (where alembic.ini is)
+    PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    STATE_DIR = os.path.join(PROJECT_ROOT, "state")
+
+    RELEASE_STORAGE_DIR = os.path.join(STATE_DIR, "releases")
+    DATA_MODELS_FILE = data_models_file
+
+    # Use aiosqlite for async SQLite access
+    SQLITE_URL = config("SQLITE_URL", default="sqlite+aiosqlite:///./atr.db")
+
+    ADMIN_USERS = {
+        "cwells",
+        "fluxo",
+        "gmcdonald",
+        "humbedooh",
+        "sbp",
+        "tn",
+        "wave",
+    }
+
+
+class ProductionConfig(AppConfig):
+    DEBUG = False
+
+
+class DebugConfig(AppConfig):
+    DEBUG = True
+    TEMPLATES_AUTO_RELOAD = True
+
+
+# Load all possible configurations
+config_dict = {
+    "Debug": DebugConfig,
+    "Production": ProductionConfig,
+}
diff --git a/atr/server.py b/atr/db/__init__.py
similarity index 54%
copy from atr/server.py
copy to atr/db/__init__.py
index 97314b3..21e200e 100644
--- a/atr/server.py
+++ b/atr/db/__init__.py
@@ -15,68 +15,25 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"server.py"
-
 import os
 
 from alembic import command
 from alembic.config import Config
+from quart import current_app
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, 
create_async_engine
 from sqlalchemy.sql import text
 from sqlmodel import SQLModel
 
-import asfquart
-import asfquart.generics
-import asfquart.session
 from asfquart.base import QuartApp
-from atr.blueprints import register_blueprints
-
-from .models import __file__ as data_models_file
-
-# Avoid OIDC
-asfquart.generics.OAUTH_URL_INIT = 
"https://oauth.apache.org/auth?state=%s&redirect_uri=%s";
-asfquart.generics.OAUTH_URL_CALLBACK = "https://oauth.apache.org/token?code=%s";
-
-
-def register_routes() -> str:
-    from . import routes
-
-    # Must do this otherwise ruff "fixes" this function by removing the import.
-    return routes.__name__
-
-
-def create_app() -> QuartApp:
-    if asfquart.construct is ...:
-        raise ValueError("asfquart.construct is not set")
-    app = asfquart.construct(__name__)
-
-    # # Configure static folder path before changing working directory
-    # app.static_folder = 
os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
 
-    @app.context_processor
-    async def app_wide():
-        return {"current_user": await asfquart.session.read()}
 
+def create_database(app: QuartApp) -> None:
     @app.before_serving
-    async def create_database() -> None:
-        # Get the project root directory (where alembic.ini is)
-        project_root = 
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-
-        # Change working directory to "./state"
-        state_dir = os.path.join(project_root, "state")
-        if not os.path.isdir(state_dir):
-            raise RuntimeError(f"State directory not found: {state_dir}")
-        os.chdir(state_dir)
-        print(f"Working directory changed to: {os.getcwd()}")
-
-        # Set up release storage directory
-        release_storage = os.path.join(state_dir, "releases")
-        os.makedirs(release_storage, exist_ok=True)
-        app.config["RELEASE_STORAGE_DIR"] = release_storage
-        app.config["DATA_MODELS_FILE"] = data_models_file
+    async def create() -> None:
+        project_root = app.config["PROJECT_ROOT"]
+        sqlite_url = app.config["SQLITE_URL"]
 
         # Use aiosqlite for async SQLite access
-        sqlite_url = "sqlite+aiosqlite:///./atr.db"
         engine = create_async_engine(
             sqlite_url,
             connect_args={
@@ -87,7 +44,7 @@ def create_app() -> QuartApp:
 
         # Create async session factory
         async_session = async_sessionmaker(bind=engine, class_=AsyncSession, 
expire_on_commit=False)
-        app.config["async_session"] = async_session
+        app.async_session = async_session  # type: ignore
 
         # Set SQLite pragmas for better performance
         # Use 64 MB for the cache_size, and 5000ms for busy_timeout
@@ -115,28 +72,6 @@ def create_app() -> QuartApp:
         async with engine.begin() as conn:
             await conn.run_sync(SQLModel.metadata.create_all)
 
-        app.config["engine"] = engine
-        # TODO: apply this only for debug
-        app.config["TEMPLATES_AUTO_RELOAD"] = True
-
-    @app.after_serving
-    async def shutdown() -> None:
-        app.background_tasks.clear()
-
-    register_routes()
-    register_blueprints(app)
-
-    return app
-
-
-def main() -> None:
-    "Quart debug server"
-    app = create_app()
-    app.run(port=8080, ssl_keyfile="key.pem", ssl_certfile="cert.pem")
-
 
-app = None
-if __name__ == "__main__":
-    main()
-else:
-    app = create_app()
+def get_session() -> AsyncSession:
+    return current_app.async_session()  # type: ignore
diff --git a/atr/models.py b/atr/db/models.py
similarity index 99%
rename from atr/models.py
rename to atr/db/models.py
index ea56aa6..ef44270 100644
--- a/atr/models.py
+++ b/atr/db/models.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"models.py"
+"""models.py"""
 
 import datetime
 from enum import Enum
diff --git a/atr/db/service.py b/atr/db/service.py
new file mode 100644
index 0000000..5a1b06a
--- /dev/null
+++ b/atr/db/service.py
@@ -0,0 +1,39 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from collections.abc import Sequence
+
+from sqlmodel import select
+
+from atr.db.models import PMC
+
+from . import get_session
+
+
+async def get_pmc_by_name(project_name: str) -> PMC | None:
+    async with get_session() as db_session:
+        statement = select(PMC).where(PMC.project_name == project_name)
+        pmc = (await db_session.execute(statement)).scalar_one_or_none()
+        return pmc
+
+
+async def get_pmcs() -> Sequence[PMC]:
+    async with get_session() as db_session:
+        # Get all PMCs and their latest releases
+        statement = select(PMC)
+        pmcs = (await db_session.execute(statement)).scalars().all()
+        return pmcs
diff --git a/atr/routes.py b/atr/routes.py
index 4b8268a..0a77fb3 100644
--- a/atr/routes.py
+++ b/atr/routes.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"routes.py"
+"""routes.py"""
 
 import asyncio
 import datetime
@@ -32,7 +32,7 @@ from typing import Any, cast
 import aiofiles
 import aiofiles.os
 import gnupg
-from quart import Request, current_app, render_template, request
+from quart import Request, render_template, request
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm.attributes import InstrumentedAttribute
@@ -45,8 +45,7 @@ from asfquart.auth import require
 from asfquart.base import ASFQuartException
 from asfquart.session import ClientSession
 from asfquart.session import read as session_read
-
-from .models import (
+from atr.db.models import (
     PMC,
     Package,
     PMCKeyLink,
@@ -56,10 +55,24 @@ from .models import (
     ReleaseStage,
 )
 
+from .db import get_session
+from .db.service import get_pmc_by_name, get_pmcs
+from .util import compute_sha512, get_release_storage_dir
+
 if APP is ...:
     raise ValueError("APP is not set")
 
-ALLOWED_USERS = {"cwells", "fluxo", "gmcdonald", "humbedooh", "sbp", "tn", 
"wave"}
+
+@APP.route("/")
+async def root() -> str:
+    """Main page."""
+    return await render_template("index.html")
+
+
+@APP.route("/pages")
+async def root_pages() -> str:
+    """List all pages on the website."""
+    return await render_template("pages.html")
 
 
 async def add_release_candidate_post(session: ClientSession, request: Request) 
-> str:
@@ -92,7 +105,7 @@ async def add_release_candidate_post(session: ClientSession, 
request: Request) -
         raise ASFQuartException("Signature file must have .asc extension", 
errorcode=400)
 
     # Save files using their hashes as filenames
-    uploads_path = Path(current_app.config["RELEASE_STORAGE_DIR"])
+    uploads_path = Path(get_release_storage_dir())
     artifact_hash = await save_file_by_hash(uploads_path, artifact_file)
     # TODO: Do we need to do anything with the signature hash?
     # These should be identical, but path might be absolute?
@@ -106,8 +119,7 @@ async def add_release_candidate_post(session: 
ClientSession, request: Request) -
     checksum_512 = compute_sha512(uploads_path / artifact_hash)
 
     # Store in database
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
+    async with get_session() as db_session:
         async with db_session.begin():
             # Get PMC
             statement = select(PMC).where(PMC.project_name == project_name)
@@ -153,30 +165,10 @@ async def ephemeral_gpg_home():
         await asyncio.to_thread(shutil.rmtree, temp_dir)
 
 
-def compute_sha3_256(file_data: bytes) -> str:
-    "Compute SHA3-256 hash of file data."
-    return hashlib.sha3_256(file_data).hexdigest()
-
-
-def compute_sha512(file_path: Path) -> str:
-    "Compute SHA-512 hash of a file."
-    sha512 = hashlib.sha512()
-    with open(file_path, "rb") as f:
-        for chunk in iter(lambda: f.read(4096), b""):
-            sha512.update(chunk)
-    return sha512.hexdigest()
-
-
-@APP.route("/")
-async def root() -> str:
-    """Main page."""
-    return await render_template("index.html")
-
-
 @APP.route("/add-release-candidate", methods=["GET", "POST"])
 @require(R.committer)
 async def root_add_release_candidate() -> str:
-    "Add a release candidate to the database."
+    """Add a release candidate to the database."""
     session = await session_read()
     if session is None:
         raise ASFQuartException("Not authenticated", errorcode=401)
@@ -202,8 +194,7 @@ async def root_release_signatures_verify(release_key: str) 
-> str:
     if session is None:
         raise ASFQuartException("Not authenticated", errorcode=401)
 
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
+    async with get_session() as db_session:
         # Get the release and its packages, and PMC with its keys
         release_packages = 
selectinload(cast(InstrumentedAttribute[list[Package]], Release.packages))
         release_pmc = selectinload(cast(InstrumentedAttribute[PMC], 
Release.pmc))
@@ -229,7 +220,7 @@ async def root_release_signatures_verify(release_key: str) 
-> str:
 
         # Verify each package's signature
         verification_results = []
-        storage_dir = Path(current_app.config["RELEASE_STORAGE_DIR"])
+        storage_dir = Path(get_release_storage_dir())
 
         for package in release.packages:
             result = {"file": package.file}
@@ -253,30 +244,20 @@ async def root_release_signatures_verify(release_key: 
str) -> str:
         )
 
 
-@APP.route("/pages")
-async def root_pages() -> str:
-    "List all pages on the website."
-    return await render_template("pages.html")
-
-
 @APP.route("/pmc/<project_name>")
 async def root_pmc_arg(project_name: str) -> dict:
-    "Get a specific PMC by project name."
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
-        statement = select(PMC).where(PMC.project_name == project_name)
-        pmc = (await db_session.execute(statement)).scalar_one_or_none()
-
-        if not pmc:
-            raise ASFQuartException("PMC not found", errorcode=404)
+    """Get a specific PMC by project name."""
+    pmc = await get_pmc_by_name(project_name)
+    if not pmc:
+        raise ASFQuartException("PMC not found", errorcode=404)
 
-        return {
-            "id": pmc.id,
-            "project_name": pmc.project_name,
-            "pmc_members": pmc.pmc_members,
-            "committers": pmc.committers,
-            "release_managers": pmc.release_managers,
-        }
+    return {
+        "id": pmc.id,
+        "project_name": pmc.project_name,
+        "pmc_members": pmc.pmc_members,
+        "committers": pmc.committers,
+        "release_managers": pmc.release_managers,
+    }
 
 
 # @APP.route("/pmc/create/<project_name>")
@@ -289,8 +270,7 @@ async def root_pmc_arg(project_name: str) -> dict:
 #         release_managers=["alice"],
 #     )
 
-#     async_session = current_app.config["async_session"]
-#     async with async_session() as db_session:
+#     async with get_session() as db_session:
 #         async with db_session.begin():
 #             try:
 #                 db_session.add(pmc)
@@ -312,39 +292,32 @@ async def root_pmc_arg(project_name: str) -> dict:
 
 @APP.route("/pmc/directory")
 async def root_pmc_directory() -> str:
-    "Main PMC directory page."
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
-        # Get all PMCs and their latest releases
-        statement = select(PMC)
-        pmcs = (await db_session.execute(statement)).scalars().all()
-        return await render_template("pmc-directory.html", pmcs=pmcs)
+    """Main PMC directory page."""
+    pmcs = await get_pmcs()
+    return await render_template("pmc-directory.html", pmcs=pmcs)
 
 
 @APP.route("/pmc/list")
 async def root_pmc_list() -> list[dict]:
-    "List all PMCs in the database."
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
-        statement = select(PMC)
-        pmcs = (await db_session.execute(statement)).scalars().all()
-
-        return [
-            {
-                "id": pmc.id,
-                "project_name": pmc.project_name,
-                "pmc_members": pmc.pmc_members,
-                "committers": pmc.committers,
-                "release_managers": pmc.release_managers,
-            }
-            for pmc in pmcs
-        ]
+    """List all PMCs in the database."""
+    pmcs = await get_pmcs()
+
+    return [
+        {
+            "id": pmc.id,
+            "project_name": pmc.project_name,
+            "pmc_members": pmc.pmc_members,
+            "committers": pmc.committers,
+            "release_managers": pmc.release_managers,
+        }
+        for pmc in pmcs
+    ]
 
 
 @APP.route("/user/keys/add", methods=["GET", "POST"])
 @require(R.committer)
 async def root_user_keys_add() -> str:
-    "Add a new GPG key to the user's account."
+    """Add a new GPG key to the user's account."""
     session = await session_read()
     if session is None:
         raise ASFQuartException("Not authenticated", errorcode=401)
@@ -354,8 +327,7 @@ async def root_user_keys_add() -> str:
     user_keys = []
 
     # Get all existing keys for the user
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
+    async with get_session() as db_session:
         statement = select(PublicSigningKey).where(PublicSigningKey.user_id == 
session.uid)
         user_keys = (await db_session.execute(statement)).scalars().all()
 
@@ -380,13 +352,12 @@ async def root_user_keys_add() -> str:
 @APP.route("/user/keys/delete")
 @require(R.committer)
 async def root_user_keys_delete() -> str:
-    "Debug endpoint to delete all of a user's keys."
+    """Debug endpoint to delete all of a user's keys."""
     session = await session_read()
     if session is None:
         raise ASFQuartException("Not authenticated", errorcode=401)
 
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
+    async with get_session() as db_session:
         async with db_session.begin():
             # Get all keys for the user
             # TODO: Might be clearer if user_id were "asf_id"
@@ -405,13 +376,12 @@ async def root_user_keys_delete() -> str:
 @APP.route("/user/uploads")
 @require(R.committer)
 async def root_user_uploads() -> str:
-    "Show all release candidates uploaded by the current user."
+    """Show all release candidates uploaded by the current user."""
     session = await session_read()
     if session is None:
         raise ASFQuartException("Not authenticated", errorcode=401)
 
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
+    async with get_session() as db_session:
         # Get all releases where the user is a PMC member of the associated PMC
         # TODO: We don't actually record who uploaded the release candidate
         # We should probably add that information!
@@ -505,8 +475,7 @@ async def user_keys_add(session: ClientSession, public_key: 
str) -> tuple[str, d
         return ("Key is not long enough; must be at least 2048 bits", None)
 
     # Store key in database
-    async_session = current_app.config["async_session"]
-    async with async_session() as db_session:
+    async with get_session() as db_session:
         return await user_keys_add_session(session, public_key, key, 
db_session)
 
 
diff --git a/atr/server.py b/atr/server.py
index 97314b3..738a12b 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -15,23 +15,20 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"server.py"
+"""server.py"""
 
+import logging
 import os
 
-from alembic import command
-from alembic.config import Config
-from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, 
create_async_engine
-from sqlalchemy.sql import text
-from sqlmodel import SQLModel
+from decouple import config
 
 import asfquart
 import asfquart.generics
 import asfquart.session
 from asfquart.base import QuartApp
 from atr.blueprints import register_blueprints
-
-from .models import __file__ as data_models_file
+from atr.config import AppConfig, config_dict
+from atr.db import create_database
 
 # Avoid OIDC
 asfquart.generics.OAUTH_URL_INIT = 
"https://oauth.apache.org/auth?state=%s&redirect_uri=%s";
@@ -45,98 +42,66 @@ def register_routes() -> str:
     return routes.__name__
 
 
-def create_app() -> QuartApp:
+def create_app(app_config: type[AppConfig]) -> QuartApp:
     if asfquart.construct is ...:
         raise ValueError("asfquart.construct is not set")
     app = asfquart.construct(__name__)
+    app.config.from_object(app_config)
 
     # # Configure static folder path before changing working directory
     # app.static_folder = 
os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
 
+    create_database(app)
+    register_routes()
+    register_blueprints(app)
+
     @app.context_processor
     async def app_wide():
         return {"current_user": await asfquart.session.read()}
 
-    @app.before_serving
-    async def create_database() -> None:
-        # Get the project root directory (where alembic.ini is)
-        project_root = 
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-
-        # Change working directory to "./state"
-        state_dir = os.path.join(project_root, "state")
-        if not os.path.isdir(state_dir):
-            raise RuntimeError(f"State directory not found: {state_dir}")
-        os.chdir(state_dir)
-        print(f"Working directory changed to: {os.getcwd()}")
-
-        # Set up release storage directory
-        release_storage = os.path.join(state_dir, "releases")
-        os.makedirs(release_storage, exist_ok=True)
-        app.config["RELEASE_STORAGE_DIR"] = release_storage
-        app.config["DATA_MODELS_FILE"] = data_models_file
-
-        # Use aiosqlite for async SQLite access
-        sqlite_url = "sqlite+aiosqlite:///./atr.db"
-        engine = create_async_engine(
-            sqlite_url,
-            connect_args={
-                "check_same_thread": False,
-                "timeout": 30,
-            },
-        )
-
-        # Create async session factory
-        async_session = async_sessionmaker(bind=engine, class_=AsyncSession, 
expire_on_commit=False)
-        app.config["async_session"] = async_session
-
-        # Set SQLite pragmas for better performance
-        # Use 64 MB for the cache_size, and 5000ms for busy_timeout
-        async with engine.begin() as conn:
-            await conn.execute(text("PRAGMA journal_mode=WAL"))
-            await conn.execute(text("PRAGMA synchronous=NORMAL"))
-            await conn.execute(text("PRAGMA cache_size=-64000"))
-            await conn.execute(text("PRAGMA foreign_keys=ON"))
-            await conn.execute(text("PRAGMA busy_timeout=5000"))
-
-        # Run any pending migrations
-        # In dev we'd do this first:
-        # poetry run alembic revision --autogenerate -m "description"
-        # Then review the generated migration in migrations/versions/ and 
commit it
-        alembic_ini_path = os.path.join(project_root, "alembic.ini")
-        alembic_cfg = Config(alembic_ini_path)
-        # Override the migrations directory location to use project root
-        # TODO: Is it possible to set this in alembic.ini?
-        alembic_cfg.set_main_option("script_location", 
os.path.join(project_root, "migrations"))
-        # Set the database URL in the config
-        alembic_cfg.set_main_option("sqlalchemy.url", sqlite_url)
-        command.upgrade(alembic_cfg, "head")
-
-        # Create any tables that might be missing
-        async with engine.begin() as conn:
-            await conn.run_sync(SQLModel.metadata.create_all)
-
-        app.config["engine"] = engine
-        # TODO: apply this only for debug
-        app.config["TEMPLATES_AUTO_RELOAD"] = True
-
     @app.after_serving
     async def shutdown() -> None:
         app.background_tasks.clear()
 
-    register_routes()
-    register_blueprints(app)
-
     return app
 
 
+# WARNING: Don't run with debug turned on in production!
+DEBUG: bool = config("DEBUG", default=True, cast=bool)
+
+# Determine which configuration to use
+config_mode = "Debug" if DEBUG else "Production"
+
+try:
+    app_config = config_dict[config_mode]
+except KeyError:
+    exit("Error: Invalid <config_mode>. Expected values [Debug, Production] ")
+
+if not os.path.isdir(app_config.STATE_DIR):
+    raise RuntimeError(f"State directory not found: {app_config.STATE_DIR}")
+os.chdir(app_config.STATE_DIR)
+print(f"Working directory changed to: {os.getcwd()}")
+
+os.makedirs(app_config.RELEASE_STORAGE_DIR, exist_ok=True)
+
+app = create_app(app_config)
+
+logging.basicConfig(
+    format="[%(asctime)s.%(msecs)03d  ] [%(process)d] [%(levelname)s] 
%(message)s",
+    level=logging.INFO,
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+if DEBUG:
+    app.logger.info("DEBUG        = " + str(DEBUG))
+    app.logger.info("ENVIRONMENT  = " + config_mode)
+    app.logger.info("STATE_DIR    = " + app_config.STATE_DIR)
+
+
 def main() -> None:
-    "Quart debug server"
-    app = create_app()
+    """Quart debug server"""
     app.run(port=8080, ssl_keyfile="key.pem", ssl_certfile="cert.pem")
 
 
-app = None
 if __name__ == "__main__":
     main()
-else:
-    app = create_app()
diff --git a/atr/templates/includes/sidebar.html 
b/atr/templates/includes/sidebar.html
index c9fdcc7..f197a4b 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -17,7 +17,7 @@
         (<code>{{ current_user.uid }}</code>)
       </div>
       <a href="#"
-         onclick="location.href='/auth?logout=' + window.location.pathname;"
+         onclick="location.href='/auth?logout=/';"
          class="logout-link">Logout</a>
     {% else %}
       <a href="#"
diff --git a/atr/templates/data-browser.html 
b/atr/templates/secret/data-browser.html
similarity index 100%
rename from atr/templates/data-browser.html
rename to atr/templates/secret/data-browser.html
diff --git a/atr/templates/update-pmcs.html 
b/atr/templates/secret/update-pmcs.html
similarity index 100%
rename from atr/templates/update-pmcs.html
rename to atr/templates/secret/update-pmcs.html
diff --git a/atr/util.py b/atr/util.py
new file mode 100644
index 0000000..b3b5faf
--- /dev/null
+++ b/atr/util.py
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import hashlib
+from functools import cache
+from pathlib import Path
+
+from quart import current_app
+
+
+@cache
+def get_admin_users() -> set[str]:
+    return set(current_app.config["ADMIN_USERS"])
+
+
+def get_release_storage_dir() -> str:
+    return str(current_app.config["RELEASE_STORAGE_DIR"])
+
+
+def compute_sha3_256(file_data: bytes) -> str:
+    """Compute SHA3-256 hash of file data."""
+    return hashlib.sha3_256(file_data).hexdigest()
+
+
+def compute_sha512(file_path: Path) -> str:
+    """Compute SHA-512 hash of a file."""
+    sha512 = hashlib.sha512()
+    with open(file_path, "rb") as f:
+        for chunk in iter(lambda: f.read(4096), b""):
+            sha512.update(chunk)
+    return sha512.hexdigest()
diff --git a/poetry.lock b/poetry.lock
index 4883676..163468d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1930,6 +1930,18 @@ pytest = ">=6.2.5"
 [package.extras]
 dev = ["pre-commit", "pytest-asyncio", "tox"]
 
+[[package]]
+name = "python-decouple"
+version = "3.8"
+description = "Strict separation of settings from code."
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+    {file = "python-decouple-3.8.tar.gz", hash = 
"sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f"},
+    {file = "python_decouple-3.8-py3-none-any.whl", hash = 
"sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66"},
+]
+
 [[package]]
 name = "python-gnupg"
 version = "0.5.4"
@@ -2635,4 +2647,4 @@ propcache = ">=0.2.0"
 [metadata]
 lock-version = "2.1"
 python-versions = "~=3.13"
-content-hash = 
"83ebf1f0e0465eb2fdd013705e58ed06d047baa560bdfd0196d327ba015f30d1"
+content-hash = 
"b0037bd47d793570a6513cf1b5d6303920af3aa7470c3a79525fb5ab1ad133d6"
diff --git a/pyproject.toml b/pyproject.toml
index 68774e9..95cb593 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,7 @@ dependencies = [
   "hypercorn~=0.17",
   "python-gnupg~=0.5",
   "sqlmodel~=0.0",
+  "python-decouple~=3.8"
 ]
 
 [dependency-groups]
diff --git a/uv.lock b/uv.lock
index 38f9404..2bfd4f6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -255,7 +255,7 @@ name = "click"
 version = "8.1.8"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
-    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "colorama", marker = "platform_system == 'Windows'" },
 ]
 sdist = { url = 
"https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz";,
 hash = 
"sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size 
= 226593 }
 wheels = [
@@ -933,6 +933,15 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl";,
 hash = 
"sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size 
= 9863 },
 ]
 
+[[package]]
+name = "python-decouple"
+version = "3.8"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/e1/97/373dcd5844ec0ea5893e13c39a2c67e7537987ad8de3842fe078db4582fa/python-decouple-3.8.tar.gz";,
 hash = 
"sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f", size 
= 9612 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/a2/d4/9193206c4563ec771faf2ccf54815ca7918529fe81f6adb22ee6d0e06622/python_decouple-3.8-py3-none-any.whl";,
 hash = 
"sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66", size 
= 9947 },
+]
+
 [[package]]
 name = "python-gnupg"
 version = "0.5.4"
@@ -1107,6 +1116,7 @@ dependencies = [
     { name = "greenlet" },
     { name = "httpx" },
     { name = "hypercorn" },
+    { name = "python-decouple" },
     { name = "python-gnupg" },
     { name = "sqlmodel" },
 ]
@@ -1131,6 +1141,7 @@ requires-dist = [
     { name = "greenlet", specifier = ">=3.1.1,<4.0.0" },
     { name = "httpx", specifier = "~=0.27" },
     { name = "hypercorn", specifier = "~=0.17" },
+    { name = "python-decouple", specifier = "~=3.8" },
     { name = "python-gnupg", specifier = "~=0.5" },
     { name = "sqlmodel", specifier = "~=0.0" },
 ]
@@ -1150,7 +1161,7 @@ name = "tqdm"
 version = "4.67.1"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
-    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "colorama", marker = "platform_system == 'Windows'" },
 ]
 sdist = { url = 
"https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz";,
 hash = 
"sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size 
= 169737 }
 wheels = [


---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscr...@tooling.apache.org
For additional commands, e-mail: dev-h...@tooling.apache.org


Reply via email to