Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ SUPABASE_PUBLISHABLE_KEY=

# Flask session signing secret key
SECRET_KEY=dev-secret-key-123456789

# Background Task Queue Configuration
QUEUE_PROVIDER=celery
TASK_QUEUE_SECRET=dev-task-secret-12345
APP_BASE_URL=http://127.0.0.1:5000
CELERY_BROKER_URL=redis://127.0.0.1:6379/0
106 changes: 85 additions & 21 deletions docs/architecture/architecture-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1177,31 +1177,33 @@ key fields, relationships, and design notes.
Detailed SQL migration scripts will be developed separately from this
design specification.

## 4.10.1 project
## 4.10.1 organization

The project table stores NumFOCUS fiscally sponsored projects,
The organization table stores NumFOCUS fiscally sponsored projects,
conferences, programs, operational initiatives, and other organizational
activities tracked within the PFL.

The project table is the primary security boundary for the application.
The organization table is the primary security boundary for the application.

The project table provides the organizational container for Funding
The organization table provides the organizational container for Funding
Sources, Project Governance, Governing Agreements, Supporting Documents,
and Reporting Obligations. Financial Transactions are associated with
the Project through their assigned Funding Source.
the Organization through their assigned Funding Source.

Key Fields

| **Field** | **Type** | **Required** | **Notes** |
|----|----|----|----|
| id | uuid | Yes | Primary key |
| project_name | text | Yes | Official project name |
| project_slug | text | Yes | URL-safe unique identifier |
| id | uuid | Yes | Primary key (references organization_key.id) |
| organization_name | text | Yes | Official organization name |
| organization_slug | text | Yes | URL-safe unique identifier |
| status | text | Yes | Active, inactive, archived, dormant, closed |
| project_owner | text | No | Internal or external project owner name |
| description | text | No | General project description |
| website_url | text | No | Public project website |
| notes | text | No | Internal notes |
| organization_type | text | Yes | Fiscal Sponsorship, Event |
| description | text | No | General organization description |
| website_url | text | No | Public organization website |
| source_code_url | text | No | Public source code repository URL |
| donation_url | text | No | Donation page URL |
| join_date | date | No | Date the organization joined NumFOCUS |
| created_at | timestamp | Yes | Record creation timestamp |
| updated_at | timestamp | Yes | Last update timestamp |
| created_by_user_id | uuid | No | User who created the record |
Expand All @@ -1211,22 +1213,84 @@ Key Fields

### 4.10.1.1 Relationships

A Project may have many Funding Sources.
An Organization belongs to exactly one Organization Key.

A Project may have many Project Governance items.
An Organization has one related Organization Internal record.

An Organization may have many Funding Sources.

An Organization may have many Project Governance items.

A Project may have many related Supporting Documents.
An Organization may have many related Supporting Documents.

A Project may have many Governing Agreements through Funding Sources.
An Organization may have many Governing Agreements through Funding Sources.

A Project may have many Reporting Obligations through Funding Sources.
An Organization may have many Reporting Obligations through Funding Sources.

A Project may have many Financial Transactions through Funding Sources.
An Organization may have many Financial Transactions through Funding Sources.

### 4.10.1.2 Design Notes

Project records should be relatively stable. Project names should not be
used as foreign keys. All relationships should use project_id.
Organization records should be relatively stable. Organization names should not be
used as foreign keys. All relationships should use organization_id.

## 4.10.1.3 organization_key

The organization_key table provides a layer of indirection for the
organization identifier. It maps an auto-generated unique ID to a
user-specified import key.

Key Fields

| **Field** | **Type** | **Required** | **Notes** |
|----|----|----|----|
| id | uuid | Yes | Primary key (auto-generated) |
| import_key | text | Yes | Unique user-specified string identifier |
| created_at | timestamp | Yes | Record creation timestamp |
| updated_at | timestamp | Yes | Last update timestamp |
| created_by_user_id | uuid | No | User who created the record |
| updated_by_user_id | uuid | No | User who last updated the record |

### 4.10.1.3.1 Relationships

An Organization Key has a one-to-one relationship with an Organization.

### 4.10.1.3.2 Design Notes

This table allows internal system foreign keys to remain stable UUIDs while
accommodating changes, remapping, or customization of the user-specified
import identifiers.

## 4.10.1.4 organization_internal

The organization_internal table stores restricted/private organization
metadata. It requires more restrictive permissions to access columns
like internal notes and overhead rates.

Key Fields

