From 94daa7bfe9fa3fd4edd3a109a764d989bf8f69f4 Mon Sep 17 00:00:00 2001 From: JD Bothma Date: Mon, 27 Oct 2025 16:36:36 +0000 Subject: [PATCH 1/9] Add trivial CI test --- .github/workflows/test.yml | 53 ++++++++++++++++++++++++++++++++++++++ docker-compose.dev.yml | 16 ++++++++++++ requirements.txt | 4 ++- test/test_health.py | 22 ++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 docker-compose.dev.yml create mode 100644 test/test_health.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..546f297 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,53 @@ +name: Run Tests + +on: + push: + branches: [ '*' ] + pull_request: + branches: [ '*' ] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: folio + POSTGRES_USER: admin + POSTGRES_PASSWORD: folio-db-pass-123 + ports: + - 5434:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Set environment variables + run: | + echo "DB_HOST=localhost" >> $GITHUB_ENV + echo "DB_PORT=5434" >> $GITHUB_ENV + echo "DB_NAME=folio" >> $GITHUB_ENV + echo "DB_USER=admin" >> $GITHUB_ENV + echo "DB_PASSWORD=folio-db-pass-123" >> $GITHUB_ENV + + - name: Run tests + run: | + pytest -v diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..ad2d422 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: folio + POSTGRES_USER: admin + POSTGRES_PASSWORD: folio-db-pass-123 + ports: + - "5434:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U admin -d folio"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/requirements.txt b/requirements.txt index 0f44fc3..d2f9b0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ PyJWT==2.8.0 cryptography==45.0.7 requests==2.32.5 psycopg2-binary==2.9.7 -python-dotenv==1.0.0 \ No newline at end of file +python-dotenv==1.0.0 +minio==7.2.0 +pytest==7.4.3 diff --git a/test/test_health.py b/test/test_health.py new file mode 100644 index 0000000..e4d318e --- /dev/null +++ b/test/test_health.py @@ -0,0 +1,22 @@ +import pytest +import os +from app import app + + +@pytest.fixture +def client(): + """Create a test client for the Flask app""" + app.config['TESTING'] = True + with app.test_client() as client: + yield client + + +def test_database_health(client): + """Test that the /info/health/db endpoint returns 200""" + response = client.get('/info/health/db') + assert response.status_code == 200 + + # Optionally, check the response data + data = response.get_json() + assert data is not None + assert data.get('status') == 'healthy' From fb3e8909ede5bd9e988f81ac376e0cc6b7369bef Mon Sep 17 00:00:00 2001 From: JD Bothma Date: Mon, 27 Oct 2025 16:37:20 +0000 Subject: [PATCH 2/9] Add template endpoints --- app.py | 4 + permissions.py | 6 + templates.py | 259 +++++++++++++++++++ test/migrations/2025-10-27-add-templates.sql | 24 ++ test/rest.http | 91 ++++++- test/test_template.xlsx | 1 + 6 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 templates.py create mode 100644 test/migrations/2025-10-27-add-templates.sql create mode 100644 test/test_template.xlsx diff --git a/app.py b/app.py index e007913..07bd2ba 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ from auth import KeycloakAuth, require_auth, extract_user_info, require_permission, user_has_permission from permissions import PERMISSIONS from database import get_db_cursor, test_connection +from templates import template_ns import os import json from datetime import datetime, date @@ -46,6 +47,9 @@ def default(self, obj): # Configure Flask-RESTX to use our custom JSON encoder app.config['RESTX_JSON'] = {'cls': CustomJSONEncoder} +# Register template namespace +api.add_namespace(template_ns) + ########################## ### INFO ########################## diff --git a/permissions.py b/permissions.py index 4c6ad84..8e28b30 100644 --- a/permissions.py +++ b/permissions.py @@ -21,6 +21,12 @@ "delete_pathogen": ["system-admin"], "view_pathogens": [], + # Template management + "create_template": ["system-admin"], + "edit_template": ["system-admin"], + "delete_template": ["system-admin"], + "view_templates": [], + # Project management "create_project": ["system-admin", "agari-org-owner", "agari-org-admin"], "edit_projects": ["system-admin", "agari-org-owner", "agari-org-admin"], diff --git a/templates.py b/templates.py new file mode 100644 index 0000000..eee93ca --- /dev/null +++ b/templates.py @@ -0,0 +1,259 @@ +""" +Templates module for managing spreadsheet template files. +Templates help users prepare TSV data for genomic analysis. +""" + +from flask import request, send_file, Response +from flask_restx import Namespace, Resource +from auth import require_auth, extract_user_info, require_permission +from database import get_db_cursor +from minio import Minio +from minio.error import S3Error +import os +import io +import uuid +from logging import getLogger + +logger = getLogger(__name__) + + +# Initialize MinIO client +def get_minio_client(): + """Get configured MinIO client instance""" + endpoint = ( + os.getenv("MINIO_ENDPOINT", "http://minio:9000") + .replace("http://", "") + .replace("https://", "") + ) + access_key = os.getenv("MINIO_ACCESS_KEY", "admin") + secret_key = os.getenv("MINIO_SECRET_KEY", "admin123") + secured = os.getenv("MINIO_SECURED", "false").lower() == "true" + + return Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secured) + + +# Get bucket name for templates +TEMPLATES_BUCKET = os.getenv("MINIO_BUCKET_STATE", "state") + +# Create namespace +template_ns = Namespace("templates", description="Template management endpoints") + + +@template_ns.route("/") +class TemplateList(Resource): + + @template_ns.doc("list_templates") + def get(self): + """List all templates (public access) + + Query parameters: + - pathogen_id: Filter by pathogen ID + - schema_version: Filter by schema version + """ + try: + + with get_db_cursor() as cursor: + query = """ + SELECT + t.id, + t.pathogen_id, + t.schema_version, + t.minio_object_id, + t.filename, + t.created_at, + t.updated_at, + p.name as pathogen_name + FROM templates t + LEFT JOIN pathogens p ON t.pathogen_id = p.id + WHERE t.deleted_at IS NULL + ORDER BY t.created_at DESC + """ + cursor.execute(query) + templates = cursor.fetchall() + + return {"templates": templates}, 200 + + except Exception as e: + return {"error": f"Failed to list templates: {str(e)}"}, 500 + + # @require_auth + # @require_permission('create_template', resource_type='template') + @template_ns.doc("create_template") + def put(self): + """Create or update a template (requires authentication) + + Multipart form data: + - pathogen_id: UUID of the pathogen + - schema_version: Integer version number + - file: Template file (spreadsheet) + """ + try: + # Validate inputs + pathogen_id = request.form.get("pathogen_id") + schema_version = request.form.get("schema_version") + if not pathogen_id or not schema_version: + return {"error": "pathogen_id and schema_version are required"}, 400 + + try: + schema_version = int(schema_version) + except ValueError: + return {"error": "schema_version must be an integer"}, 400 + + if "file" not in request.files: + return {"error": "No file provided"}, 400 + + file = request.files["file"] + if file.filename == "": + return {"error": "No file selected"}, 400 + + file_data = file.read() + file_size = len(file_data) + filename = file.filename + + # Generate MinIO object ID + minio_object_id = f"templates/{uuid.uuid4()}/{filename}" + + minio_client = get_minio_client() + + if not minio_client.bucket_exists(TEMPLATES_BUCKET): + logger.info(f"Creating bucket: {TEMPLATES_BUCKET}") + minio_client.make_bucket(TEMPLATES_BUCKET) + + logger.info(f"Uploading template {filename} to MinIO: {minio_object_id}") + minio_client.put_object( + TEMPLATES_BUCKET, + minio_object_id, + io.BytesIO(file_data), + file_size, + content_type=file.content_type or "application/octet-stream", + ) + + # Check if template already exists for this pathogen and version + with get_db_cursor() as cursor: + cursor.execute( + """ + SELECT id, minio_object_id FROM templates + WHERE pathogen_id = %s AND schema_version = %s AND deleted_at IS NULL + """, + (pathogen_id, schema_version), + ) + + existing = cursor.fetchone() + + if existing: + # Update existing template + template_id = existing["id"] + old_minio_object_id = existing["minio_object_id"] + + cursor.execute( + """ + UPDATE templates + SET minio_object_id = %s, filename = %s, updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (minio_object_id, filename, template_id), + ) + + # Delete old file from MinIO + try: + minio_client.remove_object( + TEMPLATES_BUCKET, old_minio_object_id + ) + except S3Error: + pass # Ignore if old file doesn't exist + + action = "updated" + else: + # Create new template + cursor.execute( + """ + INSERT INTO templates (pathogen_id, schema_version, minio_object_id, filename) + VALUES (%s, %s, %s, %s) + RETURNING id + """, + (pathogen_id, schema_version, minio_object_id, filename), + ) + template_id = cursor.fetchone()["id"] + action = "created" + + # Fetch the template details + cursor.execute( + """ + SELECT + t.id, + t.pathogen_id, + t.schema_version, + t.minio_object_id, + t.filename, + t.created_at, + t.updated_at, + p.name as pathogen_name + FROM templates t + LEFT JOIN pathogens p ON t.pathogen_id = p.id + WHERE t.id = %s + """, + (template_id,), + ) + + row = cursor.fetchone() + row["download_url"] = f"/templates/{row['id']}/download" + return { + "message": f"Template {action} successfully", + "template": row, + }, 200 + + except Exception as e: + logger.exception("Error creating/updating template") + return { + "error": f"Failed to create/update template: {e.__class__.__name__}: {str(e)}" + }, 500 + + +@template_ns.route("//download") +class TemplateDownload(Resource): + + @template_ns.doc("download_template") + def get(self, template_id): + """Download a template file (public access)""" + try: + with get_db_cursor() as cursor: + cursor.execute( + """ + SELECT minio_object_id, filename + FROM templates + WHERE id = %s AND deleted_at IS NULL + """, + (template_id,), + ) + + row = cursor.fetchone() + + if not row: + return {"error": "Template not found"}, 404 + + minio_object_id = row["minio_object_id"] + filename = row["filename"] + + # Get file from MinIO + minio_client = get_minio_client() + + try: + response = minio_client.get_object(TEMPLATES_BUCKET, minio_object_id) + file_data = response.read() + response.close() + response.release_conn() + + # Return file as downloadable response + return Response( + file_data, + mimetype="application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + }, + ) + + except S3Error as e: + return {"error": f"Failed to retrieve file from storage: {str(e)}"}, 500 + + except Exception as e: + return {"error": f"Failed to download template: {str(e)}"}, 500 diff --git a/test/migrations/2025-10-27-add-templates.sql b/test/migrations/2025-10-27-add-templates.sql new file mode 100644 index 0000000..16acda7 --- /dev/null +++ b/test/migrations/2025-10-27-add-templates.sql @@ -0,0 +1,24 @@ +-- Create templates table +CREATE TABLE IF NOT EXISTS templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + pathogen_id UUID NOT NULL REFERENCES pathogens(id) ON DELETE CASCADE, + schema_version INTEGER NOT NULL, + minio_object_id VARCHAR(255) NOT NULL, + filename VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE NULL, + UNIQUE(pathogen_id, schema_version) +); + + +CREATE INDEX IF NOT EXISTS idx_templates_pathogen ON templates(pathogen_id); +CREATE INDEX IF NOT EXISTS idx_templates_schema_version ON templates(schema_version); + + +CREATE TRIGGER update_templates_updated_at + BEFORE UPDATE ON templates + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + +COMMENT ON TABLE templates IS 'Templates table containing spreadsheet template files for data preparation'; diff --git a/test/rest.http b/test/rest.http index d15ab81..67e501d 100644 --- a/test/rest.http +++ b/test/rest.http @@ -1,15 +1,17 @@ #@folio = http://localhost:5000 -@folio = https://folio-staging.openup.org.za -@keycloak = https://keycloak-staging.openup.org.za - +@folio = http://localhost:8000 +#@folio = https://folio-staging.openup.org.za +@keycloak = http://keycloak.local +# @keycloak = https://keycloak-staging.openup.org.za +# @keycloak = http://localhost:8080 @realm = agari @client_id = dms @client_secret = VDyLEjGR3xDQvoQlrHq5AB6OwbW0Refc -# @username = system.admin@agari.tech -@username = owner@org1.ac.za +@username = system.admin@agari.tech +#@username = owner@org1.ac.za # @username = owner@org2.ac.za # @username = org-admin@org1.ac.za # @username = org-admin@org2.ac.za @@ -34,6 +36,7 @@ username={{username}} ### @token = {{login.response.body.access_token}} + ### Keycloak generate magic link @redirect_uri = https://agari.openup.org.za # @name magiclink @@ -137,8 +140,8 @@ Content-Type: application/json } ### - -@pathogen_id = 0683f0f6-04b3-4005-a078-383d0e3e88f2 +@pathogen_id = {{create_pathogen.response.body.pathogen.id}} +#@pathogen_id = 0683f0f6-04b3-4005-a078-383d0e3e88f2 # @name get_pathogen GET {{folio}}/pathogens/{{pathogen_id}} @@ -198,7 +201,8 @@ Content-Type: application/json ### -@project_id = 8772fbeb-5c94-41d6-8159-48290874378b +@project_id = {{create_project.response.body.project.id}} +# @project_id = cc99173f-c585-4ac3-8a36-2178d52181ec ### @name get_project GET {{folio}}/projects/{{project_id}} @@ -296,6 +300,19 @@ Content-Type: application/json GET http://song.local/studies/all Authorization: Bearer {{token}} +### + +# @name create_study_in_song +POST http://song.local/studies/{{study_id}}/ +Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJLa0g4OUtCZV9lQTE4dk45RHpvSEpLOHFIWWhIS0g1UEFUby1XNTE1cFB3In0.eyJleHAiOjE3NjE0MzIxNjIsImlhdCI6MTc2MTQzMTg2MiwianRpIjoidHJydGNjOmI4ODYxYmU4LTRhMzAtNDJjZS04YTc0LWIzMDAyY2ZiYWY3YyIsImlzcyI6Imh0dHA6Ly9rZXljbG9hay5sb2NhbC9yZWFsbXMvYWdhcmkiLCJhdWQiOiJyZWFsbS1tYW5hZ2VtZW50Iiwic3ViIjoic2VydmljZS1hY2NvdW50LWRtcy1pZCIsInR5cCI6IkJlYXJlciIsImF6cCI6ImRtcyIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJzeXN0ZW0tYWRtaW4iLCJyZWFsbS1hZG1pbiIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsicmVhbG0tbWFuYWdlbWVudCI6eyJyb2xlcyI6WyJ2aWV3LXJlYWxtIiwidmlldy1pZGVudGl0eS1wcm92aWRlcnMiLCJtYW5hZ2Utb3JnYW5pemF0aW9ucyIsIm1hbmFnZS1pZGVudGl0eS1wcm92aWRlcnMiLCJpbXBlcnNvbmF0aW9uIiwicmVhbG0tYWRtaW4iLCJjcmVhdGUtY2xpZW50IiwibWFuYWdlLXVzZXJzIiwicXVlcnktcmVhbG1zIiwicHVibGlzaC1ldmVudHMiLCJ2aWV3LWF1dGhvcml6YXRpb24iLCJxdWVyeS1jbGllbnRzIiwicXVlcnktdXNlcnMiLCJtYW5hZ2UtZXZlbnRzIiwibWFuYWdlLXJlYWxtIiwidmlldy1vcmdhbml6YXRpb25zIiwidmlldy1ldmVudHMiLCJ2aWV3LXVzZXJzIiwidmlldy1jbGllbnRzIiwibWFuYWdlLWF1dGhvcml6YXRpb24iLCJtYW5hZ2UtY2xpZW50cyIsInF1ZXJ5LWdyb3VwcyJdfSwiZG1zIjp7InJvbGVzIjpbInVtYV9wcm90ZWN0aW9uIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInByZWZlcnJlZF91c2VybmFtZSI6InNlcnZpY2UtYWNjb3VudC1kbXMifQ.V7Cz6w6TjdZcWNmFhxM2DU5lJSb_2f0PduJ8vOjJbCi-3IjF-ZTCyNW8fj4x9FVfuJUvQ4g9yBW0ON6Q5c_z1XtYk7F2QAPG2GT9NK9y_cEz1rwh7lhtwCSaDC2UTrPXbADa6L7JfLIbHlgtyT1pqyw4azLdoPwXSgZ6J7sysrsL7XkTgCoMoieILDTDvlmRMwPf27qZ4e9D6NalZtwuYR-nq1rpT7RGMaqeDe0K6CSvH-o0alxtTaq-KS28vc0JTjHMY3qW6ttmbWTf45UM_s_Z-G-Uua65MzQebC4TkVFQCfYWBayv0Q1xcPqSdYrWFg3N25f4mQRVdS30yJVzBA +Content-Type: application/json + +{ + "studyId": "{{study_id}}", + "name": "name", + "description": "description", + "info": {} +} ### @@ -447,3 +464,61 @@ Content-Type: application/json "redirect_uri": "https://agari.openup.org.za/", "send_email": "false" } + + +########################## +### TEMPLATES +########################## + +# @name list_templates +GET {{folio}}/templates + +### + +# @name create_template +PUT {{folio}}/templates/ +Authorization: Bearer {{token}} +Content-Type: multipart/form-data; boundary=----WebKitFormBoundary123 + +------WebKitFormBoundary123 +Content-Disposition: form-data; name="pathogen_id" + +{{pathogen_id}} +------WebKitFormBoundary123 +Content-Disposition: form-data; name="schema_version" + +1 +------WebKitFormBoundary123 +Content-Disposition: form-data; name="file"; filename="template_v1.xlsx" +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + +< ./test_template.xlsx +------WebKitFormBoundary123-- + +### +@template_id = {{create_template.response.body.template.id}} + +# @name download_template +GET {{folio}}/templates/{{template_id}}/download + +### + +# @name update_template +PUT {{folio}}/templates/ +Authorization: Bearer {{token}} +Content-Type: multipart/form-data; boundary=----WebKitFormBoundary456 + +------WebKitFormBoundary456 +Content-Disposition: form-data; name="pathogen_id" + +{{pathogen_id}} +------WebKitFormBoundary456 +Content-Disposition: form-data; name="schema_version" + +1 +------WebKitFormBoundary456 +Content-Disposition: form-data; name="file"; filename="template_v1_updated.xlsx" +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + +< ./test_template.xlsx +------WebKitFormBoundary456-- diff --git a/test/test_template.xlsx b/test/test_template.xlsx new file mode 100644 index 0000000..1b337c9 --- /dev/null +++ b/test/test_template.xlsx @@ -0,0 +1 @@ +Sample Template File From 02ce850eadeb2a254df59c8b3fde9e219f9a6665 Mon Sep 17 00:00:00 2001 From: JD Bothma Date: Mon, 27 Oct 2025 16:47:30 +0000 Subject: [PATCH 3/9] Fix auth for template admin --- app.py | 13 +++---------- auth.py | 11 ++++++++++- templates.py => routes/templates.py | 10 +++------- 3 files changed, 16 insertions(+), 18 deletions(-) rename templates.py => routes/templates.py (97%) diff --git a/app.py b/app.py index 07bd2ba..cff053e 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,9 @@ from flask import Flask,request from flask_restx import Api, Resource -from auth import KeycloakAuth, require_auth, extract_user_info, require_permission, user_has_permission +from auth import keycloak_auth, require_auth, extract_user_info, require_permission, user_has_permission from permissions import PERMISSIONS from database import get_db_cursor, test_connection -from templates import template_ns +from routes.templates import template_ns import os import json from datetime import datetime, date @@ -28,13 +28,6 @@ def default(self, obj): song = os.getenv('OVERTURE_SONG', 'http://song.local') score = os.getenv('OVERTURE_SCORE', 'http://score.local') -keycloak_auth = KeycloakAuth( - keycloak_url=os.getenv('KEYCLOAK_URL', 'http://keycloak.local'), - realm=os.getenv('KEYCLOAK_REALM', 'agari'), - client_id=os.getenv('KEYCLOAK_CLIENT_ID', 'dms'), - client_secret=os.getenv('KEYCLOAK_CLIENT_SECRET', 'VDyLEjGR3xDQvoQlrHq5AB6OwbW0Refc') -) - app.keycloak_auth = keycloak_auth api = Api(app, @@ -1477,4 +1470,4 @@ def post(self, study_id, analysis_id): if __name__ == '__main__': port = int(os.getenv('PORT', 8000)) - app.run(debug=True, host='0.0.0.0', port=port) \ No newline at end of file + app.run(debug=True, host='0.0.0.0', port=port) diff --git a/auth.py b/auth.py index a53e1a2..c76c193 100644 --- a/auth.py +++ b/auth.py @@ -1,3 +1,4 @@ +import os import jwt import requests from functools import wraps @@ -776,4 +777,12 @@ def wrapper(*args, **kwargs): return {'error': 'Permission denied', 'details': details}, 403 return f(*args, **kwargs) return wrapper - return decorator \ No newline at end of file + return decorator + + +keycloak_auth = KeycloakAuth( + keycloak_url=os.getenv('KEYCLOAK_URL', 'http://keycloak.local'), + realm=os.getenv('KEYCLOAK_REALM', 'agari'), + client_id=os.getenv('KEYCLOAK_CLIENT_ID', 'dms'), + client_secret=os.getenv('KEYCLOAK_CLIENT_SECRET', 'VDyLEjGR3xDQvoQlrHq5AB6OwbW0Refc') +) diff --git a/templates.py b/routes/templates.py similarity index 97% rename from templates.py rename to routes/templates.py index eee93ca..fecb9b1 100644 --- a/templates.py +++ b/routes/templates.py @@ -5,7 +5,7 @@ from flask import request, send_file, Response from flask_restx import Namespace, Resource -from auth import require_auth, extract_user_info, require_permission +from auth import require_auth, extract_user_info, require_permission, keycloak_auth from database import get_db_cursor from minio import Minio from minio.error import S3Error @@ -45,10 +45,6 @@ class TemplateList(Resource): @template_ns.doc("list_templates") def get(self): """List all templates (public access) - - Query parameters: - - pathogen_id: Filter by pathogen ID - - schema_version: Filter by schema version """ try: @@ -76,8 +72,8 @@ def get(self): except Exception as e: return {"error": f"Failed to list templates: {str(e)}"}, 500 - # @require_auth - # @require_permission('create_template', resource_type='template') + @require_auth(keycloak_auth) + @require_permission('create_template', resource_type='template') @template_ns.doc("create_template") def put(self): """Create or update a template (requires authentication) From 06f44499f6a70d2bd40fdd778423da25e179b7f5 Mon Sep 17 00:00:00 2001 From: JD Bothma Date: Mon, 27 Oct 2025 16:59:36 +0000 Subject: [PATCH 4/9] Add template deletion --- routes/templates.py | 125 ++++++++++++++++++++++++++++++++++++++++++++ test/rest.http | 18 +++++++ 2 files changed, 143 insertions(+) diff --git a/routes/templates.py b/routes/templates.py index fecb9b1..7adeefe 100644 --- a/routes/templates.py +++ b/routes/templates.py @@ -205,6 +205,131 @@ def put(self): }, 500 +@template_ns.route("/") +class TemplateDetail(Resource): + + @require_auth(keycloak_auth) + @require_permission('delete_template', resource_type='template') + @template_ns.doc("delete_template") + def delete(self, template_id): + """Delete a template by ID (system-admin only) + + Query Parameters: + - hard: true/false (default: false) - If true, permanently delete from database and MinIO + """ + try: + # Check if hard delete is requested + hard_delete = request.args.get('hard', 'false').lower() == 'true' + + with get_db_cursor() as cursor: + # First get the template details + cursor.execute( + """ + SELECT id, filename, minio_object_id, deleted_at + FROM templates + WHERE id = %s + """, + (template_id,) + ) + + template = cursor.fetchone() + + if not template: + return {'error': 'Template not found'}, 404 + + if hard_delete: + # Hard delete - permanently remove from database and MinIO + cursor.execute( + """ + DELETE FROM templates + WHERE id = %s + RETURNING id, filename, minio_object_id + """, + (template_id,) + ) + + deleted_template = cursor.fetchone() + + # Delete file from MinIO + try: + minio_client = get_minio_client() + minio_client.remove_object(TEMPLATES_BUCKET, deleted_template['minio_object_id']) + logger.info(f"Deleted template file from MinIO: {deleted_template['minio_object_id']}") + except S3Error as e: + logger.warning(f"Failed to delete file from MinIO: {str(e)}") + # Continue anyway - database record is deleted + + return { + 'message': f'Template "{deleted_template["filename"]}" permanently deleted', + 'delete_type': 'hard' + }, 200 + else: + # Soft delete - set deleted_at timestamp + if template['deleted_at']: + return {'error': 'Template already deleted'}, 404 + + cursor.execute( + """ + UPDATE templates + SET deleted_at = NOW(), updated_at = NOW() + WHERE id = %s AND deleted_at IS NULL + RETURNING id, filename + """, + (template_id,) + ) + + deleted_template = cursor.fetchone() + + if not deleted_template: + return {'error': 'Template not found or already deleted'}, 404 + + return { + 'message': f'Template "{deleted_template["filename"]}" deleted (soft delete)', + 'delete_type': 'soft' + }, 200 + + except Exception as e: + logger.exception("Error deleting template") + return {'error': f'Failed to delete template: {str(e)}'}, 500 + + +@template_ns.route("//restore") +class TemplateRestore(Resource): + + @require_auth(keycloak_auth) + @require_permission('delete_template', resource_type='template') + @template_ns.doc("restore_template") + def post(self, template_id): + """Restore a soft-deleted template (system-admin only)""" + try: + with get_db_cursor() as cursor: + cursor.execute( + """ + UPDATE templates + SET deleted_at = NULL, updated_at = NOW() + WHERE id = %s AND deleted_at IS NOT NULL + RETURNING id, filename, pathogen_id, schema_version, minio_object_id, updated_at + """, + (template_id,) + ) + + restored_template = cursor.fetchone() + + if not restored_template: + return {'error': 'Template not found or not deleted'}, 404 + + return { + 'message': f'Template "{restored_template["filename"]}" restored successfully', + 'template': restored_template + }, 200 + + except Exception as e: + if 'duplicate key value violates unique constraint' in str(e): + return {'error': 'Cannot restore: A template with this pathogen and schema version already exists'}, 409 + logger.exception("Error restoring template") + return {'error': f'Failed to restore template: {str(e)}'}, 500 + + @template_ns.route("//download") class TemplateDownload(Resource): diff --git a/test/rest.http b/test/rest.http index 67e501d..1e750e8 100644 --- a/test/rest.http +++ b/test/rest.http @@ -522,3 +522,21 @@ Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet < ./test_template.xlsx ------WebKitFormBoundary456-- + +### + +# @name delete_template_soft +DELETE {{folio}}/templates/{{template_id}} +Authorization: Bearer {{token}} + +### + +# @name restore_template +POST {{folio}}/templates/{{template_id}}/restore +Authorization: Bearer {{token}} + +### + +# @name delete_template_hard +DELETE {{folio}}/templates/{{template_id}}?hard=true +Authorization: Bearer {{token}} From 8abbb27b826730d050e270485008a3892c8f859e Mon Sep 17 00:00:00 2001 From: JD Bothma Date: Mon, 27 Oct 2025 17:04:33 +0000 Subject: [PATCH 5/9] Just the templates rest client stuff --- test/rest.http | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/test/rest.http b/test/rest.http index 1e750e8..b7f5c27 100644 --- a/test/rest.http +++ b/test/rest.http @@ -1,17 +1,15 @@ #@folio = http://localhost:5000 -@folio = http://localhost:8000 -#@folio = https://folio-staging.openup.org.za -@keycloak = http://keycloak.local -# @keycloak = https://keycloak-staging.openup.org.za -# @keycloak = http://localhost:8080 +@folio = https://folio-staging.openup.org.za +@keycloak = https://keycloak-staging.openup.org.za + @realm = agari @client_id = dms @client_secret = VDyLEjGR3xDQvoQlrHq5AB6OwbW0Refc -@username = system.admin@agari.tech -#@username = owner@org1.ac.za +# @username = system.admin@agari.tech +@username = owner@org1.ac.za # @username = owner@org2.ac.za # @username = org-admin@org1.ac.za # @username = org-admin@org2.ac.za @@ -36,7 +34,6 @@ username={{username}} ### @token = {{login.response.body.access_token}} - ### Keycloak generate magic link @redirect_uri = https://agari.openup.org.za # @name magiclink @@ -140,8 +137,8 @@ Content-Type: application/json } ### -@pathogen_id = {{create_pathogen.response.body.pathogen.id}} -#@pathogen_id = 0683f0f6-04b3-4005-a078-383d0e3e88f2 + +@pathogen_id = 0683f0f6-04b3-4005-a078-383d0e3e88f2 # @name get_pathogen GET {{folio}}/pathogens/{{pathogen_id}} @@ -201,8 +198,7 @@ Content-Type: application/json ### -@project_id = {{create_project.response.body.project.id}} -# @project_id = cc99173f-c585-4ac3-8a36-2178d52181ec +@project_id = 8772fbeb-5c94-41d6-8159-48290874378b ### @name get_project GET {{folio}}/projects/{{project_id}} @@ -300,19 +296,6 @@ Content-Type: application/json GET http://song.local/studies/all Authorization: Bearer {{token}} -### - -# @name create_study_in_song -POST http://song.local/studies/{{study_id}}/ -Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJLa0g4OUtCZV9lQTE4dk45RHpvSEpLOHFIWWhIS0g1UEFUby1XNTE1cFB3In0.eyJleHAiOjE3NjE0MzIxNjIsImlhdCI6MTc2MTQzMTg2MiwianRpIjoidHJydGNjOmI4ODYxYmU4LTRhMzAtNDJjZS04YTc0LWIzMDAyY2ZiYWY3YyIsImlzcyI6Imh0dHA6Ly9rZXljbG9hay5sb2NhbC9yZWFsbXMvYWdhcmkiLCJhdWQiOiJyZWFsbS1tYW5hZ2VtZW50Iiwic3ViIjoic2VydmljZS1hY2NvdW50LWRtcy1pZCIsInR5cCI6IkJlYXJlciIsImF6cCI6ImRtcyIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJzeXN0ZW0tYWRtaW4iLCJyZWFsbS1hZG1pbiIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsicmVhbG0tbWFuYWdlbWVudCI6eyJyb2xlcyI6WyJ2aWV3LXJlYWxtIiwidmlldy1pZGVudGl0eS1wcm92aWRlcnMiLCJtYW5hZ2Utb3JnYW5pemF0aW9ucyIsIm1hbmFnZS1pZGVudGl0eS1wcm92aWRlcnMiLCJpbXBlcnNvbmF0aW9uIiwicmVhbG0tYWRtaW4iLCJjcmVhdGUtY2xpZW50IiwibWFuYWdlLXVzZXJzIiwicXVlcnktcmVhbG1zIiwicHVibGlzaC1ldmVudHMiLCJ2aWV3LWF1dGhvcml6YXRpb24iLCJxdWVyeS1jbGllbnRzIiwicXVlcnktdXNlcnMiLCJtYW5hZ2UtZXZlbnRzIiwibWFuYWdlLXJlYWxtIiwidmlldy1vcmdhbml6YXRpb25zIiwidmlldy1ldmVudHMiLCJ2aWV3LXVzZXJzIiwidmlldy1jbGllbnRzIiwibWFuYWdlLWF1dGhvcml6YXRpb24iLCJtYW5hZ2UtY2xpZW50cyIsInF1ZXJ5LWdyb3VwcyJdfSwiZG1zIjp7InJvbGVzIjpbInVtYV9wcm90ZWN0aW9uIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInByZWZlcnJlZF91c2VybmFtZSI6InNlcnZpY2UtYWNjb3VudC1kbXMifQ.V7Cz6w6TjdZcWNmFhxM2DU5lJSb_2f0PduJ8vOjJbCi-3IjF-ZTCyNW8fj4x9FVfuJUvQ4g9yBW0ON6Q5c_z1XtYk7F2QAPG2GT9NK9y_cEz1rwh7lhtwCSaDC2UTrPXbADa6L7JfLIbHlgtyT1pqyw4azLdoPwXSgZ6J7sysrsL7XkTgCoMoieILDTDvlmRMwPf27qZ4e9D6NalZtwuYR-nq1rpT7RGMaqeDe0K6CSvH-o0alxtTaq-KS28vc0JTjHMY3qW6ttmbWTf45UM_s_Z-G-Uua65MzQebC4TkVFQCfYWBayv0Q1xcPqSdYrWFg3N25f4mQRVdS30yJVzBA -Content-Type: application/json - -{ - "studyId": "{{study_id}}", - "name": "name", - "description": "description", - "info": {} -} ### @@ -466,6 +449,7 @@ Content-Type: application/json } + ########################## ### TEMPLATES ########################## From c0aeab50eeae39228712ce678da001a2064f5104 Mon Sep 17 00:00:00 2001 From: JD Bothma Date: Mon, 27 Oct 2025 22:04:56 +0000 Subject: [PATCH 6/9] Move env vars to settings.py, fix import from thests --- __init__.py | 0 app.py | 9 ++++----- auth.py | 10 +++++----- database.py | 12 ++++++------ docker-compose.dev.yml | 6 +++--- routes/templates.py | 24 ++++++++++++----------- settings.py | 43 ++++++++++++++++++++++++++++++++++++++++++ test/__init__.py | 0 test/conftest.py | 19 +++++++++++++++++++ test/test_health.py | 17 +---------------- 10 files changed, 94 insertions(+), 46 deletions(-) create mode 100644 __init__.py create mode 100644 settings.py create mode 100644 test/__init__.py create mode 100644 test/conftest.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py index cff053e..216433b 100644 --- a/app.py +++ b/app.py @@ -4,7 +4,7 @@ from permissions import PERMISSIONS from database import get_db_cursor, test_connection from routes.templates import template_ns -import os +from settings import OVERTURE_SONG_URL, OVERTURE_SCORE_URL, PORT import json from datetime import datetime, date from decimal import Decimal @@ -25,8 +25,8 @@ def default(self, obj): app = Flask(__name__) app.json_encoder = CustomJSONEncoder -song = os.getenv('OVERTURE_SONG', 'http://song.local') -score = os.getenv('OVERTURE_SCORE', 'http://score.local') +song = OVERTURE_SONG_URL +score = OVERTURE_SCORE_URL app.keycloak_auth = keycloak_auth @@ -1469,5 +1469,4 @@ def post(self, study_id, analysis_id): if __name__ == '__main__': - port = int(os.getenv('PORT', 8000)) - app.run(debug=True, host='0.0.0.0', port=port) + app.run(debug=True, host='0.0.0.0', port=PORT) diff --git a/auth.py b/auth.py index c76c193..d068c8e 100644 --- a/auth.py +++ b/auth.py @@ -1,10 +1,10 @@ -import os import jwt import requests from functools import wraps from flask import request, jsonify, current_app from jwt.exceptions import InvalidTokenError, ExpiredSignatureError from permissions import PERMISSIONS +from settings import KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET class KeycloakAuth: def __init__(self, keycloak_url, realm, client_id, client_secret): @@ -781,8 +781,8 @@ def wrapper(*args, **kwargs): keycloak_auth = KeycloakAuth( - keycloak_url=os.getenv('KEYCLOAK_URL', 'http://keycloak.local'), - realm=os.getenv('KEYCLOAK_REALM', 'agari'), - client_id=os.getenv('KEYCLOAK_CLIENT_ID', 'dms'), - client_secret=os.getenv('KEYCLOAK_CLIENT_SECRET', 'VDyLEjGR3xDQvoQlrHq5AB6OwbW0Refc') + keycloak_url=KEYCLOAK_URL, + realm=KEYCLOAK_REALM, + client_id=KEYCLOAK_CLIENT_ID, + client_secret=KEYCLOAK_CLIENT_SECRET ) diff --git a/database.py b/database.py index e127b55..c5dfd8e 100644 --- a/database.py +++ b/database.py @@ -1,16 +1,16 @@ -import os import psycopg2 from psycopg2.extras import RealDictCursor from contextlib import contextmanager import logging +from settings import DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD # Database configuration DB_CONFIG = { - 'host': os.getenv('DB_HOST', 'localhost'), - 'port': os.getenv('DB_PORT', '5434'), - 'database': os.getenv('DB_NAME', 'folio'), - 'user': os.getenv('DB_USER', 'admin'), - 'password': os.getenv('DB_PASSWORD', 'folio-db-pass-123') + 'host': DB_HOST, + 'port': DB_PORT, + 'database': DB_NAME, + 'user': DB_USER, + 'password': DB_PASSWORD } # Configure logging diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ad2d422..6fa1efc 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,13 +4,13 @@ services: postgres: image: postgres:15 environment: - POSTGRES_DB: folio + POSTGRES_DB: folio_test POSTGRES_USER: admin POSTGRES_PASSWORD: folio-db-pass-123 ports: - - "5434:5432" + - "5435:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U admin -d folio"] + test: ["CMD-SHELL", "pg_isready -U admin -d folio_test"] interval: 10s timeout: 5s retries: 5 diff --git a/routes/templates.py b/routes/templates.py index 7adeefe..9447024 100644 --- a/routes/templates.py +++ b/routes/templates.py @@ -9,7 +9,13 @@ from database import get_db_cursor from minio import Minio from minio.error import S3Error -import os +from settings import ( + MINIO_ENDPOINT_CLEAN, + MINIO_ACCESS_KEY, + MINIO_SECRET_KEY, + MINIO_SECURED, + MINIO_BUCKET_STATE +) import io import uuid from logging import getLogger @@ -20,20 +26,16 @@ # Initialize MinIO client def get_minio_client(): """Get configured MinIO client instance""" - endpoint = ( - os.getenv("MINIO_ENDPOINT", "http://minio:9000") - .replace("http://", "") - .replace("https://", "") + return Minio( + MINIO_ENDPOINT_CLEAN, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=MINIO_SECURED ) - access_key = os.getenv("MINIO_ACCESS_KEY", "admin") - secret_key = os.getenv("MINIO_SECRET_KEY", "admin123") - secured = os.getenv("MINIO_SECURED", "false").lower() == "true" - - return Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secured) # Get bucket name for templates -TEMPLATES_BUCKET = os.getenv("MINIO_BUCKET_STATE", "state") +TEMPLATES_BUCKET = MINIO_BUCKET_STATE # Create namespace template_ns = Namespace("templates", description="Template management endpoints") diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..163173b --- /dev/null +++ b/settings.py @@ -0,0 +1,43 @@ +""" +Application settings and configuration. +All environment variables are centralized here. +""" + +import os +from dotenv import load_dotenv + +# Load environment variables from .env file if it exists +load_dotenv() + +# Server configuration +PORT = int(os.getenv('PORT', '8000')) + +# Database configuration +DB_HOST = os.getenv('FOLIO_DB_HOST', os.getenv('DB_HOST', 'localhost')) +DB_PORT = os.getenv('FOLIO_DB_PORT', os.getenv('DB_PORT', '5434')) +DB_NAME = os.getenv('FOLIO_DB_NAME', os.getenv('DB_NAME', 'folio')) +DB_USER = os.getenv('FOLIO_DB_USER', os.getenv('DB_USER', 'admin')) +DB_PASSWORD = os.getenv('FOLIO_DB_PASSWORD', os.getenv('DB_PASSWORD', 'folio-db-pass-123')) + +# Keycloak authentication configuration +KEYCLOAK_URL = os.getenv('KEYCLOAK_URL', 'http://keycloak.local') +KEYCLOAK_HOST = os.getenv('KEYCLOAK_HOST', KEYCLOAK_URL) # Alias for backwards compatibility +KEYCLOAK_REALM = os.getenv('KEYCLOAK_REALM', 'agari') +KEYCLOAK_CLIENT_ID = os.getenv('KEYCLOAK_CLIENT_ID', 'dms') +KEYCLOAK_CLIENT_SECRET = os.getenv('KEYCLOAK_CLIENT_SECRET', 'VDyLEjGR3xDQvoQlrHq5AB6OwbW0Refc') +KEYCLOAK_ISSUER = os.getenv('KEYCLOAK_ISSUER', f'{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}') + +# Overture stack services configuration +OVERTURE_SONG_URL = os.getenv('OVERTURE_SONG', 'http://song.local') +OVERTURE_SCORE_URL = os.getenv('OVERTURE_SCORE', 'http://score.local') + +# MinIO configuration +MINIO_ENDPOINT = os.getenv('MINIO_ENDPOINT', 'http://minio:9000') +MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY', 'admin') +MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY', 'admin123') +MINIO_SECURED = os.getenv('MINIO_SECURED', 'false').lower() == 'true' +MINIO_BUCKET_OBJECT = os.getenv('MINIO_BUCKET_OBJECT', 'object') +MINIO_BUCKET_STATE = os.getenv('MINIO_BUCKET_STATE', 'state') + +# Helper to get clean MinIO endpoint (without http:// or https://) +MINIO_ENDPOINT_CLEAN = MINIO_ENDPOINT.replace('http://', '').replace('https://', '') diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..cd1094e --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,19 @@ +import pytest +import settings + +settings.DB_NAME = 'folio_test' +settings.DB_PORT = '5435' +settings.DB_HOST = 'localhost' +settings.DB_USER = 'admin' +settings.DB_PASSWORD = 'folio-db-pass-123' + +# Import app after overriding settings +from app import app + + +@pytest.fixture +def client(): + """Create a test client for the Flask app""" + app.config['TESTING'] = True + with app.test_client() as client: + yield client diff --git a/test/test_health.py b/test/test_health.py index e4d318e..7e521a0 100644 --- a/test/test_health.py +++ b/test/test_health.py @@ -1,22 +1,7 @@ -import pytest -import os -from app import app - - -@pytest.fixture -def client(): - """Create a test client for the Flask app""" - app.config['TESTING'] = True - with app.test_client() as client: - yield client - - def test_database_health(client): """Test that the /info/health/db endpoint returns 200""" response = client.get('/info/health/db') + assert response.status_code == 200 - - # Optionally, check the response data data = response.get_json() - assert data is not None assert data.get('status') == 'healthy' From 3e8aee87bfdb091fac6a16433905775293fcb8e9 Mon Sep 17 00:00:00 2001 From: JD Bothma Date: Mon, 27 Oct 2025 22:18:13 +0000 Subject: [PATCH 7/9] Fix bucket name --- .github/workflows/test.yml | 2 +- routes/templates.py | 19 ++++++++----------- settings.py | 3 +-- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 546f297..df1efc7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: POSTGRES_USER: admin POSTGRES_PASSWORD: folio-db-pass-123 ports: - - 5434:5432 + - 5435:5432 options: >- --health-cmd pg_isready --health-interval 10s diff --git a/routes/templates.py b/routes/templates.py index 9447024..264b1c2 100644 --- a/routes/templates.py +++ b/routes/templates.py @@ -14,7 +14,7 @@ MINIO_ACCESS_KEY, MINIO_SECRET_KEY, MINIO_SECURED, - MINIO_BUCKET_STATE + MINIO_BUCKET, ) import io import uuid @@ -34,9 +34,6 @@ def get_minio_client(): ) -# Get bucket name for templates -TEMPLATES_BUCKET = MINIO_BUCKET_STATE - # Create namespace template_ns = Namespace("templates", description="Template management endpoints") @@ -113,13 +110,13 @@ def put(self): minio_client = get_minio_client() - if not minio_client.bucket_exists(TEMPLATES_BUCKET): - logger.info(f"Creating bucket: {TEMPLATES_BUCKET}") - minio_client.make_bucket(TEMPLATES_BUCKET) + if not minio_client.bucket_exists(MINIO_BUCKET): + logger.info(f"Creating bucket: {MINIO_BUCKET}") + minio_client.make_bucket(MINIO_BUCKET) logger.info(f"Uploading template {filename} to MinIO: {minio_object_id}") minio_client.put_object( - TEMPLATES_BUCKET, + MINIO_BUCKET, minio_object_id, io.BytesIO(file_data), file_size, @@ -155,7 +152,7 @@ def put(self): # Delete old file from MinIO try: minio_client.remove_object( - TEMPLATES_BUCKET, old_minio_object_id + MINIO_BUCKET, old_minio_object_id ) except S3Error: pass # Ignore if old file doesn't exist @@ -255,7 +252,7 @@ def delete(self, template_id): # Delete file from MinIO try: minio_client = get_minio_client() - minio_client.remove_object(TEMPLATES_BUCKET, deleted_template['minio_object_id']) + minio_client.remove_object(MINIO_BUCKET, deleted_template['minio_object_id']) logger.info(f"Deleted template file from MinIO: {deleted_template['minio_object_id']}") except S3Error as e: logger.warning(f"Failed to delete file from MinIO: {str(e)}") @@ -361,7 +358,7 @@ def get(self, template_id): minio_client = get_minio_client() try: - response = minio_client.get_object(TEMPLATES_BUCKET, minio_object_id) + response = minio_client.get_object(MINIO_BUCKET, minio_object_id) file_data = response.read() response.close() response.release_conn() diff --git a/settings.py b/settings.py index 163173b..e5ba091 100644 --- a/settings.py +++ b/settings.py @@ -36,8 +36,7 @@ MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY', 'admin') MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY', 'admin123') MINIO_SECURED = os.getenv('MINIO_SECURED', 'false').lower() == 'true' -MINIO_BUCKET_OBJECT = os.getenv('MINIO_BUCKET_OBJECT', 'object') -MINIO_BUCKET_STATE = os.getenv('MINIO_BUCKET_STATE', 'state') +MINIO_BUCKET = os.getenv('MINIO_BUCKET', 'folio') # Helper to get clean MinIO endpoint (without http:// or https://) MINIO_ENDPOINT_CLEAN = MINIO_ENDPOINT.replace('http://', '').replace('https://', '') From 2aacb31e4a2757f21eab7ef4344e58d1a1706af1 Mon Sep 17 00:00:00 2001 From: JD Bothma Date: Mon, 27 Oct 2025 22:21:05 +0000 Subject: [PATCH 8/9] Fix db name --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df1efc7..7b5a1a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: postgres: image: postgres:15 env: - POSTGRES_DB: folio + POSTGRES_DB: folio_test POSTGRES_USER: admin POSTGRES_PASSWORD: folio-db-pass-123 ports: From e628d4a3da45e64e20a5c2bf7a36d178b7e7cbf4 Mon Sep 17 00:00:00 2001 From: JD Bothma Date: Mon, 27 Oct 2025 22:28:12 +0000 Subject: [PATCH 9/9] Document running tests --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d4c763d..f14b9a9 100644 --- a/README.md +++ b/README.md @@ -26,4 +26,37 @@ Replace `` and `` with your deployment details. ## Proxy for Overture Services SONG and SCORE -This service also acts as a proxy for the Overture services SONG and SCORE, facilitating secure and authenticated access to these services through Agari-Folio's permission system. \ No newline at end of file +This service also acts as a proxy for the Overture services SONG and SCORE, facilitating secure and authenticated access to these services through Agari-Folio's permission system. + +## Development + +### Install or upgrade dependencies: + +(Working in a python virtual environment is recommended) + +``` +pip install -U --upgrade-strategy=eager requirements.txt +``` + +### Running the app + +Configure environment variables for accessing the backing services, e.g. using a .env file or direnv. + +See settings.py for the variables. + +Run a local server with + +``` +python app.py +``` + +### Tests + +Run tests using pytest: + +``` +pytest +``` + +It'll discover the tests in the `test` directory, e.g. modules beginning with `test_`. Reusable fixtures are generally defined in conftest.py. +