diff --git a/project_funding_ledger/__init__.py b/project_funding_ledger/__init__.py index 912f55c..30515f6 100644 --- a/project_funding_ledger/__init__.py +++ b/project_funding_ledger/__init__.py @@ -1,11 +1,14 @@ import os +import flask from flask import Flask, redirect, url_for from dotenv import load_dotenv from project_funding_ledger.auth import auth_bp +import project_funding_ledger.exceptions 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 +from project_funding_ledger.routes.organization import org_bp from project_funding_ledger.routes.public_org import public_org_bp # Load environment variables from .env file @@ -31,6 +34,7 @@ def create_app(test_config=None): app.register_blueprint(profile_bp) app.register_blueprint(tasks_bp) app.register_blueprint(org_import_bp) + app.register_blueprint(org_bp) app.register_blueprint(public_org_bp) # After-request hook to persist refreshed Supabase tokens in session cookie @@ -38,22 +42,11 @@ def create_app(test_config=None): @app.route('/') def index(): - # 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') - def hello(): - return 'Hello, World!' + return redirect(url_for('public_org.organization_list')) + + @app.errorhandler(project_funding_ledger.exceptions.AuthRequiredError) + def auth_required(error): + # TODO(tswast): allow auth.login to redirect to the desired page, with protections to avoid redirecting off of the current site. + return redirect(url_for('auth.login')) return app diff --git a/project_funding_ledger/auth.py b/project_funding_ledger/auth.py index 635ddf9..8385f3b 100644 --- a/project_funding_ledger/auth.py +++ b/project_funding_ledger/auth.py @@ -63,7 +63,17 @@ def login(): if user_type == 'System Administrator': return redirect(url_for('org_import.admin_dashboard')) else: - return redirect(url_for('profile.profile_page')) + try: + profile_res = client.table('user_profile').select('id').eq('auth_user_id', res.user.id).single().execute() + if profile_res.data: + user_profile_id = profile_res.data['id'] + perms_res = client.table('organization_permission').select('organization_id').eq('user_id', user_profile_id).eq('status', 'Active').execute() + perms = perms_res.data or [] + if len(perms) == 1: + return redirect(url_for('org.org_detail', org_id=perms[0]['organization_id'])) + except Exception: + pass + return redirect(url_for('org.dashboard')) except Exception as e: flash(f"Login failed: {str(e)}", "error") @@ -206,7 +216,17 @@ def register(): if user_type == 'System Administrator': return redirect(url_for('org_import.admin_dashboard')) else: - return redirect(url_for('profile.profile_page')) + try: + profile_res = client.table('user_profile').select('id').eq('auth_user_id', signup_res.user.id).single().execute() + if profile_res.data: + user_profile_id = profile_res.data['id'] + perms_res = client.table('organization_permission').select('organization_id').eq('user_id', user_profile_id).eq('status', 'Active').execute() + perms = perms_res.data or [] + if len(perms) == 1: + return redirect(url_for('org.org_detail', org_id=perms[0]['organization_id'])) + except Exception: + pass + return redirect(url_for('org.dashboard')) else: flash("Registration successful! Please check your email for a confirmation link.", "success") return redirect(url_for('auth.login')) diff --git a/project_funding_ledger/exceptions.py b/project_funding_ledger/exceptions.py new file mode 100644 index 0000000..d945b23 --- /dev/null +++ b/project_funding_ledger/exceptions.py @@ -0,0 +1,3 @@ + +class AuthRequiredError(Exception): + "Exception raised for pages that require login first." diff --git a/project_funding_ledger/routes/organization.py b/project_funding_ledger/routes/organization.py new file mode 100644 index 0000000..a27bde0 --- /dev/null +++ b/project_funding_ledger/routes/organization.py @@ -0,0 +1,337 @@ +import os +import uuid +import datetime +from flask import Blueprint, request, jsonify, abort, render_template, redirect, url_for, flash +from werkzeug.exceptions import HTTPException +from project_funding_ledger.supabase_client import get_supabase_client +import project_funding_ledger.exceptions + +org_bp = Blueprint('org', __name__) + +def check_user_auth(client) -> tuple[str, dict | None]: + """ + Checks if the user in the current Supabase context is authenticated. + Returns (user, user_profile) or raises AuthRequiredError if the user isn't logged in. + """ + user_res = client.auth.get_user() + if not user_res or not user_res.user: + raise project_funding_ledger.exceptions.AuthRequiredError() + user = user_res.user + profile_res = client.table('user_profile').select('*').eq('auth_user_id', user.id).single().execute() + if not profile_res.data: + return user, None + return user, profile_res.data + +def get_permitted_organizations(client, user_profile): + """ + Fetches the list of organizations the given user profile is permitted to access. + System Administrators receive all active organizations. + Other users receive organizations with an Active entry in organization_permission. + """ + if not user_profile: + return [] + + user_type = user_profile.get('user_type') + if user_type == 'System Administrator': + res = client.table('organization').select('*').is_('deleted_at', 'null').order('organization_name').execute() + orgs = res.data or [] + for org in orgs: + org['permission_level'] = 'Manage' + return orgs + + # Non-admin users: query active permissions + perms_res = client.table('organization_permission') \ + .select('permission_level, organization:organization_id(*)') \ + .eq('user_id', user_profile['id']) \ + .eq('status', 'Active') \ + .execute() + + orgs = [] + for perm in (perms_res.data or []): + org = perm.get('organization') + if org and org.get('deleted_at') is None: + org['permission_level'] = perm.get('permission_level') + orgs.append(org) + + orgs.sort(key=lambda x: x.get('organization_name', '').lower()) + return orgs + +def check_organization_permission(client, user_profile, org_id): + """ + Checks if the user has access to a specific org_id. + Returns (has_access, permission_level). + """ + if not user_profile: + return False, None + + if user_profile.get('user_type') == 'System Administrator': + return True, 'Manage' + + perms_res = client.table('organization_permission') \ + .select('permission_level') \ + .eq('user_id', user_profile['id']) \ + .eq('organization_id', org_id) \ + .eq('status', 'Active') \ + .execute() + + if perms_res.data and len(perms_res.data) > 0: + return True, perms_res.data[0].get('permission_level') + + return False, None + + +@org_bp.route('/dashboard', methods=['GET']) +def dashboard(): + """ + Renders the My Orgs dashboard page. + """ + client = get_supabase_client() + user, profile = check_user_auth(client) + if not user or not profile: + flash("Please log in to access your dashboard.", "warning") + return redirect(url_for('auth.login')) + + return render_template('dashboard.html', user_profile=profile) + + +@org_bp.route('/organizations/', methods=['GET']) +def org_detail(org_id: uuid.UUID): + """ + Renders the Organization Details page for authorized users. + """ + client = get_supabase_client() + user, profile = check_user_auth(client) + if not user or not profile: + flash("Please log in to view organization details.", "warning") + return redirect(url_for('auth.login')) + + has_access, perm_level = check_organization_permission(client, profile, org_id) + if not has_access: + flash("You do not have permission to view this organization.", "error") + return redirect(url_for('org.dashboard')) + + # Fetch org details + try: + org_res = client.table('organization').select('*').eq('id', org_id).single().execute() + org = org_res.data if org_res else None + if not org or org.get('deleted_at') is not None: + flash("Organization not found.", "error") + return redirect(url_for('org.dashboard')) + except Exception as e: + flash(f"Error fetching organization details: {str(e)}", "error") + return redirect(url_for('org.dashboard')) + + return render_template('org_detail.html', user_profile=profile, organization=org, permission_level=perm_level) + + +@org_bp.route('/api/my-organizations', methods=['GET']) +def api_my_organizations(): + """ + API returning the list of organizations the authenticated user has access to. + """ + client = get_supabase_client() + user, profile = check_user_auth(client) + if not user or not profile: + return jsonify({"error": "Authentication required"}), 401 + + orgs = get_permitted_organizations(client, profile) + return jsonify(orgs), 200 + + +@org_bp.route('/api/organizations/', methods=['GET']) +def api_get_organization(org_id): + """ + API returning a single organization details if authorized. + """ + client = get_supabase_client() + user, profile = check_user_auth(client) + if not user or not profile: + return jsonify({"error": "Authentication required"}), 401 + + has_access, perm_level = check_organization_permission(client, profile, org_id) + if not has_access: + return jsonify({"error": "Permission denied"}), 403 + + try: + org_res = client.table('organization').select('*').eq('id', org_id).single().execute() + if not org_res.data or org_res.data.get('deleted_at') is not None: + return jsonify({"error": "Organization not found"}), 404 + + data = org_res.data + data['permission_level'] = perm_level + return jsonify(data), 200 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@org_bp.route('/api/organizations', methods=['POST']) +def api_create_organization(): + """ + API to create a new organization (Program Managers & System Administrators). + """ + client = get_supabase_client() + user, profile = check_user_auth(client) + if not user or not profile: + return jsonify({"error": "Authentication required"}), 401 + + user_type = profile.get('user_type') + if user_type not in ['System Administrator', 'Program Manager']: + return jsonify({"error": "Creating an organization requires Program Manager or Admin privileges"}), 403 + + data = request.json or {} + organization_name = (data.get('organization_name') or '').strip() + organization_slug = (data.get('organization_slug') or '').strip().lower() + organization_type = data.get('organization_type', 'Fiscal Sponsorship') + status = data.get('status', 'Active') + description = data.get('description', '') + website_url = data.get('website_url', '') + source_code_url = data.get('source_code_url', '') + donation_url = data.get('donation_url', '') + join_date = data.get('join_date') or datetime.date.today().isoformat() + + if not organization_name or not organization_slug: + return jsonify({"error": "Organization Name and Organization Slug are required"}), 400 + + valid_types = ['Fiscal Sponsorship', 'Event'] + valid_statuses = ['Active', 'Inactive', 'Archived', 'Dormant', 'Closed'] + if organization_type not in valid_types: + return jsonify({"error": f"Invalid type. Must be one of: {', '.join(valid_types)}"}), 400 + if status not in valid_statuses: + return jsonify({"error": f"Invalid status. Must be one of: {', '.join(valid_statuses)}"}), 400 + + try: + # Check duplicate slug + existing_slug = client.table('organization').select('id').eq('organization_slug', organization_slug).execute() + if existing_slug.data: + return jsonify({"error": f"Organization with slug '{organization_slug}' already exists."}), 400 + + # 1. Insert key + import_key = organization_slug + key_res = client.table('organization_key').insert({ + 'import_key': import_key, + 'created_by_user_id': profile['id'], + 'updated_by_user_id': profile['id'] + }).execute() + + if not key_res.data: + raise ValueError("Failed to create organization_key record") + + org_id = key_res.data[0]['id'] + + # 2. Insert organization + org_res = client.table('organization').insert({ + 'id': org_id, + 'organization_name': organization_name, + 'organization_slug': organization_slug, + 'status': status, + 'organization_type': organization_type, + 'description': description, + 'website_url': website_url, + 'source_code_url': source_code_url, + 'donation_url': donation_url, + 'join_date': join_date, + 'created_by_user_id': profile['id'], + 'updated_by_user_id': profile['id'] + }).execute() + + if not org_res.data: + raise ValueError("Failed to create organization record") + + new_org = org_res.data[0] + + # 3. Insert organization_internal default + client.table('organization_internal').insert({ + 'id': org_id, + 'created_by_user_id': profile['id'], + 'updated_by_user_id': profile['id'] + }).execute() + + # 4. If created by Program Manager, automatically grant them 'Manage' permission to the new org + if user_type == 'Program Manager': + client.table('organization_permission').insert({ + 'user_id': profile['id'], + 'organization_id': org_id, + 'permission_level': 'Manage', + 'status': 'Active', + 'created_by_user_id': profile['id'] + }).execute() + + # Audit log + from project_funding_ledger.audit import log_audit_event_async + log_audit_event_async( + user_id=user.id, + action_type='Create', + entity_type='Organization', + table_name='organization', + record_id=org_id, + new_value=new_org, + summary=f"User created Organization '{organization_name}'." + ) + + return jsonify(new_org), 201 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@org_bp.route('/api/organizations/', methods=['PUT']) +def api_update_organization(org_id): + """ + API to update an existing organization (Program Managers or Users with Edit Metadata/Manage perm). + """ + client = get_supabase_client() + user, profile = check_user_auth(client) + if not user or not profile: + return jsonify({"error": "Authentication required"}), 401 + + has_access, perm_level = check_organization_permission(client, profile, org_id) + if not has_access or (perm_level == 'View' and profile.get('user_type') != 'Program Manager'): + return jsonify({"error": "Permission denied: edit access required"}), 403 + + data = request.json or {} + update_data = {} + if 'organization_name' in data: + update_data['organization_name'] = data['organization_name'].strip() + if 'status' in data: + update_data['status'] = data['status'] + if 'organization_type' in data: + update_data['organization_type'] = data['organization_type'] + if 'description' in data: + update_data['description'] = data['description'] + if 'website_url' in data: + update_data['website_url'] = data['website_url'] + if 'source_code_url' in data: + update_data['source_code_url'] = data['source_code_url'] + if 'donation_url' in data: + update_data['donation_url'] = data['donation_url'] + + if not update_data: + return jsonify({"error": "No update fields provided"}), 400 + + update_data['updated_by_user_id'] = profile['id'] + + try: + old_org_res = client.table('organization').select('*').eq('id', org_id).single().execute() + old_org = old_org_res.data if old_org_res else {} + + res = client.table('organization').update(update_data).eq('id', org_id).execute() + if not res.data: + return jsonify({"error": "Failed to update organization"}), 500 + + updated_org = res.data[0] + + # Audit log + from project_funding_ledger.audit import log_audit_event_async + log_audit_event_async( + user_id=user.id, + action_type='Update', + entity_type='Organization', + table_name='organization', + record_id=org_id, + old_value=old_org, + new_value=updated_org, + summary=f"User updated Organization metadata for '{updated_org.get('organization_name')}'." + ) + + return jsonify(updated_org), 200 + except Exception as e: + return jsonify({"error": str(e)}), 500 diff --git a/project_funding_ledger/templates/dashboard.html b/project_funding_ledger/templates/dashboard.html new file mode 100644 index 0000000..f9399ec --- /dev/null +++ b/project_funding_ledger/templates/dashboard.html @@ -0,0 +1,526 @@ +{% extends "base.html" %} + +{% block title %}My Organizations | Project Funding Ledger{% endblock %} + +{% block head %} + + + + + + + + + + + + + +{% endblock %} + +{% block content %} +
+ + +{% endblock %} diff --git a/project_funding_ledger/templates/org_detail.html b/project_funding_ledger/templates/org_detail.html new file mode 100644 index 0000000..9563e42 --- /dev/null +++ b/project_funding_ledger/templates/org_detail.html @@ -0,0 +1,422 @@ +{% extends "base.html" %} + +{% block title %}{{ organization.organization_name }} | Project Funding Ledger{% endblock %} + +{% block head %} + + + + + + + + + + + + + +{% endblock %} + +{% block content %} +
+ + +{% endblock %} diff --git a/tests/unit/test_organization_dashboard.py b/tests/unit/test_organization_dashboard.py new file mode 100644 index 0000000..56f2e16 --- /dev/null +++ b/tests/unit/test_organization_dashboard.py @@ -0,0 +1,218 @@ +import pytest +from unittest.mock import MagicMock, patch + +@pytest.fixture +def app(): + from project_funding_ledger import create_app + app = create_app() + app.config.update({ + "TESTING": True, + "SECRET_KEY": "test-secret-key" + }) + return app + +@pytest.fixture +def client(app): + return app.test_client() + +@patch("project_funding_ledger.supabase_client.create_client") +def test_login_redirect_single_org(mock_create_client, client): + """ + Test that a logged-in user with permission for exactly 1 organization is redirected to the details page. + """ + mock_client = MagicMock() + mock_client.auth.get_session.return_value = None + mock_create_client.return_value = mock_client + + mock_user = MagicMock() + mock_user.id = "user-123" + mock_user.email = "stakeholder@example.com" + mock_user.user_metadata = {"full_name": "Test Stakeholder"} + mock_client.auth.sign_in_with_password.return_value = MagicMock( + session=MagicMock(access_token="tok", refresh_token="ref"), + user=mock_user + ) + + def mock_table_routing(table_name): + mock_tbl = MagicMock() + if table_name == 'user_profile': + mock_tbl.select.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"id": "prof-123", "user_type": "Organization Stakeholder"}] + ) + mock_tbl.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data={"id": "prof-123", "user_type": "Organization Stakeholder"} + ) + elif table_name == 'organization_permission': + mock_tbl.select.return_value.eq.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"organization_id": "org-uuid-single"}] + ) + return mock_tbl + + mock_client.table.side_effect = mock_table_routing + + response = client.post('/login', data={ + "email": "stakeholder@example.com", + "password": "password123" + }, follow_redirects=False) + + assert response.status_code == 302 + assert "/organizations/org-uuid-single" in response.headers["Location"] + + +@patch("project_funding_ledger.supabase_client.create_client") +def test_login_redirect_multiple_orgs(mock_create_client, client): + """ + Test that a logged-in user with 2+ permitted organizations is redirected to the dashboard. + """ + mock_client = MagicMock() + mock_client.auth.get_session.return_value = None + mock_create_client.return_value = mock_client + + mock_user = MagicMock() + mock_user.id = "user-456" + mock_user.email = "pm@example.com" + mock_user.user_metadata = {"full_name": "Test PM"} + mock_client.auth.sign_in_with_password.return_value = MagicMock( + session=MagicMock(access_token="tok", refresh_token="ref"), + user=mock_user + ) + + def mock_table_routing(table_name): + mock_tbl = MagicMock() + if table_name == 'user_profile': + mock_tbl.select.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"id": "prof-456", "user_type": "Program Manager"}] + ) + mock_tbl.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data={"id": "prof-456", "user_type": "Program Manager"} + ) + elif table_name == 'organization_permission': + mock_tbl.select.return_value.eq.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"organization_id": "org-1"}, {"organization_id": "org-2"}] + ) + return mock_tbl + + mock_client.table.side_effect = mock_table_routing + + response = client.post('/login', data={ + "email": "pm@example.com", + "password": "password123" + }, follow_redirects=False) + + assert response.status_code == 302 + assert "/dashboard" in response.headers["Location"] + + +@patch("project_funding_ledger.supabase_client.create_client") +def test_api_my_organizations(mock_create_client, client): + """ + Test GET /api/my-organizations returns permitted organizations for authenticated user. + """ + mock_client = MagicMock() + mock_client.auth.get_session.return_value = None + mock_create_client.return_value = mock_client + + mock_user_res = MagicMock() + mock_user_res.user.id = "user-789" + mock_client.auth.get_user.return_value = mock_user_res + + mock_profile = {"id": "prof-789", "user_type": "Organization Stakeholder"} + mock_perm = [{ + "permission_level": "View", + "organization": { + "id": "org-789", + "organization_name": "Alpha Org", + "organization_slug": "alpha-org", + "status": "Active", + "organization_type": "Fiscal Sponsorship" + } + }] + + def mock_table_routing(table_name): + mock_tbl = MagicMock() + if table_name == 'user_profile': + mock_tbl.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock(data=mock_profile) + elif table_name == 'organization_permission': + mock_tbl.select.return_value.eq.return_value.eq.return_value.execute.return_value = MagicMock(data=mock_perm) + return mock_tbl + + mock_client.table.side_effect = mock_table_routing + + response = client.get('/api/my-organizations') + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 1 + assert data[0]["organization_name"] == "Alpha Org" + assert data[0]["permission_level"] == "View" + + +@patch("project_funding_ledger.supabase_client.create_client") +def test_api_organization_detail_unauthorized(mock_create_client, client): + """ + Test GET /api/organizations/ returns 403 when user does not have permission. + """ + mock_client = MagicMock() + mock_client.auth.get_session.return_value = None + mock_create_client.return_value = mock_client + + mock_user_res = MagicMock() + mock_user_res.user.id = "user-999" + mock_client.auth.get_user.return_value = mock_user_res + + mock_profile = {"id": "prof-999", "user_type": "Organization Stakeholder"} + + def mock_table_routing(table_name): + mock_tbl = MagicMock() + if table_name == 'user_profile': + mock_tbl.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock(data=mock_profile) + elif table_name == 'organization_permission': + mock_tbl.select.return_value.eq.return_value.eq.return_value.eq.return_value.execute.return_value = MagicMock(data=[]) + return mock_tbl + + mock_client.table.side_effect = mock_table_routing + + response = client.get('/api/organizations/forbidden-org-id') + assert response.status_code == 403 + assert "Permission denied" in response.get_json()["error"] + + +@patch("project_funding_ledger.supabase_client.create_client") +def test_api_update_organization_program_manager(mock_create_client, client): + """ + Test PUT /api/organizations/ allows Program Manager to edit org metadata. + """ + mock_client = MagicMock() + mock_client.auth.get_session.return_value = None + mock_create_client.return_value = mock_client + + mock_user_res = MagicMock() + mock_user_res.user.id = "pm-user-1" + mock_client.auth.get_user.return_value = mock_user_res + + mock_profile = {"id": "pm-prof-1", "user_type": "Program Manager"} + + def mock_table_routing(table_name): + mock_tbl = MagicMock() + if table_name == 'user_profile': + mock_tbl.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock(data=mock_profile) + elif table_name == 'organization_permission': + mock_tbl.select.return_value.eq.return_value.eq.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"permission_level": "Edit Metadata"}] + ) + elif table_name == 'organization': + mock_tbl.select.return_value.eq.return_value.single.return_value.execute.return_value = MagicMock( + data={"id": "org-pm-1", "organization_name": "Old Name"} + ) + mock_tbl.update.return_value.eq.return_value.execute.return_value = MagicMock( + data=[{"id": "org-pm-1", "organization_name": "New Updated Name", "status": "Active"}] + ) + return mock_tbl + + mock_client.table.side_effect = mock_table_routing + + response = client.put('/api/organizations/org-pm-1', json={ + "organization_name": "New Updated Name" + }) + assert response.status_code == 200 + data = response.get_json() + assert data["organization_name"] == "New Updated Name" diff --git a/tests/unit/test_queue.py b/tests/unit/test_queue.py index c3a798d..8f3e62b 100644 --- a/tests/unit/test_queue.py +++ b/tests/unit/test_queue.py @@ -32,6 +32,10 @@ def sync_test_task(val): # Test client factory when Celery is not available (or is) def test_queue_client_factory(): + # Reset client cache to allow provider testing + import project_funding_ledger.queue.client + project_funding_ledger.queue.client._client_instance = None + # If we set provider to sync, it should return SyncTaskQueue with patch.dict(os.environ, {"QUEUE_PROVIDER": "sync"}): client = get_queue_client() diff --git a/tests/unit/test_register.py b/tests/unit/test_register.py index 59f485a..3a27b09 100644 --- a/tests/unit/test_register.py +++ b/tests/unit/test_register.py @@ -86,7 +86,7 @@ def test_register_complete_registration_success(mock_get_supabase, client): 'confirm_password': 'password123' }) - # Verify redirect to profile page (since we automatically log in on success) + # Verify redirect to dashboard (since we automatically log in on success) assert response.status_code == 302 - assert response.location.endswith('/profile') + assert response.location.endswith('/dashboard') assert mock_log.called diff --git a/uv.lock b/uv.lock index 610e12e..9a90baf 100644 --- a/uv.lock +++ b/uv.lock @@ -572,7 +572,7 @@ wheels = [ [[package]] name = "project-funding-ledger" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "flask" }, { name = "openpyxl" },