| **Field** | **Type** | **Required** | **Notes** |
|----|----|----|----|
| id | uuid | Yes | Primary key (references organization.id) |
| overhead_grant | numeric | Yes | Overhead rate for grants (default 0.0) |
| overhead_donation_general | numeric | Yes | Overhead rate for general donations (default 0.0) |
| overhead_donation_corporate | numeric | Yes | Overhead rate for corporate donations (default 0.0) |
| notes | text | No | Internal private notes |
| created_at | timestamp | Yes | Record creation timestamp |
| updated_at | timestamp | Yes | Last update timestamp |
| created_by_user_id | uuid | No | User who created the record |
| updated_by_user_id | uuid | No | User who last updated the record |
| deleted_at | timestamp | No | Soft deletion timestamp |
| deleted_by_user_id | uuid | No | User who soft deleted the record |

### 4.10.1.4.1 Relationships

An Organization Internal record belongs to exactly one Organization.

### 4.10.1.4.2 Design Notes

This table enforces strict role-based access control (RBAC) separating public
organization metadata from private financial and administrative overhead details.

## 4.10.2 funding_source

Expand Down Expand Up @@ -2313,7 +2377,7 @@ data.

Examples of unique constraints include, but are not limited to:

- project.project_slug
- organization.organization_slug

- import_batch.source_file_hash

Expand Down
14 changes: 12 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,15 @@ supabase-start:
dev-db-reset: supabase-start
npx supabase db reset

dev: supabase-start
uv run python -m flask --app project_funding_ledger run --debug
redis-start:
{{ if os() == "windows" { "if (docker ps -a --filter name=local-redis -q) { docker start local-redis } else { docker run -d --name local-redis -p 6379:6379 redis:alpine }" } else { "docker start local-redis 2>/dev/null || docker run -d --name local-redis -p 6379:6379 redis:alpine" } }}

celery-worker: redis-start
{{ if os() == "windows" { ".venv/Scripts/uv run celery -A project_funding_ledger.queue.celery_worker:celery_app worker --loglevel=info -P solo" } else { ".venv/bin/uv run celery -A project_funding_ledger.queue.celery_worker:celery_app worker --loglevel=info" } }}

celery-start: redis-start
{{ if os() == "windows" { "Start-Process .venv/Scripts/uv -ArgumentList 'run', 'celery', '-A', 'project_funding_ledger.queue.celery_worker:celery_app', 'worker', '--loglevel=info', '-P', 'solo'" } else { ".venv/bin/uv run celery -A project_funding_ledger.queue.celery_worker:celery_app worker --loglevel=info &" } }}

dev: supabase-start celery-start
{{ if os() == "windows" { ".venv/Scripts/uv run python -m flask --app project_funding_ledger run --debug" } else { ".venv/bin/uv run python -m flask --app project_funding_ledger run --debug" } }}

18 changes: 17 additions & 1 deletion project_funding_ledger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from project_funding_ledger.auth import auth_bp
from project_funding_ledger.profile import profile_bp
from project_funding_ledger.supabase_client import save_supabase_session
from project_funding_ledger.queue.webhooks import tasks_bp
from project_funding_ledger.routes.org_import import org_import_bp

# Load environment variables from .env file
load_dotenv()
Expand All @@ -26,13 +28,27 @@ def create_app(test_config=None):
# Register blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(tasks_bp)
app.register_blueprint(org_import_bp)


# After-request hook to persist refreshed Supabase tokens in session cookie
app.after_request(save_supabase_session)

@app.route('/')
def index():
# Redirect index to profile page
# Redirect index to admin dashboard if System Administrator, else profile page
from project_funding_ledger.supabase_client import get_supabase_client
client = get_supabase_client()
try:
user_res = client.auth.get_user()
user = user_res.user if user_res else None
if user:
profile_res = client.table('user_profile').select('user_type').eq('auth_user_id', user.id).execute()
if profile_res.data and profile_res.data[0].get('user_type') == 'System Administrator':
return redirect(url_for('org_import.admin_dashboard'))
except Exception:
pass
return redirect(url_for('profile.profile_page'))

@app.route('/hello')
Expand Down
64 changes: 64 additions & 0 deletions project_funding_ledger/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,67 @@ def log_audit_event(client: Client, user_id: str, action_type: str, entity_type:
# Prevent audit logging issues from failing the user's primary action,
# but print/log the exception for debug purposes.
print(f"Error writing audit log: {str(e)}")

def log_audit_event_async(user_id: str, action_type: str, entity_type: str, summary: str,
table_name: str = None, record_id: str = None, related_organization_id: str = None,
related_funding_source_id: str = None, old_value: dict = None, new_value: dict = None):
"""
Enqueues an audit event to be logged asynchronously in the background.
Captures current Flask request context metadata (IP, User-Agent, user session tokens).
If the queue client is not available or fails, falls back to synchronous execution.
"""
from flask import request, session, has_request_context
from project_funding_ledger.queue import get_queue_client

ip_address = None
user_agent = None
access_token = None
refresh_token = None

if has_request_context():
ip_address = request.remote_addr
user_agent = request.headers.get('User-Agent')
# Retrieve user auth session tokens from secure cookie
access_token = session.get("access_token")
refresh_token = session.get("refresh_token")

audit_data = {
'user_id': user_id,
'action_type': action_type,
'entity_type': entity_type,
'table_name': table_name,
'record_id': record_id,
'related_organization_id': related_organization_id,
'related_funding_source_id': related_funding_source_id,
'summary': summary,
'old_value': old_value,
'new_value': new_value,
'ip_address': ip_address,
'user_agent': user_agent
}

try:
queue = get_queue_client()
queue.enqueue("log_audit_event", audit_data=audit_data, access_token=access_token, refresh_token=refresh_token)
except Exception as e:
# Prevent logging subsystem failures from crashing the main flow.
# Fall back to synchronous logging so audit integrity is maintained.
print(f"Error enqueuing audit log, falling back to sync: {str(e)}")
try:
from project_funding_ledger.supabase_client import get_supabase_client
log_audit_event(
client=get_supabase_client(),
user_id=user_id,
action_type=action_type,
entity_type=entity_type,
summary=summary,
table_name=table_name,
record_id=record_id,
related_organization_id=related_organization_id,
related_funding_source_id=related_funding_source_id,
old_value=old_value,
new_value=new_value
)
except Exception as sync_e:
print(f"Sync fallback audit logging failed: {str(sync_e)}")

14 changes: 13 additions & 1 deletion project_funding_ledger/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,20 @@ def login():
summary=f"User {email} logged in successfully."
)

# Get user type for redirect routing
user_type = 'Project Stakeholder'
try:
profile_res = client.table('user_profile').select('user_type').eq('auth_user_id', res.user.id).execute()
if profile_res.data:
user_type = profile_res.data[0]['user_type']
except Exception:
pass

flash("Welcome back!", "success")
return redirect(url_for('profile.profile_page'))
if user_type == 'System Administrator':
return redirect(url_for('org_import.admin_dashboard'))
else:
return redirect(url_for('profile.profile_page'))
except Exception as e:
flash(f"Login failed: {str(e)}", "error")

Expand Down
9 changes: 9 additions & 0 deletions project_funding_ledger/queue/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from project_funding_ledger.queue.client import get_queue_client
from project_funding_ledger.queue.registry import register_task

# Ensure tasks are registered when queue package is imported
try:
from project_funding_ledger.queue import tasks
except ImportError:
# Handle potential circular imports or missing modules gracefully
pass
22 changes: 22 additions & 0 deletions project_funding_ledger/queue/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from abc import ABC, abstractmethod
from typing import Any

class BaseTaskQueue(ABC):
"""
Abstract base class for pushing tasks to a background queue.
"""

@abstractmethod
def enqueue(self, task_name: str, *args: Any, **kwargs: Any) -> str:
"""
Pushes a task to the queue to be executed asynchronously.

Args:
task_name: The name of the registered task.
*args: Positional arguments for the task.
**kwargs: Keyword arguments for the task.

Returns:
A unique identifier for the queued task.
"""
pass
51 changes: 51 additions & 0 deletions project_funding_ledger/queue/celery_queue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from typing import Any
import os
import logging
from project_funding_ledger.queue.base import BaseTaskQueue

logger = logging.getLogger(__name__)

class CeleryTaskQueue(BaseTaskQueue):
"""
Queue client that enqueues tasks by scheduling the Celery worker
to send an HTTP POST request to the application's webhook endpoint.
"""

def __init__(self):
try:
from celery import Celery
except ImportError as e:
raise ImportError(
"Celery is not installed in the current environment. "
"Ensure dev dependencies are installed or change your QUEUE_PROVIDER."
) from e

broker_url = os.environ.get("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0")
# Initialize celery client to match worker configuration
self.celery_app = Celery("pfl_tasks", broker=broker_url)

def enqueue(self, task_name: str, *args: Any, **kwargs: Any) -> str:
# Determine where the Flask application is running
app_base_url = os.environ.get("APP_BASE_URL", "http://127.0.0.1:5000")
webhook_url = f"{app_base_url.rstrip('/')}/tasks/webhook"

# Get the secret token
secret = os.environ.get("TASK_QUEUE_SECRET", "dev-task-secret-12345")

# Build the payload
payload = {
"task_name": task_name,
"args": list(args),
"kwargs": kwargs
}

logger.info(f"Enqueuing task '{task_name}' via Celery HTTP forwarder targeting: {webhook_url}")

# Trigger the Celery task dynamically by name to avoid static import coupling.
# The worker defines "pfl_tasks.trigger_webhook".
result = self.celery_app.send_task(
"pfl_tasks.trigger_webhook",
args=[webhook_url, payload, secret]
)

return result.id
Loading