diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e5f23d44f..886c0feeb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,8 +244,11 @@ jobs: run: >- python manage.py test opportunities.tests + opportunities.test_public_source_knowledge services.test_task32_opportunities services.test_task32_concurrency + services.test_owner_relations + services.test_native_start_api - name: Run Payments and Services T33 PostgreSQL tests if: ${{ matrix.shard == 'commerce' }} run: >- @@ -262,6 +265,7 @@ jobs: capacity.test_access_bridge capacity.test_concurrency commerce.tests + commerce.test_offer_price_facts commerce.test_order_edge_cases transport.tests transport.test_task25_booking diff --git a/.github/workflows/demo-generation-validation.yml b/.github/workflows/demo-generation-validation.yml new file mode 100644 index 0000000000..8228a185b0 --- /dev/null +++ b/.github/workflows/demo-generation-validation.yml @@ -0,0 +1,44 @@ +name: Demo generation validation + +on: + pull_request: + branches: [main] + paths: + - 'scripts/demo_generation/**' + - 'funding/**' + - 'groups/**' + - 'objectives/**' + - 'payments/**' + - 'journeys/**' + - '.github/workflows/demo-generation-validation.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: demo-generation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + validate-demo: + runs-on: ubuntu-latest + env: + DJANGO_ENV: test + DJANGO_DEBUG: 'False' + DJANGO_SECRET_KEY: ci-demo-validation-placeholder-not-production + DJANGO_ALLOWED_HOSTS: 127.0.0.1,localhost,testserver + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.13' + cache: pip + - name: Install dependencies + run: | + python -m pip install -r requirements.txt + python -m pip check + - name: Verify demo generation syntax + run: python -m compileall -q scripts/demo_generation + - name: Validate isolated demo domain integration + run: python -m scripts.demo_generation validate --quick diff --git a/.github/workflows/funding-postgresql.yml b/.github/workflows/funding-postgresql.yml index 9e6565f04a..aae34cb2cf 100644 --- a/.github/workflows/funding-postgresql.yml +++ b/.github/workflows/funding-postgresql.yml @@ -1,6 +1,8 @@ name: Funding PostgreSQL on: + # Explicit full gate for pre-merge / operational verification. + workflow_dispatch: push: branches: - main @@ -31,8 +33,53 @@ permissions: contents: read jobs: + # GitHub's PR paths evaluation is capped at 300 changed files. Resolve the + # complete diff ourselves before allocating a PostgreSQL runner. + scope: + runs-on: ubuntu-latest + outputs: + run_suite: ${{ steps.changed.outputs.run_suite }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + - name: Resolve Funding impact from complete change set + id: changed + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then + echo "run_suite=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + range="$BASE_SHA...$HEAD_SHA" + else + range="$BASE_SHA..$HEAD_SHA" + fi + # On any diff error, preserve the full safety gate. + if ! git diff --name-only "$range" > /tmp/funding-changed.txt; then + echo "run_suite=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + run_suite=false + while IFS= read -r file; do + case "$file" in + funding/*|payments/*|activities/*|accounts/*|organizations/*|authorization/*|subscriptions/*|commerce/*|discovery/*|core/*|social/*|topics/*|geography/*|config/*|templates/*|test_support/*|scripts/*|requirements.txt|requirements-agents.txt|manage.py|.github/workflows/funding-postgresql.yml) + run_suite=true + break + ;; + esac + done < /tmp/funding-changed.txt + echo "run_suite=$run_suite" >> "$GITHUB_OUTPUT" + echo "Funding PostgreSQL impact: $run_suite" + funding-postgresql: - if: ${{ github.event_name == 'push' || github.event.pull_request.draft == false }} + needs: scope + if: ${{ (github.event_name == 'push' || github.event.pull_request.draft == false) && needs.scope.outputs.run_suite == 'true' }} runs-on: ubuntu-latest timeout-minutes: 12 services: diff --git a/.github/workflows/mobile-android-build.yml b/.github/workflows/mobile-android-build.yml index ab2afc0638..7cf63a360c 100644 --- a/.github/workflows/mobile-android-build.yml +++ b/.github/workflows/mobile-android-build.yml @@ -1,20 +1,24 @@ name: Mobile Android Build +run-name: "Mobile DEV APK - ${{ github.head_ref || github.ref_name }}" on: - pull_request: + push: + branches: + - fix/discover-a-continuation-context-20261014 paths: - "mobile/lib/**" - "mobile/android/**" + - "mobile/assets/**" - "mobile/pubspec.yaml" - "mobile/pubspec.lock" - "mobile/.flutter-version" - "mobile/config/dev.json" - ".github/workflows/mobile-android-build.yml" - push: - branches: [main] + pull_request: paths: - "mobile/lib/**" - "mobile/android/**" + - "mobile/assets/**" - "mobile/pubspec.yaml" - "mobile/pubspec.lock" - "mobile/.flutter-version" @@ -53,16 +57,39 @@ jobs: java -version flutter --version - name: Resolve dependencies - run: | - flutter pub get - git diff --exit-code -- pubspec.lock + run: flutter pub get - name: Generate Dart sources run: dart run build_runner build --delete-conflicting-outputs + - name: Validate remote DEV API + run: | + set -euo pipefail + jq -e ' + .MAKOLO_ENVIRONMENT == "dev" + and (.MAKOLO_API_BASE_URL | type == "string" + and test("^https://[^/]+/?$")) + and (.MAKOLO_API_BASE_URL | test( + "^https://(localhost|127\\.0\\.0\\.1|10\\.|192\\.168\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|[^/]*\\.local)([:/]|$)"; + "i" + ) | not) + ' config/dev.json > /dev/null + echo "DEV build configured for a remote HTTPS API." + - name: Validate configured DEV mapping + run: | + set -euo pipefail + jq -e ' + .MAKOLO_ENVIRONMENT == "dev" + and .MAKOLO_MAPS_ENABLED == "true" + and ((.MAKOLO_MAP_STYLE // "") | startswith("https://")) + and .MAKOLO_GEOCODING_ENABLED == "true" + and ((.MAKOLO_GEOCODING_API_KEY // "") | length > 0) + ' config/dev.json > /dev/null + echo "DEV maps and geographic suggestions configured." - name: Build Android debug run: flutter build apk --debug --flavor dev --dart-define-from-file=config/dev.json - name: Upload DEV APK uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: makolo-dev-debug-apk + name: makolo-dev-debug-apk-${{ github.event.pull_request.head.sha || github.sha }} path: mobile/build/app/outputs/flutter-apk/app-dev-debug.apk if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/mobile-apk.yml b/.github/workflows/mobile-apk.yml index 40313eb64a..57c754ffc1 100644 --- a/.github/workflows/mobile-apk.yml +++ b/.github/workflows/mobile-apk.yml @@ -71,9 +71,20 @@ jobs: - name: Full checkpoint validation if: github.event_name == 'workflow_dispatch' && inputs.validation_mode == 'full' run: | - dart format --output=none --set-exit-if-changed lib test + bash tool/dart_format.sh check flutter analyze flutter test --exclude-tags golden + - name: Validate configured DEV mapping + run: | + set -euo pipefail + jq -e ' + .MAKOLO_ENVIRONMENT == "dev" + and .MAKOLO_MAPS_ENABLED == "true" + and ((.MAKOLO_MAP_STYLE // "") | startswith("https://")) + and .MAKOLO_GEOCODING_ENABLED == "true" + and ((.MAKOLO_GEOCODING_API_KEY // "") | length > 0) + ' config/dev.json > /dev/null + echo "DEV maps and geographic suggestions configured." - name: Prepare DEV runtime configuration env: MAKOLO_APK_API_BASE_URL: ${{ inputs.api_base_url || '' }} @@ -84,6 +95,18 @@ jobs: jq --arg api "$MAKOLO_APK_API_BASE_URL" '.MAKOLO_API_BASE_URL = $api' /tmp/makolo-dev-runtime.json > /tmp/makolo-dev-runtime.override.json mv /tmp/makolo-dev-runtime.override.json /tmp/makolo-dev-runtime.json fi + - name: Verify effective APK DEV runtime + run: | + set -euo pipefail + jq -e ' + .MAKOLO_ENVIRONMENT == "dev" + and (.MAKOLO_API_BASE_URL | type == "string" and test("^https://[^/]+/?$")) + and .MAKOLO_MAPS_ENABLED == "true" + and ((.MAKOLO_MAP_STYLE // "") | startswith("https://")) + and .MAKOLO_GEOCODING_ENABLED == "true" + and ((.MAKOLO_GEOCODING_API_KEY // "") | length > 0) + ' /tmp/makolo-dev-runtime.json > /dev/null + echo "Effective DEV APK configuration verified (no provider values printed)." - name: Build Android DEV debug APK run: flutter build apk --debug --flavor dev --dart-define-from-file=/tmp/makolo-dev-runtime.json - name: Publish Android DEV debug APK diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index 35fc125fad..3c44cab60c 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -95,6 +95,8 @@ jobs: run: flutter test test/architecture_guard_test.dart - name: Analyze run: flutter analyze + - name: Space Subscription identity and continuation regressions + run: flutter test test/space_subscription_screen_test.dart test/space_subscription_test.dart test/billing_screen_test.dart test/subscription_actions_panel_test.dart test/subscription_personal_flow_test.dart - name: Run impacted tests shell: bash run: | diff --git a/.github/workflows/now-golden-baselines.yml b/.github/workflows/now-golden-baselines.yml index 700c20690d..ed52f0bb4c 100644 --- a/.github/workflows/now-golden-baselines.yml +++ b/.github/workflows/now-golden-baselines.yml @@ -38,8 +38,15 @@ jobs: run: dart run build_runner build --delete-conflicting-outputs - name: Verify generated-code policy run: git diff --exit-code -- lib test - - name: Canonical format check - run: bash tool/dart_format.sh check + # Mobile CI owns the full lib/test formatting gate. Golden tests check + # only their own inputs, so unrelated Flutter screens cannot block Now. + - name: Format check for Now golden sources + run: >- + dart format --output=none --set-exit-if-changed + lib/features/now + lib/presentation/contracts/now_presentation.dart + lib/dev/scenarios/now_scenarios.dart + test/now_golden_test.dart - name: Verify approved G01 S1-S5 references run: flutter test test/now_golden_test.dart - name: Render review artifacts without changing the branch diff --git a/.github/workflows/space-metier-mt.yml b/.github/workflows/space-metier-mt.yml new file mode 100644 index 0000000000..020d93d910 --- /dev/null +++ b/.github/workflows/space-metier-mt.yml @@ -0,0 +1,125 @@ +name: Space Metier MT1-MT8 + +on: + pull_request: + branches: [main] + paths: + - "organizations/api/space_work*.py" + - "organizations/space_work*.py" + - "organizations/test_space_work*.py" + - "organizations/test_ws3_space_work_web.py" + - "organizations/test_zs3_space_work_api.py" + - "organizations/urls.py" + - "templates/organizations/space/work*.html" + - "mobile/lib/features/space/space_work*.dart" + - "mobile/lib/features/continuity/space_retrieval_owner_screen.dart" + - "mobile/lib/presentation/contracts/space_presentation.dart" + - "mobile/test/space_work*.dart" + - "docs/implementation/space-metier-*.md" + - ".github/workflows/space-metier-mt.yml" + workflow_dispatch: + +concurrency: + group: space-metier-mt-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + server-owner-contracts: + name: MT1-MT8 Django/PostgreSQL owners + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: makolo_space_mt_ci + POSTGRES_USER: makolo + POSTGRES_PASSWORD: makolo-ci-password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U makolo -d makolo_space_mt_ci" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DJANGO_ENV: test + DJANGO_DEBUG: "False" + DJANGO_SECRET_KEY: ci-space-metier-not-a-production-secret + DJANGO_ALLOWED_HOSTS: 127.0.0.1,localhost,testserver + DJANGO_DATABASE_ENGINE: postgresql + DJANGO_DATABASE_NAME: makolo_space_mt_ci + DJANGO_DATABASE_USER: makolo + DJANGO_DATABASE_PASSWORD: makolo-ci-password + DJANGO_DATABASE_HOST: 127.0.0.1 + DJANGO_DATABASE_PORT: "5432" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + cache: pip + - name: Install locked Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip check + - name: Check Django and schema invariants + run: | + python manage.py check + python manage.py makemigrations --check --dry-run + - name: Validate MT2-MT8 and existing Space Work contracts + run: >- + python manage.py test + organizations.test_space_work_mt2 + organizations.test_space_work_mt3 + organizations.test_space_work_mt3_native_create + organizations.test_space_work_mt4 + organizations.test_space_work_mt5 + organizations.test_space_work_mt5_operations + organizations.test_space_work_mt6 + organizations.test_space_work_mt7 + organizations.test_space_work_group2 + organizations.test_space_work_group3 + organizations.test_space_work_group4 + organizations.test_ws3_space_work_web + organizations.test_zs3_space_work_api + + mobile-owner-contracts: + name: MT1-MT8 Flutter owner depths + runs-on: ubuntu-latest + timeout-minutes: 35 + defaults: + run: + working-directory: mobile + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + flutter-version: "3.47.3" + channel: stable + cache: true + - name: Resolve dependencies + run: | + flutter pub get + git diff --exit-code -- pubspec.lock + - name: Generate owner and Drift sources + run: dart run build_runner build --delete-conflicting-outputs + - name: Verify generated-code policy + run: git diff --exit-code -- lib test + - name: Check canonical Dart format + run: bash tool/dart_format.sh check + - name: Analyze Flutter + run: flutter analyze + - name: Exercise MT1-MT8 Work surfaces and native depths + run: >- + flutter test + test/space_work_surface_test.dart + test/space_work_g1_realizations_test.dart + test/space_work_g2_depth_test.dart + test/space_work_g3_depth_test.dart + test/space_work_g4_depth_test.dart + test/space_work_mt7_test.dart diff --git a/.github/workflows/subscriptions.yml b/.github/workflows/subscriptions.yml index 093bbaf82f..829ff98ec8 100644 --- a/.github/workflows/subscriptions.yml +++ b/.github/workflows/subscriptions.yml @@ -80,7 +80,7 @@ jobs: run: | python manage.py check python manage.py makemigrations --check --dry-run - python manage.py test subscriptions.tests subscriptions.test_s3_eligibility subscriptions.test_s3_requirement_validation subscriptions.test_s4_transitions subscriptions.test_s4_validation subscriptions.test_s4_payment_bridge subscriptions.test_s4_preview subscriptions.test_s5_security_events subscriptions.test_s5_authorized_facade subscriptions.test_s6_product_ux subscriptions.test_s6_performance analytics_app.test_subscription_analytics + python manage.py test subscriptions.tests subscriptions.test_funded_coverages subscriptions.test_funded_coverage_native_api subscriptions.test_coverage_native_api subscriptions.test_s3_eligibility subscriptions.test_s3_requirement_validation subscriptions.test_s4_transitions subscriptions.test_s4_validation subscriptions.test_s4_payment_bridge subscriptions.test_s4_preview subscriptions.test_s5_security_events subscriptions.test_s5_authorized_facade subscriptions.test_s6_product_ux subscriptions.test_s6_performance subscriptions.test_space_native_api subscriptions.test_native_api subscriptions.test_native_payment_continuation subscriptions.test_lot3_enforcement subscriptions.test_lot4_continuation analytics_app.test_subscription_analytics postgresql: name: postgresql (${{ matrix.shard }}) @@ -134,10 +134,10 @@ jobs: python manage.py migrate --noinput - name: Run Subscription catalogue regressions if: ${{ matrix.shard == 'catalog-runtime' }} - run: python manage.py test subscriptions.tests + run: python manage.py test subscriptions.tests subscriptions.test_funded_coverages subscriptions.test_funded_coverage_native_api subscriptions.test_coverage_native_api - name: Run Subscription S2 runtime regressions if: ${{ matrix.shard == 'catalog-runtime' }} - run: python manage.py test subscriptions.test_s2_runtime subscriptions.test_s2_grant_regressions + run: python manage.py test subscriptions.test_s2_runtime subscriptions.test_s2_grant_regressions subscriptions.test_lot3_enforcement - name: Run Subscription S3 eligibility regressions if: ${{ matrix.shard == 'catalog-runtime' }} run: python manage.py test subscriptions.test_s3_eligibility subscriptions.test_s3_requirement_validation requirements @@ -149,7 +149,7 @@ jobs: run: python manage.py test subscriptions.test_s5_security_events subscriptions.test_s5_authorized_facade notifications automation domain_events authorization - name: Run Subscription S6 product UX analytics hardening regressions if: ${{ matrix.shard == 'ux-concurrency' }} - run: python manage.py test subscriptions.test_s6_product_ux subscriptions.test_s6_performance analytics_app.test_subscription_analytics + run: python manage.py test subscriptions.test_s6_product_ux subscriptions.test_s6_performance subscriptions.test_space_native_api subscriptions.test_native_api subscriptions.test_native_payment_continuation subscriptions.test_web_checkout_continuation subscriptions.test_lot4_continuation analytics_app.test_subscription_analytics - name: Run Subscription PostgreSQL concurrency regressions if: ${{ matrix.shard == 'ux-concurrency' }} - run: python manage.py test subscriptions.test_s2_concurrency subscriptions.test_s4_concurrency + run: python manage.py test subscriptions.test_s2_concurrency subscriptions.test_s4_concurrency subscriptions.test_coverage_concurrency subscriptions.test_lot5_team_concurrency subscriptions.test_funded_coverage_concurrency diff --git a/.gitignore b/.gitignore index 7a427979a2..6be97290e4 100644 --- a/.gitignore +++ b/.gitignore @@ -92,7 +92,8 @@ dist/ # IDE # ============================================================ -.vscode/ +.vscode/* +!.vscode/launch.json .idea/ *.code-workspace diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..31ef8986b9 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,80 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Makolo DEV — PythonAnywhere", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "cwd": "${workspaceFolder}/mobile", + "flutterMode": "debug", + "deviceId": "RFCNA0800DA", + "args": [ + "--flavor", + "dev", + "--dart-define-from-file=config/dev.json" + ] + }, + { + "name": "Makolo DEV — Local USB", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "cwd": "${workspaceFolder}/mobile", + "flutterMode": "debug", + "deviceId": "RFCNA0800DA", + "preLaunchTask": "Makolo — ADB Reverse Samsung USB", + "args": [ + "--flavor", + "dev", + "--dart-define-from-file=config/dev.json", + "--dart-define=MAKOLO_API_BASE_URL=http://127.0.0.1:9999" + ] + }, + { + "name": "Makolo DEV — Tailscale", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "cwd": "${workspaceFolder}/mobile", + "flutterMode": "debug", + "deviceId": "RFCNA0800DA", + "args": [ + "--flavor", + "dev", + "--dart-define-from-file=config/dev.json", + "--dart-define=MAKOLO_API_BASE_URL=http://100.127.52.102:9999" + ] + }, + { + "name": "Makolo DEV — Émulateur Local", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "cwd": "${workspaceFolder}/mobile", + "flutterMode": "debug", + "deviceId": "emulator-5554", + "preLaunchTask": "Makolo — ADB Reverse Emulator", + "args": [ + "--flavor", + "dev", + "--dart-define-from-file=config/dev.json", + "--dart-define=MAKOLO_API_BASE_URL=http://127.0.0.1:9999" + ] + }, + { + "name": "Makolo DEV — Émulateur PythonAnywhere", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "cwd": "${workspaceFolder}/mobile", + "flutterMode": "debug", + "deviceId": "emulator-5554", + "args": [ + "--flavor", + "dev", + "--dart-define-from-file=config/dev.json" + ] + } + ] +} diff --git a/accounts/api/public_profile_views.py b/accounts/api/public_profile_views.py new file mode 100644 index 0000000000..93b0133021 --- /dev/null +++ b/accounts/api/public_profile_views.py @@ -0,0 +1,46 @@ +"""Minimal public Profile projection derived from the canonical Passeport. + +This endpoint neither grants authority nor exposes the private Profile. +""" +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.api.projections import projection_envelope +from sharing.passport import PASSPORT_PUBLIC, build_profile_passport + + +class PublicProfileAPIView(APIView): + permission_classes = [AllowAny] + + def get(self, request, profile_id): + user = get_object_or_404( + get_user_model().objects.filter( + is_active=True, + profile__public_profile=True, + ), + pk=profile_id, + ) + # The public Passeport remains the owner of this representation. + identity = build_profile_passport( + user, variant=PASSPORT_PUBLIC, + ).identity + response = Response(projection_envelope( + projection="accounts.public-profile", + scope="public", + data={ + "profile": { + "kind": "profile", + "id": str(user.pk), + "name": identity["name"], + "avatar_url": identity["avatar_url"], + "headline": identity["headline"], + "bio": identity["bio"], + "location": identity["location"], + }, + }, + )) + response["Cache-Control"] = "private, no-store" + return response diff --git a/accounts/api/urls.py b/accounts/api/urls.py index 928fb243c0..920f187115 100644 --- a/accounts/api/urls.py +++ b/accounts/api/urls.py @@ -3,6 +3,7 @@ from rest_framework.routers import DefaultRouter from rest_framework_simplejwt.views import TokenRefreshView +from .public_profile_views import PublicProfileAPIView from .views import ( AccountDeleteAPIView, IdentifierAvailabilityAPIView, @@ -25,6 +26,7 @@ router.register(r"users", UserViewSet, basename="users") urlpatterns = [ + path("public-profiles//", PublicProfileAPIView.as_view(), name="public-profile"), path("auth/register/", RegisterAPIView.as_view(), name="register"), path( "auth/identifier/availability/", diff --git a/accounts/migrations/0009_profile_visibility_policies.py b/accounts/migrations/0009_profile_visibility_policies.py new file mode 100644 index 0000000000..a57dce3e14 --- /dev/null +++ b/accounts/migrations/0009_profile_visibility_policies.py @@ -0,0 +1,216 @@ +import uuid + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +# Additive only. Existing is_public flags and existing profile choices remain intact. +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0008_passport_public_defaults"), + ("groups", "0005_community_layer"), + ("organizations", "0007_organization_searchable"), + ("topics", "0004_space_open_to_and_match_kinds"), + ] + + operations = [ + migrations.CreateModel( + name="ProfileVisibilityPolicy", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("target_kind", models.CharField(choices=[ + ("field", "Information de profil"), + ("interest", "Centre d'intérêt"), + ("open_to", "Ouvert à"), + ], max_length=16)), + ("field_key", models.CharField(blank=True, default="", max_length=48)), + ("owner", models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="visibility_policies", + to=settings.AUTH_USER_MODEL, + )), + ("interest", models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="visibility_policies", + to="topics.profileinterest", + )), + ("open_to", models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="visibility_policies", + to="topics.profileopento", + )), + ], + ), + migrations.CreateModel( + name="ProfileVisibilityAudience", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("kind", models.CharField(choices=[ + ("public", "Tout le monde"), + ("my_collectives", "Mes collectifs"), + ("selected_space", "Espace choisi"), + ("selected_team", "Équipe choisie"), + ("selected_group", "Groupe choisi"), + ("selected_profile", "Personne choisie"), + ], max_length=24)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("policy", models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="audiences", + to="accounts.profilevisibilitypolicy", + )), + ("selected_profile", models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="profile_visibility_grants_received", + to=settings.AUTH_USER_MODEL, + )), + ("selected_space", models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="profile_visibility_audiences", + to="organizations.organization", + )), + ("selected_team", models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="profile_visibility_audiences", + to="organizations.team", + )), + ("selected_group", models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="profile_visibility_audiences", + to="groups.group", + )), + ], + ), + migrations.AddConstraint( + model_name="profilevisibilitypolicy", + constraint=models.CheckConstraint( + condition=( + models.Q( + target_kind="field", + field_key__in=( + "name", "avatar", "bio", "profession", "country", + "city", "company_name", "organization_name", + "website", "linkedin_url", "facebook_url", + "instagram_url", "tiktok_url", "x_url", "youtube_url", + ), + interest__isnull=True, + open_to__isnull=True, + ) + | models.Q(target_kind="interest", field_key="", interest__isnull=False, open_to__isnull=True) + | models.Q(target_kind="open_to", field_key="", interest__isnull=True, open_to__isnull=False) + ), + name="prof_visibility_target_shape", + ), + ), + migrations.AddConstraint( + model_name="profilevisibilitypolicy", + constraint=models.UniqueConstraint( + fields=("owner", "field_key"), condition=models.Q(target_kind="field"), + name="prof_vis_uniq_field", + ), + ), + migrations.AddConstraint( + model_name="profilevisibilitypolicy", + constraint=models.UniqueConstraint( + fields=("owner", "interest"), condition=models.Q(target_kind="interest"), + name="prof_vis_uniq_interest", + ), + ), + migrations.AddConstraint( + model_name="profilevisibilitypolicy", + constraint=models.UniqueConstraint( + fields=("owner", "open_to"), condition=models.Q(target_kind="open_to"), + name="prof_vis_uniq_open_to", + ), + ), + migrations.AddIndex( + model_name="profilevisibilitypolicy", + index=models.Index(fields=["owner", "target_kind"], name="prof_visibility_owner_idx"), + ), + migrations.AddConstraint( + model_name="profilevisibilityaudience", + constraint=models.CheckConstraint( + condition=( + models.Q( + kind__in=("public", "my_collectives"), + selected_profile__isnull=True, selected_space__isnull=True, + selected_team__isnull=True, selected_group__isnull=True, + ) + | models.Q( + kind="selected_profile", selected_profile__isnull=False, + selected_space__isnull=True, selected_team__isnull=True, + selected_group__isnull=True, + ) + | models.Q( + kind="selected_space", selected_profile__isnull=True, + selected_space__isnull=False, selected_team__isnull=True, + selected_group__isnull=True, + ) + | models.Q( + kind="selected_team", selected_profile__isnull=True, + selected_space__isnull=True, selected_team__isnull=False, + selected_group__isnull=True, + ) + | models.Q( + kind="selected_group", selected_profile__isnull=True, + selected_space__isnull=True, selected_team__isnull=True, + selected_group__isnull=False, + ) + ), + name="prof_visibility_audience_shape", + ), + ), + migrations.AddConstraint( + model_name="profilevisibilityaudience", + constraint=models.UniqueConstraint( + fields=("policy", "kind"), + condition=models.Q(kind__in=("public", "my_collectives")), + name="prof_vis_uniq_broad", + ), + ), + migrations.AddConstraint( + model_name="profilevisibilityaudience", + constraint=models.UniqueConstraint( + fields=("policy", "selected_profile"), + condition=models.Q(kind="selected_profile"), + name="prof_vis_uniq_profile", + ), + ), + migrations.AddConstraint( + model_name="profilevisibilityaudience", + constraint=models.UniqueConstraint( + fields=("policy", "selected_space"), + condition=models.Q(kind="selected_space"), + name="prof_vis_uniq_space", + ), + ), + migrations.AddConstraint( + model_name="profilevisibilityaudience", + constraint=models.UniqueConstraint( + fields=("policy", "selected_team"), + condition=models.Q(kind="selected_team"), + name="prof_vis_uniq_team", + ), + ), + migrations.AddConstraint( + model_name="profilevisibilityaudience", + constraint=models.UniqueConstraint( + fields=("policy", "selected_group"), + condition=models.Q(kind="selected_group"), + name="prof_vis_uniq_group", + ), + ), + migrations.AddIndex( + model_name="profilevisibilityaudience", + index=models.Index(fields=["policy", "kind"], name="prof_visibility_aud_idx"), + ), + ] diff --git a/accounts/migrations/0010_align_profile_visibility_field_constraint.py b/accounts/migrations/0010_align_profile_visibility_field_constraint.py new file mode 100644 index 0000000000..7a30659110 --- /dev/null +++ b/accounts/migrations/0010_align_profile_visibility_field_constraint.py @@ -0,0 +1,64 @@ +from django.db import migrations, models + + +# Keep database checks aligned with the explicit, published-field allowlist +# in accounts.models. This expands the accepted field targets without relaxing +# the per-owner or per-audience visibility rules. +PUBLISHABLE_FIELDS = ( + "name", + "avatar", + "bio", + "profession", + "country", + "city", + "company_name", + "organization_name", + "website", + "linkedin_url", + "facebook_url", + "instagram_url", + "tiktok_url", + "x_url", + "youtube_url", + "interests", + "open_to", +) + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0009_profile_visibility_policies"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="profilevisibilitypolicy", + name="prof_visibility_target_shape", + ), + migrations.AddConstraint( + model_name="profilevisibilitypolicy", + constraint=models.CheckConstraint( + condition=( + models.Q( + target_kind="field", + field_key__in=PUBLISHABLE_FIELDS, + interest__isnull=True, + open_to__isnull=True, + ) + | models.Q( + target_kind="interest", + field_key="", + interest__isnull=False, + open_to__isnull=True, + ) + | models.Q( + target_kind="open_to", + field_key="", + interest__isnull=True, + open_to__isnull=False, + ) + ), + name="prof_visibility_target_shape", + ), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index cf3d547021..ec7d8f9a8a 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -263,3 +263,248 @@ class NotificationPreference(UUIDModel, TimeStampedModel): def __str__(self): return f"@{self.user.username} notification preferences" + + +# A Profile is the only owner of its presentation privacy choices. +# These policies never duplicate the canonical values in Accounts or Topics. +class ProfileVisibilityTargetKind(models.TextChoices): + FIELD = "field", "Information de profil" + INTEREST = "interest", "Centre d'intérêt" + OPEN_TO = "open_to", "Ouvert à" + + +# Sensitive account information (email, phone, birth date, exact address, +# coordinates, tokens) is deliberately absent from this allowlist. +PROFILE_VISIBILITY_FIELDS = ( + "name", + "avatar", + "bio", + "profession", + "country", + "city", + "company_name", + "organization_name", + "website", + "linkedin_url", + "facebook_url", + "instagram_url", + "tiktok_url", + "x_url", + "youtube_url", + "interests", + "open_to", +) + + +class ProfileVisibilityPolicy(UUIDModel, TimeStampedModel): + """Owner-controlled visibility of one canonical, publishable Profile fact. + + An absent policy leaves existing owner-domain visibility in force until + the resolver is integrated. A saved policy with no audiences means private. + """ + + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="visibility_policies", + ) + target_kind = models.CharField(max_length=16, choices=ProfileVisibilityTargetKind.choices) + field_key = models.CharField(max_length=48, blank=True, default="") + interest = models.ForeignKey( + "topics.ProfileInterest", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="visibility_policies", + ) + open_to = models.ForeignKey( + "topics.ProfileOpenTo", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="visibility_policies", + ) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + Q(target_kind="field", field_key__in=tuple(PROFILE_VISIBILITY_FIELDS), + interest__isnull=True, open_to__isnull=True) + | Q(target_kind="interest", field_key="", interest__isnull=False, open_to__isnull=True) + | Q(target_kind="open_to", field_key="", interest__isnull=True, open_to__isnull=False) + ), + name="prof_visibility_target_shape", + ), + models.UniqueConstraint( + fields=["owner", "field_key"], + condition=Q(target_kind="field"), + name="prof_vis_uniq_field", + ), + models.UniqueConstraint( + fields=["owner", "interest"], + condition=Q(target_kind="interest"), + name="prof_vis_uniq_interest", + ), + models.UniqueConstraint( + fields=["owner", "open_to"], + condition=Q(target_kind="open_to"), + name="prof_vis_uniq_open_to", + ), + ] + indexes = [ + models.Index(fields=["owner", "target_kind"], name="prof_visibility_owner_idx"), + ] + + def clean(self): + super().clean() + if self.target_kind == ProfileVisibilityTargetKind.FIELD: + if self.field_key not in PROFILE_VISIBILITY_FIELDS or self.interest_id or self.open_to_id: + raise ValidationError("La cible de confidentialité du Profil n'est pas autorisée.") + elif self.target_kind == ProfileVisibilityTargetKind.INTEREST: + if self.field_key or not self.interest_id or self.open_to_id: + raise ValidationError("Une politique d'intérêt exige un intérêt unique.") + if self.owner_id and self.interest.profile_id != self.owner_id: + raise ValidationError("Cet intérêt n'appartient pas au Profil.") + elif self.target_kind == ProfileVisibilityTargetKind.OPEN_TO: + if self.field_key or self.interest_id or not self.open_to_id: + raise ValidationError("Une politique Ouvert à exige une déclaration unique.") + if self.owner_id and self.open_to.profile_id != self.owner_id: + raise ValidationError("Cette déclaration n'appartient pas au Profil.") + else: + raise ValidationError("Type de cible de confidentialité inconnu.") + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + +class ProfileVisibilityAudienceKind(models.TextChoices): + PUBLIC = "public", "Tout le monde" + MY_COLLECTIVES = "my_collectives", "Mes collectifs" + SELECTED_SPACE = "selected_space", "Espace choisi" + SELECTED_TEAM = "selected_team", "Équipe choisie" + SELECTED_GROUP = "selected_group", "Groupe choisi" + SELECTED_PROFILE = "selected_profile", "Personne choisie" + + +class ProfileVisibilityAudience(UUIDModel): + """An additive audience rule; current membership is resolved at read time. + + No rows = owner only. PUBLIC makes other audience rules redundant, but they + can coexist. Membership grants visibility only when the owner chose it. + """ + + policy = models.ForeignKey( + ProfileVisibilityPolicy, + on_delete=models.CASCADE, + related_name="audiences", + ) + kind = models.CharField(max_length=24, choices=ProfileVisibilityAudienceKind.choices) + selected_profile = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="profile_visibility_grants_received", + ) + selected_space = models.ForeignKey( + "organizations.Organization", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="profile_visibility_audiences", + ) + selected_team = models.ForeignKey( + "organizations.Team", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="profile_visibility_audiences", + ) + selected_group = models.ForeignKey( + "groups.Group", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="profile_visibility_audiences", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + Q(kind__in=("public", "my_collectives"), + selected_profile__isnull=True, selected_space__isnull=True, + selected_team__isnull=True, selected_group__isnull=True) + | Q(kind="selected_profile", selected_profile__isnull=False, + selected_space__isnull=True, selected_team__isnull=True, + selected_group__isnull=True) + | Q(kind="selected_space", selected_profile__isnull=True, + selected_space__isnull=False, selected_team__isnull=True, + selected_group__isnull=True) + | Q(kind="selected_team", selected_profile__isnull=True, + selected_space__isnull=True, selected_team__isnull=False, + selected_group__isnull=True) + | Q(kind="selected_group", selected_profile__isnull=True, + selected_space__isnull=True, selected_team__isnull=True, + selected_group__isnull=False) + ), + name="prof_visibility_audience_shape", + ), + models.UniqueConstraint( + fields=["policy", "kind"], + condition=Q(kind__in=("public", "my_collectives")), + name="prof_vis_uniq_broad", + ), + models.UniqueConstraint( + fields=["policy", "selected_profile"], + condition=Q(kind="selected_profile"), + name="prof_vis_uniq_profile", + ), + models.UniqueConstraint( + fields=["policy", "selected_space"], + condition=Q(kind="selected_space"), + name="prof_vis_uniq_space", + ), + models.UniqueConstraint( + fields=["policy", "selected_team"], + condition=Q(kind="selected_team"), + name="prof_vis_uniq_team", + ), + models.UniqueConstraint( + fields=["policy", "selected_group"], + condition=Q(kind="selected_group"), + name="prof_vis_uniq_group", + ), + ] + indexes = [ + models.Index(fields=["policy", "kind"], name="prof_visibility_aud_idx"), + ] + + def clean(self): + super().clean() + expected = { + ProfileVisibilityAudienceKind.PUBLIC: None, + ProfileVisibilityAudienceKind.MY_COLLECTIVES: None, + ProfileVisibilityAudienceKind.SELECTED_PROFILE: "selected_profile_id", + ProfileVisibilityAudienceKind.SELECTED_SPACE: "selected_space_id", + ProfileVisibilityAudienceKind.SELECTED_TEAM: "selected_team_id", + ProfileVisibilityAudienceKind.SELECTED_GROUP: "selected_group_id", + } + if self.kind not in expected: + raise ValidationError("Audience de confidentialité inconnue.") + selected = { + key for key in ( + "selected_profile_id", "selected_space_id", + "selected_team_id", "selected_group_id" + ) if getattr(self, key) + } + required = expected[self.kind] + if selected != ({required} if required else set()): + raise ValidationError("L'audience ne correspond pas à son destinataire.") + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) diff --git a/accounts/profile_visibility.py b/accounts/profile_visibility.py new file mode 100644 index 0000000000..bdb98b6c8c --- /dev/null +++ b/accounts/profile_visibility.py @@ -0,0 +1,238 @@ +"""Owner-controlled Profile presentation visibility. + +Privacy policies compose canonical facts; they are not copies of those facts. +The current viewer's active collective relationships are evaluated at read +time. Organizational permissions never grant Passport visibility by themselves. +""" +from __future__ import annotations + +from django.core.exceptions import ValidationError +from django.db import transaction + +from accounts.models import ( + PROFILE_VISIBILITY_FIELDS, + ProfileVisibilityAudience, + ProfileVisibilityAudienceKind as Audience, + ProfileVisibilityPolicy, + ProfileVisibilityTargetKind as Target, +) +from groups.models import Group, GroupMembership, GroupMembershipStatus, GroupStatus +from organizations.models import TeamMembership, TeamMembershipStatus + + +def _authenticated(profile): + return bool( + profile is not None + and getattr(profile, "is_authenticated", False) + and getattr(profile, "is_active", False) + ) + + +def _collective_memberships(profile): + """Same canonical sources as Moi, without treating administrative access as membership. + + The Me preview is bounded, so it cannot be used as the authority source. + Authority (including a real Mandate) is never evidence of membership. + """ + if not _authenticated(profile): + return {"spaces": set(), "teams": set(), "groups": set()} + teams = set( + TeamMembership.objects.filter( + user=profile, status=TeamMembershipStatus.ACTIVE, + team__is_active=True, + ).values_list("team_id", flat=True) + ) + team_spaces = set( + TeamMembership.objects.filter( + user=profile, status=TeamMembershipStatus.ACTIVE, + team__is_active=True, + ).values_list("team__organization_id", flat=True) + ) + # Groups in Moi also include groups viewable via permission; visibility + # of another person's Profile requires a real member/owner relationship. + groups = set( + GroupMembership.objects.filter( + profile=profile, status=GroupMembershipStatus.ACTIVE, + group__status=GroupStatus.ACTIVE, + ).values_list("group_id", flat=True) + ) + groups.update( + Group.objects.filter(owner_profile=profile, status=GroupStatus.ACTIVE) + .values_list("pk", flat=True) + ) + return {"spaces": team_spaces, "teams": teams, "groups": groups} + + +class ProfileVisibilityResolver: + """Authoritative, request-scoped policy evaluator; fail-closed for overrides.""" + + def __init__(self, *, owner, viewer=None, allow_collectives=True): + self.owner = owner + self.viewer = viewer + self.is_owner = bool(_authenticated(viewer) and owner.pk == viewer.pk) + self.allow_collectives = allow_collectives + self._policies = {} + for row in ( + ProfileVisibilityPolicy.objects.filter(owner=owner) + .prefetch_related("audiences") + ): + key = ( + row.target_kind, + row.field_key if row.target_kind == Target.FIELD + else str(row.interest_id if row.target_kind == Target.INTEREST else row.open_to_id), + ) + self._policies[key] = tuple(row.audiences.all()) + self._owner_memberships = None + self._viewer_memberships = None + + def _audience_allows(self, rules): + if any(row.kind == Audience.PUBLIC for row in rules): + return True + if not _authenticated(self.viewer) or not self.allow_collectives: + # Explicit person grants should still work when collective + # audiences are disabled (for example on a direct view). + return any( + row.kind == Audience.SELECTED_PROFILE + and row.selected_profile_id == getattr(self.viewer, "pk", None) + for row in rules + ) if _authenticated(self.viewer) else False + if any( + row.kind == Audience.SELECTED_PROFILE + and row.selected_profile_id == self.viewer.pk + for row in rules + ): + return True + if self._owner_memberships is None: + self._owner_memberships = _collective_memberships(self.owner) + if self._viewer_memberships is None: + self._viewer_memberships = _collective_memberships(self.viewer) + owner, viewer = self._owner_memberships, self._viewer_memberships + common = { + kind: owner[kind] & viewer[kind] + for kind in ("spaces", "teams", "groups") + } + for row in rules: + if row.kind == Audience.MY_COLLECTIVES and any(common.values()): + return True + if row.kind == Audience.SELECTED_SPACE and row.selected_space_id in common["spaces"]: + return True + if row.kind == Audience.SELECTED_TEAM and row.selected_team_id in common["teams"]: + return True + if row.kind == Audience.SELECTED_GROUP and row.selected_group_id in common["groups"]: + return True + return False + + def allows(self, *, target_kind, key, legacy_public=True): + if self.is_owner: + return True + rules = self._policies.get((target_kind, str(key))) + if rules is None: + return bool(legacy_public) + # An existing policy with no audiences is a deliberate private choice. + return self._audience_allows(rules) + + def field(self, key): + if key not in PROFILE_VISIBILITY_FIELDS: + return False + return self.allows(target_kind=Target.FIELD, key=key) + + def interest(self, row): + return self.field("interests") and self.allows( + target_kind=Target.INTEREST, key=row.pk, + legacy_public=row.is_public, + ) + + def open_to(self, row): + return self.field("open_to") and self.allows( + target_kind=Target.OPEN_TO, key=row.pk, + legacy_public=row.is_public and row.is_active, + ) + + +@transaction.atomic +def replace_profile_visibility(*, owner, target_kind, field_key="", interest=None, + open_to=None, audiences=()): + """Replace all audiences atomically, strictly scoped to the owner. + + Audience items are dicts with kind and optional id. No raw model field is + selectable outside the publishable allowlist. Empty audiences = private. + """ + if not _authenticated(owner): + raise ValidationError("Le propriétaire doit être authentifié.") + if target_kind == Target.FIELD: + if field_key not in PROFILE_VISIBILITY_FIELDS or interest or open_to: + raise ValidationError("Information de Profil non publiable.") + elif target_kind == Target.INTEREST: + if field_key or not interest or open_to or interest.profile_id != owner.pk: + raise ValidationError("Intérêt non autorisé.") + elif target_kind == Target.OPEN_TO: + if field_key or interest or not open_to or open_to.profile_id != owner.pk: + raise ValidationError("Ouvert à non autorisé.") + else: + raise ValidationError("Information de confidentialité inconnue.") + if not isinstance(audiences, (list, tuple)) or len(audiences) > 100: + raise ValidationError("Nombre d'audiences invalide.") + + selected = [] + keys = set() + memberships = _collective_memberships(owner) + for item in audiences: + if not isinstance(item, dict): + raise ValidationError("Audience invalide.") + kind, target_id = item.get("kind"), item.get("id") + if kind in (Audience.PUBLIC, Audience.MY_COLLECTIVES): + if target_id is not None: + raise ValidationError("Cette audience ne prend pas de destinataire.") + key = (kind, "") + values = {} + elif kind == Audience.SELECTED_PROFILE: + from django.contrib.auth import get_user_model + recipient = get_user_model().objects.filter(pk=target_id, is_active=True).first() + if not recipient: + raise ValidationError("Destinataire inconnu.") + key = (kind, str(recipient.pk)) + values = {"selected_profile": recipient} + elif kind in (Audience.SELECTED_SPACE, Audience.SELECTED_TEAM, Audience.SELECTED_GROUP): + from organizations.models import Organization, Team + model, membership_key, values_key = { + Audience.SELECTED_SPACE: (Organization, "spaces", "selected_space"), + Audience.SELECTED_TEAM: (Team, "teams", "selected_team"), + Audience.SELECTED_GROUP: (Group, "groups", "selected_group"), + }[kind] + obj = model.objects.filter(pk=target_id).first() + if obj is None or obj.pk not in memberships[membership_key]: + raise ValidationError("Ce collectif ne vous appartient pas en tant que relation active.") + key = (kind, str(obj.pk)) + values = {values_key: obj} + else: + raise ValidationError("Audience inconnue.") + if key in keys: + raise ValidationError("Une audience est présente plusieurs fois.") + keys.add(key) + selected.append((kind, values)) + + # PUBLIC already includes every viewer; mixing it with narrower audiences + # is an easy way to disclose a fact unintentionally from the editor. + if (Audience.PUBLIC, "") in keys and len(keys) > 1: + raise ValidationError( + "Tout le monde est exclusif : retirez Public pour restreindre la visibilité." + ) + + criteria = {"owner": owner, "target_kind": target_kind} + if target_kind == Target.FIELD: + criteria["field_key"] = field_key + elif target_kind == Target.INTEREST: + criteria["interest"] = interest + else: + criteria["open_to"] = open_to + policy, _created = ProfileVisibilityPolicy.objects.select_for_update().get_or_create(**criteria) + # Validate all new rules before changing the old selection. + entries = [ + ProfileVisibilityAudience(policy=policy, kind=kind, **values) + for kind, values in selected + ] + for row in entries: + row.full_clean(validate_unique=False, validate_constraints=False) + policy.audiences.all().delete() + ProfileVisibilityAudience.objects.bulk_create(entries) + return policy diff --git a/accounts/profile_visibility_projection.py b/accounts/profile_visibility_projection.py new file mode 100644 index 0000000000..4c0749d0fe --- /dev/null +++ b/accounts/profile_visibility_projection.py @@ -0,0 +1,117 @@ +"""Owner-only privacy settings read model shared by API and Web. + +It reads canonical facts and existing collective relationships; it never +persists presentation copies or grants authority to act for another Profile. +""" +from accounts.models import ( + PROFILE_VISIBILITY_FIELDS, ProfileVisibilityPolicy, + ProfileVisibilityTargetKind as Target, +) +from accounts.profile_visibility import _collective_memberships +from groups.models import Group +from organizations.models import Organization, Team +from topics.models import ProfileInterest, ProfileOpenTo + + +def build_profile_visibility_settings(profile, *, compact=False): + policies = ProfileVisibilityPolicy.objects.filter(owner=profile) + if compact: + policies = policies.filter(target_kind=Target.FIELD) + policies = policies.prefetch_related("audiences") + by_key = { + (p.target_kind, p.field_key if p.target_kind == Target.FIELD + else str(p.interest_id if p.target_kind == Target.INTEREST else p.open_to_id)): p + for p in policies + } + + def setting(kind, key, *, default_public): + policy = by_key.get((kind, str(key))) + if policy is None: + return {"default_public": bool(default_public), "audiences": [ + {"kind": "public"}] if default_public else [], "configured": False} + return { + "default_public": bool(default_public), + "configured": True, + "audiences": [ + {"kind": row.kind, **( + {"id": str(row.selected_profile_id), + "label": row.selected_profile.full_name or row.selected_profile.username} + if row.selected_profile_id else + {"id": str(row.selected_space_id)} if row.selected_space_id else + {"id": str(row.selected_team_id)} if row.selected_team_id else + {"id": str(row.selected_group_id)} if row.selected_group_id else {} + )} + for row in policy.audiences.all() + ], + } + + # A visibility policy describes permission, not the existence of a value. + # Avoid making blank fields look like published information in the editor. + profile_row = getattr(profile, "profile", None) + metadata = profile.metadata if isinstance(profile.metadata, dict) else {} + field_values = { + "name": profile.full_name or profile.username, + "avatar": bool(getattr(profile, "avatar", None)) + or bool(metadata.get("avatar_url")), + "bio": profile.bio, + "profession": getattr(profile_row, "profession", None), + "country": getattr(profile_row, "country", None), + "city": getattr(profile_row, "city", None), + "company_name": getattr(profile_row, "company_name", None), + "organization_name": getattr(profile_row, "organization_name", None), + "website": profile.website, + "linkedin_url": profile.linkedin_url, + "facebook_url": profile.facebook_url, + "instagram_url": profile.instagram_url, + "tiktok_url": profile.tiktok_url, + "x_url": profile.x_url, + "youtube_url": profile.youtube_url, + } + + memberships = _collective_memberships(profile) + return { + "collectives": { + "spaces": [ + {"id": str(obj.pk), "label": obj.name, "kind": "selected_space"} + for obj in Organization.objects.filter(pk__in=memberships["spaces"]).order_by("name") + ], + "teams": [ + {"id": str(obj.pk), "label": obj.name, "kind": "selected_team"} + for obj in Team.objects.filter(pk__in=memberships["teams"]).order_by("name") + ], + "groups": [ + {"id": str(obj.pk), "label": obj.name, "kind": "selected_group"} + for obj in Group.objects.filter(pk__in=memberships["groups"]).order_by("name") + ], + }, + "audience_kinds": [ + "public", "my_collectives", "selected_space", "selected_team", + "selected_group", "selected_profile", + ], + "collections": { + "interests": setting(Target.FIELD, "interests", default_public=True), + "open_to": setting(Target.FIELD, "open_to", default_public=True), + }, + "fields": { + key: { + **setting(Target.FIELD, key, default_public=True), + "has_value": bool(field_values.get(key)), + } + for key in PROFILE_VISIBILITY_FIELDS if key not in ("interests", "open_to") + }, + "interests": [] if compact else [ + {"id": str(row.pk), "label": row.topic.label, + **setting(Target.INTEREST, row.pk, default_public=row.is_public)} + for row in ProfileInterest.objects.filter( + profile=profile, topic__is_active=True + ).select_related("topic").order_by("topic__label") + ], + "open_to": [] if compact else [ + {"id": str(row.pk), "label": row.get_kind_display(), + **setting(Target.OPEN_TO, row.pk, default_public=row.is_public)} + for row in ProfileOpenTo.objects.filter( + profile=profile, is_active=True, + ).order_by("kind") + ], + } + diff --git a/accounts/test_passport_visibility_integration.py b/accounts/test_passport_visibility_integration.py new file mode 100644 index 0000000000..4d46763f3c --- /dev/null +++ b/accounts/test_passport_visibility_integration.py @@ -0,0 +1,361 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from accounts.models import ProfileVisibilityAudience, ProfileVisibilityPolicy, UserProfile +from accounts.profile_visibility import ProfileVisibilityResolver, replace_profile_visibility +from groups.models import Group, GroupMembership, GroupMembershipStatus +from authorization.constants import SystemRoleCode +from authorization.services import grant_space_role +from organizations.models import Organization, Team, TeamMembership, TeamMembershipStatus +from sharing.passport import PASSPORT_PUBLIC, build_profile_passport +from topics.models import ProfileInterest, ProfileOpenTo, Topic + + +class PassportVisibilityIntegrationTests(TestCase): + def setUp(self): + User = get_user_model() + self.owner = User.objects.create_user( + username="privacy-owner-a", + first_name="Amina", last_name="Privée", + bio="Texte confidentiel", email="secret@example.test", + ) + self.viewer = User.objects.create_user(username="privacy-viewer-b") + self.outsider = User.objects.create_user(username="privacy-outsider-c") + UserProfile.objects.create(user=self.owner, public_profile=True, searchable=True, + profession="Architecte", city="Lubumbashi") + self.topic = Topic.objects.create(code="privacy-engineering", label="Génie") + self.interest = ProfileInterest.objects.create( + profile=self.owner, topic=self.topic, is_public=False, + ) + self.open_to = ProfileOpenTo.objects.create( + profile=self.owner, kind="mentor", is_public=False, is_active=True, + ) + self.endpoint = reverse("personal-projections:passport-visibility") + + def _public(self, viewer=None): + return build_profile_passport( + self.owner, variant=PASSPORT_PUBLIC, viewer=viewer, + ) + + def test_existing_private_topic_stays_private_until_explicitly_granted(self): + self.assertEqual(len(self._public().interests), 0) + self.assertEqual(len(self._public(self.viewer).interests), 0) + replace_profile_visibility( + owner=self.owner, target_kind="interest", interest=self.interest, + audiences=[{"kind": "public"}], + ) + self.assertEqual(len(self._public().interests), 1) + + def test_name_can_be_hidden_but_identifier_remains(self): + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="name", audiences=[], + ) + identity = self._public().identity + self.assertEqual(identity["name"], self.owner.username) + self.assertEqual(identity["identifier"], self.owner.username) + self.assertNotIn("Amina", str(identity)) + self.assertNotIn(self.owner.email, str(identity)) + + def test_web_public_reader_preserves_only_authorized_personal_fields(self): + self.client.force_login(self.owner) + url = reverse("sharing:passport-me") + current = self.client.get(url, {"variant": "public"}) + self.assertEqual(current.status_code, 200) + self.assertIn(("Prénom", "Amina"), current.context["personal_details"]) + self.assertIn(("Identifiant Makolo", "privacy-owner-a"), + current.context["personal_details"]) + self.assertNotIn(self.owner.email, str(current.context["personal_details"])) + + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="name", audiences=[], + ) + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="city", audiences=[], + ) + hidden = self.client.get(url, {"variant": "thematic"}) + self.assertEqual(hidden.status_code, 200) + details = hidden.context["personal_details"] + self.assertNotIn(("Prénom", "Amina"), details) + self.assertNotIn(("Ville", "Lubumbashi"), details) + self.assertIn(("Identifiant Makolo", "privacy-owner-a"), details) + + def test_restricted_profession_is_visible_to_chosen_profile_not_strangers(self): + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="profession", + audiences=[{"kind": "selected_profile", "id": str(self.viewer.pk)}], + ) + self.assertEqual(self._public().identity["profession"], "") + self.assertEqual(self._public(self.outsider).identity["profession"], "") + self.assertEqual(self._public(self.viewer).identity["profession"], "Architecte") + + def test_collectives_with_zero_memberships_are_empty_then_dynamic(self): + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="bio", + audiences=[{"kind": "my_collectives"}], + ) + self.assertEqual(self._public(self.viewer).identity["bio"], "") + group = Group.objects.create(name="Club Études", owner_profile=self.outsider) + GroupMembership.objects.create(group=group, profile=self.viewer) + self.assertEqual(self._public(self.viewer).identity["bio"], "") + GroupMembership.objects.create(group=group, profile=self.owner) + self.assertEqual(self._public(self.viewer).identity["bio"], "Texte confidentiel") + membership = GroupMembership.objects.get(group=group, profile=self.viewer) + membership.status = GroupMembershipStatus.LEFT + membership.save() + self.assertEqual(self._public(self.viewer).identity["bio"], "") + + def test_space_mandate_alone_does_not_grant_my_collectives_visibility(self): + space_owner = self.outsider + space = Organization.objects.create( + name="Espace mandat sans appartenance", + slug="privacy-mandate-only-space", + created_by=space_owner, + ) + grant_space_role( + profile=self.owner, space=space, + role=SystemRoleCode.SPACE_OWNER, + source="passport-visibility-test", + ) + grant_space_role( + profile=self.viewer, space=space, + role=SystemRoleCode.SPACE_OWNER, + source="passport-visibility-test", + ) + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="bio", + audiences=[{"kind": "my_collectives"}], + ) + self.assertEqual(self._public(self.viewer).identity["bio"], "") + # Only an active, actual Team membership creates a shared Space relation. + team = Team.objects.create(organization=space, name="Équipe réelle") + TeamMembership.objects.create( + team=team, user=self.owner, status=TeamMembershipStatus.ACTIVE, + ) + TeamMembership.objects.create( + team=team, user=self.viewer, status=TeamMembershipStatus.ACTIVE, + ) + self.assertEqual( + self._public(self.viewer).identity["bio"], "Texte confidentiel", + ) + TeamMembership.objects.filter(team=team, user=self.viewer).update( + status=TeamMembershipStatus.LEFT, + ) + self.assertEqual(self._public(self.viewer).identity["bio"], "") + + def test_mandate_alone_cannot_be_selected_as_collective_audience(self): + space = Organization.objects.create( + name="Espace mandat selection", + slug="privacy-mandate-selection-space", + created_by=self.outsider, + ) + grant_space_role( + profile=self.owner, space=space, + role=SystemRoleCode.SPACE_OWNER, + source="passport-visibility-test", + ) + with self.assertRaises(Exception): + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="bio", + audiences=[{"kind": "selected_space", "id": str(space.pk)}], + ) + self.assertFalse( + ProfileVisibilityPolicy.objects.filter( + owner=self.owner, target_kind="field", field_key="bio", + ).exists() + ) + team = Team.objects.create(organization=space, name="Membres") + TeamMembership.objects.create( + user=self.owner, team=team, status=TeamMembershipStatus.ACTIVE, + ) + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="bio", + audiences=[{"kind": "selected_space", "id": str(space.pk)}], + ) + self.assertEqual(self._public(self.viewer).identity["bio"], "") + TeamMembership.objects.create( + user=self.viewer, team=team, status=TeamMembershipStatus.ACTIVE, + ) + self.assertEqual( + self._public(self.viewer).identity["bio"], "Texte confidentiel", + ) + + def test_chosen_collective_and_person_are_additive(self): + group = Group.objects.create(name="Association", owner_profile=self.owner) + GroupMembership.objects.create(group=group, profile=self.owner) + GroupMembership.objects.create(group=group, profile=self.viewer) + replace_profile_visibility( + owner=self.owner, target_kind="interest", interest=self.interest, + audiences=[ + {"kind": "selected_group", "id": str(group.pk)}, + {"kind": "selected_profile", "id": str(self.outsider.pk)}, + ], + ) + self.assertEqual(len(self._public().interests), 0) + self.assertEqual(len(self._public(self.viewer).interests), 1) + self.assertEqual(len(self._public(self.outsider).interests), 1) + self.assertEqual(len(self._public().open_to), 0) + + def test_cannot_choose_a_foreign_collective(self): + group = Group.objects.create(name="Privé", owner_profile=self.outsider) + with self.assertRaises(Exception): + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="bio", + audiences=[{"kind": "selected_group", "id": str(group.pk)}], + ) + self.assertFalse( + ProfileVisibilityPolicy.objects.filter(owner=self.owner, field_key="bio").exists() + ) + + def test_owner_endpoint_requires_auth_and_rejects_foreign_interest(self): + self.assertIn(self.client.get(self.endpoint).status_code, (401, 403)) + self.client.force_login(self.owner) + data = self.client.get(self.endpoint) + self.assertEqual(data.status_code, 200) + self.assertIn("fields", data.json()["data"]) + self.assertEqual(self.client.put( + self.endpoint, data={ + "target_kind": "interest", "target_id": str(self.interest.pk), + "audiences": [{"kind": "my_collectives"}], + }, content_type="application/json", + ).status_code, 200) + self.assertEqual(ProfileVisibilityAudience.objects.count(), 1) + self.client.force_login(self.viewer) + attempt = self.client.put( + self.endpoint, data={ + "target_kind": "interest", "target_id": str(self.interest.pk), + "audiences": [{"kind": "public"}], + }, content_type="application/json", + ) + self.assertEqual(attempt.status_code, 404) + + def test_owner_complete_does_not_lose_private_facts(self): + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="bio", audiences=[], + ) + from sharing.passport import PASSPORT_COMPLETE + complete = build_profile_passport(self.owner, variant=PASSPORT_COMPLETE) + self.assertEqual(complete.identity["bio"], "Texte confidentiel") + self.assertEqual(len(complete.interests), 1) + + def test_authenticated_collective_view_only_expands_live_html_not_export(self): + group = Group.objects.create(name="Forum professionnel", owner_profile=self.owner) + GroupMembership.objects.create(group=group, profile=self.viewer) + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="bio", + audiences=[{"kind": "my_collectives"}], + ) + public_url = reverse( + "sharing:passport-profile", kwargs={"profile_id": self.owner.pk}, + ) + anon = self.client.get(public_url) + self.assertEqual(anon.status_code, 200) + self.assertNotContains(anon, "Texte confidentiel") + self.client.force_login(self.viewer) + still_public = self.client.get(public_url) + self.assertEqual(still_public.status_code, 200) + self.assertNotContains(still_public, "Texte confidentiel") + visible = self.client.get(public_url, {"audience": "mine"}) + self.assertEqual(visible.status_code, 200) + self.assertContains(visible, "Texte confidentiel") + exported = self.client.get(public_url, {"audience": "mine", "download": "1"}) + self.assertEqual(exported.status_code, 200) + self.assertNotContains(exported, "Texte confidentiel") + + def test_web_visibility_editor_is_owner_scoped(self): + url = reverse("sharing:passport-visibility") + self.assertEqual(self.client.get(url).status_code, 302) + self.client.force_login(self.owner) + self.assertEqual(self.client.get(url).status_code, 200) + response = self.client.post(url, { + "target_kind": "field", + "target_id": "profession", + "audience": ["my_collectives"], + }) + self.assertEqual(response.status_code, 302) + policy = ProfileVisibilityPolicy.objects.get(owner=self.owner, field_key="profession") + self.assertEqual(list(policy.audiences.values_list("kind", flat=True)), ["my_collectives"]) + self.client.force_login(self.viewer) + self.client.post(url, { + "target_kind": "field", + "target_id": "profession", + "audience": ["public"], + }) + policy.refresh_from_db() + self.assertEqual(list(policy.audiences.values_list("kind", flat=True)), ["my_collectives"]) + + def test_company_and_organization_follow_independent_disclosure(self): + profile = UserProfile.objects.get(user=self.owner) + profile.company_name = "Atelier personnel" + profile.organization_name = "Association déclarée" + profile.save(update_fields=["company_name", "organization_name"]) + replace_profile_visibility( + owner=self.owner, target_kind="field", + field_key="company_name", audiences=[], + ) + anonymous = self._public() + self.assertEqual(anonymous.identity["company_name"], "") + self.assertEqual(anonymous.identity["organization_name"], "Association déclarée") + replace_profile_visibility( + owner=self.owner, target_kind="field", + field_key="company_name", + audiences=[{"kind": "selected_profile", "id": str(self.viewer.pk)}], + ) + self.assertEqual(self._public(self.viewer).identity["company_name"], "Atelier personnel") + self.assertEqual(self._public(self.outsider).identity["company_name"], "") + from sharing.passport_documents import serialize_passport_projection + payload = serialize_passport_projection(self._public()) + self.assertEqual(payload["identity"]["company_name"], "") + self.assertEqual(payload["identity"]["organization_name"], "Association déclarée") + + def test_owner_complete_includes_groups_without_exposing_them_publicly(self): + group = Group.objects.create(name="Collectif personnel", owner_profile=self.owner) + complete = build_profile_passport(self.owner, variant="complete") + self.assertIn("Collectif personnel", [row["name"] for row in complete.group_affiliations]) + public = self._public() + self.assertEqual(public.group_affiliations, ()) + from sharing.passport_documents import serialize_passport_projection + self.assertEqual(serialize_passport_projection(public)["group_affiliations"], []) + + def test_malformed_privacy_target_returns_400_without_server_error(self): + self.client.force_login(self.owner) + response = self.client.put( + self.endpoint, data={ + "target_kind": "interest", "target_id": "not-a-uuid", + "audiences": [{"kind": "public"}], + }, content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_public_verification_hides_old_personal_content_after_privacy_revocation(self): + from sharing.passport_documents import ( + issue_passport_snapshot, passport_verification_token, + ) + snapshot = issue_passport_snapshot(self._public()) + verification = reverse("sharing:passport-verify", kwargs={ + "token": passport_verification_token(snapshot), + }) + before = self.client.get(verification) + self.assertEqual(before.status_code, 200) + self.assertTrue(before.context["can_view_dated_passport"]) + replace_profile_visibility( + owner=self.owner, target_kind="field", field_key="bio", + audiences=[], + ) + after = self.client.get(verification) + self.assertEqual(after.status_code, 200) + self.assertTrue(after.context["snapshot_intact"]) + self.assertFalse(after.context["can_view_dated_passport"]) + self.assertEqual(after.context["dated_payload"], {}) + self.assertNotContains(after, "Texte confidentiel") + + def test_platform_superuser_global_visibility_is_not_personal_collective_membership(self): + from django.contrib.auth import get_user_model + admin = get_user_model().objects.create_superuser( + username="passport-platform-superuser", password="OnlyForTests123!", + ) + Group.objects.create(name="Association privée extérieure", owner_profile=self.owner) + from sharing.passport import PASSPORT_COMPLETE + projection = build_profile_passport(admin, variant=PASSPORT_COMPLETE) + self.assertEqual(projection.group_affiliations, ()) + self.assertEqual(projection.authorized_space_affiliations, ()) diff --git a/accounts/test_profile_visibility.py b/accounts/test_profile_visibility.py new file mode 100644 index 0000000000..3438efe07d --- /dev/null +++ b/accounts/test_profile_visibility.py @@ -0,0 +1,175 @@ +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.test import TestCase + +from accounts.models import ( + PROFILE_VISIBILITY_FIELDS, + ProfileVisibilityAudience, + ProfileVisibilityAudienceKind, + ProfileVisibilityPolicy, + ProfileVisibilityTargetKind, +) +from topics.models import ProfileInterest, ProfileOpenTo, Topic + + +class ProfileVisibilityPolicyTests(TestCase): + def setUp(self): + User = get_user_model() + self.owner = User.objects.create_user(username="privacy-owner") + self.other = User.objects.create_user(username="privacy-other") + self.topic = Topic.objects.create(code="privacy-topic", label="Confidentialité") + + def test_publishable_fields_are_allowlisted_and_identity_handle_is_not_hideable(self): + self.assertIn("name", PROFILE_VISIBILITY_FIELDS) + self.assertIn("profession", PROFILE_VISIBILITY_FIELDS) + for sensitive in ("email", "phone", "birth_date", "address", "latitude", "longitude", "username"): + self.assertNotIn(sensitive, PROFILE_VISIBILITY_FIELDS) + + policy = ProfileVisibilityPolicy.objects.create( + owner=self.owner, + target_kind=ProfileVisibilityTargetKind.FIELD, + field_key="profession", + ) + self.assertEqual(policy.owner, self.owner) + with self.assertRaises(ValidationError): + ProfileVisibilityPolicy.objects.create( + owner=self.owner, + target_kind=ProfileVisibilityTargetKind.FIELD, + field_key="email", + ) + + def test_owner_settings_distinguish_public_permission_from_blank_value(self): + from accounts.profile_visibility_projection import build_profile_visibility_settings + + settings = build_profile_visibility_settings(self.owner) + self.assertTrue(settings["fields"]["website"]["default_public"]) + self.assertFalse(settings["fields"]["website"]["has_value"]) + self.assertTrue(settings["fields"]["name"]["has_value"]) + self.owner.website = "https://example.test" + self.owner.save(update_fields=["website", "updated_at"]) + updated = build_profile_visibility_settings(self.owner) + self.assertTrue(updated["fields"]["website"]["has_value"]) + self.assertTrue(updated["fields"]["website"]["default_public"]) + + def test_one_policy_per_owner_and_field(self): + ProfileVisibilityPolicy.objects.create( + owner=self.owner, + target_kind=ProfileVisibilityTargetKind.FIELD, + field_key="name", + ) + with self.assertRaises(ValidationError): + ProfileVisibilityPolicy.objects.create( + owner=self.owner, + target_kind=ProfileVisibilityTargetKind.FIELD, + field_key="name", + ) + ProfileVisibilityPolicy.objects.create( + owner=self.other, + target_kind=ProfileVisibilityTargetKind.FIELD, + field_key="name", + ) + + def test_interest_and_open_to_targets_preserve_canonical_ownership(self): + interest = ProfileInterest.objects.create(profile=self.owner, topic=self.topic) + open_to = ProfileOpenTo.objects.create( + profile=self.owner, + kind="collaborate", + topic=self.topic, + ) + for kind, target in ( + (ProfileVisibilityTargetKind.INTEREST, {"interest": interest}), + (ProfileVisibilityTargetKind.OPEN_TO, {"open_to": open_to}), + ): + policy = ProfileVisibilityPolicy.objects.create( + owner=self.owner, target_kind=kind, **target + ) + self.assertEqual(policy.owner, self.owner) + with self.assertRaises(ValidationError): + ProfileVisibilityPolicy.objects.create( + owner=self.other, target_kind=kind, **target + ) + + def test_private_policy_has_no_audiences_and_multi_audience_is_additive(self): + policy = ProfileVisibilityPolicy.objects.create( + owner=self.owner, + target_kind=ProfileVisibilityTargetKind.FIELD, + field_key="city", + ) + self.assertFalse(policy.audiences.exists()) + + ProfileVisibilityAudience.objects.create( + policy=policy, + kind=ProfileVisibilityAudienceKind.MY_COLLECTIVES, + ) + ProfileVisibilityAudience.objects.create( + policy=policy, + kind=ProfileVisibilityAudienceKind.SELECTED_PROFILE, + selected_profile=self.other, + ) + self.assertEqual( + set(policy.audiences.values_list("kind", flat=True)), + {"my_collectives", "selected_profile"}, + ) + # "Mes collectifs" is an audience selector, not a materialized list: + # no Group or Team membership needs to exist when it is selected. + with self.assertRaises(ValidationError): + ProfileVisibilityAudience.objects.create( + policy=policy, kind=ProfileVisibilityAudienceKind.MY_COLLECTIVES + ) + + def test_audience_kind_cannot_reference_a_different_target_shape(self): + policy = ProfileVisibilityPolicy.objects.create( + owner=self.owner, + target_kind=ProfileVisibilityTargetKind.FIELD, + field_key="bio", + ) + with self.assertRaises(ValidationError): + ProfileVisibilityAudience.objects.create( + policy=policy, + kind=ProfileVisibilityAudienceKind.PUBLIC, + selected_profile=self.other, + ) + with self.assertRaises(ValidationError): + ProfileVisibilityAudience.objects.create( + policy=policy, + kind=ProfileVisibilityAudienceKind.SELECTED_GROUP, + ) + ProfileVisibilityAudience.objects.create( + policy=policy, + kind=ProfileVisibilityAudienceKind.PUBLIC, + ) + + def test_unrelated_legacy_visibility_flags_are_unchanged(self): + interest = ProfileInterest.objects.create(profile=self.owner, topic=self.topic, is_public=False) + open_to = ProfileOpenTo.objects.create( + profile=self.owner, kind="mentor", topic=self.topic, is_public=False + ) + self.assertFalse(interest.is_public) + self.assertFalse(open_to.is_public) + ProfileVisibilityPolicy.objects.create( + owner=self.owner, + target_kind=ProfileVisibilityTargetKind.INTEREST, + interest=interest, + ) + interest.refresh_from_db() + open_to.refresh_from_db() + self.assertFalse(interest.is_public) + self.assertFalse(open_to.is_public) + + def test_public_cannot_accidentally_coexist_with_restricted_audience(self): + policy = ProfileVisibilityPolicy.objects.create( + owner=self.owner, + target_kind=ProfileVisibilityTargetKind.FIELD, + field_key="bio", + ) + from accounts.profile_visibility import replace_profile_visibility + with self.assertRaises(ValidationError): + replace_profile_visibility( + owner=self.owner, target_kind=ProfileVisibilityTargetKind.FIELD, + field_key="bio", + audiences=[ + {"kind": "public"}, + {"kind": "selected_profile", "id": str(self.other.pk)}, + ], + ) + self.assertFalse(policy.audiences.exists()) diff --git a/accounts/test_public_profile_api.py b/accounts/test_public_profile_api.py new file mode 100644 index 0000000000..7dd5f61ebf --- /dev/null +++ b/accounts/test_public_profile_api.py @@ -0,0 +1,49 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from accounts.models import UserProfile + + +class PublicProfileAPITests(TestCase): + def setUp(self): + self.owner = get_user_model().objects.create_user( + username="native-public-owner", + first_name="Amina", + last_name="Test", + bio="Présentation publique", + phone="+243999000111", + email="private-profile@example.test", + ) + self.profile = UserProfile.objects.create( + user=self.owner, + public_profile=True, + searchable=True, + city="Lubumbashi", + address="Ne jamais exposer", + ) + self.url = f"/api/v1/accounts/public-profiles/{self.owner.pk}/" + + def test_anonymous_sees_only_canonical_public_identity(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + profile = response.json()["data"]["profile"] + self.assertEqual(profile["kind"], "profile") + self.assertEqual(profile["id"], str(self.owner.pk)) + self.assertEqual(profile["name"], "Amina Test") + self.assertEqual(profile["bio"], "Présentation publique") + self.assertNotIn("email", profile) + self.assertNotIn("phone", profile) + self.assertNotIn("address", profile) + + def test_unsearchable_profile_remains_directly_accessible_but_private_is_hidden(self): + self.profile.searchable = False + self.profile.save(update_fields=["searchable", "updated_at"]) + self.assertEqual(self.client.get(self.url).status_code, 200) + self.profile.public_profile = False + self.profile.save(update_fields=["public_profile", "updated_at"]) + self.assertEqual(self.client.get(self.url).status_code, 404) + self.profile.public_profile = True + self.profile.save(update_fields=["public_profile", "updated_at"]) + self.owner.is_active = False + self.owner.save(update_fields=["is_active"]) + self.assertEqual(self.client.get(self.url).status_code, 404) diff --git a/activities/api_views.py b/activities/api_views.py index 67b330fdcc..aef01ca18f 100644 --- a/activities/api_views.py +++ b/activities/api_views.py @@ -454,6 +454,8 @@ def get(self, request, pk): "kind": "activity", "id": str(activity.pk), "title": activity.title, + # Only canonical vocabulary; no new vertical or workflow truth. + "vocabulary": _vocabulary(activity), }, "state": {"code": occurrence.status}, "timing": _timing(occurrence), diff --git a/activities/services.py b/activities/services.py index a9756603b1..99187cea40 100644 --- a/activities/services.py +++ b/activities/services.py @@ -9,6 +9,8 @@ from domain_events.contracts import DomainEventType from domain_events.services import emit_domain_event from organizations.services import require_space_operational +from subscriptions.entitlements import resolve_entitlement +from subscriptions.entitlements import resolve_entitlement from .models import ( Activity, @@ -83,6 +85,20 @@ def create_activity(*, created_by, title, space=None, owner_profile=None, **fiel {"owner_profile": "Une Activity personnelle doit être créée par son propriétaire."} ) + # Permission/Mandate belongs to the caller's workflow. Product capability + # belongs solely to the logical Activity owner, never the acting Profile. + entitlement_subject = space if space is not None else owner_profile + capability = resolve_entitlement(entitlement_subject, "activities.create") + if not capability.allowed: + raise ValidationError({ + "subscription": "La création d'Activity n'est pas disponible pour ce sujet." + }) + + subject = space if space is not None else owner_profile + entitlement = resolve_entitlement(subject, "activities.create") + if not entitlement.allowed: + raise ValidationError({"subscription": "Votre formule ne permet pas de créer une activité actuellement."}) + activity = Activity( space=space, owner_profile=owner_profile, diff --git a/activities/views.py b/activities/views.py index c95f0c64e0..5c30d1cf93 100644 --- a/activities/views.py +++ b/activities/views.py @@ -1,12 +1,13 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError from django.shortcuts import get_object_or_404, redirect from django.views.generic import FormView from authorization.constants import PermissionCode from authorization.services import can -from organizations.models import Organization +from organizations.models import Organization, SpaceArchetype +from subscriptions.ux_continuation import action_subscription_handoff, is_subscription_denial from .forms import ActivityCreateForm from .models import ActivityStatus @@ -23,6 +24,49 @@ def get_form_kwargs(self): kwargs["user"] = self.request.user return kwargs + def _context_space(self): + """Contextual creation is a suggestion, never a Mandate.""" + slug = (self.request.GET.get("space") or "").strip() + if not slug: + return None + space = Organization.objects.filter(slug=slug).first() + if space is None or not can( + self.request.user, PermissionCode.SPACE_ACTIVITIES_MANAGE, space + ): + return None + if space.archetype not in { + SpaceArchetype.GENERIC, SpaceArchetype.COMMUNITY, + SpaceArchetype.CREATIVE, SpaceArchetype.MEDIA, + }: + return None + return space + + def get_initial(self): + initial = super().get_initial() + space = self._context_space() + if space is not None: + initial["organization"] = space.pk + return initial + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + space = self._context_space() + if space is not None: + context["create_space_context"] = space + context["create_space_label"] = ( + "Nouvelle initiative" + if space.archetype == SpaceArchetype.COMMUNITY + else "Nouvelle création" + if space.archetype == SpaceArchetype.CREATIVE + else "Nouvelle production" + if space.archetype == SpaceArchetype.MEDIA + else "Nouvelle activité" + ) + context["create_space_return"] = True + if getattr(self, "_subscription_handoff", None): + context["subscription_handoff"] = self._subscription_handoff + return context + def post(self, request, *args, **kwargs): # Reject a forged Space context before form validation turns it into a # generic invalid choice. The submitted identifier is never trusted as @@ -42,18 +86,36 @@ def form_valid(self, form): space, ): raise PermissionDenied("Vous ne pouvez pas organiser une Activity au nom de cet Espace.") - activity = create_activity( - space=space, - owner_profile=self.request.user if space is None else None, - created_by=self.request.user, - title=form.cleaned_data["title"], - short_description=form.cleaned_data["short_description"], - description=form.cleaned_data["description"], - visibility=form.cleaned_data["visibility"], - status=ActivityStatus.DRAFT, - ) + try: + activity = create_activity( + space=space, + owner_profile=self.request.user if space is None else None, + created_by=self.request.user, + title=form.cleaned_data["title"], + short_description=form.cleaned_data["short_description"], + description=form.cleaned_data["description"], + visibility=form.cleaned_data["visibility"], + status=ActivityStatus.DRAFT, + ) + except ValidationError as exc: + if is_subscription_denial(exc): + # Only a confirmed Subscription gate, not any field error, + # produces a scoped continuation handoff. + from django.urls import reverse + from urllib.parse import urlencode + action_url = reverse("activities:create") + if space is not None: + action_url += "?" + urlencode({"space": space.slug}) + self._subscription_handoff = action_subscription_handoff( + self.request, space=space, action_url=action_url, + ) + form.add_error(None, "; ".join(exc.messages if hasattr(exc, "messages") else [str(exc)])) + return self.form_invalid(form) messages.success( self.request, f"Activité « {activity.title} » créée en brouillon.", ) + context_space = self._context_space() + if context_space is not None and space == context_space: + return redirect("organizations:space-work", slug=context_space.slug) return redirect("core:participant-home") diff --git a/authorization/services.py b/authorization/services.py index c67984d246..daa1202d2c 100644 --- a/authorization/services.py +++ b/authorization/services.py @@ -192,6 +192,51 @@ def get_system_role(code: str, *, scope_type=AuthorityScope.SPACE) -> Role: raise ValidationError(f"Le rôle système « {code} » n'est pas disponible.") from exc +@transaction.atomic +def create_custom_space_role(*, actor, space, code, name, description="", permission_codes=()): + """Create a Space role with both explicit authority and Subscription capacity. + + There is no public role-creation endpoint today. This is the canonical + mutation entrypoint for future clients; raw ORM/test fixtures are not a + source of commercial entitlement or authority. Existing roles are kept. + """ + from organizations.models import Organization + from subscriptions.entitlements import resolve_entitlement + + space = Organization.objects.select_for_update().order_by().get(pk=space.pk) + if not can(actor, PermissionCode.SPACE_OWNERSHIP_MANAGE, space): + raise PermissionDenied("L'autorité de gouvernance de cet Espace est requise.") + if not resolve_entitlement(space, "custom_roles").allowed: + raise ValidationError({ + "subscription": "Les rôles personnalisés ne sont pas disponibles pour cet Espace." + }) + + if Role.objects.filter(organization=space, is_system=False, code=code).exists(): + raise ValidationError({"code": "Ce code de rôle existe déjà dans cet Espace."}) + + codes = tuple(dict.fromkeys(permission_codes)) + permissions = list(Permission.objects.filter( + code__in=codes, scope_type=AuthorityScope.SPACE, is_active=True, + )) + if len(permissions) != len(codes): + raise ValidationError({"permissions": "Permission Espace inconnue ou inactive."}) + if any(not can(actor, permission.code, space) for permission in permissions): + raise PermissionDenied("Une permission non détenue ne peut pas être déléguée.") + + role = Role( + code=code, name=name, description=description, + scope_type=AuthorityScope.SPACE, organization=space, + is_system=False, is_active=True, + ) + role.full_clean() + role.save() + for permission in permissions: + assignment = RolePermission(role=role, permission=permission) + assignment.full_clean() + assignment.save() + return role + + def validate_role_for_space(role: Role, space) -> None: if not role.is_active or role.scope_type != AuthorityScope.SPACE: raise ValidationError("Ce rôle ne peut pas être accordé dans un Espace.") diff --git a/commerce/native_activity_checkout_api.py b/commerce/native_activity_checkout_api.py new file mode 100644 index 0000000000..06dbfb8202 --- /dev/null +++ b/commerce/native_activity_checkout_api.py @@ -0,0 +1,204 @@ +"""Activity-level Commerce checkout, without a universal Journey start.""" +from django.core.exceptions import ValidationError as DomainValidationError +from django.db import transaction +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import NotAuthenticated, ValidationError +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from accounts.models import UserProfile +from activities.models import Activity, ActivityStatus, ActivityVisibility, Occurrence, OccurrenceStatus +from commerce.models import CommerceOrder, Offer, PaymentMode +from commerce.selectors import currently_available_offers +from commerce.services import create_order, confirm_order +from events.models import Event +from groups.selectors import eligible_activity_ids_for_profile +from journeys.models import Journey, JourneyStatus, WorkflowKind +from journeys.services import create_journey +from organizations.models import SpaceLifecycle +from payments.obligation_services import create_commerce_payment_obligation + +from .native_checkout_api import ACTIVE_ORDER_STATUSES, CommerceCheckoutInput, _order_payload + + +def public_activity_for_commerce(actor, activity_id, *, lock=False): + qs = Activity.objects.filter( + pk=activity_id, status=ActivityStatus.PUBLISHED, + visibility=ActivityVisibility.PUBLIC, + ).select_related("space", "owner_profile") + if lock: + qs = qs.select_for_update() + activity = get_object_or_404(qs) + if ( + activity.pk not in eligible_activity_ids_for_profile(actor, [activity.pk]) + or Event.objects.filter(activity=activity).exists() + ): + raise Http404 + if activity.space_id: + space = activity.space + visible = ( + space.public_profile and space.searchable + and space.lifecycle == SpaceLifecycle.ACTIVE + and space.verification_status != "suspended" + ) + else: + visible = UserProfile.objects.filter( + user_id=activity.owner_profile_id, + public_profile=True, searchable=True, user__is_active=True, + ).exists() + if not visible: + raise Http404 + return activity + + +def activity_offer_choices(activity): + """Published Offer, Occurrence and Capacity facts; never infer from price.""" + occurrences = list( + Occurrence.objects.filter( + activity=activity, status=OccurrenceStatus.SCHEDULED, + ).order_by("start_at", "pk")[:50] + ) + rows = [] + for occurrence in occurrences or [None]: + for offer in currently_available_offers( + activity=activity, occurrence=occurrence, + ).prefetch_related("payment_options"): + if not offer.is_currently_available: + continue + # after_approval requires a separately approved owner Journey. + modes = [mode for mode in offer.allowed_payment_modes + if mode != PaymentMode.AFTER_APPROVAL] + if not modes: + continue + rows.append({ + "id": str(offer.pk), + "occurrence_id": str(occurrence.pk) if occurrence else None, + "occurrence_label": ( + occurrence.label or str(occurrence.start_at or "") + if occurrence else "" + ), + "label": offer.name, + "unit_price": str(offer.unit_price), + "currency": offer.currency, + "min_quantity": offer.min_quantity, + "max_quantity": offer.max_quantity, + "payment_modes": modes, + "default_payment_mode": offer.payment_mode if offer.payment_mode in modes else modes[0], + }) + return rows + + +def active_activity_order(actor, activity): + if not getattr(actor, "is_authenticated", False): + return None + return CommerceOrder.objects.filter( + buyer=actor, journey__activity=activity, + status__in=ACTIVE_ORDER_STATUSES, + ).order_by("-created_at", "-pk").first() + + +class ActivityCommerceCheckoutAPIView(APIView): + """Atomically create a Purchase Journey and owner-confirmed CommerceOrder.""" + permission_classes = [AllowAny] + + def get(self, request, activity_id): + activity = public_activity_for_commerce(request.user, activity_id) + existing = active_activity_order(request.user, activity) + offers = activity_offer_choices(activity) if existing is None else [] + response = Response({ + "activity": {"id": str(activity.pk), "title": activity.title}, + "journey_id": None, + "can_order": bool(offers), + "existing_order": _order_payload(existing) if existing else None, + "offers": offers, + }) + response["Cache-Control"] = "private, no-store" + return response + + @transaction.atomic + def post(self, request, activity_id): + if not getattr(request.user, "is_authenticated", False): + raise NotAuthenticated() + serializer = CommerceCheckoutInput(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + activity = public_activity_for_commerce(request.user, activity_id, lock=True) + occurrence_id = data.get("occurrence_id") + occurrence = ( + get_object_or_404(Occurrence, pk=occurrence_id, activity=activity, + status=OccurrenceStatus.SCHEDULED) + if occurrence_id else None + ) + key = data["idempotency_key"].strip() + prior = CommerceOrder.objects.filter(idempotency_key=key).first() + if prior is not None: + lines = list(prior.items.values("offer_id", "quantity")) + if ( + prior.buyer_id != request.user.pk + or prior.journey.activity_id != activity.pk + or prior.journey.occurrence_id != (occurrence.pk if occurrence else None) + or prior.payment_mode != data["payment_mode"] + or len(lines) != 1 + or lines[0]["offer_id"] != data["offer_id"] + or lines[0]["quantity"] != data["quantity"] + ): + raise ValidationError({"idempotency_key": "Cette référence concerne une autre demande."}) + result = Response(_order_payload(prior)) + result["Cache-Control"] = "private, no-store" + return result + existing = active_activity_order(request.user, activity) + if existing is not None: + result = Response({ + "detail": "Une commande existe déjà.", + "existing_order": _order_payload(existing), + }, status=status.HTTP_409_CONFLICT) + result["Cache-Control"] = "private, no-store" + return result + context_id = str(occurrence.pk) if occurrence else None + matching = next(( + row for row in activity_offer_choices(activity) + if row["id"] == str(data["offer_id"]) + and row["occurrence_id"] == context_id + and data["payment_mode"] in row["payment_modes"] + ), None) + if matching is None: + raise ValidationError({"offer_id": "Cette offre n’est plus disponible."}) + try: + # An unfinished personal purchase Journey is the same domain + # context, not a reason to create a duplicate one. + journey = ( + Journey.objects.select_for_update(of=("self",)) + .filter( + initiated_by=request.user, beneficiary=request.user, + activity=activity, occurrence=occurrence, + workflow=WorkflowKind.PURCHASE, + status=JourneyStatus.DRAFT, + ) + .order_by("-created_at", "-pk") + .first() + ) + if journey is None: + journey = create_journey( + initiated_by=request.user, beneficiary=request.user, + activity=activity, occurrence=occurrence, + workflow=WorkflowKind.PURCHASE, + ) + order = create_order( + journey=journey, buyer=request.user, + selections=[{"offer": Offer.objects.get(pk=data["offer_id"]), + "quantity": data["quantity"]}], + payment_mode=data["payment_mode"], + idempotency_key=key, + ) + if order.total > 0 and order.payment_mode == PaymentMode.UPFRONT: + create_commerce_payment_obligation(commerce_order=order, actor=request.user) + if order.total == 0 and order.payment_mode == PaymentMode.NONE: + confirm_order(order=order, actor=request.user) + except DomainValidationError as exc: + raise ValidationError({"detail": exc.messages}) from exc + response = Response(_order_payload(order), status=status.HTTP_201_CREATED) + response["Cache-Control"] = "private, no-store" + return response diff --git a/commerce/native_api_urls.py b/commerce/native_api_urls.py new file mode 100644 index 0000000000..9061739cec --- /dev/null +++ b/commerce/native_api_urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from .native_checkout_api import PersonalCommerceCheckoutAPIView +from .native_activity_checkout_api import ActivityCommerceCheckoutAPIView + +app_name = "commerce_api" + +urlpatterns = [ + path("activities//checkout/", ActivityCommerceCheckoutAPIView.as_view(), name="activity-checkout"), + path("journeys//checkout/", PersonalCommerceCheckoutAPIView.as_view(), name="journey-checkout"), +] diff --git a/commerce/native_checkout_api.py b/commerce/native_checkout_api.py new file mode 100644 index 0000000000..fa493c0d93 --- /dev/null +++ b/commerce/native_checkout_api.py @@ -0,0 +1,279 @@ +"""Commerce-owned checkout for an *existing* personal Journey. + +No client-created Journey, price, payment, access or capacity truth. +""" +from django.core.exceptions import ValidationError as DomainValidationError +from django.db import transaction +from django.shortcuts import get_object_or_404 +from rest_framework import serializers, status +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from activities.models import ActivityStatus, ActivityVisibility +from commerce.models import CommerceOrder, CommerceOrderStatus, Offer, PaymentMode +from commerce.selectors import currently_available_offers +from commerce.services import create_order, confirm_order +from groups.community_services import profile_is_eligible_for_activity +from journeys.models import Journey, JourneyStatus, WorkflowKind +from payments.models import PaymentObligation +from payments.obligation_services import create_commerce_payment_obligation + + +ALLOWED_WORKFLOWS = { + WorkflowKind.PURCHASE, WorkflowKind.ORDER_APPROVAL, WorkflowKind.RESERVATION, +} +ACTIVE_ORDER_STATUSES = { + CommerceOrderStatus.PENDING, CommerceOrderStatus.CONFIRMED, +} + + +class CommerceCheckoutInput(serializers.Serializer): + offer_id = serializers.UUIDField() + occurrence_id = serializers.UUIDField(required=False, allow_null=True) + quantity = serializers.IntegerField(min_value=1, max_value=10000) + payment_mode = serializers.ChoiceField(choices=PaymentMode.choices) + idempotency_key = serializers.CharField(max_length=128, allow_blank=False) + + +def _journey_for_actor(actor, journey_id, *, lock=False): + qs = Journey.objects.filter(beneficiary=actor).select_related("activity", "occurrence") + if lock: + qs = qs.select_for_update() + return get_object_or_404(qs, pk=journey_id) + + +def _active_order(journey): + return ( + CommerceOrder.objects.filter(journey=journey, status__in=ACTIVE_ORDER_STATUSES) + .order_by("-created_at", "-pk").first() + ) + + +def _order_payload(order): + obligation = PaymentObligation.objects.filter(commerce_order=order).order_by("-created_at").first() + return { + "kind": "commerce_order", + "id": str(order.pk), + "journey_id": str(order.journey_id), + "reference": order.reference, + "status": order.status, + "total": str(order.total), + "currency": order.currency, + "payment_mode": order.payment_mode, + "payment_obligation_id": str(obligation.pk) if obligation else None, + "payment_obligation_status": obligation.status if obligation else None, + } + + +def _checkout_offers(journey): + return currently_available_offers( + activity=journey.activity, + occurrence=journey.occurrence, + ).prefetch_related("payment_options") + + +def _allowed_to_checkout(actor, journey): + allowed_states = { + WorkflowKind.PURCHASE: { + JourneyStatus.DRAFT, JourneyStatus.PENDING_PAYMENT, + }, + WorkflowKind.ORDER_APPROVAL: { + JourneyStatus.APPROVED, JourneyStatus.PENDING_PAYMENT, + }, + WorkflowKind.RESERVATION: { + JourneyStatus.DRAFT, JourneyStatus.SUBMITTED, JourneyStatus.APPROVED, + }, + } + if ( + journey.status not in allowed_states.get(journey.workflow, set()) + or journey.activity.status != ActivityStatus.PUBLISHED + or journey.activity.visibility != ActivityVisibility.PUBLIC + or not profile_is_eligible_for_activity(actor, journey.activity) + ): + return False + return True + + +def _journey_offer_modes(journey, offer): + modes = list(offer.allowed_payment_modes) + if journey.workflow == WorkflowKind.PURCHASE: + return [mode for mode in modes if mode != PaymentMode.AFTER_APPROVAL] + if journey.workflow == WorkflowKind.ORDER_APPROVAL: + return [mode for mode in modes if mode != PaymentMode.UPFRONT] + # Reservation Journey transitions do not support generic upfront payment. + return [ + mode for mode in modes + if mode not in {PaymentMode.UPFRONT, PaymentMode.AFTER_APPROVAL} + ] + + +def personal_commerce_handoffs(activity_ids, *, actor): + """Owner-scoped continuation; only the beneficiary may see their Journey.""" + if not getattr(actor, "is_authenticated", False) or not activity_ids: + return {} + terminal = { + JourneyStatus.FULFILLED, JourneyStatus.REJECTED, + JourneyStatus.CANCELLED, JourneyStatus.EXPIRED, + } + journeys = ( + Journey.objects.filter(beneficiary=actor, activity_id__in=activity_ids) + .exclude(status__in=terminal) + .select_related("activity", "occurrence") + .order_by("-created_at", "-pk") + ) + result = {} + for journey in journeys: + activity_id = str(journey.activity_id) + if activity_id in result: + continue + order = _active_order(journey) + if order is not None: + result[activity_id] = { + "state": "owner_api", + "owner": "commerce", + "capabilities": ["resume_order"], + "resource": {"kind": "commerce_order", "id": str(order.pk)}, + "links": { + "native": f"/commerce/orders/{order.pk}", + "detail": f"/api/v1/me/orders/{order.pk}/", + }, + "occurrence_id": str(journey.occurrence_id) if journey.occurrence_id else None, + } + elif ( + journey.workflow in ALLOWED_WORKFLOWS + and _allowed_to_checkout(actor, journey) + and any( + offer.is_currently_available + and bool(_journey_offer_modes(journey, offer)) + for offer in _checkout_offers(journey) + ) + ): + result[activity_id] = { + "state": "owner_api", + "owner": "commerce", + "capabilities": ["checkout"], + "resource": {"kind": "journey", "id": str(journey.pk)}, + "links": { + "native": f"/commerce/journeys/{journey.pk}/checkout", + "checkout": f"/api/v1/commerce/journeys/{journey.pk}/checkout/", + }, + "occurrence_id": str(journey.occurrence_id) if journey.occurrence_id else None, + } + else: + result[activity_id] = { + "state": "owner_api", + "owner": "journeys", + "capabilities": ["continue"], + "resource": {"kind": "journey", "id": str(journey.pk)}, + "links": {"native": f"/journeys/{journey.pk}"}, + "occurrence_id": str(journey.occurrence_id) if journey.occurrence_id else None, + } + return result + + +class PersonalCommerceCheckoutAPIView(APIView): + """Browse eligible offers and create at most one active order per Journey.""" + permission_classes = [IsAuthenticated] + + def get(self, request, journey_id): + journey = _journey_for_actor(request.user, journey_id) + order = _active_order(journey) + allowed = _allowed_to_checkout(request.user, journey) + offers = [] + if allowed and order is None: + for offer in _checkout_offers(journey): + if not offer.is_currently_available: + continue + modes = _journey_offer_modes(journey, offer) + if not modes: + continue + offers.append({ + "id": str(offer.pk), + "label": offer.name, + "description": offer.description, + "unit_price": str(offer.unit_price), + "currency": offer.currency, + "min_quantity": offer.min_quantity, + "max_quantity": offer.max_quantity, + "payment_modes": modes, + "default_payment_mode": ( + offer.payment_mode if offer.payment_mode in modes else modes[0] + ), + "occurrence_id": str(offer.occurrence_id) if offer.occurrence_id else None, + }) + response = Response({ + "journey_id": str(journey.pk), + "activity": {"id": str(journey.activity_id), "title": journey.activity.title}, + "can_order": allowed and order is None and bool(offers), + "existing_order": _order_payload(order) if order else None, + "offers": offers, + }) + response["Cache-Control"] = "private, no-store" + return response + + @transaction.atomic + def post(self, request, journey_id): + values = CommerceCheckoutInput(data=request.data) + values.is_valid(raise_exception=True) + data = values.validated_data + journey = _journey_for_actor(request.user, journey_id, lock=True) + key = data["idempotency_key"].strip() + existing_by_key = CommerceOrder.objects.filter(idempotency_key=key).first() + if existing_by_key is not None: + lines = list(existing_by_key.items.values("offer_id", "quantity")) + if ( + existing_by_key.journey_id != journey.pk + or existing_by_key.buyer_id != request.user.pk + or existing_by_key.payment_mode != data["payment_mode"] + or len(lines) != 1 + or lines[0]["offer_id"] != data["offer_id"] + or lines[0]["quantity"] != data["quantity"] + ): + raise ValidationError({"idempotency_key": "Cette référence concerne une autre demande."}) + response = Response(_order_payload(existing_by_key), status=status.HTTP_200_OK) + response["Cache-Control"] = "private, no-store" + return response + + # An exact replay is safe even if the Journey advanced; reject any + # second order before checking new-checkout eligibility. + active = _active_order(journey) + if active is not None: + response = Response({ + "detail": "Une commande existe déjà pour cette démarche.", + "existing_order": _order_payload(active), + }, status=status.HTTP_409_CONFLICT) + response["Cache-Control"] = "private, no-store" + return response + + if not _allowed_to_checkout(request.user, journey): + raise ValidationError({"journey": "Cette démarche ne permet pas cette commande."}) + offer = get_object_or_404(_checkout_offers(journey), pk=data["offer_id"]) + if ( + not offer.is_currently_available + or data["payment_mode"] not in _journey_offer_modes(journey, offer) + ): + raise ValidationError({"offer_id": "Cette offre n’est plus disponible dans ces conditions."}) + try: + order = create_order( + journey=journey, + buyer=request.user, + selections=[{"offer": offer, "quantity": data["quantity"]}], + payment_mode=data["payment_mode"], + idempotency_key=key, + ) + # A provider obligation is appropriate only for a payable mode. + # Never equate on-site/later payment with an online payment. + if ( + order.total > 0 + and order.payment_mode in {PaymentMode.UPFRONT, PaymentMode.AFTER_APPROVAL} + ): + create_commerce_payment_obligation(commerce_order=order, actor=request.user) + if order.total == 0 and order.payment_mode == PaymentMode.NONE and journey.workflow == WorkflowKind.PURCHASE: + confirm_order(order=order, actor=request.user) + except DomainValidationError as exc: + raise ValidationError({"detail": exc.messages}) from exc + response = Response(_order_payload(order), status=status.HTTP_201_CREATED) + response["Cache-Control"] = "private, no-store" + return response diff --git a/commerce/offer_price_facts.py b/commerce/offer_price_facts.py new file mode 100644 index 0000000000..cd6206e31a --- /dev/null +++ b/commerce/offer_price_facts.py @@ -0,0 +1,257 @@ +"""Owner-level price facts for read models; not a checkout quotation. + +The caller resolves the Activity/Occurrence visibility and authorization. This +selector never infers a zero price from a missing Offer and never combines +currencies or computes taxes/processing charges. +""" +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from django.db.models import Count, Max, Min +from django.utils import timezone + +from .selectors import currently_available_offers + + +def _amount_as_text(amount: Decimal | None) -> str | None: + """Serialize owner Offer unit prices with stable two-decimal precision.""" + return format(amount, ".2f") if amount is not None else None + + +@dataclass(frozen=True) +class OfferPriceFacts: + state: str + minimum: Decimal | None + maximum: Decimal | None + currency: str | None + offer_count: int + reason: str | None + owner_updated_at: object | None + earliest_offer_end_at: object | None + + def as_dict(self) -> dict: + return { + "state": self.state, + "minimum": _amount_as_text(self.minimum), + "maximum": _amount_as_text(self.maximum), + "currency": self.currency, + "offer_count": self.offer_count, + "reason": self.reason, + "basis": "active_offers_in_time_window", + "owner_updated_at": ( + self.owner_updated_at.isoformat() + if self.owner_updated_at is not None else None + ), + "earliest_offer_end_at": ( + self.earliest_offer_end_at.isoformat() + if self.earliest_offer_end_at is not None else None + ), + # The owner record's update time is not a check of real-world + # price, capacity, taxes, discounts or payment readiness. + "fact_freshness": "not_asserted", + } + + +def offer_price_facts(*, activity=None, occurrence=None, at=None) -> OfferPriceFacts: + """Summarize prices the Commerce owner can establish in one context. + + For an Activity alone, only Activity-scoped Offers are applicable. + With an Occurrence, its specific Offers and genuinely shared Activity + Offers may apply. Distinct Occurrence variants should be evaluated per + Occurrence rather than silently folded into a fictional Activity price. + + Results are indicative unit prices, not a checkout quote or a claim of + capacity. Commerce order services remain authoritative for totals. + """ + if (activity is None) == (occurrence is None): + raise ValueError("Précisez exactement une Activity ou une Occurrence.") + now = at if at is not None else timezone.now() + offers = currently_available_offers( + activity=activity, occurrence=occurrence, now=now + ) + stats = offers.aggregate( + offer_count=Count("id"), + currency_count=Count("currency", distinct=True), + minimum=Min("unit_price"), + maximum=Max("unit_price"), + owner_updated_at=Max("updated_at"), + earliest_offer_end_at=Min("available_until"), + ) + count = stats["offer_count"] + if count == 0 or stats["currency_count"] != 1: + return OfferPriceFacts( + state="unknown", minimum=None, maximum=None, currency=None, + offer_count=count, + reason="no_applicable_offer" if count == 0 else "mixed_currencies", + owner_updated_at=stats["owner_updated_at"], + earliest_offer_end_at=stats["earliest_offer_end_at"], + ) + + currency = offers.values_list("currency", flat=True).first() + minimum = stats["minimum"] + maximum = stats["maximum"] + if maximum == Decimal("0.00"): + state = "free" + elif minimum == maximum: + state = "exact" + else: + state = "range" + return OfferPriceFacts( + state=state, minimum=minimum, maximum=maximum, + currency=currency, offer_count=count, reason=None, + owner_updated_at=stats["owner_updated_at"], + earliest_offer_end_at=stats["earliest_offer_end_at"], + ) + + +def occurrence_variant_price_facts(*, occurrences, at=None) -> dict: + """Compare at most 12 owner-scoped Occurrences selected by the caller. + + The caller MUST pass Occurrences whose visibility and viewer eligibility + have already been checked. This is an on-demand owner read operation, not + a public ID-based comparison API and not a replacement for checkout. + """ + selected = list(occurrences) + if not 2 <= len(selected) <= 12: + raise ValueError("La comparaison exige entre 2 et 12 Occurrences.") + identities = {occurrence.pk for occurrence in selected} + activity_ids = {occurrence.activity_id for occurrence in selected} + if ( + len(identities) != len(selected) + or len(activity_ids) != 1 + or None in identities + ): + raise ValueError("Les variantes doivent être distinctes et partager une Activity.") + + snapshot_at = at if at is not None else timezone.now() + rows = [ + ( + occurrence, + offer_price_facts(occurrence=occurrence, at=snapshot_at), + ) + for occurrence in selected + ] + prices = [price for _, price in rows] + currencies = {price.currency for price in prices} + comparable = ( + len(currencies) == 1 and None not in currencies + and all(price.state in {"free", "exact", "range"} for price in prices) + ) + if comparable: + minimum = min(price.minimum for price in prices) + maximum = max(price.maximum for price in prices) + distinct_terms = {(price.minimum, price.maximum) for price in prices} + if len(distinct_terms) > 1: + state = "variant_dependent" + elif maximum == Decimal("0.00"): + state = "free" + elif minimum == maximum: + state = "exact" + else: + state = "range" + currency = prices[0].currency + else: + state = "unknown" + minimum = maximum = currency = None + return { + "state": state, + "minimum": _amount_as_text(minimum), + "maximum": _amount_as_text(maximum), + "currency": currency, + "basis": "selected_occurrence_offers", + "fact_freshness": "not_asserted", + "variants": [ + { + "occurrence": {"kind": "occurrence", "id": str(occurrence.pk)}, + "price": price.as_dict(), + } + for occurrence, price in rows + ], + } + + + +def offer_price_facts_many(*, contexts, at=None) -> dict: + """Batch owner price facts for admitted (Activity, optional Occurrence) pairs. + + Contexts are trusted only after a caller has enforced visibility. This + preserves applicable_offers semantics and does not assert Capacity or + checkout totals. Unlike repeated offer_price_facts calls, it uses one + query for the selected page. + """ + from django.db.models import Q + + from .models import Offer, OfferStatus + + selected = { + (str(activity_id), str(occurrence_id) if occurrence_id else None) + for activity_id, occurrence_id in contexts + } + if not selected: + return {} + activity_ids = {activity_id for activity_id, _ in selected} + now = at if at is not None else timezone.now() + offers = list( + Offer.objects.filter( + activity_id__in=activity_ids, + status=OfferStatus.ACTIVE, + ).filter( + Q(available_from__isnull=True) | Q(available_from__lte=now), + Q(available_until__isnull=True) | Q(available_until__gt=now), + ).select_related("capacity_pool") + ) + by_activity = {} + for offer in offers: + by_activity.setdefault(str(offer.activity_id), []).append(offer) + + result = {} + for activity_id, occurrence_id in selected: + applicable = [ + offer + for offer in by_activity.get(activity_id, ()) + if ( + offer.occurrence_id is None + or (occurrence_id is not None and str(offer.occurrence_id) == occurrence_id) + ) + and ( + offer.capacity_pool_id is None + or offer.capacity_pool.occurrence_id is None + or ( + occurrence_id is not None + and str(offer.capacity_pool.occurrence_id) == occurrence_id + ) + ) + ] + currencies = {offer.currency for offer in applicable} + minimum = maximum = currency = None + reason = None + if not applicable: + state = "unknown" + reason = "no_applicable_offer" + elif len(currencies) != 1: + state = "unknown" + reason = "mixed_currencies" + else: + currency = next(iter(currencies)) + minimum = min(offer.unit_price for offer in applicable) + maximum = max(offer.unit_price for offer in applicable) + state = ( + "free" if maximum == Decimal("0.00") + else "exact" if minimum == maximum + else "range" + ) + timestamps = [offer.updated_at for offer in applicable if offer.updated_at] + ends = [offer.available_until for offer in applicable if offer.available_until] + result[(activity_id, occurrence_id)] = OfferPriceFacts( + state=state, + minimum=minimum, + maximum=maximum, + currency=currency, + offer_count=len(applicable), + reason=reason, + owner_updated_at=max(timestamps) if timestamps else None, + earliest_offer_end_at=min(ends) if ends else None, + ) + return result diff --git a/commerce/personal_order_views.py b/commerce/personal_order_views.py index 60f54f4ee7..c31cf3fe83 100644 --- a/commerce/personal_order_views.py +++ b/commerce/personal_order_views.py @@ -5,6 +5,7 @@ from rest_framework.views import APIView from commerce.models import CommerceOrder +from commerce.native_checkout_api import _order_payload class PersonalCommerceOrderDetailAPIView(APIView): @@ -35,6 +36,15 @@ def get(self, request, order_id): }, "cancelled_at": order.cancelled_at.isoformat() if order.cancelled_at else None, + "checkout": _order_payload(order), + "items": [ + { + "label": line.label_snapshot, + "quantity": line.quantity, + "line_total": str(line.line_total), + } + for line in order.items.all() + ], }) response["Cache-Control"] = "private, no-store" return response diff --git a/commerce/test_native_activity_checkout_api.py b/commerce/test_native_activity_checkout_api.py new file mode 100644 index 0000000000..0709736c8a --- /dev/null +++ b/commerce/test_native_activity_checkout_api.py @@ -0,0 +1,140 @@ +import json +from datetime import timedelta +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from activities.models import Activity, ActivityStatus, ActivityVisibility, Occurrence, OccurrenceStatus +from capacity.models import CapacityPool, CapacityReservation +from commerce.models import CommerceOrder, Offer, OfferStatus, PaymentMode +from journeys.models import Journey, WorkflowKind +from journeys.services import create_journey +from organizations.models import Organization +from payments.models import Payment, PaymentObligation + + +class ActivityCheckoutAPITests(TestCase): + def setUp(self): + users = get_user_model() + self.user = users.objects.create_user( + username="direct-commerce-buyer", email="buyer@example.test", + password="test-pass-2026", + ) + self.other = users.objects.create_user( + username="direct-commerce-other", email="other@example.test", + password="test-pass-2026", + ) + self.space = Organization.objects.create(name="Direct Commerce", created_by=self.user) + self.activity = Activity.objects.create( + space=self.space, created_by=self.user, title="Place à réserver", + status=ActivityStatus.PUBLISHED, visibility=ActivityVisibility.PUBLIC, + ) + self.occurrence = Occurrence.objects.create( + activity=self.activity, status=OccurrenceStatus.SCHEDULED, + start_at=timezone.now() + timedelta(days=3), + end_at=timezone.now() + timedelta(days=3, hours=2), + ) + self.pool = CapacityPool.objects.create( + activity=self.activity, occurrence=self.occurrence, + total_quantity=3, + ) + self.offer = Offer.objects.create( + activity=self.activity, occurrence=self.occurrence, + capacity_pool=self.pool, name="Réservation", + unit_price=Decimal("0.00"), currency="USD", + payment_mode=PaymentMode.NONE, status=OfferStatus.ACTIVE, + ) + self.url = reverse("commerce_api:activity-checkout", kwargs={ + "activity_id": self.activity.pk, + }) + + def send(self, *, key="direct-order-01", quantity=1, mode="none", occurrence=None): + return self.client.post( + self.url, + data=json.dumps({ + "idempotency_key": key, + "offer_id": str(self.offer.pk), + "occurrence_id": str(occurrence or self.occurrence.pk), + "quantity": quantity, + "payment_mode": mode, + }), + content_type="application/json", + ) + + def test_guest_can_browse_offers_but_cannot_mutate(self): + result = self.client.get(self.url) + self.assertEqual(result.status_code, 200) + self.assertTrue(result.json()["can_order"]) + self.assertEqual(result.json()["offers"][0]["occurrence_id"], str(self.occurrence.pk)) + self.assertIn(self.send().status_code, (401, 403)) + self.assertEqual(Journey.objects.count(), 0) + + def test_create_is_atomic_and_does_not_double_hold_capacity(self): + self.client.force_login(self.user) + first = self.send() + self.assertEqual(first.status_code, 201) + self.assertEqual(first.json()["status"], "confirmed") + self.assertIsNone(first.json()["payment_obligation_id"]) + second = self.send() + self.assertEqual(second.status_code, 200) + self.assertEqual(first.json()["id"], second.json()["id"]) + self.assertEqual(CommerceOrder.objects.count(), 1) + self.assertEqual(Journey.objects.count(), 1) + self.assertEqual(CapacityReservation.objects.count(), 1) + self.assertEqual(Payment.objects.count(), 0) + conflict = self.send(key="direct-order-02") + self.assertEqual(conflict.status_code, 409) + self.assertEqual(Journey.objects.count(), 1) + self.assertEqual(CommerceOrder.objects.count(), 1) + + def test_direct_checkout_reuses_existing_purchase_draft(self): + self.client.force_login(self.user) + existing = create_journey( + initiated_by=self.user, beneficiary=self.user, + activity=self.activity, occurrence=self.occurrence, + workflow=WorkflowKind.PURCHASE, + ) + result = self.send() + self.assertEqual(result.status_code, 201) + order = CommerceOrder.objects.get() + self.assertEqual(order.journey_id, existing.pk) + self.assertEqual(Journey.objects.count(), 1) + + def test_replay_must_preserve_buyer_occurrence_and_quantity(self): + self.client.force_login(self.user) + self.assertEqual(self.send().status_code, 201) + self.assertEqual(self.send(quantity=2).status_code, 400) + self.client.force_login(self.other) + self.assertEqual(self.send().status_code, 400) + self.assertEqual(Journey.objects.count(), 1) + + def test_offer_context_and_private_owner_are_enforced(self): + self.client.force_login(self.user) + other_occurrence = Occurrence.objects.create( + activity=self.activity, status=OccurrenceStatus.SCHEDULED, + start_at=timezone.now() + timedelta(days=5), + end_at=timezone.now() + timedelta(days=5, hours=1), + ) + self.assertEqual(self.send(occurrence=other_occurrence.pk).status_code, 400) + self.assertEqual(CommerceOrder.objects.count(), 0) + self.activity.visibility = ActivityVisibility.PRIVATE + self.activity.save() + self.assertEqual(self.client.get(self.url).status_code, 404) + self.assertEqual(self.send().status_code, 404) + + def test_online_paid_order_creates_obligation_not_payment(self): + self.client.force_login(self.user) + self.offer.unit_price = Decimal("19.00") + self.offer.payment_mode = PaymentMode.UPFRONT + self.offer.save() + result = self.send(mode="upfront") + self.assertEqual(result.status_code, 201) + order = CommerceOrder.objects.get() + self.assertEqual(order.status, "pending") + self.assertEqual(order.total, Decimal("19.00")) + obligation = PaymentObligation.objects.get(commerce_order=order) + self.assertEqual(result.json()["payment_obligation_id"], str(obligation.pk)) + self.assertEqual(Payment.objects.count(), 0) diff --git a/commerce/test_native_checkout_api.py b/commerce/test_native_checkout_api.py new file mode 100644 index 0000000000..6f8285dedb --- /dev/null +++ b/commerce/test_native_checkout_api.py @@ -0,0 +1,141 @@ +import json +from datetime import timedelta +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from activities.models import Activity, ActivityStatus, ActivityVisibility, Occurrence +from capacity.models import CapacityPool, CapacityReservation +from commerce.models import CommerceOrder, Offer, OfferStatus, PaymentMode +from journeys.models import WorkflowKind +from journeys.services import create_journey +from organizations.models import Organization +from payments.models import PaymentObligation + + +class PersonalCommerceCheckoutAPITests(TestCase): + def setUp(self): + users = get_user_model() + self.actor = users.objects.create_user( + username="native-checkout-user", email="checkout@example.test", + password="test-pass-2026", + ) + self.other = users.objects.create_user( + username="native-checkout-other", email="other-checkout@example.test", + password="test-pass-2026", + ) + self.space = Organization.objects.create(name="Native Commerce", created_by=self.actor) + self.activity = Activity.objects.create( + space=self.space, created_by=self.actor, title="Réservation test", + status=ActivityStatus.PUBLISHED, visibility=ActivityVisibility.PUBLIC, + ) + self.occurrence = Occurrence.objects.create( + activity=self.activity, + start_at=timezone.now() + timedelta(days=2), + end_at=timezone.now() + timedelta(days=2, hours=2), + ) + self.journey = create_journey( + initiated_by=self.actor, beneficiary=self.actor, activity=self.activity, + occurrence=self.occurrence, workflow=WorkflowKind.PURCHASE, + ) + self.pool = CapacityPool.objects.create( + activity=self.activity, occurrence=self.occurrence, total_quantity=2, + ) + self.offer = Offer.objects.create( + activity=self.activity, occurrence=self.occurrence, + capacity_pool=self.pool, name="Réservation", unit_price=Decimal("0.00"), + currency="USD", payment_mode=PaymentMode.NONE, status=OfferStatus.ACTIVE, + ) + self.url = reverse("commerce_api:journey-checkout", kwargs={"journey_id": self.journey.pk}) + + def post(self, *, key="mobile-order-001", offer=None, quantity=1, mode="none"): + return self.client.post(self.url, data=json.dumps({ + "idempotency_key": key, + "offer_id": str((offer or self.offer).pk), + "quantity": quantity, + "payment_mode": mode, + }), content_type="application/json") + + def test_checkout_requires_beneficiary_identity(self): + self.assertIn(self.client.get(self.url).status_code, (401, 403)) + self.client.force_login(self.other) + self.assertEqual(self.client.get(self.url).status_code, 404) + self.assertEqual(self.post().status_code, 404) + self.assertEqual(CommerceOrder.objects.count(), 0) + + def test_free_checkout_is_owner_confirmed_and_idempotent(self): + self.client.force_login(self.actor) + listing = self.client.get(self.url) + self.assertEqual(listing.status_code, 200) + self.assertTrue(listing.json()["can_order"]) + self.assertEqual(len(listing.json()["offers"]), 1) + first = self.post() + self.assertEqual(first.status_code, 201) + self.assertEqual(first.json()["status"], "confirmed") + self.assertEqual(first.json()["total"], "0.00") + self.assertIsNone(first.json()["payment_obligation_id"]) + repeated = self.post() + self.assertEqual(repeated.status_code, 200) + self.assertEqual(repeated.json()["id"], first.json()["id"]) + self.assertEqual(CommerceOrder.objects.count(), 1) + self.assertEqual(CapacityReservation.objects.filter(journey=self.journey).count(), 1) + self.assertEqual(PaymentObligation.objects.count(), 0) + listing = self.client.get(self.url).json() + self.assertFalse(listing["can_order"]) + self.assertEqual(listing["existing_order"]["id"], first.json()["id"]) + + def test_second_key_does_not_create_second_order(self): + self.client.force_login(self.actor) + self.assertEqual(self.post().status_code, 201) + second = self.post(key="another-mobile-key") + self.assertEqual(second.status_code, 409) + self.assertEqual(CommerceOrder.objects.count(), 1) + self.assertEqual(CapacityReservation.objects.count(), 1) + + def test_replayed_key_cannot_change_purchase_intent(self): + self.client.force_login(self.actor) + self.assertEqual(self.post().status_code, 201) + changed = self.post(quantity=2) + self.assertEqual(changed.status_code, 400) + self.assertEqual(CommerceOrder.objects.count(), 1) + + def test_foreign_offer_and_unavailable_offer_are_rejected(self): + self.client.force_login(self.actor) + other_activity = Activity.objects.create( + space=self.space, created_by=self.actor, title="Other activity", + ) + foreign = Offer.objects.create( + activity=other_activity, name="Other", unit_price=Decimal("0.00"), + payment_mode=PaymentMode.NONE, status=OfferStatus.ACTIVE, + ) + self.assertEqual(self.post(offer=foreign).status_code, 404) + self.offer.status = OfferStatus.INACTIVE + self.offer.save() + self.assertEqual(self.post().status_code, 404) + self.assertEqual(CommerceOrder.objects.count(), 0) + + def test_paid_checkout_creates_obligation_not_a_successful_payment(self): + self.client.force_login(self.actor) + self.offer.unit_price = Decimal("25.00") + self.offer.payment_mode = PaymentMode.UPFRONT + self.offer.save() + result = self.post(mode="upfront") + self.assertEqual(result.status_code, 201) + order = CommerceOrder.objects.get() + self.assertEqual(order.total, Decimal("25.00")) + self.assertEqual(order.status, "pending") + self.assertEqual(PaymentObligation.objects.filter(commerce_order=order).count(), 1) + self.assertEqual(result.json()["payment_obligation_id"], + str(PaymentObligation.objects.get(commerce_order=order).pk)) + detail = self.client.get( + reverse("personal-projections:commerce-order-detail", kwargs={"order_id": order.pk}) + ) + self.assertEqual(detail.status_code, 200) + self.assertEqual(detail.json()["checkout"]["id"], str(order.pk)) + self.client.force_login(self.other) + self.assertEqual(self.client.get( + reverse("personal-projections:commerce-order-detail", kwargs={"order_id": order.pk}) + ).status_code, 404) diff --git a/commerce/test_offer_price_facts.py b/commerce/test_offer_price_facts.py new file mode 100644 index 0000000000..7263ce4c15 --- /dev/null +++ b/commerce/test_offer_price_facts.py @@ -0,0 +1,178 @@ +from datetime import timedelta +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone + +from activities.models import Activity, Occurrence +from organizations.models import Organization + +from .models import Offer, OfferStatus, PaymentMode +from .offer_price_facts import offer_price_facts, occurrence_variant_price_facts, offer_price_facts_many + + +class OfferPriceFactsTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username="offer-facts-owner", email="offer-facts@example.test", + password="test-pass-2026", + ) + self.space = Organization.objects.create( + name="Offer Facts Space", created_by=self.user, + ) + self.activity = Activity.objects.create( + space=self.space, created_by=self.user, title="Example Activity", + ) + self.occurrence = Occurrence.objects.create( + activity=self.activity, label="Session A", + start_at=timezone.now() + timedelta(days=2), + end_at=timezone.now() + timedelta(days=2, hours=1), + ) + self.other_occurrence = Occurrence.objects.create( + activity=self.activity, label="Session B", + start_at=timezone.now() + timedelta(days=3), + end_at=timezone.now() + timedelta(days=3, hours=1), + ) + self.now = timezone.now() + + def make_offer( + self, price, *, occurrence=None, currency="USD", status=OfferStatus.ACTIVE, + available_from=None, available_until=None, + ): + return Offer.objects.create( + activity=self.activity, occurrence=occurrence, + name=f"Offer {price} {currency}", + unit_price=Decimal(price), currency=currency, status=status, + payment_mode=( + PaymentMode.NONE if Decimal(price) == 0 else PaymentMode.UPFRONT + ), + available_from=available_from, available_until=available_until, + ) + + def test_batch_owner_facts_match_single_contexts(self): + self.make_offer("10.00") + self.make_offer("30.00", occurrence=self.occurrence) + self.make_offer("70.00", occurrence=self.other_occurrence) + contexts = ( + (self.activity.pk, None), + (self.activity.pk, self.occurrence.pk), + (self.activity.pk, self.other_occurrence.pk), + ) + with self.assertNumQueries(1): + batch = offer_price_facts_many(contexts=contexts, at=self.now) + for activity_id, occurrence_id in contexts: + single = ( + offer_price_facts(activity=self.activity, at=self.now) + if occurrence_id is None else + offer_price_facts( + occurrence=self.occurrence if occurrence_id == self.occurrence.pk + else self.other_occurrence, + at=self.now, + ) + ) + self.assertEqual( + batch[(str(activity_id), str(occurrence_id) if occurrence_id else None)].as_dict(), + single.as_dict(), + ) + + def test_missing_offer_is_unknown_not_free(self): + result = offer_price_facts(activity=self.activity, at=self.now) + self.assertEqual(result.as_dict()["state"], "unknown") + self.assertEqual(result.as_dict()["reason"], "no_applicable_offer") + self.assertIsNone(result.minimum) + + def test_active_free_offer_is_explicitly_free(self): + self.make_offer("0.00") + result = offer_price_facts(activity=self.activity, at=self.now) + self.assertEqual(result.state, "free") + self.assertEqual(result.as_dict()["minimum"], "0.00") + self.assertEqual(result.as_dict()["fact_freshness"], "not_asserted") + + def test_exact_and_range_prices_preserve_currency(self): + self.make_offer("10.00") + exact = offer_price_facts(activity=self.activity, at=self.now) + self.assertEqual(exact.state, "exact") + self.assertEqual(exact.currency, "USD") + self.make_offer("30.00") + ranged = offer_price_facts(activity=self.activity, at=self.now) + self.assertEqual(ranged.state, "range") + self.assertEqual((ranged.minimum, ranged.maximum), ( + Decimal("10.00"), Decimal("30.00") + )) + + def test_mixed_currencies_are_not_compared(self): + self.make_offer("20.00", currency="USD") + self.make_offer("1000.00", currency="CDF") + result = offer_price_facts(activity=self.activity, at=self.now) + self.assertEqual(result.state, "unknown") + self.assertEqual(result.reason, "mixed_currencies") + self.assertIsNone(result.currency) + self.assertIsNone(result.minimum) + + def test_scope_excludes_other_occurrence_price(self): + self.make_offer("10.00") + self.make_offer("30.00", occurrence=self.occurrence) + self.make_offer("70.00", occurrence=self.other_occurrence) + activity_facts = offer_price_facts(activity=self.activity, at=self.now) + occurrence_facts = offer_price_facts(occurrence=self.occurrence, at=self.now) + self.assertEqual(activity_facts.state, "exact") + self.assertEqual(activity_facts.minimum, Decimal("10.00")) + self.assertEqual(occurrence_facts.state, "range") + self.assertEqual(occurrence_facts.maximum, Decimal("30.00")) + + def test_future_expired_and_inactive_offers_are_not_present_prices(self): + self.make_offer("10.00", available_from=self.now + timedelta(days=1)) + self.make_offer("20.00", available_until=self.now - timedelta(seconds=1)) + self.make_offer("30.00", status=OfferStatus.INACTIVE) + result = offer_price_facts(activity=self.activity, at=self.now) + self.assertEqual(result.state, "unknown") + self.assertEqual(result.offer_count, 0) + + def test_source_update_and_end_are_not_fact_verification(self): + end = self.now + timedelta(days=4) + self.make_offer("10.00", available_until=end) + facts = offer_price_facts(activity=self.activity, at=self.now).as_dict() + self.assertIsNotNone(facts["owner_updated_at"]) + self.assertEqual(facts["earliest_offer_end_at"], end.isoformat()) + self.assertEqual(facts["fact_freshness"], "not_asserted") + + def test_context_is_required(self): + with self.assertRaises(ValueError): + offer_price_facts() + with self.assertRaises(ValueError): + offer_price_facts(activity=self.activity, occurrence=self.occurrence) + + def test_occurrence_variants_show_owner_backed_different_prices(self): + self.make_offer("10.00", occurrence=self.occurrence) + self.make_offer("40.00", occurrence=self.other_occurrence) + result = occurrence_variant_price_facts( + occurrences=[self.occurrence, self.other_occurrence], at=self.now, + ) + self.assertEqual(result["state"], "variant_dependent") + self.assertEqual(result["minimum"], "10.00") + self.assertEqual(result["maximum"], "40.00") + self.assertEqual(result["currency"], "USD") + self.assertEqual( + {v["occurrence"]["id"] for v in result["variants"]}, + {str(self.occurrence.pk), str(self.other_occurrence.pk)}, + ) + + def test_variants_do_not_convert_currencies_or_infer_unknown_price(self): + self.make_offer("10.00", occurrence=self.occurrence) + self.make_offer( + "1000.00", occurrence=self.other_occurrence, currency="CDF", + ) + result = occurrence_variant_price_facts( + occurrences=[self.occurrence, self.other_occurrence], at=self.now, + ) + self.assertEqual(result["state"], "unknown") + self.assertIsNone(result["currency"]) + self.assertIsNone(result["minimum"]) + self.assertEqual(len(result["variants"]), 2) + + def test_variant_comparison_rejects_unrelated_or_repeated_occurrences(self): + with self.assertRaises(ValueError): + occurrence_variant_price_facts( + occurrences=[self.occurrence, self.occurrence], + ) diff --git a/config/settings.py b/config/settings.py index 8b9031bd9b..1608834673 100644 --- a/config/settings.py +++ b/config/settings.py @@ -30,6 +30,7 @@ "intelligence.apps.IntelligenceConfig", "objectives.apps.ObjectivesConfig", "personal_assets.apps.PersonalAssetsConfig", + "space_assets.apps.SpaceAssetsConfig", "conversations.apps.ConversationsConfig", "recognition.apps.RecognitionConfig", "funding.apps.FundingConfig", diff --git a/config/urls.py b/config/urls.py index e4e10f46c0..b4b6301544 100644 --- a/config/urls.py +++ b/config/urls.py @@ -23,6 +23,7 @@ path("api/v1/me/", include("core.api.detail_urls")), path("api/v1/me/", include("core.api.mark_urls")), path("api/v1/activities/", include("activities.api_urls")), + path("api/v1/commerce/", include("commerce.native_api_urls")), path("api/v1/occurrences/", include("activities.occurrence_api_urls")), path("api/v1/objectives/", include("objectives.api_urls")), path("api/v1/accounts/", include("accounts.api.urls")), @@ -30,6 +31,7 @@ path("api/v1/events/", include("events.api.urls")), path("api/v1/tickets/", include("tickets.api.urls")), path("api/v1/payments/", include("payments.api.urls")), + path("api/v1/subscriptions/", include("subscriptions.native_api_urls")), path("api/v1/notifications/", include("notifications.api.urls")), path("api/v1/conversations/", include("conversations.api_urls")), path("api/v1/questionnaires/", include("questionnaires.api_urls")), @@ -46,10 +48,12 @@ path("api/v1/loyalty/", include("loyalty.api.urls")), path("api/v1/operations/", include("operations.api.urls")), path("api/v1/discovery/", include("discovery.api.urls")), + path("api/v1/sharing/", include("sharing.api_urls")), path("api/v1/social/", include("social.api_urls")), path("api/v1/goals/", include("goals.api_urls")), path("api/v1/growth/", include("growth.api.urls")), path("api/v1/funding/", include("funding.api_urls")), + path("api/v1/services/", include("services.api_urls")), path("api/v1/obtention/", include("obtention.api_urls")), path("api/v1/platform/", include("core.api.platform_urls")), path("api/v1/spatiotemporal/", include("spatiotemporal.api_urls")), diff --git a/conversations/api_urls.py b/conversations/api_urls.py index 64af793468..272978cf96 100644 --- a/conversations/api_urls.py +++ b/conversations/api_urls.py @@ -3,6 +3,16 @@ from .api_views import ( ConversationAttentionPresenceAPIView, ConversationDetailAPIView, + ConversationPointsDepthAPIView, + ConversationComposeAPIView, + ConversationFormOptionsAPIView, + ConversationPointMediaResponseAPIView, + ConversationFormComposeAPIView, + ConversationMediaUploadAPIView, + ConversationAttachmentDownloadAPIView, + ConversationExchangeEntriesAPIView, + ConversationContextEnsureAPIView, + ConversationContextLookupAPIView, ConversationInvitationListAPIView, ConversationInvitationRespondAPIView, ConversationListAPIView, @@ -17,9 +27,22 @@ urlpatterns = [ path("", ConversationListAPIView.as_view(), name="list"), path("attention-presence/", ConversationAttentionPresenceAPIView.as_view(), name="attention-presence"), + path("by-context///", ConversationContextLookupAPIView.as_view(), name="by-context"), + path("by-context///ensure/", ConversationContextEnsureAPIView.as_view(), name="context-ensure"), path("/", ConversationDetailAPIView.as_view(), name="detail"), + path("/points/", ConversationPointsDepthAPIView.as_view(), name="point-depth"), + path("/compose/", ConversationComposeAPIView.as_view(), name="compose"), + path("/forms/options/", ConversationFormOptionsAPIView.as_view(), name="form-options"), + path("/forms/", ConversationFormComposeAPIView.as_view(), name="form-compose"), + path("/media/", ConversationMediaUploadAPIView.as_view(), name="media-upload"), + path("attachments//download/", ConversationAttachmentDownloadAPIView.as_view(), name="attachment-download"), + path("points//entries/", ConversationExchangeEntriesAPIView.as_view(), name="exchange-entries"), + path("points//entries/", ConversationExchangeEntriesAPIView.as_view(), name="exchange-entry"), + # GET and POST share the same guarded owner endpoint. + path("/personal-state/", ConversationPersonalStateAPIView.as_view(), name="personal-state"), path("points//respond/", ConversationPointResponseAPIView.as_view(), name="point-respond"), + path("points//reply-media/", ConversationPointMediaResponseAPIView.as_view(), name="point-reply-media"), path("points//acknowledge/", ConversationPointAcknowledgeAPIView.as_view(), name="point-acknowledge"), path("invitations/", ConversationInvitationListAPIView.as_view(), name="invitation-list"), path("invitations//respond/", ConversationInvitationRespondAPIView.as_view(), name="invitation-respond"), diff --git a/conversations/api_views.py b/conversations/api_views.py index dc7c1063cb..5311d931e3 100644 --- a/conversations/api_views.py +++ b/conversations/api_views.py @@ -1,30 +1,51 @@ from __future__ import annotations +import json + from django.core.exceptions import PermissionDenied, ValidationError +from django.http import FileResponse, Http404 +from uuid import UUID from django.shortcuts import get_object_or_404 +from django.utils import timezone from django.utils.dateparse import parse_datetime from rest_framework import permissions, status, views from rest_framework.exceptions import NotFound, ValidationError as DRFValidationError from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser from core.api.privacy import PrivateNoStoreMixin from organizations.models import Organization from .attention import has_conversation_attention -from .core_models import Conversation, ConversationInvitation, ConversationInvitationStatus +from .detail_presentation import conversation_detail_extra_for_profile +from .history import page_points_for_profile, point_personal_result +from .composing import can_ensure_context_conversation, composer_capabilities, contribute_text, publish_composed_point +from .core_models import Conversation, ConversationContext, ConversationContextKind, ConversationInvitation, ConversationInvitationStatus from .point_models import ConversationPoint, ConversationPointResponseMode -from .point_services import acknowledge_point, point_response_allowed, point_visible_to, submit_point_response +from .form_services import form_request_for_profile +from .media_services import attachment_for_download, can_download_attachment +from .rich_services import ( + available_form_versions, can_prepare_form, publish_conversation_form, + readable_exchange_entries, serialize_attachment, upload_conversation_media, + visible_attachments_for_profile, + submit_point_media_response, +) +from .point_services import acknowledge_point, point_response_allowed, point_visible_to, point_resolution_visible_to, submit_point_response from .presentation import ( conversation_context_label, + conversations_for_context, conversation_rows_for_profile, essential_points_for_profile, now_points_for_profile, + other_visible_points_for_profile, search_conversations_for_profile, + visible_exchange_entries_for_profile, ) from .services import ( can_view_conversation, respond_to_conversation_invitation, update_personal_conversation_state, + ensure_context_conversation, ) @@ -32,6 +53,19 @@ +def _request_boolean(request, key, default=False): + value = request.data.get(key, default) + if value is True or value is False: + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1"}: + return True + if normalized in {"false", "0", ""}: + return False + raise DRFValidationError({key: "Valeur booléenne invalide."}) + + def _bounded_limit(request, default=50): try: return min(max(int(request.query_params.get("limit", default)), 1), MAX_PAGE_SIZE) @@ -39,8 +73,8 @@ def _bounded_limit(request, default=50): raise DRFValidationError({"limit": "limit doit être un entier entre 1 et 100."}) from exc -def _resolution_summary(point): - if point.lifecycle != "resolved": +def _resolution_summary(point, profile): + if point.lifecycle != "resolved" or not point_resolution_visible_to(profile, point): return "" try: return point.resolution.summary or "" @@ -49,12 +83,45 @@ def _resolution_summary(point): def _serialize_point(point, *, profile, reason=None, section=None): + linked_form = form_request_for_profile(point, profile) if point.kind == "form_request" else None + prior = point.supersedes if point.supersedes_id else None + successor = ( + point.superseded_by_points.order_by("-published_at", "-created_at").first() + if point.lifecycle == "superseded" else None + ) return { "id": str(point.pk), "conversation_id": str(point.conversation_id), "kind": point.kind, + "client_reference": ( + point.client_reference if point.published_by_id == profile.pk else None + ), + # Identity only for an already-authorized Point. No account IDs, + # contact details or private Profile fields escape this projection. + "author": { + "label": ( + (point.represented_space.name if point.represented_space_id else "") + or ( + point.published_by.get_full_name().strip() + if point.published_by_id == profile.pk else "" + ) + or point.published_by.username + or "Participant" + ), + "is_mine": point.published_by_id == profile.pk, + "represented_space": bool(point.represented_space_id), + }, "response_mode": point.response_mode, "lifecycle": point.lifecycle, + "response_status": point_personal_result(profile, point), + "can_change_response": point.allow_response_change, + "supersedes_point_id": ( + str(prior.pk) if prior is not None and point_visible_to(profile, prior) else None + ), + "replaced_by_point_id": ( + str(successor.pk) if successor is not None + and point_visible_to(profile, successor) else None + ), "importance": point.importance, "title": point.title, "body": point.body, @@ -62,14 +129,48 @@ def _serialize_point(point, *, profile, reason=None, section=None): "opens_at": point.opens_at, "deadline_at": point.deadline_at, "valid_until": point.valid_until, - "can_respond": point_response_allowed(profile, point), + "can_respond": ( + point.conversation.lifecycle == "open" + and point.lifecycle == "open" + and (point.opens_at is None or point.opens_at <= timezone.now()) + and (point.deadline_at is None or point.deadline_at > timezone.now()) + and (point.valid_until is None or point.valid_until > timezone.now()) + and point_response_allowed(profile, point) + ), "options": [ {"id": str(option.pk), "label": option.label} for option in point.options.all() ], + "exchange_preview": [ + { + "id": str(entry.pk), "body": entry.body, + "created_at": entry.created_at, "own": entry.author_id == profile.pk, + "client_reference": ( + entry.client_reference if entry.author_id == profile.pk else None + ), + "attachments": [ + serialize_attachment(item) + for item in entry.attachments.all() if can_download_attachment(profile, item) + ], + } + for entry in visible_exchange_entries_for_profile(profile, point, limit=5) + ] if point.kind == "exchange" else [], + "attachments": [ + serialize_attachment(item) + for item in visible_attachments_for_profile(profile, point) + if item.exchange_entry_id is None + ], + "form_request": ({ + "id": str(linked_form.pk), "status": linked_form.status, + "links": { + "detail": f"api/v1/questionnaires/requests/{linked_form.pk}/", + "save": f"api/v1/questionnaires/requests/{linked_form.pk}/save/", + "submit": f"api/v1/questionnaires/requests/{linked_form.pk}/submit/", + }, + } if linked_form is not None else None), "attention_reason": reason, "section": section, - "resolution_summary": _resolution_summary(point), + "resolution_summary": _resolution_summary(point, profile), "published_at": point.published_at, "updated_at": point.updated_at, } @@ -86,10 +187,35 @@ def _serialize_conversation_row(row): "attention_count": row.attention_count, "latest_result": row.latest_result, "all_clear": row.all_clear, + "card": row.card, "updated_at": conversation.updated_at, } +class ConversationContextLookupAPIView(PrivateNoStoreMixin, views.APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, kind, context_pk): + conversations = conversations_for_context(request.user, kind, context_pk) + return Response({ + "count": len(conversations), + "can_ensure": can_ensure_context_conversation(request.user, kind, context_pk), + "results": [ + { + "id": str(item.pk), + "title": item.title_override or conversation_context_label(item, request.user), + "purpose": item.purpose, + "context": { + "kind": item.context.kind, + "label": conversation_context_label(item, request.user), + }, + "updated_at": item.updated_at, + } + for item in conversations + ], + }) + + def _visible_conversation_or_404(profile, pk): conversation = get_object_or_404(Conversation.objects.select_related("context"), pk=pk) if not can_view_conversation(profile, conversation): @@ -134,19 +260,38 @@ class ConversationListAPIView(PrivateNoStoreMixin, views.APIView): def get(self, request): limit = _bounded_limit(request) + tab = (request.query_params.get("tab") or "all").strip().lower() + if tab not in {"all", "for-me", "archived"}: + raise DRFValidationError({"tab": "Filtre inconnu."}) q = (request.query_params.get("q") or "").strip()[:120] updated_after_raw = (request.query_params.get("updated_after") or "").strip() updated_after = parse_datetime(updated_after_raw) if updated_after_raw else None - if updated_after_raw and updated_after is None: - raise DRFValidationError({"updated_after": "Date/heure ISO invalide."}) + if updated_after_raw and ( + updated_after is None or timezone.is_naive(updated_after) + ): + raise DRFValidationError({ + "updated_after": "Date/heure ISO avec fuseau horaire attendue.", + }) if q: + attention_by_conversation = ( + { + row.conversation.pk: row.attention_count + for row in conversation_rows_for_profile( + request.user, only_attention=True, limit=300, + ) + } if tab == "for-me" else {} + ) seen = set() payload = [] - for result in search_conversations_for_profile(request.user, q, limit=limit * 2): + for result in search_conversations_for_profile( + request.user, q, limit=limit * 2, include_archived=tab == "archived", + ): conversation = result.conversation if conversation.pk in seen: continue + if tab == "for-me" and conversation.pk not in attention_by_conversation: + continue if updated_after and conversation.updated_at <= updated_after: continue seen.add(conversation.pk) @@ -157,19 +302,57 @@ def get(self, request): "lifecycle": conversation.lifecycle, "context": {"kind": conversation.context.kind, "label": result.context_label}, "matched_point_id": str(result.point.pk) if result.point else None, + "attention_count": attention_by_conversation.get(conversation.pk, 0), + "card": { + **result.card, + "attention_count": attention_by_conversation.get(conversation.pk, 0), + }, "updated_at": conversation.updated_at, }) if len(payload) >= limit: break return Response({"results": payload, "count": len(payload)}) - rows = conversation_rows_for_profile(request.user, only_attention=False, limit=limit * 2) + rows = conversation_rows_for_profile( + request.user, + archived=tab == "archived", + only_attention=tab == "for-me", + limit=limit * 2, + ) if updated_after: rows = [row for row in rows if row.conversation.updated_at > updated_after] rows = rows[:limit] return Response({"results": [_serialize_conversation_row(row) for row in rows], "count": len(rows)}) +class ConversationPointsDepthAPIView(PrivateNoStoreMixin, views.APIView): + """Search and stable-keyset history of real Points; never a chat timeline.""" + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, pk): + conversation = _visible_conversation_or_404(request.user, pk) + try: + page = page_points_for_profile( + request.user, conversation, + query=request.query_params.get("q", ""), + section=request.query_params.get("section", "history"), + cursor=request.query_params.get("cursor") or None, + limit=request.query_params.get("limit", 25), + ) + except ValidationError as exc: + return _service_error(exc) + return Response({ + "results": [ + { + **_serialize_point(point, profile=request.user, section="history"), + } + for point in page["results"] + ], + "next_cursor": page["next_cursor"], + "section": request.query_params.get("section", "history"), + }) + + class ConversationDetailAPIView(PrivateNoStoreMixin, views.APIView): permission_classes = [permissions.IsAuthenticated] @@ -177,7 +360,29 @@ def get(self, request, pk): conversation = _visible_conversation_or_404(request.user, pk) limit = _bounded_limit(request) rows = now_points_for_profile(request.user, conversation, limit=limit) - essential = essential_points_for_profile(request.user, conversation, limit=limit) + active_ids = {item["point"].pk for item in rows} + essential = [ + point for point in essential_points_for_profile(request.user, conversation, limit=limit) + if point.pk not in active_ids + ] + seen = active_ids | {point.pk for point in essential} + elsewhere = other_visible_points_for_profile( + request.user, conversation, excluded_ids=seen, limit=limit, + ) + seen.update(point.pk for point in elsewhere) + focused_point = None + focused_raw = (request.query_params.get("focus_point") or "").strip() + if focused_raw: + try: + focused_id = UUID(focused_raw) + except (ValueError, AttributeError): + raise DRFValidationError({"focus_point": "Point invalide."}) + focused = ConversationPoint.objects.filter( + pk=focused_id, conversation=conversation, + ).select_related("published_by", "represented_space", "visibility_audience", "response_audience", "resolution_audience").prefetch_related("options", "responses").first() + if focused and point_visible_to(request.user, focused): + if focused.pk not in seen: + focused_point = _serialize_point(focused, profile=request.user, section="focus") label = conversation_context_label(conversation, request.user) state = conversation.user_states.filter(profile=request.user).first() return Response({ @@ -194,6 +399,12 @@ def get(self, request, pk): _serialize_point(point, profile=request.user, section="essential") for point in essential ], + "elsewhere": [ + _serialize_point(point, profile=request.user, section="elsewhere") + for point in elsewhere + ], + "focused_point": focused_point, + "composer": composer_capabilities(request.user, conversation), "personal_state": { "muted": bool(state and state.muted_at), "hidden": bool(state and state.hidden_at), @@ -202,6 +413,285 @@ def get(self, request, pk): "revisit": bool(state and state.revisit_at), }, "updated_at": conversation.updated_at, + **conversation_detail_extra_for_profile(request.user, conversation, label), + }) + + + +def _represented_space_from_request(request): + raw = request.data.get("represented_space_id") + if raw in (None, ""): + return None + try: + return Organization.objects.get(pk=raw) + except (Organization.DoesNotExist, ValidationError, ValueError, TypeError) as exc: + raise PermissionDenied("Espace non autorisé.") from exc + + +class ConversationComposeAPIView(PrivateNoStoreMixin, views.APIView): + """Owner-confirmed Point publication or contribution to an existing Exchange.""" + permission_classes = [permissions.IsAuthenticated] + + def post(self, request, pk): + conversation = _visible_conversation_or_404(request.user, pk) + mode = (request.data.get("mode") or "text").strip() + client_ref = request.data.get("client_reference") + represented_space = _represented_space_from_request(request) + try: + if mode == "text": + outcome, result, point_id = contribute_text( + actor=request.user, conversation=conversation, + body=request.data.get("body"), + point_id=request.data.get("point_id"), + client_reference=client_ref, represented_space=represented_space, + audience_id=request.data.get("audience_id"), + ) + return Response({"kind": outcome, "id": str(result.pk), "point_id": str(point_id)}) + if mode == "point": + point = publish_composed_point( + actor=request.user, conversation=conversation, + kind=request.data.get("kind"), title=request.data.get("title", ""), + body=request.data.get("body", ""), options=request.data.get("options"), + multiple_choice=_request_boolean(request, "multiple_choice"), + audience_id=request.data.get("audience_id"), + represented_space=represented_space, client_reference=client_ref, + ) + return Response({"kind": "point", "id": str(point.pk), "point_id": str(point.pk)}) + raise DRFValidationError({"mode": "Mode de composition inconnu."}) + except PermissionDenied: + raise + except ValidationError as exc: + return _service_error(exc) + + +class ConversationExchangeEntryAPIView(PrivateNoStoreMixin, views.APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request, point_pk): + point = _visible_point_or_404(request.user, point_pk) + try: + outcome, entry, point_id = contribute_text( + actor=request.user, conversation=point.conversation, + body=request.data.get("body"), point_id=str(point.pk), + represented_space=_represented_space_from_request(request), + client_reference=request.data.get("client_reference"), + ) + return Response({"kind": outcome, "id": str(entry.pk), "point_id": str(point_id)}) + except PermissionDenied: + raise + except ValidationError as exc: + return _service_error(exc) + + +class ConversationContextEnsureAPIView(PrivateNoStoreMixin, views.APIView): + """Open a permitted contextual Conversation; never infer direct consent.""" + permission_classes = [permissions.IsAuthenticated] + + def post(self, request, kind, context_pk): + if kind == ConversationContextKind.DIRECT or kind not in ConversationContext.TARGET_FIELDS: + raise PermissionDenied("Une Conversation directe exige un consentement existant.") + field = ConversationContext.TARGET_FIELDS[kind][0] + related_model = ConversationContext._meta.get_field(field).remote_field.model + try: + target = related_model.objects.get(pk=context_pk) + except (related_model.DoesNotExist, ValidationError, ValueError, TypeError): + raise NotFound("Contexte introuvable.") + try: + conversation = ensure_context_conversation( + actor=request.user, kind=kind, **{field: target}, + ) + except PermissionDenied: + raise + except ValidationError as exc: + return _service_error(exc) + if not can_view_conversation(request.user, conversation): + raise NotFound("Conversation introuvable.") + return Response({"id": str(conversation.pk)}) + + +class ConversationPointMediaResponseAPIView(PrivateNoStoreMixin, views.APIView): + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + def post(self, request, point_pk): + point = _visible_point_or_404(request.user, point_pk) + uploaded = request.FILES.get("file") + if uploaded is None: + raise DRFValidationError({"file": "Choisissez un fichier."}) + declared = request.data.get("mime_type") + if declared and uploaded.content_type in {"application/octet-stream", ""}: + uploaded.content_type = str(declared) + try: + response, attachment = submit_point_media_response( + actor=request.user, point=point, + uploaded_file=uploaded, kind=request.data.get("kind", "file"), + client_reference=request.data.get("client_reference"), + represented_space=_represented_space_from_request(request), + ) + return Response({ + "point_id": str(point.pk), "response_id": str(response.pk), + "attachment": serialize_attachment(attachment), + }) + except PermissionDenied: + raise + except ValidationError as exc: + return _service_error(exc) + + +class ConversationFormOptionsAPIView(PrivateNoStoreMixin, views.APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, pk): + conversation = _visible_conversation_or_404(request.user, pk) + return Response({ + "can_prepare": can_prepare_form(request.user, conversation), + "results": available_form_versions(request.user, conversation), + }) + + +class ConversationFormComposeAPIView(PrivateNoStoreMixin, views.APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request, pk): + conversation = _visible_conversation_or_404(request.user, pk) + try: + point = publish_conversation_form( + actor=request.user, conversation=conversation, + title=request.data.get("title"), + client_reference=request.data.get("client_reference"), + form_version_id=request.data.get("form_version_id"), + questions=request.data.get("questions"), + audience_id=request.data.get("audience_id"), + represented_space=_represented_space_from_request(request), + ) + return Response({ + "id": str(point.pk), "point_id": str(point.pk), "kind": "form_request", + }) + except PermissionDenied: + raise + except ValidationError as exc: + return _service_error(exc) + + +class ConversationMediaUploadAPIView(PrivateNoStoreMixin, views.APIView): + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + def post(self, request, pk): + conversation = _visible_conversation_or_404(request.user, pk) + upload = request.FILES.get("file") + if upload is None: + raise DRFValidationError({"file": "Choisissez un fichier."}) + # The mobile multipart transport does not set per-file MIME. The + # supplied declaration is only a candidate; owner validation checks + # the actual file signature before any Point is created. + declared = request.data.get("mime_type") + if declared and upload.content_type in {"application/octet-stream", ""}: + upload.content_type = str(declared) + + def structured_list(field): + raw = request.data.get(field) + if raw is None: + return None + try: + parsed = json.loads(raw) if isinstance(raw, str) else raw + except (TypeError, ValueError, json.JSONDecodeError): + raise DRFValidationError({field: "Liste invalide."}) + if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): + raise DRFValidationError({field: "Liste invalide."}) + return parsed + + options = structured_list("options") + questions = structured_list("questions") + try: + point, attachment = upload_conversation_media( + actor=request.user, conversation=conversation, + uploaded_file=upload, kind=request.data.get("kind", "file"), + body=request.data.get("body", ""), + client_reference=request.data.get("client_reference"), + point_id=request.data.get("point_id") or None, + audience_id=request.data.get("audience_id") or None, + represented_space=_represented_space_from_request(request), + point_kind=request.data.get("point_kind") or None, + title=request.data.get("title") or "", + options=options, + multiple_choice=request.data.get("multiple_choice", False), + form_version_id=request.data.get("form_version_id") or None, + questions=questions, + ) + return Response({ + "point_id": str(point.pk), "attachment": serialize_attachment(attachment), + }) + except PermissionDenied: + raise + except ValidationError as exc: + return _service_error(exc) + + +class ConversationAttachmentDownloadAPIView(PrivateNoStoreMixin, views.APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, attachment_pk): + try: + attachment = attachment_for_download( + actor=request.user, attachment_id=attachment_pk, + ) + except PermissionDenied as exc: + raise NotFound("Pièce inaccessible.") from exc + attachment.file.open("rb") + reply = FileResponse( + attachment.file, as_attachment=True, filename=attachment.original_name, + content_type=attachment.mime_type, + ) + reply["X-Content-Type-Options"] = "nosniff" + reply["Cache-Control"] = "private, no-store" + return reply + + +class ConversationExchangeEntriesAPIView(PrivateNoStoreMixin, views.APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request, point_pk): + point = _visible_point_or_404(request.user, point_pk) + try: + _, entry, target_id = contribute_text( + actor=request.user, conversation=point.conversation, + body=request.data.get("body"), point_id=str(point.pk), + represented_space=_represented_space_from_request(request), + client_reference=request.data.get("client_reference"), + ) + return Response({"kind": "exchange", "id": str(entry.pk), "point_id": str(target_id)}) + except PermissionDenied: + raise + except ValidationError as exc: + return _service_error(exc) + + def get(self, request, point_pk): + point = _visible_point_or_404(request.user, point_pk) + limit = _bounded_limit(request, default=25) + try: + entries, cursor = readable_exchange_entries( + request.user, point, cursor=request.query_params.get("cursor"), + limit=limit, + ) + except PermissionDenied as exc: + raise NotFound("Échange inaccessible.") from exc + except ValidationError as exc: + return _service_error(exc) + return Response({ + "results": [ + { + "id": str(entry.pk), "body": entry.body, + "created_at": entry.created_at, "own": entry.author_id == request.user.pk, + "attachments": [ + serialize_attachment(item) + for item in entry.attachments.all() + if can_download_attachment(request.user, item) + ], + } + for entry in entries + ], + "next_cursor": cursor, }) diff --git a/conversations/attention.py b/conversations/attention.py index 63a3528a26..068c41f73d 100644 --- a/conversations/attention.py +++ b/conversations/attention.py @@ -6,6 +6,7 @@ from django.utils import timezone from .audience_services import profile_in_audience +from .core_models import ConversationLifecycle from .point_models import ( ConversationPoint, ConversationPointKind, @@ -97,6 +98,10 @@ def point_attention_reason( conversation_manage_cache=None, ): at = at or timezone.now() + # A closed Conversation may be read, but it must no longer demand a + # response, acknowledgement, resolution or other unfinished action. + if point.conversation.lifecycle != ConversationLifecycle.OPEN: + return None if point.lifecycle not in ATTENTION_LIFECYCLES: return None if not _conversation_visible( diff --git a/conversations/composing.py b/conversations/composing.py new file mode 100644 index 0000000000..0b7479e90d --- /dev/null +++ b/conversations/composing.py @@ -0,0 +1,290 @@ +"""C3 Conversations composition, delegating all writes to canonical Point services. + +The returned capabilities are viewer-scoped hints. POST actions recheck the +same permissions, policy, audience, lifecycle and represented actor. +""" +from __future__ import annotations + +from django.core.exceptions import PermissionDenied, ValidationError + +from authorization.constants import PermissionCode +from authorization.services import can +from django.utils import timezone + +from .audience_models import ConversationAudienceSet, ConversationAudienceStatus +from .core_models import ConversationContext, ConversationContextKind, ConversationLifecycle +from .point_models import ( + ConversationPoint, ConversationPointKind, ConversationPointLifecycle, + ConversationResponseVisibility, + ConversationPointResolutionPolicy, ConversationPointResponseMode, +) +from .point_services import ( + POINT_POLICY_CAPABILITY, create_exchange_entry, create_point, + point_response_allowed, point_visible_to, +) +from .services import _has_scope_permission, can_publish_in_conversation, can_view_conversation + + +COMPOSABLE_KINDS = { + ConversationPointKind.INFORMATION, + ConversationPointKind.QUESTION, + ConversationPointKind.REQUEST, + ConversationPointKind.CONFIRMATION, + ConversationPointKind.POLL, + ConversationPointKind.EXCHANGE, +} +MAX_TEXT_LENGTH = 5000 + + +def _clean_text(value, field, *, limit=MAX_TEXT_LENGTH): + if not isinstance(value, str) or not value.strip(): + raise ValidationError({field: "Ce champ est obligatoire."}) + value = value.strip() + if len(value) > limit: + raise ValidationError({field: "Ce texte est trop long."}) + return value + + +def _active_exchange_points(profile, conversation): + policy = getattr(conversation, "policy", None) + if conversation.lifecycle != ConversationLifecycle.OPEN or not policy or not policy.allow_free_exchange: + return [] + now = timezone.now() + candidates = ConversationPoint.objects.filter( + conversation=conversation, kind=ConversationPointKind.EXCHANGE, + lifecycle=ConversationPointLifecycle.OPEN, + ).select_related("conversation", "visibility_audience", "response_audience").order_by("-published_at", "pk")[:50] + return [ + point for point in candidates + if (point.opens_at is None or point.opens_at <= now) + and (point.deadline_at is None or point.deadline_at > now) + and (point.valid_until is None or point.valid_until > now) + and point_response_allowed(profile, point, at=now) + ] + + +def composer_capabilities(profile, conversation): + if not can_view_conversation(profile, conversation): + raise PermissionDenied("Conversation non accessible.") + policy = getattr(conversation, "policy", None) + can_create = bool( + conversation.lifecycle == ConversationLifecycle.OPEN + and policy is not None and can_publish_in_conversation(profile, conversation) + ) + allowed_kinds = sorted( + kind for kind in COMPOSABLE_KINDS + if can_create and getattr(policy, POINT_POLICY_CAPABILITY[kind], False) + ) + audiences = list(ConversationAudienceSet.objects.filter( + conversation=conversation, status=ConversationAudienceStatus.ACTIVE, + ).order_by("label", "pk")) if can_create else [] + exchange_points = _active_exchange_points(profile, conversation) + # A unique active Exchange accepts plain text; never guess among several. + default_exchange_id = str(exchange_points[0].pk) if len(exchange_points) == 1 else None + text_mode = ( + "exchange" if default_exchange_id else + "exchange_select" if len(exchange_points) > 1 else + "information" if ConversationPointKind.INFORMATION in allowed_kinds else + None + ) + representative_space = _context_space_for_composer(conversation) + representable_space_id = ( + str(representative_space.pk) + if representative_space is not None and + can(profile, PermissionCode.SPACE_CONVERSATIONS_PUBLISH, space=representative_space) + else None + ) + from .media_services import KIND_POLICY_FIELD + from .rich_services import available_form_versions, can_prepare_form + can_send_media = bool(can_create or exchange_points) + media_kinds = sorted( + kind for kind, capability in KIND_POLICY_FIELD.items() + if can_send_media and getattr(policy, capability, False) + ) + form_options = available_form_versions(profile, conversation) if can_create else [] + return { + "can_create": can_create, + "can_form": bool(can_create and can_prepare_form(profile, conversation)), + "form_options": form_options, + "media_kinds": media_kinds, + "allowed_kinds": allowed_kinds, + "audiences": [{"id": str(item.pk), "label": item.label} for item in audiences], + "audience_selection_required": len(audiences) > 1, + "default_audience_id": str(audiences[0].pk) if len(audiences) == 1 else None, + "text_mode": text_mode, + "representable_space_id": representable_space_id, + "default_exchange_point_id": default_exchange_id, + "exchange_targets": [ + {"id": str(item.pk), "title": item.title or "Échange"} + for item in exchange_points + ], + } + + +def _audience_for_publish(conversation, audience_id): + if not audience_id: + if conversation.audience_sets.filter(status=ConversationAudienceStatus.ACTIVE).count() > 1: + raise ValidationError({"audience_id": "Choisissez les destinataires."}) + return conversation.audience_sets.filter( + status=ConversationAudienceStatus.ACTIVE, + ).first() + try: + return ConversationAudienceSet.objects.get( + pk=audience_id, conversation=conversation, + status=ConversationAudienceStatus.ACTIVE, + ) + except (ConversationAudienceSet.DoesNotExist, ValueError, TypeError) as exc: + raise ValidationError({"audience_id": "Audience indisponible."}) from exc + + +def _context_space_for_composer(conversation): + context = conversation.context + if context.kind == ConversationContextKind.SPACE: + return context.space + if context.kind == ConversationContextKind.GROUP: + return context.group.space + if context.kind == ConversationContextKind.ACTIVITY: + return context.activity.space + if context.kind == ConversationContextKind.OCCURRENCE: + return context.occurrence.activity.space + if context.kind == ConversationContextKind.JOURNEY: + return context.journey.activity.space + if context.kind == ConversationContextKind.DOSSIER: + return context.dossier.owning_space + if context.kind == ConversationContextKind.PROJECT: + return context.project.owning_space + return None + + +def _validate_represented_context(conversation, represented_space): + if represented_space is None: + return + owner_space = _context_space_for_composer(conversation) + if owner_space is None or owner_space.pk != represented_space.pk: + raise PermissionDenied("Cet Espace ne porte pas cette Conversation.") + + +def publish_composed_point( + *, actor, conversation, kind, title="", body="", options=None, + multiple_choice=False, audience_id=None, represented_space=None, client_reference=None, +): + if kind not in COMPOSABLE_KINDS: + raise ValidationError({"kind": "Ce type de Point n'est pas disponible."}) + if conversation.lifecycle != ConversationLifecycle.OPEN or not can_publish_in_conversation(actor, conversation): + raise PermissionDenied("Publication non autorisée.") + policy = getattr(conversation, "policy", None) + if not policy or not getattr(policy, POINT_POLICY_CAPABILITY[kind], False): + raise PermissionDenied("Ce type de Point n'est pas autorisé.") + _validate_represented_context(conversation, represented_space) + if not isinstance(client_reference, str) or not client_reference.strip() or len(client_reference) > 80: + raise ValidationError({"client_reference": "Référence de publication obligatoire."}) + audience = _audience_for_publish(conversation, audience_id) + title = (title or "").strip() + body = (body or "").strip() + if kind == ConversationPointKind.INFORMATION: + body = _clean_text(body, "body") + title = "" + else: + title = _clean_text(title, "title", limit=220) + if len(body) > MAX_TEXT_LENGTH: + raise ValidationError({"body": "Ce texte est trop long."}) + if not isinstance(multiple_choice, bool): + raise ValidationError({"multiple_choice": "Valeur de choix multiples invalide."}) + if kind != ConversationPointKind.POLL and multiple_choice: + raise ValidationError({"multiple_choice": "Cette option concerne uniquement les sondages."}) + if kind == ConversationPointKind.POLL: + if not isinstance(options, list): + raise ValidationError({"options": "Ajoutez au moins deux choix."}) + labels = [_clean_text(label, "options", limit=220) for label in options] + if not 2 <= len(labels) <= 12 or len(set(labels)) != len(labels): + raise ValidationError({"options": "Prévoyez entre 2 et 12 choix distincts."}) + else: + if options: + raise ValidationError({"options": "Aucun choix attendu pour ce Point."}) + labels = [] + mode = { + ConversationPointKind.INFORMATION: ConversationPointResponseMode.NONE, + ConversationPointKind.QUESTION: ConversationPointResponseMode.FREE_TEXT, + # A Request is an action to take, not another open-ended question: + # the expected recipient commits to taking it or declines explicitly. + ConversationPointKind.REQUEST: ConversationPointResponseMode.BOOLEAN, + ConversationPointKind.CONFIRMATION: ConversationPointResponseMode.BOOLEAN, + ConversationPointKind.POLL: ( + ConversationPointResponseMode.MULTIPLE_CHOICE + if multiple_choice else ConversationPointResponseMode.SINGLE_CHOICE + ), + ConversationPointKind.EXCHANGE: ConversationPointResponseMode.FREE_TEXT, + }[kind] + return create_point( + actor=actor, conversation=conversation, kind=kind, + response_mode=mode, title=title, body=body, options=labels, + resolution_policy=( + ConversationPointResolutionPolicy.PLURALITY + if kind == ConversationPointKind.POLL + else ConversationPointResolutionPolicy.MANUAL + ), + visibility_audience=audience, + response_audience=audience if kind != ConversationPointKind.INFORMATION else None, + expected_action_audience=( + audience if kind == ConversationPointKind.REQUEST else None + ), + represented_space=represented_space, + response_visibility=( + ConversationResponseVisibility.POINT_VIEWERS + if kind == ConversationPointKind.EXCHANGE + else ConversationResponseVisibility.RESPONDENT_AND_AUTHORITIES + ), + client_reference=client_reference, + ) + + +def contribute_text( + *, actor, conversation, body, point_id=None, represented_space=None, + client_reference=None, audience_id=None, +): + if not can_view_conversation(actor, conversation): + raise PermissionDenied("Conversation non accessible.") + _validate_represented_context(conversation, represented_space) + body = _clean_text(body, "body") + if not isinstance(client_reference, str) or not client_reference.strip() or len(client_reference) > 80: + raise ValidationError({"client_reference": "Référence d'envoi obligatoire."}) + exchange_points = _active_exchange_points(actor, conversation) + target = next((item for item in exchange_points if str(item.pk) == str(point_id)), None) if point_id else ( + exchange_points[0] if len(exchange_points) == 1 else None + ) + if target is not None: + entry = create_exchange_entry( + actor=actor, point=target, body=body, + represented_space=represented_space, + client_reference=client_reference, + ) + return "exchange", entry, target.pk + if point_id: + raise PermissionDenied("Cet échange n'est plus accessible.") + if len(exchange_points) > 1: + raise ValidationError({"point_id": "Choisissez l'échange auquel répondre."}) + if not can_publish_in_conversation(actor, conversation): + raise PermissionDenied("Vous ne pouvez pas publier dans cette Conversation.") + point = publish_composed_point( + actor=actor, conversation=conversation, + kind=ConversationPointKind.INFORMATION, body=body, + represented_space=represented_space, + audience_id=audience_id, + client_reference=client_reference, + ) + return "point", point, point.pk + + +def can_ensure_context_conversation(profile, kind, context_pk): + """Only explicit manage authority permits contextual creation; never direct DM.""" + if kind not in ConversationContext.TARGET_FIELDS or kind == ConversationContextKind.DIRECT: + return False + field = ConversationContext.TARGET_FIELDS[kind][0] + target_model = ConversationContext._meta.get_field(field).remote_field.model + try: + target = target_model.objects.get(pk=context_pk) + pending = ConversationContext(kind=kind, **{field: target}) + pending.clean() + except (target_model.DoesNotExist, ValidationError, ValueError, TypeError): + return False + return _has_scope_permission(profile, pending, "MANAGE") diff --git a/conversations/detail_presentation.py b/conversations/detail_presentation.py new file mode 100644 index 0000000000..db89c7c4c1 --- /dev/null +++ b/conversations/detail_presentation.py @@ -0,0 +1,238 @@ +"""Read-only, owner-scoped detail presentation for Makolo Conversations. + +Never infer a member from an Audience rule, a permission from participation, +or a private media URL from a visible Point. Consumers only present these facts. +""" +from urllib.parse import urlsplit + +from .core_models import ( + ConversationContextKind, + ConversationParticipation, + ConversationParticipationStatus, +) +from .list_presentation import _public_context_image +from .media_models import ConversationPointAttachment +from .media_services import can_download_attachment +from .point_models import ConversationPoint, ConversationPointLifecycle +from .point_services import point_visible_to +from .services import can_manage_conversation + + +def _safe_local_image(value): + """No arbitrary remote/avatar metadata is loaded by the native client.""" + if not isinstance(value, str): + return None + parsed = urlsplit(value) + if (parsed.scheme or parsed.netloc or parsed.query or parsed.fragment + or not parsed.path.startswith("/") or parsed.path.startswith("//")): + return None + return parsed.path + + +def conversation_subject_for_profile(profile, conversation, label): + context = conversation.context + kind = context.kind + image = _public_context_image(conversation) + identity = label + target_id = None + route = None + if kind == ConversationContextKind.DIRECT: + other = ( + context.direct_profile_b + if context.direct_profile_a_id == profile.pk + else context.direct_profile_a + ) + if other and other.is_active: + identity = other.full_name or other.username + target_id = str(other.pk) + route = f"/people/{other.pk}" + # Profile publishes its portrait independently of Conversation. + # The visibility policy decides; merely seeing a Conversation + # does not grant access to this Profile media. + from accounts.profile_visibility import ProfileVisibilityResolver + from sharing.passport import profile_has_public_passport + if (profile_has_public_passport(other) + and ProfileVisibilityResolver(owner=other, viewer=profile).field("avatar")): + try: + image = _safe_local_image(other.avatar.url) if other.avatar else None + except (ValueError, OSError): + image = None + else: + target = getattr(context, kind, None) + if kind == ConversationContextKind.JOURNEY: + target = context.journey + if target is not None: + target_id = str(target.pk) + if kind == ConversationContextKind.SPACE and context.space_id: + route = f"/spaces/{context.space.slug}" + elif kind == ConversationContextKind.GROUP and context.group_id: + route = f"/groups/{context.group_id}" + elif kind == ConversationContextKind.JOURNEY and context.journey_id: + route = f"/journeys/{context.journey_id}" + return { + "kind": kind, + "label": identity, + "id": target_id, + "route": route, + "image_url": image, + } + + +def conversation_members_for_profile(profile, conversation): + """Explicit participants only; never expand collective derived audiences.""" + kind = conversation.context.kind + if kind == ConversationContextKind.DIRECT: + ids = ( + conversation.context.direct_profile_a, + conversation.context.direct_profile_b, + ) + return { + "scope": "direct", + "people": [ + {"label": item.full_name or item.username, "is_me": item.pk == profile.pk} + for item in ids if item is not None and item.is_active + ], + } + if not can_manage_conversation(profile, conversation): + # A Group member or Activity participant is not automatically + # authorized to enumerate the other viewers or authorities. + return {"scope": "restricted", "people": []} + if kind == ConversationContextKind.GROUP: + from groups.models import GroupMembership, GroupMembershipStatus + memberships = GroupMembership.objects.filter( + group=conversation.context.group, + status=GroupMembershipStatus.ACTIVE, + profile__is_active=True, + ) + total = memberships.count() + visible = memberships.select_related("profile").order_by( + "joined_at", "pk", + )[:30] + return { + "scope": "group", + "total_count": total, + "people": [ + { + "label": row.profile.full_name or row.profile.username, + "is_me": row.profile_id == profile.pk, + } + for row in visible + ], + } + participants = ( + ConversationParticipation.objects.filter( + conversation=conversation, + status=ConversationParticipationStatus.ACTIVE, + profile__is_active=True, + ).select_related("profile").order_by("joined_at", "pk")[:30] + ) + return { + "scope": "explicit", + "people": [ + { + "label": participation.profile.full_name + or participation.profile.username, + "is_me": participation.profile_id == profile.pk, + } + for participation in participants + ], + } + + +def conversation_media_for_profile(profile, conversation, limit=24): + """Private attachment index; every item is checked through its owner ACL.""" + points = ConversationPoint.objects.filter( + conversation=conversation, + ).exclude(lifecycle=ConversationPointLifecycle.DRAFT) + candidates = ConversationPointAttachment.objects.filter( + point__in=points, + ).select_related( + "point", "point__conversation", "point__visibility_audience", + "point__response_audience", "response", "exchange_entry", + ).order_by("-created_at", "-pk")[:120] + results = [] + visibility_cache, audience_cache, history_cache, manager_cache = {}, {}, {}, {} + for attachment in candidates: + if not point_visible_to(profile, attachment.point, + visibility_cache=visibility_cache, audience_cache=audience_cache, + history_cache=history_cache): + continue + if not can_download_attachment(profile, attachment, + visibility_cache=visibility_cache, audience_cache=audience_cache, + history_cache=history_cache, manager_cache=manager_cache): + continue + results.append({ + "id": str(attachment.pk), + "kind": attachment.kind, + "name": attachment.original_name, + "mime_type": attachment.mime_type, + "size": attachment.size, + }) + if len(results) >= limit: + break + return results + + +def conversation_resources_for_profile(profile, conversation, limit=8): + """Only canonical ActivityResource metadata, never a shadow attachment.""" + context = conversation.context + activity = None + occurrence = None + if context.kind == ConversationContextKind.ACTIVITY: + activity = context.activity + elif context.kind == ConversationContextKind.OCCURRENCE: + occurrence = context.occurrence + activity = occurrence.activity + elif context.kind == ConversationContextKind.JOURNEY: + occurrence = context.journey.occurrence + activity = context.journey.activity + if activity is None: + return [] + from django.db.models import Q + from preparation.models import ActivityResource, ResourceStatus + from preparation.services import can_view_resource + + candidates = ( + ActivityResource.objects.filter( + activity=activity, status=ResourceStatus.PUBLISHED, + ).filter(Q(occurrence__isnull=True) | Q(occurrence=occurrence)) + .select_related("activity", "occurrence") + .order_by("-published_at", "-pk")[:70] + ) + resources = [] + for item in candidates: + if can_view_resource(profile, item): + resources.append({ + "id": str(item.pk), + "title": item.title, + "kind": item.kind, + "description": item.description[:240], + }) + if len(resources) >= limit: + break + return resources + + +def conversation_detail_extra_for_profile(profile, conversation, label): + """Composition of read models only. No new owner, state, or authority.""" + can_manage = can_manage_conversation(profile, conversation) + return { + "subject": conversation_subject_for_profile(profile, conversation, label), + "members": conversation_members_for_profile(profile, conversation), + "media": conversation_media_for_profile(profile, conversation), + "resources": conversation_resources_for_profile(profile, conversation), + "created_at": conversation.created_at, + "can_manage": can_manage, + "configuration": { + "mode": conversation.get_mode_preset_display(), + "entry": conversation.get_entry_mode_display(), + "history": conversation.get_history_policy_display(), + "lifecycle": conversation.get_lifecycle_display(), + "created_by": ( + conversation.created_by.full_name + or conversation.created_by.username + ) if can_manage else None, + }, + "share_path": f"/conversations/{conversation.pk}/", + "share_requires_access": True, + } diff --git a/conversations/form_services.py b/conversations/form_services.py index f78b2aff0e..6fdf265da9 100644 --- a/conversations/form_services.py +++ b/conversations/form_services.py @@ -7,7 +7,7 @@ from questionnaires.models import FormRequestStatus, FormVersionStatus from questionnaires.services import request_form_for_profile -from .audience_services import resolve_audience_ids +from .audience_services import conversation_viewer_ids, resolve_audience_ids from .core_models import ConversationContextKind from .form_models import ConversationPointFormRequest from .point_models import ConversationPoint, ConversationPointKind @@ -56,7 +56,9 @@ def ensure_form_requests_for_point(*, actor, point: ConversationPoint, form_vers if audience is None: raise ValidationError("Un Point Formulaire doit définir une audience attendue.") - target_ids = resolve_audience_ids(audience) + target_ids = resolve_audience_ids(audience) & conversation_viewer_ids(locked.conversation) + if not target_ids: + raise ValidationError("Aucun destinataire actuellement autorisé pour ce formulaire.") existing = { link.target_profile_id: link for link in ConversationPointFormRequest.objects.select_related("form_request").filter( diff --git a/conversations/history.py b/conversations/history.py new file mode 100644 index 0000000000..13274857cf --- /dev/null +++ b/conversations/history.py @@ -0,0 +1,218 @@ +"""Owner-scoped C5 Point depth and search without duplicating domain history. + +Signed keyset continuation binds the viewer, Conversation, search, filter and +snapshot ceiling. A Point's audience is re-evaluated on *every* page. +""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from django.core import signing +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db.models import Q +from django.utils import timezone + +from .core_models import ConversationHistoryPolicy, ConversationParticipation, ConversationParticipationStatus +from .media_services import can_download_attachment +from .point_models import ( + ConversationPoint, ConversationPointLifecycle, ConversationResponseVisibility, + ConversationPointResponseStatus, ConversationExchangeModerationState, +) +from .point_services import point_visible_to, point_resolution_visible_to +from .services import can_manage_conversation, can_view_conversation + +_CURSOR_SALT = "conversation-point-depth-c5-v1" +_MAX_CURSOR_LENGTH = 4096 +_MAX_PAGE = 60 + + +def _cursor_scope(profile, conversation, *, query, section, limit): + return { + "version": 1, "viewer": str(profile.pk), "conversation": str(conversation.pk), + "query": query, "section": section, "limit": limit, + } + + +def _decode_cursor(raw, *, expected): + if not isinstance(raw, str) or len(raw) > _MAX_CURSOR_LENGTH: + raise ValidationError({"cursor": "Curseur invalide."}) + try: + value = signing.loads(raw, salt=_CURSOR_SALT, max_age=7 * 86400) + if not isinstance(value, dict) or any(value.get(key) != part for key, part in expected.items()): + raise ValueError("cursor scope mismatch") + snapshot = datetime.fromisoformat(value["snapshot"]) + date = datetime.fromisoformat(value["date"]) + pk = UUID(value["point"]) + if timezone.is_naive(snapshot) or timezone.is_naive(date): + raise ValueError("timezone required") + if date > snapshot: + raise ValueError("invalid ordering") + except (signing.BadSignature, ValueError, TypeError, KeyError, OverflowError) as exc: + raise ValidationError({"cursor": "Ce résultat de recherche a changé. Recommencez."}) from exc + return snapshot, date, pk + + +def _history_start(profile, conversation): + """Explicit FROM_JOIN contracts never expose older entries via pagination.""" + if conversation.history_policy != ConversationHistoryPolicy.FROM_JOIN: + return None + if can_manage_conversation(profile, conversation): + return None + membership = ConversationParticipation.objects.filter( + conversation=conversation, profile=profile, + status=ConversationParticipationStatus.ACTIVE, + ).first() + # Without an explicit participation the owner's FROM_JOIN start is + # unknown, so the historical projection fails closed. + return membership.joined_at if membership is not None else timezone.now() + + +def _matching_visible_content(profile, point, term): + if not term: + return True + if term in (point.title or "").casefold() or term in (point.body or "").casefold(): + return True + if point.lifecycle == ConversationPointLifecycle.RESOLVED and point_resolution_visible_to(profile, point): + try: + if term in (point.resolution.summary or "").casefold(): + return True + except (AttributeError, ObjectDoesNotExist): + pass + + # A private Exchange entry cannot cause an observable search hit for another + # profile. Only text that the same profile can read is searched. + if point.kind == "exchange": + entries = point.exchange_entries.filter( + moderation_state=ConversationExchangeModerationState.VISIBLE, + body__icontains=term, + ) + if can_manage_conversation(profile, point.conversation) or ( + point.response_visibility == ConversationResponseVisibility.POINT_VIEWERS + ): + if entries.exists(): + return True + elif point.response_visibility == ConversationResponseVisibility.RESPONDENT_AND_AUTHORITIES: + if entries.filter(author=profile).exists(): + return True + + for item in point.attachments.filter(original_name__icontains=term).select_related( + "response", "exchange_entry", + )[:40]: + if can_download_attachment(profile, item): + return True + return False + + +def page_points_for_profile(profile, conversation, *, query="", section="history", limit=25, cursor=None): + """A bounded, signed, permission-checked read model over published Points.""" + if not can_view_conversation(profile, conversation): + return {"results": [], "next_cursor": None} + query = (query or "").strip()[:120] + if section not in {"history", "all", "active"}: + raise ValidationError({"section": "Filtre inconnu."}) + try: + limit = int(limit) + except (TypeError, ValueError) as exc: + raise ValidationError({"limit": "Limite invalide."}) from exc + if limit < 1 or limit > _MAX_PAGE: + raise ValidationError({"limit": "Limite attendue entre 1 et 60."}) + + scope = _cursor_scope(profile, conversation, query=query, section=section, limit=limit) + snapshot = timezone.now() + last_date = last_pk = None + if cursor: + snapshot, last_date, last_pk = _decode_cursor(cursor, expected=scope) + earliest = _history_start(profile, conversation) + points = ConversationPoint.objects.filter( + conversation=conversation, published_at__isnull=False, + published_at__lte=snapshot, + ).exclude(lifecycle=ConversationPointLifecycle.DRAFT) + if earliest is not None: + points = points.filter(published_at__gte=earliest) + if section == "history": + points = points.exclude(lifecycle__in=( + ConversationPointLifecycle.OPEN, ConversationPointLifecycle.RESPONSE_CLOSED, + )) + elif section == "active": + points = points.filter(lifecycle__in=( + ConversationPointLifecycle.OPEN, ConversationPointLifecycle.RESPONSE_CLOSED, + )) + if last_date is not None: + points = points.filter( + Q(published_at__lt=last_date) | + Q(published_at=last_date, pk__lt=last_pk) + ) + if query: + points = points.filter( + Q(title__icontains=query) | Q(body__icontains=query) | + Q(resolution__summary__icontains=query) | + Q(exchange_entries__body__icontains=query) | + Q(attachments__original_name__icontains=query) + ).distinct() + + points = points.select_related( + "conversation__context", "visibility_audience", "resolution_audience", + "response_audience", "expected_action_audience", + ).prefetch_related("options", "responses").order_by("-published_at", "-pk") + received = [] + # Keep scanning until there are limit+1 *visible* matches. A hard + # candidate cap silently loses results when thousands of candidates + # belong to another audience; it is not valid pagination. + for point in points.iterator(chunk_size=100): + if not point_visible_to(profile, point): + continue + if not _matching_visible_content(profile, point, query.casefold()): + continue + received.append(point) + if len(received) > limit: + break + + has_next = len(received) > limit + result = received[:limit] + next_cursor = None + if has_next and result: + marker = result[-1] + next_cursor = signing.dumps( + { + **scope, "snapshot": snapshot.isoformat(), + "date": marker.published_at.isoformat(), "point": str(marker.pk), + }, + salt=_CURSOR_SALT, + compress=True, + ) + return {"results": result, "next_cursor": next_cursor} + + +def point_personal_result(profile, point): + """Return only the viewer's response and permitted aggregate count.""" + cached = getattr(point, "_prefetched_objects_cache", {}).get("responses") + if cached is not None: + active = [ + row for row in cached + if row.status == ConversationPointResponseStatus.ACTIVE + ] + mine = next(( + row for row in active if row.actor_id == profile.pk + and row.represented_space_id is None + ), None) + else: + mine = point.responses.filter( + actor=profile, status=ConversationPointResponseStatus.ACTIVE, + represented_space__isnull=True, + ).order_by("-submitted_at").first() + active = None + manager = can_manage_conversation(profile, point.conversation) + total = None + if manager or point.response_visibility in ( + ConversationResponseVisibility.POINT_VIEWERS, + ConversationResponseVisibility.AGGREGATE, + ): + total = len(active) if active is not None else point.responses.filter( + status=ConversationPointResponseStatus.ACTIVE, + ).count() + return { + "responded": mine is not None, + "responded_at": mine.submitted_at if mine else None, + "response_count": total, + } diff --git a/conversations/lifecycle_services.py b/conversations/lifecycle_services.py index 88f2ca72f7..dfa4de9359 100644 --- a/conversations/lifecycle_services.py +++ b/conversations/lifecycle_services.py @@ -4,6 +4,7 @@ from django.db import transaction from django.utils import timezone +from .core_models import ConversationLifecycle from .contact_models import CommunicationRoute, CommunicationRouteStatus from .point_models import ConversationPoint, ConversationPointLifecycle from .services import can_manage_conversation, can_manage_conversation_routes @@ -16,6 +17,8 @@ def cancel_point(*, actor, point: ConversationPoint): raise PermissionDenied("Vous ne pouvez pas annuler ce Point.") if locked.lifecycle == ConversationPointLifecycle.CANCELLED: return locked + if locked.conversation.lifecycle != ConversationLifecycle.OPEN: + raise ValidationError("La Conversation est fermée.") if locked.lifecycle not in { ConversationPointLifecycle.OPEN, ConversationPointLifecycle.RESPONSE_CLOSED, diff --git a/conversations/list_presentation.py b/conversations/list_presentation.py new file mode 100644 index 0000000000..ccfd5b57e7 --- /dev/null +++ b/conversations/list_presentation.py @@ -0,0 +1,177 @@ +"""Minimal, viewer-scoped list presentation for Conversations. + +This module composes already-owned context, Point and media facts. It never +copies ownership, invents media or grants access from a Conversation title. +""" +from __future__ import annotations + +from urllib.parse import urlsplit + +from activities.models import ActivityStatus, ActivityVisibility + +from .core_models import ConversationContextKind +from .media_services import can_download_attachment +from .point_models import ConversationPointKind, ConversationPointLifecycle +from .point_services import point_visible_to + +_KIND_LABELS = { + ConversationContextKind.DIRECT: "Conversation directe", + ConversationContextKind.SPACE: "Espace", + ConversationContextKind.GROUP: "Groupe", + ConversationContextKind.ACTIVITY: "Activité", + ConversationContextKind.OCCURRENCE: "Occurrence", + ConversationContextKind.JOURNEY: "Démarche", + ConversationContextKind.DOSSIER: "Dossier", + ConversationContextKind.PROJECT: "Projet", + ConversationContextKind.ACTION_PROPOSAL: "Proposition", +} +_POINT_KINDS = { + ConversationPointKind.POLL: "poll", + ConversationPointKind.CONFIRMATION: "confirmation", + ConversationPointKind.FORM_REQUEST: "form", + ConversationPointKind.EXCHANGE: "exchange", +} +_MEDIA_KINDS = {"voice", "audio", "image", "video", "file"} +_SIGNAL_ORDER = ("voice", "image", "video", "audio", "file", "poll", "form", "confirmation", "exchange") +_MAX_PREVIEW_CANDIDATES = 25 + + +def _short_identity(conversation, context_label): + """Keep the contextual subject, not the repeated bureaucratic prefix. + + The complete original Conversation title stays available in its N1 detail. + This is a *presentation* of the existing title/context, not a rename. + """ + label = (context_label or "").strip() + override = (conversation.title_override or "").strip() + if not label: + return (override or "Conversation", "") + if conversation.context.kind == ConversationContextKind.DIRECT: + return (label, _KIND_LABELS[ConversationContextKind.DIRECT]) + # If an owner-authored override is genuinely distinct, preserve it. + if override and label.casefold() not in override.casefold(): + return (override, label) + for separator in (" — ", " – "): + if separator in label: + first, remainder = label.split(separator, 1) + if first.strip() and remainder.strip(): + return (first.strip(), remainder.strip()) + return (label, _KIND_LABELS.get(conversation.context.kind, "Conversation")) + + +def _public_context_image(conversation): + """Only use a public, published Event's existing cover via Discovery.""" + context = conversation.context + activity = None + occurrence = None + if context.kind == ConversationContextKind.ACTIVITY: + activity = context.activity + elif context.kind == ConversationContextKind.OCCURRENCE: + occurrence = context.occurrence + activity = occurrence.activity + if activity is None: + return None + if activity.visibility != ActivityVisibility.PUBLIC or activity.status != ActivityStatus.PUBLISHED: + return None + # A cover prepared for an unpublished event is not public media. + from django.core.exceptions import ObjectDoesNotExist + try: + event = activity.event_vertical + except (AttributeError, ObjectDoesNotExist): + return None + if not event.published_at: + return None + from discovery.representation import resolve_activity_representation + presentation = resolve_activity_representation(activity=activity, occurrence=occurrence) + if presentation.kind != "image" or not presentation.image_url: + return None + # Never turn an arbitrary remote URL into an unguarded image request. + parsed = urlsplit(presentation.image_url) + if parsed.scheme or parsed.netloc or not parsed.path.startswith("/") or parsed.path.startswith("//"): + return None + return parsed.path + + +def _visible_candidates(profile, conversation, *, visibility_cache, audience_cache, history_cache): + seen = set() + for field in ("m9c_active_points", "m9c_resolved_points"): + values = getattr(conversation, field, None) + if values is None: + continue + for point in values: + if point.pk in seen: + continue + seen.add(point.pk) + if point.lifecycle == ConversationPointLifecycle.DRAFT: + continue + if not point_visible_to( + profile, point, visibility_cache=visibility_cache, + audience_cache=audience_cache, history_cache=history_cache, + ): + continue + yield point + + +def _signals_and_preview(profile, conversation): + """Only signals from Points and attachments the *current* viewer can read.""" + signal_set = set() + preview = None + preview_kind = None + seen = 0 + # Per-call caches never survive a response and are never shared across + # viewers. All decisions are computed by the canonical ACL services. + visibility_cache = {} + audience_cache = {} + history_cache = {} + manager_cache = {} + candidates = _visible_candidates( + profile, conversation, visibility_cache=visibility_cache, + audience_cache=audience_cache, history_cache=history_cache, + ) + for point in candidates: + seen += 1 + if seen > _MAX_PREVIEW_CANDIDATES: + break + mapped_kind = _POINT_KINDS.get(point.kind) + if mapped_kind: + signal_set.add(mapped_kind) + # Media kinds signal actual privately validated attachment objects; + # never expose filename, URL, identity or a private thumbnail here. + for attachment in point.attachments.all(): + if attachment.kind not in _MEDIA_KINDS: + continue + if can_download_attachment( + profile, attachment, visibility_cache=visibility_cache, + audience_cache=audience_cache, history_cache=history_cache, + manager_cache=manager_cache, + ): + signal_set.add(attachment.kind) + if preview is None: + candidate = (point.title or point.body or "").strip() + if candidate: + preview = candidate + preview_kind = mapped_kind or "point" + if len(signal_set) == len(_SIGNAL_ORDER) and preview: + break + return { + "media_kinds": [kind for kind in _SIGNAL_ORDER if kind in signal_set][:4], + "point_preview": preview, + "preview_kind": preview_kind, + } + + +def conversation_list_card(profile, conversation, *, context_label, latest_result, attention_count): + title, subtitle = _short_identity(conversation, context_label) + signals = _signals_and_preview(profile, conversation) + result = (latest_result or "").strip() + preview = result or signals["point_preview"] or (conversation.purpose or "").strip() + return { + "title": title, + "context_label": subtitle, + "context_kind": conversation.context.kind, + "context_image_url": _public_context_image(conversation), + "preview": preview, + "preview_kind": "result" if result else signals["preview_kind"] or "purpose", + "media_kinds": signals["media_kinds"], + "attention_count": attention_count, + } diff --git a/conversations/media_services.py b/conversations/media_services.py index 2c56c2f279..4a6ab6da0d 100644 --- a/conversations/media_services.py +++ b/conversations/media_services.py @@ -1,6 +1,9 @@ from __future__ import annotations from pathlib import Path +import hashlib + +from django.conf import settings from django.core.exceptions import PermissionDenied, ValidationError from django.core.files.base import ContentFile @@ -32,6 +35,43 @@ def _validate_kind_mime(kind, mime_type): raise ValidationError("Une pièce jointe Vidéo doit contenir une vidéo.") +def validate_conversation_upload(uploaded_file, kind): + """Conversation audio is not a JourneyArtifact and needs its own validation. + + The Journey validator remains the owner of document/image validation. + No uploaded MIME declaration alone is trusted for recorded media. + """ + if kind not in {ConversationAttachmentKind.AUDIO, ConversationAttachmentKind.VOICE, ConversationAttachmentKind.VIDEO}: + return validate_artifact_upload(uploaded_file) + if uploaded_file is None: + raise ValidationError("Un fichier est obligatoire.") + bound = int(getattr(settings, "DATA_UPLOAD_MAX_MEMORY_SIZE", 12 * 1024 * 1024)) + size = int(getattr(uploaded_file, "size", 0) or 0) + if size <= 0 or size > bound: + raise ValidationError("Taille de média non prise en charge.") + mime = (getattr(uploaded_file, "content_type", "") or "").split(";", 1)[0].strip().lower() + signatures = { + "audio/webm": lambda b: b.startswith(b"\x1a\x45\xdf\xa3") and b"A_OPUS" in b[:8192], + "audio/ogg": lambda b: b.startswith(b"OggS") and (b"OpusHead" in b[:4096] or b"vorbis" in b[:4096]), + "audio/mp4": lambda b: len(b) >= 12 and b[4:8] == b"ftyp", + "audio/x-m4a": lambda b: len(b) >= 12 and b[4:8] == b"ftyp", + "audio/wav": lambda b: b.startswith(b"RIFF") and b[8:12] == b"WAVE", + "audio/mpeg": lambda b: b.startswith(b"ID3") or (len(b) > 2 and b[0] == 255 and b[1] & 0xE0 == 0xE0), + "video/mp4": lambda b: len(b) >= 12 and b[4:8] == b"ftyp", + "video/webm": lambda b: b.startswith(b"\x1a\x45\xdf\xa3"), + } + allowed = ( + {"video/mp4", "video/webm"} if kind == ConversationAttachmentKind.VIDEO + else {"audio/webm", "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/wav", "audio/mpeg"} + ) + uploaded_file.seek(0) + data = uploaded_file.read(bound + 1) + uploaded_file.seek(0) + if mime not in allowed or len(data) != size or not signatures[mime](data): + raise ValidationError("Format ou signature du média non pris en charge.") + return data, mime, hashlib.sha256(data).hexdigest() + + def _ensure_policy(point, kind): policy = getattr(point.conversation, "policy", None) if policy is None or not getattr(policy, KIND_POLICY_FIELD[kind]): @@ -59,7 +99,7 @@ def _ensure_upload_authority(*, actor, point, response=None, exchange_entry=None def create_point_attachment(*, actor, point, uploaded_file, kind=ConversationAttachmentKind.FILE, response=None, exchange_entry=None): _ensure_upload_authority(actor=actor, point=point, response=response, exchange_entry=exchange_entry) _ensure_policy(point, kind) - data, mime_type, content_hash = validate_artifact_upload(uploaded_file) + data, mime_type, content_hash = validate_conversation_upload(uploaded_file, kind) _validate_kind_mime(kind, mime_type) attachment = ConversationPointAttachment( point=point, @@ -73,17 +113,38 @@ def create_point_attachment(*, actor, point, uploaded_file, kind=ConversationAtt uploaded_by=actor, ) attachment.file.save("payload.bin", ContentFile(data), save=False) - attachment.save() + try: + attachment.save() + except Exception: + if attachment.file.name: + attachment.file.storage.delete(attachment.file.name) + raise return attachment -def can_download_attachment(actor, attachment: ConversationPointAttachment) -> bool: +def can_download_attachment( + actor, attachment: ConversationPointAttachment, *, + visibility_cache=None, audience_cache=None, history_cache=None, + manager_cache=None, +) -> bool: point = attachment.point - if not point_visible_to(actor, point): + if not point_visible_to( + actor, point, visibility_cache=visibility_cache, + audience_cache=audience_cache, history_cache=history_cache, + ): return False + + def _can_manage(): + if manager_cache is None: + return can_manage_conversation(actor, point.conversation) + key = (getattr(actor, "pk", None), point.conversation_id) + if key not in manager_cache: + manager_cache[key] = can_manage_conversation(actor, point.conversation) + return manager_cache[key] + if attachment.response_id: response = attachment.response - if can_manage_conversation(actor, point.conversation): + if _can_manage(): return True if response.actor_id == getattr(actor, "pk", None): return True @@ -93,9 +154,16 @@ def can_download_attachment(actor, attachment: ConversationPointAttachment) -> b return False if attachment.exchange_entry_id: entry = attachment.exchange_entry - if entry.moderation_state == ConversationExchangeModerationState.VISIBLE: + manager = _can_manage() + if entry.moderation_state != ConversationExchangeModerationState.VISIBLE: + return bool(manager or entry.author_id == getattr(actor, "pk", None)) + if manager: + return True + if point.response_visibility == ConversationResponseVisibility.POINT_VIEWERS: return True - return bool(entry.author_id == getattr(actor, "pk", None) or can_manage_conversation(actor, point.conversation)) + if point.response_visibility == ConversationResponseVisibility.RESPONDENT_AND_AUTHORITIES: + return entry.author_id == getattr(actor, "pk", None) + return False return True diff --git a/conversations/media_views.py b/conversations/media_views.py index cb155ac0e1..1c5324db59 100644 --- a/conversations/media_views.py +++ b/conversations/media_views.py @@ -16,3 +16,33 @@ def serve_attachment(request, attachment_pk): response["X-Content-Type-Options"] = "nosniff" response["Cache-Control"] = "private, no-store" return response + + +INLINE_MEDIA_TYPES = { + "image/jpeg", "image/png", + "audio/webm", "audio/ogg", "audio/mp4", "audio/x-m4a", + "audio/wav", "audio/mpeg", + "video/mp4", "video/webm", +} + + +@login_required(login_url="core:login") +def serve_inline_attachment(request, attachment_pk): + """First-party inline rendition: same live ACL, no public storage URL.""" + try: + attachment = attachment_for_download( + actor=request.user, attachment_id=attachment_pk, + ) + except PermissionDenied as exc: + raise Http404 from exc + if attachment.mime_type not in INLINE_MEDIA_TYPES: + raise Http404("Aperçu non pris en charge.") + attachment.file.open("rb") + response = FileResponse( + attachment.file, as_attachment=False, + filename=attachment.original_name, content_type=attachment.mime_type, + ) + response["X-Content-Type-Options"] = "nosniff" + response["Cache-Control"] = "private, no-store" + response["Content-Security-Policy"] = "default-src 'none'" + return response diff --git a/conversations/point_services.py b/conversations/point_services.py index 5dbfbbb3f8..04f0132106 100644 --- a/conversations/point_services.py +++ b/conversations/point_services.py @@ -11,7 +11,10 @@ from authorization.services import can from .audience_services import conversation_viewer_ids, profile_in_audience, resolve_audience_ids -from .core_models import ConversationLifecycle +from .core_models import ( + Conversation, ConversationHistoryPolicy, ConversationLifecycle, + ConversationParticipation, ConversationParticipationStatus, +) from .point_models import ( ConversationPoint, ConversationPointKind, @@ -41,15 +44,88 @@ } -def point_visible_to(profile, point, *, at=None): - if not can_view_conversation(profile, point.conversation): +def point_visible_to( + profile, point, *, at=None, + visibility_cache=None, audience_cache=None, history_cache=None, +): + """Canonical viewer check with optional request-local memoization. + + Cache keys include the Profile and object identity. Callers never supply a + positive authorization flag: the service fills every cache from the live + policy itself. Recheck on every new request/acquisition. + """ + scope = (getattr(profile, "pk", None), point.conversation_id) + if visibility_cache is None: + visible = can_view_conversation(profile, point.conversation) + else: + if scope not in visibility_cache: + visibility_cache[scope] = can_view_conversation(profile, point.conversation) + visible = visibility_cache[scope] + if not visible: return False - return not point.visibility_audience_id or profile_in_audience(profile, point.visibility_audience, at=at) + + if point.lifecycle == ConversationPointLifecycle.DRAFT: + if point.published_by_id != getattr(profile, "pk", None) and not can_manage_conversation(profile, point.conversation): + return False + if ( + point.published_at is not None + and point.conversation.history_policy == ConversationHistoryPolicy.FROM_JOIN + ): + if history_cache is None: + manager = can_manage_conversation(profile, point.conversation) + joined_at = None if manager else ConversationParticipation.objects.filter( + conversation=point.conversation, profile=profile, + status=ConversationParticipationStatus.ACTIVE, + ).values_list("joined_at", flat=True).first() + else: + if scope not in history_cache: + manager = can_manage_conversation(profile, point.conversation) + joined_at = None if manager else ConversationParticipation.objects.filter( + conversation=point.conversation, profile=profile, + status=ConversationParticipationStatus.ACTIVE, + ).values_list("joined_at", flat=True).first() + history_cache[scope] = (manager, joined_at) + manager, joined_at = history_cache[scope] + # Never infer an historic join from derived access. + if not manager and (joined_at is None or point.published_at < joined_at): + return False + + if not point.visibility_audience_id: + return True + audience_scope = (getattr(profile, "pk", None), point.visibility_audience_id, at) + if audience_cache is None: + return profile_in_audience(profile, point.visibility_audience, at=at) + if audience_scope not in audience_cache: + audience_cache[audience_scope] = profile_in_audience( + profile, point.visibility_audience, at=at, + ) + return audience_cache[audience_scope] + + +def point_resolution_visible_to(profile, point, *, at=None): + """Gate resolution summaries and all derived result projections. + + An ability to read the Point does not imply a right to its resolution. + Contextual authorities may inspect the result; other viewers require an + explicit resolution audience when one exists. + """ + if not point_visible_to(profile, point, at=at): + return False + return ( + not point.resolution_audience_id + or can_manage_conversation(profile, point.conversation) + or profile_in_audience(profile, point.resolution_audience, at=at) + ) + def point_response_allowed(profile, point, *, at=None): if not point_visible_to(profile, point, at=at): return False + # Publishing a Point does not remove its author's right to participate. + # Everyone else remains constrained by the explicit response audience. + if point.published_by_id == getattr(profile, "pk", None): + return True return not point.response_audience_id or profile_in_audience(profile, point.response_audience, at=at) @@ -68,6 +144,8 @@ def _validate_represented_space(actor, represented_space): def _active_window(point, *, at=None): at = at or timezone.now() + if point.conversation.lifecycle != ConversationLifecycle.OPEN: + raise ValidationError("Cette Conversation est fermée.") if point.lifecycle != ConversationPointLifecycle.OPEN: raise ValidationError("Ce Point n’accepte pas de réponse actuellement.") if point.opens_at and at < point.opens_at: @@ -156,6 +234,9 @@ def create_point( opens_at=None, deadline_at=None, valid_until=None, allow_response_change=False, threshold_value=None, represented_space=None, client_reference=None, options=(), publish=True, ): + # Serialize creation for one Conversation so retries with the same client + # reference do not race on the global uniqueness constraint. + conversation = Conversation.objects.select_for_update(of=("self",)).get(pk=conversation.pk) if not can_publish_in_conversation(actor, conversation): raise PermissionDenied("Vous ne pouvez pas publier dans cette Conversation.") if conversation.lifecycle != ConversationLifecycle.OPEN: @@ -165,7 +246,20 @@ def create_point( if ref: existing = ConversationPoint.objects.filter(client_reference=ref).first() if existing: - if existing.conversation_id == conversation.pk: + same_request = ( + existing.conversation_id == conversation.pk + and existing.published_by_id == actor.pk + and existing.kind == kind + and existing.response_mode == response_mode + and existing.title == (title or "").strip() + and existing.body == (body or "").strip() + and existing.visibility_audience_id == getattr(visibility_audience, "pk", None) + and existing.response_audience_id == getattr(response_audience, "pk", None) + and existing.expected_action_audience_id == getattr(expected_action_audience, "pk", None) + and existing.represented_space_id == getattr(represented_space, "pk", None) + and list(existing.options.order_by("position").values_list("label", flat=True)) == list(options) + ) + if same_request: return existing raise ValidationError("Cette référence d’idempotence est déjà utilisée.") point = ConversationPoint( @@ -243,6 +337,8 @@ def submit_point_response(*, actor, point, value=None, represented_space=None, c @transaction.atomic def acknowledge_point(*, actor, point): locked = ConversationPoint.objects.select_for_update(of=("self",)).select_related("conversation", "visibility_audience").get(pk=point.pk) + if locked.conversation.lifecycle != ConversationLifecycle.OPEN: + raise ValidationError("La Conversation est fermée.") if not locked.requires_acknowledgement: raise ValidationError("Ce Point ne demande pas de confirmation de lecture.") if not point_visible_to(actor, locked): @@ -347,6 +443,8 @@ def resolve_point(*, actor, point, summary, selected_option_ids=(), result_paylo return locked.resolution if locked.lifecycle not in {ConversationPointLifecycle.OPEN, ConversationPointLifecycle.RESPONSE_CLOSED}: raise ValidationError("Ce Point ne peut pas être résolu dans son état actuel.") + if locked.conversation.lifecycle != ConversationLifecycle.OPEN: + raise ValidationError("La Conversation est fermée.") if locked.resolution_audience_id and not profile_in_audience(actor, locked.resolution_audience): raise PermissionDenied("Vous ne faites pas partie de l’audience de résolution.") if not can_manage_conversation(actor, locked.conversation): @@ -360,6 +458,8 @@ def resolve_point(*, actor, point, summary, selected_option_ids=(), result_paylo @transaction.atomic def close_point_responses(*, actor, point): locked = ConversationPoint.objects.select_for_update(of=("self",)).select_related("conversation").get(pk=point.pk) + if locked.conversation.lifecycle != ConversationLifecycle.OPEN: + raise ValidationError("La Conversation est fermée.") if not can_manage_conversation(actor, locked.conversation): raise PermissionDenied("Vous ne pouvez pas fermer les réponses de ce Point.") if locked.lifecycle != ConversationPointLifecycle.OPEN: @@ -375,6 +475,8 @@ def close_point_responses(*, actor, point): @transaction.atomic def supersede_point(*, actor, old_point, new_point): old = ConversationPoint.objects.select_for_update(of=("self",)).select_related("conversation").get(pk=old_point.pk) + if old.conversation.lifecycle != ConversationLifecycle.OPEN: + raise ValidationError("La Conversation est fermée.") if old.conversation_id != new_point.conversation_id: raise ValidationError("Le Point de remplacement doit appartenir à la même Conversation.") if not can_publish_in_conversation(actor, old.conversation): @@ -404,7 +506,12 @@ def create_exchange_entry(*, actor, point, body, reply_to=None, represented_spac if ref: existing = PointExchangeEntry.objects.filter(client_reference=ref).first() if existing: - if existing.point_id == locked.pk and existing.author_id == actor.pk: + if ( + existing.point_id == locked.pk and existing.author_id == actor.pk + and existing.body == (body or "").strip() + and existing.represented_space_id == getattr(represented_space, "pk", None) + and existing.reply_to_id == getattr(reply_to, "pk", None) + ): return existing raise ValidationError("Cette référence d’idempotence est déjà utilisée.") return PointExchangeEntry.objects.create( diff --git a/conversations/presentation.py b/conversations/presentation.py index ec7b369c66..6216452b66 100644 --- a/conversations/presentation.py +++ b/conversations/presentation.py @@ -1,8 +1,10 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace +from django.core.exceptions import ValidationError from django.db.models import Prefetch, Q +from rest_framework.exceptions import NotFound from django.utils import timezone from .attention import point_attention_reason @@ -13,9 +15,11 @@ ConversationParticipationStatus, ConversationUserState, ) -from .point_models import ConversationPoint, ConversationPointKind, ConversationPointLifecycle -from .point_services import point_visible_to -from .services import can_view_conversation +from .point_models import ConversationPoint, ConversationPointKind, ConversationPointLifecycle, ConversationResponseVisibility +from .media_models import ConversationPointAttachment +from .list_presentation import conversation_list_card +from .point_services import point_visible_to, point_resolution_visible_to +from .services import can_manage_conversation, can_view_conversation @dataclass(frozen=True) @@ -25,6 +29,7 @@ class ConversationRow: attention_count: int latest_result: str all_clear: bool + card: dict | None = None @dataclass(frozen=True) @@ -33,6 +38,54 @@ class ConversationSearchRow: context_label: str point: ConversationPoint | None rank: int + card: dict | None = None + + +def conversations_for_context(profile, kind, context_pk): + """Read-only contextual entry; never creates a Conversation or grants entry.""" + if kind == ConversationContextKind.DIRECT: + filters = Q(context__direct_profile_a=profile, context__direct_profile_b_id=context_pk) | Q( + context__direct_profile_b=profile, context__direct_profile_a_id=context_pk + ) + else: + field = { + ConversationContextKind.SPACE: "space", + ConversationContextKind.GROUP: "group", + ConversationContextKind.ACTIVITY: "activity", + ConversationContextKind.OCCURRENCE: "occurrence", + ConversationContextKind.DOSSIER: "dossier", + ConversationContextKind.PROJECT: "project", + ConversationContextKind.JOURNEY: "journey", + ConversationContextKind.ACTION_PROPOSAL: "action_proposal", + }.get(kind) + if field is None: + raise NotFound("Contexte introuvable.") + filters = Q(**{f"context__{field}_id": context_pk}) + try: + candidates = list( + Conversation.objects.select_related("context").filter( + filters, context__kind=kind, + ).order_by("-updated_at", "pk")[:200] + ) + except (ValidationError, ValueError, TypeError): + return [] + return [conversation for conversation in candidates if can_view_conversation(profile, conversation)][:50] + + + +def visible_exchange_entries_for_profile(profile, point, *, limit=5): + """A Point's visibility never implies access to every author's raw text.""" + if point.kind != ConversationPointKind.EXCHANGE or not point_visible_to(profile, point): + return [] + entries = point.exchange_entries.filter(moderation_state="visible") + if not can_manage_conversation(profile, point.conversation): + if point.response_visibility == ConversationResponseVisibility.POINT_VIEWERS: + pass + elif point.response_visibility == ConversationResponseVisibility.RESPONDENT_AND_AUTHORITIES: + entries = entries.filter(author=profile) + else: + return [] + return list(reversed(list(entries.order_by("-created_at", "-pk")[:limit]))) def conversation_context_label(conversation, profile=None): @@ -67,15 +120,25 @@ def conversation_context_label(conversation, profile=None): def _list_projection_queryset(profile): active_points = ConversationPoint.objects.filter( lifecycle__in={ConversationPointLifecycle.OPEN, ConversationPointLifecycle.RESPONSE_CLOSED} - ).select_related("visibility_audience", "response_audience", "expected_action_audience", "resolution_audience") + ).select_related( + "conversation", "visibility_audience", "response_audience", + "expected_action_audience", "resolution_audience", + ).prefetch_related( + Prefetch("attachments", queryset=ConversationPointAttachment.objects.select_related("response", "exchange_entry")), + ) resolved_points = ConversationPoint.objects.filter( lifecycle=ConversationPointLifecycle.RESOLVED - ).select_related("resolution", "visibility_audience").order_by("-resolved_at") + ).select_related( + "conversation", "resolution", "visibility_audience", "resolution_audience", + ).prefetch_related( + Prefetch("attachments", queryset=ConversationPointAttachment.objects.select_related("response", "exchange_entry")), + ).order_by("-resolved_at") return ( Conversation.objects.select_related( "context__space", "context__group", "context__activity", "context__occurrence__activity", "context__dossier", "context__project", "context__journey__activity", "context__action_proposal__need", "context__direct_profile_a", "context__direct_profile_b", + "context__activity__event_vertical", "context__occurrence__activity__event_vertical", ) .prefetch_related( Prefetch( @@ -131,7 +194,7 @@ def _latest_resolution_summary(conversation, profile): if points is None: points = conversation.points.filter(lifecycle=ConversationPointLifecycle.RESOLVED).select_related("resolution", "visibility_audience").order_by("-resolved_at")[:20] for point in points[:20]: - if point_visible_to(profile, point): + if point_resolution_visible_to(profile, point): try: return point.resolution.summary except Exception: @@ -156,13 +219,19 @@ def conversation_rows_for_profile(profile, *, archived=False, only_attention=Fal attention += 1 if only_attention and not attention: continue + context_label = conversation_context_label(conversation, profile) + latest_result = _latest_resolution_summary(conversation, profile) rows.append( ConversationRow( conversation=conversation, - context_label=conversation_context_label(conversation, profile), + context_label=context_label, attention_count=attention, - latest_result=_latest_resolution_summary(conversation, profile), + latest_result=latest_result, all_clear=attention == 0, + card=conversation_list_card( + profile, conversation, context_label=context_label, + latest_result=latest_result, attention_count=attention, + ), ) ) if len(rows) >= limit: @@ -177,8 +246,8 @@ def now_points_for_profile(profile, conversation, *, limit=100): now = timezone.now() rows = [] points = conversation.points.filter(lifecycle__in={ConversationPointLifecycle.OPEN, ConversationPointLifecycle.RESPONSE_CLOSED}).select_related( - "visibility_audience", "response_audience", "expected_action_audience", "resolution_audience" - ).prefetch_related("options").order_by("deadline_at", "-importance", "published_at")[: max(limit * 3, 100)] + "published_by", "represented_space", "visibility_audience", "response_audience", "expected_action_audience", "resolution_audience" + ).prefetch_related("options", "responses").order_by("deadline_at", "-importance", "published_at")[: max(limit * 3, 100)] for point in points: if not point_visible_to(profile, point, at=now): continue @@ -192,12 +261,33 @@ def now_points_for_profile(profile, conversation, *, limit=100): return rows +def other_visible_points_for_profile(profile, conversation, *, excluded_ids=(), limit=50): + """Visible open Points outside the owner's attention/essential selection.""" + if not can_view_conversation(profile, conversation): + return [] + excluded = set(excluded_ids) + candidates = conversation.points.filter( + lifecycle__in={ConversationPointLifecycle.OPEN, ConversationPointLifecycle.RESPONSE_CLOSED}, + ).select_related( + "published_by", "represented_space", "visibility_audience", "response_audience", "expected_action_audience", + "resolution_audience", + ).prefetch_related("options", "responses").order_by("published_at", "pk")[: max(limit * 4, 100)] + result = [] + for point in candidates: + if point.pk in excluded or not point_visible_to(profile, point): + continue + result.append(point) + if len(result) >= limit: + break + return result + + def essential_points_for_profile(profile, conversation, *, limit=50): if not can_view_conversation(profile, conversation): return [] candidates = conversation.points.filter( lifecycle__in={ConversationPointLifecycle.OPEN, ConversationPointLifecycle.RESPONSE_CLOSED, ConversationPointLifecycle.RESOLVED} - ).select_related("visibility_audience", "resolution").prefetch_related("options").order_by("-shared_pinned_at", "-resolved_at", "-published_at")[: max(limit * 3, 100)] + ).select_related("published_by", "represented_space", "visibility_audience", "resolution").prefetch_related("options", "responses").order_by("-shared_pinned_at", "-resolved_at", "-published_at")[: max(limit * 3, 100)] result = [] for point in candidates: if not point_visible_to(profile, point): @@ -224,11 +314,18 @@ def search_conversation(profile, conversation, query, *, limit=50): if not point_visible_to(profile, point): continue resolution_summary = "" - if point.lifecycle == ConversationPointLifecycle.RESOLVED: + if (point.lifecycle == ConversationPointLifecycle.RESOLVED and + point_resolution_visible_to(profile, point)): try: resolution_summary = point.resolution.summary or "" except Exception: resolution_summary = "" + if not ( + normalized in (point.title or "").casefold() + or normalized in (point.body or "").casefold() + or normalized in resolution_summary.casefold() + ): + continue if resolution_summary and normalized in resolution_summary.casefold(): score = 0 elif point.lifecycle in {ConversationPointLifecycle.OPEN, ConversationPointLifecycle.RESPONSE_CLOSED}: @@ -244,20 +341,25 @@ def search_conversation(profile, conversation, query, *, limit=50): return [row[2] for row in scored[:limit]] -def search_conversations_for_profile(profile, query, *, limit=50): +def search_conversations_for_profile(profile, query, *, limit=50, include_archived=False): """Privacy-safe global Conversation search with outcome/current-state precedence.""" query = (query or "").strip()[:120] if not query or not getattr(profile, "is_authenticated", False): return [] normalized = query.casefold() rows = [] - for conversation in _accessible_conversations(profile, include_archived=False, limit=300): + for conversation in _accessible_conversations(profile, include_archived=include_archived, limit=300): + if include_archived: + state = _profile_state(conversation) + if not (state and state.archived_at): + continue context_label = conversation_context_label(conversation, profile) haystack = " ".join((conversation.title_override or "", conversation.purpose or "", context_label)).casefold() if normalized in haystack: rows.append(ConversationSearchRow(conversation=conversation, context_label=context_label, point=None, rank=2)) for point in search_conversation(profile, conversation, query, limit=5): - if point.lifecycle == ConversationPointLifecycle.RESOLVED: + if (point.lifecycle == ConversationPointLifecycle.RESOLVED and + point_resolution_visible_to(profile, point)): try: summary = point.resolution.summary or "" except Exception: @@ -280,7 +382,16 @@ def search_conversations_for_profile(profile, query, *, limit=50): ), ) ) - return rows[:limit] + return [ + replace( + row, + card=conversation_list_card( + profile, row.conversation, context_label=row.context_label, + latest_result="", attention_count=0, + ), + ) + for row in rows[:limit] + ] def catch_up_summary(profile, conversation, *, since=None): diff --git a/conversations/rich_services.py b/conversations/rich_services.py new file mode 100644 index 0000000000..b7d5d58866 --- /dev/null +++ b/conversations/rich_services.py @@ -0,0 +1,368 @@ +"""C4 bridges from Conversation to canonical Questionnaires and private media. + +All operations remain server-authorized; neither Form nor Attachment is a +shadow Conversation domain and none becomes a JourneyArtifact or Proof. +""" +from __future__ import annotations + +from hashlib import sha256 +from pathlib import Path +from uuid import UUID + +from django.core.exceptions import PermissionDenied, ValidationError +from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import transaction +from django.db.models import Q + +from authorization.constants import PermissionCode +from authorization.services import can +from questionnaires.models import Form, FormStatus, FormVersion, FormVersionStatus, QuestionType +from questionnaires.services import ( + add_question, create_form, create_form_version, publish_form_version, +) + +from .composing import ( + _active_exchange_points, _audience_for_publish, _clean_text, + _validate_represented_context, composer_capabilities, contribute_text, + publish_composed_point, +) +from .core_models import Conversation, ConversationLifecycle +from .form_services import _conversation_activity, ensure_form_requests_for_point +from .media_models import ConversationAttachmentKind, ConversationPointAttachment +from .media_services import ( + KIND_POLICY_FIELD, can_download_attachment, create_point_attachment, + validate_conversation_upload, +) +from .point_models import ( + PointExchangeEntry, ConversationPoint, ConversationPointKind, ConversationPointLifecycle, + ConversationPointResponse, + ConversationPointResponseMode, ConversationResponseVisibility, + ConversationExchangeModerationState, +) +from .point_services import create_point, point_visible_to, submit_point_response +from .services import can_manage_conversation, can_publish_in_conversation, can_view_conversation + + +def _form_context_activity(actor, conversation): + if conversation.lifecycle != ConversationLifecycle.OPEN: + return None + policy = getattr(conversation, "policy", None) + if policy is None or not policy.allow_form_requests: + return None + if not can_publish_in_conversation(actor, conversation): + return None + activity = _conversation_activity(conversation) + if activity is None or not can(actor, PermissionCode.ACTIVITY_MANAGE, activity=activity): + return None + return activity + + +def available_form_versions(actor, conversation): + activity = _form_context_activity(actor, conversation) + if activity is None: + return [] + versions = FormVersion.objects.filter( + form__activity=activity, + form__status=FormStatus.ACTIVE, + status=FormVersionStatus.PUBLISHED, + ).select_related("form").order_by("form_id", "-version", "-published_at")[:200] + seen = set() + available = [] + for version in versions: + if version.form_id in seen: + continue + seen.add(version.form_id) + available.append({ + "id": str(version.pk), "title": version.title, + "description": version.description[:240], "version": version.version, + }) + if len(available) >= 50: + break + return available + + +def can_prepare_form(actor, conversation): + return _form_context_activity(actor, conversation) is not None + + +def _validate_compose_reference(client_reference): + if not isinstance(client_reference, str) or not 0 < len(client_reference.strip()) <= 80: + raise ValidationError({"client_reference": "Référence de publication obligatoire."}) + return client_reference.strip() + + +@transaction.atomic +def publish_conversation_form( + *, actor, conversation, title, client_reference, form_version_id=None, + questions=None, audience_id=None, represented_space=None, +): + # Serialize duplicate form preparations before the Form/Version rows are + # created, not only when the canonical Point is published. + conversation = Conversation.objects.select_for_update(of=("self",)).get(pk=conversation.pk) + activity = _form_context_activity(actor, conversation) + if activity is None: + raise PermissionDenied("Vous ne pouvez pas demander de formulaire ici.") + _validate_represented_context(conversation, represented_space) + ref = _validate_compose_reference(client_reference) + title = _clean_text(title, "title", limit=220) + audience = _audience_for_publish(conversation, audience_id) + if audience is None: + raise ValidationError({"audience_id": "Choisissez les personnes concernées."}) + existing = ConversationPoint.objects.filter(client_reference=ref).first() + if existing: + if (existing.conversation_id != conversation.pk + or existing.published_by_id != actor.pk + or existing.kind != ConversationPointKind.FORM_REQUEST + or existing.title != title + or existing.visibility_audience_id != audience.pk + or existing.represented_space_id != getattr(represented_space, "pk", None)): + raise ValidationError("Cette référence d'idempotence est déjà utilisée.") + link = existing.form_request_links.select_related("form_request").first() + if link is None: + raise ValidationError("Le formulaire précédent est incomplet.") + if form_version_id and str(link.form_request.form_version_id) != str(form_version_id): + raise ValidationError("Cette référence désigne un autre formulaire.") + if questions is not None: + actual = list(link.form_request.form_version.questions.order_by("position").values_list("label", flat=True)) + if actual != questions: + raise ValidationError("La préparation du formulaire a changé.") + return existing + + if form_version_id: + try: + version = FormVersion.objects.select_related("form").get( + pk=form_version_id, form__activity=activity, + form__status=FormStatus.ACTIVE, status=FormVersionStatus.PUBLISHED, + ) + except (FormVersion.DoesNotExist, ValueError, TypeError, ValidationError) as exc: + raise ValidationError({"form_version_id": "Formulaire indisponible."}) from exc + else: + if not isinstance(questions, list) or not 1 <= len(questions) <= 8: + raise ValidationError({"questions": "Prévoyez entre une et huit questions."}) + labels = [_clean_text(label, "questions", limit=220) for label in questions] + if len(set(labels)) != len(labels): + raise ValidationError({"questions": "Chaque question doit être distincte."}) + # Only Questionnaire owns form structure; deterministic key protects a + # retry under the same client reference without an orphan template. + key = "conversation-" + sha256(f"{conversation.pk}:{ref}".encode()).hexdigest()[:28] + form = create_form(activity=activity, key=key, title=title, actor=actor) + version = create_form_version(form=form, actor=actor) + for position, label in enumerate(labels): + add_question( + form_version=version, actor=actor, key=f"question_{position+1}", + label=label, question_type=QuestionType.SHORT_TEXT, + position=position, required=True, + ) + publish_form_version(form_version=version, actor=actor) + + point = create_point( + actor=actor, conversation=conversation, + kind=ConversationPointKind.FORM_REQUEST, + response_mode=ConversationPointResponseMode.NONE, + title=title, visibility_audience=audience, + expected_action_audience=audience, + response_audience=audience, + represented_space=represented_space, + client_reference=ref, + ) + ensure_form_requests_for_point(actor=actor, point=point, form_version=version) + return point + + +def visible_attachments_for_profile(profile, point, *, limit=30): + if not point_visible_to(profile, point): + return [] + candidates = point.attachments.select_related( + "response", "exchange_entry", "point__conversation__context", + ).order_by("created_at", "pk")[:max(1, min(limit, 100))] + return [attachment for attachment in candidates if can_download_attachment(profile, attachment)] + + +def serialize_attachment(attachment): + return { + "id": str(attachment.pk), + "kind": attachment.kind, + "name": attachment.original_name, + "mime_type": attachment.mime_type, + "size": attachment.size, + "download_path": f"api/v1/conversations/attachments/{attachment.pk}/download/", + } + + +def readable_exchange_entries(profile, point, *, cursor=None, limit=25): + if point.kind != ConversationPointKind.EXCHANGE or not point_visible_to(profile, point): + raise PermissionDenied("Échange non accessible.") + rows = point.exchange_entries.filter( + moderation_state=ConversationExchangeModerationState.VISIBLE, + ) + if not can_manage_conversation(profile, point.conversation): + if point.response_visibility == ConversationResponseVisibility.POINT_VIEWERS: + pass + elif point.response_visibility == ConversationResponseVisibility.RESPONDENT_AND_AUTHORITIES: + rows = rows.filter(author=profile) + else: + rows = rows.none() + if cursor: + try: + marker = rows.get(pk=UUID(str(cursor))) + except (ValueError, TypeError, PointExchangeEntry.DoesNotExist) as exc: + raise ValidationError({"cursor": "Curseur invalide."}) from exc + rows = rows.filter(Q(created_at__gt=marker.created_at) | Q( + created_at=marker.created_at, pk__gt=marker.pk, + )) + page = list(rows.order_by("created_at", "pk")[:limit+1]) + has_more = len(page) > limit + page = page[:limit] + return page, str(page[-1].pk) if has_more and page else None + + +@transaction.atomic +def upload_conversation_media( + *, actor, conversation, uploaded_file, kind, client_reference, + body="", audience_id=None, point_id=None, represented_space=None, + point_kind=None, title="", options=None, multiple_choice=False, + form_version_id=None, questions=None, +): + """Upload a validated private attachment to an authorized Exchange or Point.""" + if kind not in ConversationAttachmentKind.values: + raise ValidationError({"kind": "Type de pièce jointe inconnu."}) + ref = _validate_compose_reference(client_reference) + if not can_view_conversation(actor, conversation): + raise PermissionDenied("Conversation non accessible.") + if conversation.lifecycle != ConversationLifecycle.OPEN: + raise PermissionDenied("Conversation fermée.") + _validate_represented_context(conversation, represented_space) + policy = getattr(conversation, "policy", None) + if policy is None or not getattr(policy, KIND_POLICY_FIELD[kind], False): + raise PermissionDenied("Ce média n'est pas autorisé dans cette Conversation.") + data, mime, digest = validate_conversation_upload(uploaded_file, kind) + raw_name = Path(getattr(uploaded_file, "name", "fichier") or "fichier").name[:255] + caption = (body or "").strip() + if len(caption) > 5000: + raise ValidationError({"body": "Ce texte est trop long."}) + # A typed contribution and its first attachment are one owner mutation. + # Reuse Conversation Point, Questionnaire, and Attachment ownership: + # do not create a second media-Point or a detached resource. + if point_kind: + if point_id: + raise ValidationError({"point_id": "Choisissez un seul contexte d'envoi."}) + if point_kind == ConversationPointKind.FORM_REQUEST: + point = publish_conversation_form( + actor=actor, conversation=conversation, title=title, + client_reference=ref, form_version_id=form_version_id, + questions=questions, audience_id=audience_id, + represented_space=represented_space, + ) + elif point_kind in { + ConversationPointKind.QUESTION, ConversationPointKind.REQUEST, + ConversationPointKind.CONFIRMATION, ConversationPointKind.POLL, + }: + point = publish_composed_point( + actor=actor, conversation=conversation, kind=point_kind, + title=title, body=caption, options=options, + multiple_choice=multiple_choice, + audience_id=audience_id, represented_space=represented_space, + client_reference=ref, + ) + else: + raise ValidationError({"point_kind": "Ce type de Point n'est pas autorisé."}) + entry = None + else: + active = _active_exchange_points(actor, conversation) + if point_id: + selected = next((point for point in active if str(point.pk) == str(point_id)), None) + if selected is None: + raise PermissionDenied("L'échange sélectionné est indisponible.") + elif len(active) == 1: + selected = active[0] + elif len(active) > 1: + raise ValidationError({"point_id": "Choisissez l'échange."}) + else: + selected = None + + entry = None + if selected is not None: + _, entry, _ = contribute_text( + actor=actor, conversation=conversation, + body=caption or raw_name, + point_id=str(selected.pk), represented_space=represented_space, + client_reference=ref, + ) + point = selected + else: + point = publish_composed_point( + actor=actor, conversation=conversation, + kind=ConversationPointKind.INFORMATION, body=caption or raw_name, + audience_id=audience_id, represented_space=represented_space, + client_reference=ref, + ) + # A retry cannot add a second attachment or replace the bytes associated + # with an already confirmed client reference. + existing = point.attachments.filter( + exchange_entry=entry, uploaded_by=actor, + ).order_by("created_at", "pk").first() + if existing is not None: + if existing.kind != kind or existing.content_hash != digest: + raise ValidationError("Cette référence d'envoi désigne un autre média.") + return point, existing + incoming = SimpleUploadedFile(raw_name, data, content_type=mime) + attachment = create_point_attachment( + actor=actor, point=point, uploaded_file=incoming, kind=kind, + exchange_entry=entry, + ) + return point, attachment + + +@transaction.atomic +def submit_point_media_response( + *, actor, point, uploaded_file, kind, client_reference, + represented_space=None, +): + """A FILE/VOICE response is owned by ConversationPointResponse. + + The private attachment is associated with that response in the same DB + transaction. Neither a free-text URL nor a new Information is created. + """ + ref = _validate_compose_reference(client_reference) + mode = point.response_mode + if mode not in {ConversationPointResponseMode.FILE, ConversationPointResponseMode.VOICE}: + raise ValidationError({"response_mode": "Ce Point n'attend pas de réponse média."}) + expected_kinds = { + ConversationPointResponseMode.FILE: {ConversationAttachmentKind.FILE, ConversationAttachmentKind.IMAGE}, + ConversationPointResponseMode.VOICE: {ConversationAttachmentKind.VOICE, ConversationAttachmentKind.AUDIO}, + } + if kind not in expected_kinds[mode]: + raise ValidationError({"kind": "Type de média incompatible avec ce Point."}) + _validate_represented_context(point.conversation, represented_space) + policy = getattr(point.conversation, "policy", None) + if not policy or not getattr(policy, KIND_POLICY_FIELD[kind], False): + raise PermissionDenied("Ce type de réponse n'est pas autorisé par la Conversation.") + data, mime, digest = validate_conversation_upload(uploaded_file, kind) + raw_name = Path(getattr(uploaded_file, "name", "fichier") or "fichier").name[:255] + from .point_services import point_response_allowed + if not point_response_allowed(actor, point): + raise PermissionDenied("Réponse média non autorisée.") + existing_response = ConversationPointResponse.objects.filter( + client_reference=ref, point=point, actor=actor, + represented_space=represented_space, + ).first() + if existing_response is not None: + previous_attachment = existing_response.attachments.first() + if previous_attachment is None or previous_attachment.kind != kind or previous_attachment.content_hash != digest: + raise ValidationError("Cette référence de réponse désigne un autre fichier.") + return existing_response, previous_attachment + response = submit_point_response( + actor=actor, point=point, value=None, + represented_space=represented_space, client_reference=ref, + ) + attachment = response.attachments.order_by("created_at", "pk").first() + if attachment is not None: + if attachment.kind != kind or attachment.content_hash != digest: + raise ValidationError("Cette référence de réponse désigne un autre fichier.") + return response, attachment + attachment = create_point_attachment( + actor=actor, point=point, uploaded_file=SimpleUploadedFile( + raw_name, data, content_type=mime, + ), kind=kind, response=response, + ) + return response, attachment diff --git a/conversations/services.py b/conversations/services.py index 5d3cd1050e..592a0c5903 100644 --- a/conversations/services.py +++ b/conversations/services.py @@ -383,8 +383,11 @@ def ensure_context_conversation( conversation.save() context.conversation = conversation try: - context.save() + with transaction.atomic(): + context.save() except IntegrityError: + # A concurrent ensure won the unique context. Roll back the failed + # insert to a savepoint before querying the canonical winner. canonical = ConversationContext.objects.select_related("conversation").get(**lookup) conversation.delete() return canonical.conversation @@ -450,10 +453,12 @@ def activate_participation( "created_by": actor, }, ) + was_active = participation.status == ConversationParticipationStatus.ACTIVE participation.status = ConversationParticipationStatus.ACTIVE participation.source = source participation.represented_space = represented_space - participation.joined_at = timezone.now() + if not was_active: + participation.joined_at = timezone.now() participation.left_at = None participation.removed_at = None participation.save() diff --git a/conversations/templatetags/conversation_tags.py b/conversations/templatetags/conversation_tags.py index 5c6d1bc002..1f543f3e37 100644 --- a/conversations/templatetags/conversation_tags.py +++ b/conversations/templatetags/conversation_tags.py @@ -1,9 +1,11 @@ +from django.core.exceptions import ObjectDoesNotExist from django import template from conversations.attention import attention_points_for_profile, conversation_attention_badge_count, has_conversation_attention from conversations.form_services import form_request_for_profile from conversations.point_models import ConversationPoint -from conversations.presentation import conversation_context_label +from conversations.presentation import conversation_context_label, conversations_for_context, visible_exchange_entries_for_profile +from conversations.composing import can_ensure_context_conversation from core.web.request_context import get_request_context @@ -81,3 +83,87 @@ def conversation_attention_preview(profile, limit=5): @register.simple_tag def conversation_point_form_request(point, profile): return form_request_for_profile(point, profile) + + +@register.simple_tag +def conversation_context_entry(profile, kind, context_pk): + """Whether an existing, visible Conversation can be entered from context.""" + if not getattr(profile, "is_authenticated", False) or not context_pk: + return False + try: + return bool(conversations_for_context(profile, kind, str(context_pk))) + except (ValueError, TypeError): + return False + + +@register.simple_tag +def conversation_context_can_ensure(profile, kind, context_pk): + if not getattr(profile, "is_authenticated", False) or not context_pk: + return False + return can_ensure_context_conversation(profile, kind, str(context_pk)) + + +@register.simple_tag +def conversation_point_exchange_preview(point, profile): + return visible_exchange_entries_for_profile(profile, point, limit=5) + + + +@register.simple_tag +def conversation_point_attachments(point, profile): + from conversations.rich_services import visible_attachments_for_profile + return [ + item for item in visible_attachments_for_profile(profile, point) + if item.exchange_entry_id is None + ] + + +@register.simple_tag +def conversation_exchange_attachments(entry, profile): + from conversations.media_services import can_download_attachment + return [ + item for item in entry.attachments.all() + if can_download_attachment(profile, item) + ] + + +@register.simple_tag +def conversation_visible_successor(point, profile): + """A replaced Point never reveals the identity of a private successor.""" + from conversations.point_services import point_visible_to + if point.lifecycle != "superseded" or not getattr(profile, "is_authenticated", False): + return None + for candidate in point.superseded_by_points.order_by("-published_at", "-created_at")[:5]: + if point_visible_to(profile, candidate): + return candidate + return None + + +@register.simple_tag +def conversation_visible_resolution(point, profile): + """Use the same result-audience projection as the API and search.""" + from conversations.point_services import point_resolution_visible_to + if point.lifecycle != "resolved" or not getattr(profile, "is_authenticated", False): + return None + if not point_resolution_visible_to(profile, point): + return None + try: + return point.resolution.summary or None + except ObjectDoesNotExist: + return None + + +@register.simple_tag +def conversation_media_label(kind): + """Only labels for media/Point kinds the server actually confirmed visible.""" + return { + "voice": "Vocal", + "audio": "Audio", + "image": "Photo", + "video": "Vidéo", + "file": "Document", + "poll": "Sondage", + "form": "Formulaire", + "confirmation": "Confirmation", + "exchange": "Échanges", + }.get(kind, "Contenu") diff --git a/conversations/test_c3_composition.py b/conversations/test_c3_composition.py new file mode 100644 index 0000000000..cf14e89b21 --- /dev/null +++ b/conversations/test_c3_composition.py @@ -0,0 +1,342 @@ +"""C3 end-to-end contracts: canonical writes, authority, idempotence and drafts.""" +import json + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from activities.models import Activity +from authorization.constants import SystemRoleCode +from authorization.services import grant_activity_role, grant_space_role +from organizations.models import Organization + +from .audience_models import ConversationAudienceRuleKind, ConversationAudienceRuleOperation +from .audience_services import add_audience_rule, create_audience_set +from .core_models import Conversation, ConversationContextKind +from .point_models import ConversationPoint, ConversationPointKind, ConversationPointResponseMode, ConversationResponseVisibility, PointExchangeEntry +from .point_services import create_exchange_entry, create_point +from .services import activate_participation, ensure_context_conversation + + +User = get_user_model() + + +class ConversationC3CompositionTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user(username="c3-owner", password="StrongPass2026!") + self.member = User.objects.create_user(username="c3-member", password="StrongPass2026!") + self.outsider = User.objects.create_user(username="c3-outsider", password="StrongPass2026!") + self.space = Organization.objects.create(name="C3 Espace", created_by=self.owner) + self.activity = Activity.objects.create( + space=self.space, created_by=self.owner, title="C3 Activité", + ) + grant_activity_role( + profile=self.owner, activity=self.activity, + role_code=SystemRoleCode.ACTIVITY_LOCAL_MANAGER, + granted_by=self.owner, source="c3-activity", + ) + self.conversation = ensure_context_conversation( + actor=self.owner, kind=ConversationContextKind.ACTIVITY, + activity=self.activity, + ) + activate_participation( + actor=self.owner, conversation=self.conversation, profile=self.member, + ) + self.audience = create_audience_set( + actor=self.owner, conversation=self.conversation, label="Équipe", + ) + add_audience_rule( + actor=self.owner, audience_set=self.audience, + operation=ConversationAudienceRuleOperation.INCLUDE, + kind=ConversationAudienceRuleKind.EXPLICIT_PROFILE, profile=self.member, + ) + + def _url(self, conversation=None): + return reverse("conversations-api:compose", kwargs={ + "pk": (conversation or self.conversation).pk, + }) + + def _post(self, payload, *, conversation=None): + return self.client.post( + self._url(conversation), json.dumps(payload), + content_type="application/json", + ) + + def test_owner_capabilities_and_member_read_only_are_server_owned(self): + self.client.force_login(self.owner) + owner = self.client.get(reverse( + "conversations-api:detail", kwargs={"pk": self.conversation.pk}, + )) + self.assertEqual(owner.status_code, 200) + self.assertEqual(owner["Cache-Control"], "private, no-store") + capabilities = owner.json()["composer"] + self.assertTrue(capabilities["can_create"]) + self.assertIn("poll", capabilities["allowed_kinds"]) + self.assertEqual(capabilities["default_audience_id"], str(self.audience.pk)) + self.assertEqual(capabilities["text_mode"], "information") + + self.client.force_login(self.member) + member = self.client.get(reverse( + "conversations-api:detail", kwargs={"pk": self.conversation.pk}, + )) + self.assertEqual(member.status_code, 200) + self.assertFalse(member.json()["composer"]["can_create"]) + self.assertIsNone(member.json()["composer"]["text_mode"]) + self.assertEqual(member.json()["composer"]["audiences"], []) + + self.client.force_login(self.outsider) + self.assertEqual(self.client.get(reverse( + "conversations-api:detail", kwargs={"pk": self.conversation.pk}, + )).status_code, 404) + + def test_publishing_text_retries_once_and_blocks_reference_reuse(self): + self.client.force_login(self.owner) + payload = { + "mode": "text", + "body": "Rendez-vous à 17 h", + "client_reference": "c3-text-idempotent", + } + first = self._post(payload) + second = self._post(payload) + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.json()["id"], second.json()["id"]) + self.assertEqual( + ConversationPoint.objects.filter(conversation=self.conversation).count(), 1, + ) + point = ConversationPoint.objects.get(pk=first.json()["id"]) + self.assertEqual(point.body, "Rendez-vous à 17 h") + self.assertEqual(point.visibility_audience, self.audience) + changed = self._post({**payload, "body": "Autre information"}) + self.assertEqual(changed.status_code, 400) + self.assertEqual(ConversationPoint.objects.count(), 1) + + self.client.force_login(self.member) + denied = self._post({**payload, "client_reference": "c3-member-denied"}) + self.assertEqual(denied.status_code, 403) + self.client.force_login(self.outsider) + self.assertEqual(self._post(payload).status_code, 404) + + def test_poll_confirmation_and_second_audience_require_an_explicit_choice(self): + second_audience = create_audience_set( + actor=self.owner, conversation=self.conversation, label="Direction", + ) + self.client.force_login(self.owner) + preview = self.client.get(reverse( + "conversations-api:detail", kwargs={"pk": self.conversation.pk}, + )).json()["composer"] + self.assertTrue(preview["audience_selection_required"]) + payload = { + "mode": "point", "kind": "poll", "title": "Quelle heure ?", + "options": ["14 h", "15 h"], + "client_reference": "c3-poll", + } + self.assertEqual(self._post(payload).status_code, 400) + result = self._post({**payload, "audience_id": str(second_audience.pk)}) + self.assertEqual(result.status_code, 200) + poll = ConversationPoint.objects.get(pk=result.json()["point_id"]) + self.assertEqual(poll.kind, "poll") + self.assertEqual(poll.response_mode, "single_choice") + self.assertEqual(poll.visibility_audience, second_audience) + self.assertEqual(poll.options.count(), 2) + self.assertEqual( + self._post({ + "mode": "point", "kind": "confirmation", "title": "Vous serez là ?", + "audience_id": str(self.audience.pk), + "client_reference": "c3-confirmation", + }).status_code, 200, + ) + self.assertEqual( + self._post({ + **payload, "audience_id": str(self.audience.pk), + "client_reference": "c3-other-audience", + }).status_code, 200, + ) + + def test_plain_text_contributes_to_unique_exchange_not_a_new_point(self): + self.client.force_login(self.owner) + started = self._post({ + "mode": "point", "kind": "exchange", "title": "Questions à partager", + "client_reference": "c3-exchange-start", + }) + self.assertEqual(started.status_code, 200) + point_id = started.json()["point_id"] + self.client.force_login(self.member) + detail = self.client.get(reverse( + "conversations-api:detail", kwargs={"pk": self.conversation.pk}, + )).json() + self.assertEqual(detail["composer"]["text_mode"], "exchange") + self.assertEqual(detail["composer"]["default_exchange_point_id"], point_id) + payload = { + "mode": "text", "body": "Je confirme la préparation.", + "client_reference": "c3-exchange-message", + } + first = self._post(payload) + replay = self._post(payload) + self.assertEqual(first.status_code, 200) + self.assertEqual(first.json()["kind"], "exchange") + self.assertEqual(first.json()["id"], replay.json()["id"]) + self.assertEqual(ConversationPoint.objects.count(), 1) + self.assertEqual(PointExchangeEntry.objects.count(), 1) + self.assertEqual( + self._post({**payload, "body": "Message différent"}).status_code, 400, + ) + self.assertEqual(PointExchangeEntry.objects.count(), 1) + detail = self.client.get(reverse( + "conversations-api:detail", kwargs={"pk": self.conversation.pk}, + )).json() + points = detail["points"] + detail["essential"] + detail["elsewhere"] + item = next(item for item in points if item["id"] == point_id) + self.assertEqual(item["exchange_preview"][0]["body"], payload["body"]) + + def test_direct_profile_conversation_is_composable_only_after_owner_ensures_it(self): + # Consent is established by the domain before the UI opens this + # Conversation. The public Profile route cannot create a DM. + direct = ensure_context_conversation( + actor=self.owner, kind=ConversationContextKind.DIRECT, + direct_profile_a=self.owner, direct_profile_b=self.member, + ) + self.client.force_login(self.member) + result = self._post({ + "mode": "text", "body": "Préparons cette action.", + "client_reference": "c3-direct-existing", + }, conversation=direct) + self.assertEqual(result.status_code, 200) + self.assertEqual( + ConversationPoint.objects.get(pk=result.json()["point_id"]).conversation, + direct, + ) + self.client.force_login(self.outsider) + denied = self._post({ + "mode": "text", "body": "Je ne suis pas dans cet échange.", + "client_reference": "c3-direct-outsider", + }, conversation=direct) + self.assertEqual(denied.status_code, 404) + + def test_c3_private_exchange_preview_never_leaks_other_people_text(self): + another = User.objects.create_user( + username="c3-another", password="StrongPass2026!", + ) + activate_participation( + actor=self.owner, conversation=self.conversation, profile=another, + ) + add_audience_rule( + actor=self.owner, audience_set=self.audience, + operation=ConversationAudienceRuleOperation.INCLUDE, + kind=ConversationAudienceRuleKind.EXPLICIT_PROFILE, profile=another, + ) + point = create_point( + actor=self.owner, conversation=self.conversation, + kind=ConversationPointKind.EXCHANGE, + response_mode=ConversationPointResponseMode.FREE_TEXT, + title="Échange privé", + visibility_audience=self.audience, + response_audience=self.audience, + response_visibility=ConversationResponseVisibility.RESPONDENT_AND_AUTHORITIES, + ) + create_exchange_entry( + actor=self.member, point=point, + body="Contenu privé de la réponse de membre 1", + client_reference="c3-private-message-1", + ) + self.client.force_login(another) + detail = self.client.get(reverse( + "conversations-api:detail", kwargs={"pk": self.conversation.pk}, + )) + self.assertEqual(detail.status_code, 200) + entries = [ + entry for key in ("points", "essential", "elsewhere") + for item in detail.json()[key] if item["id"] == str(point.pk) + for entry in item["exchange_preview"] + ] + self.assertEqual(entries, []) + html = self.client.get(reverse( + "conversations:detail", kwargs={"pk": self.conversation.pk}, + )) + self.assertNotContains(html, "Contenu privé de la réponse de membre 1") + self.client.force_login(self.member) + detail = self.client.get(reverse( + "conversations-api:detail", kwargs={"pk": self.conversation.pk}, + )).json() + self.assertTrue(any( + entry["body"] == "Contenu privé de la réponse de membre 1" + for key in ("points", "essential", "elsewhere") + for item in detail[key] if item["id"] == str(point.pk) + for entry in item["exchange_preview"] + )) + + def test_context_ensure_reuses_one_conversation_and_requires_manage_authority(self): + grant_space_role( + profile=self.owner, space=self.space, role=SystemRoleCode.SPACE_OWNER, + granted_by=self.owner, source="c3-space", + ) + context = { + "kind": "space", "context_pk": str(self.space.pk), + } + url = reverse("conversations-api:context-ensure", kwargs=context) + self.client.force_login(self.member) + no_permission = self.client.post(url, {}, content_type="application/json") + self.assertEqual(no_permission.status_code, 403) + + self.client.force_login(self.owner) + decision = self.client.get( + reverse("conversations-api:by-context", kwargs=context), + ).json() + self.assertTrue(decision["can_ensure"]) + before = Conversation.objects.count() + opened = self.client.post(url, {}, content_type="application/json") + again = self.client.post(url, {}, content_type="application/json") + self.assertEqual(opened.status_code, 200) + self.assertEqual(opened.json()["id"], again.json()["id"]) + self.assertEqual(Conversation.objects.count(), before + 1) + + self.assertEqual(self.client.post( + reverse("conversations-api:context-ensure", kwargs={ + "kind": "direct", "context_pk": str(self.member.pk), + }), {}, content_type="application/json", + ).status_code, 403) + + def test_represented_space_is_checked_against_context_and_mandate(self): + grant_space_role( + profile=self.owner, space=self.space, role=SystemRoleCode.SPACE_OWNER, + granted_by=self.owner, source="c3-representation", + ) + self.client.force_login(self.owner) + accepted = self._post({ + "mode": "text", "body": "Au nom de notre Espace", + "represented_space_id": str(self.space.pk), + "client_reference": "c3-space-publish", + }) + self.assertEqual(accepted.status_code, 200) + self.assertEqual( + ConversationPoint.objects.get(pk=accepted.json()["id"]).represented_space, + self.space, + ) + other = Organization.objects.create(name="Étranger C3", created_by=self.outsider) + rejected = self._post({ + "mode": "text", "body": "Publier ailleurs", + "represented_space_id": str(other.pk), + "client_reference": "c3-wrong-space", + }) + self.assertEqual(rejected.status_code, 403) + self.assertEqual(ConversationPoint.objects.count(), 1) + + def test_web_compose_keeps_invalid_draft_and_focuses_success(self): + self.client.force_login(self.owner) + url = reverse("conversations:compose", kwargs={"pk": self.conversation.pk}) + invalid = self.client.post(url, { + "mode": "point", "kind": "poll", "title": "", + "options": "14 h\n15 h", "client_reference": "c3-web-draft", + }) + self.assertEqual(invalid.status_code, 200) + self.assertContains(invalid, "c3-web-draft") + self.assertContains(invalid, "14 h") + success = self.client.post(url, { + "mode": "text", "body": "Message depuis le Web", + "client_reference": "c3-web-valid", + }) + self.assertEqual(success.status_code, 302) + self.assertIn("focus=", success["Location"]) + self.assertEqual( + ConversationPoint.objects.filter(client_reference="c3-web-valid").count(), 1, + ) diff --git a/conversations/test_c3_conversation_details.py b/conversations/test_c3_conversation_details.py new file mode 100644 index 0000000000..24af49856d --- /dev/null +++ b/conversations/test_c3_conversation_details.py @@ -0,0 +1,108 @@ +"""Targeted privacy contract for Conversation detail presentation.""" +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase + +from conversations.detail_presentation import ( + _safe_local_image, + conversation_members_for_profile, + conversation_media_for_profile, + conversation_subject_for_profile, +) + + +class ConversationDetailPresentationTests(SimpleTestCase): + def test_image_rejects_remote_and_malformed_urls(self): + self.assertEqual(_safe_local_image("/media/portraits/one.jpg"), + "/media/portraits/one.jpg") + for url in ( + "https://external.example/pic.png", "//external.example/pic.png", + "javascript:alert(1)", "/media/a.png?secret=123", + "/media/a.png#fragment", "avatar.png", + ): + with self.subTest(url=url): + self.assertIsNone(_safe_local_image(url)) + + def test_derived_group_audience_never_lists_people(self): + viewer = SimpleNamespace(pk="viewer") + conversation = SimpleNamespace( + context=SimpleNamespace(kind="group"), + ) + with patch("conversations.detail_presentation.can_manage_conversation", + return_value=False): + data = conversation_members_for_profile(viewer, conversation) + self.assertEqual(data, {"scope": "restricted", "people": []}) + + def test_direct_avatar_obeys_profile_visibility(self): + viewer = SimpleNamespace(pk="viewer") + other = SimpleNamespace( + pk="other", full_name="Léa", username="lea", is_active=True, + avatar=SimpleNamespace(url="/media/private.jpg"), + ) + context = SimpleNamespace( + kind="direct", direct_profile_a_id="viewer", + direct_profile_a=viewer, direct_profile_b=other, + ) + conversation = SimpleNamespace(context=context) + with ( + patch("conversations.detail_presentation._public_context_image", + return_value=None), + patch("accounts.profile_visibility.ProfileVisibilityResolver") as resolver, + patch("sharing.passport.profile_has_public_passport", return_value=True), + ): + resolver.return_value.field.return_value = False + data = conversation_subject_for_profile(viewer, conversation, "Léa") + self.assertEqual(data["label"], "Léa") + self.assertIsNone(data["image_url"]) + resolver.return_value.field.assert_called_with("avatar") + resolver.return_value.field.return_value = True + data = conversation_subject_for_profile(viewer, conversation, "Léa") + self.assertEqual(data["image_url"], "/media/private.jpg") + self.assertEqual(data["route"], "/people/other") + + def test_only_authorized_attachments_appear_in_media_index(self): + viewer = SimpleNamespace(pk="viewer") + conversation = SimpleNamespace(pk="conv") + point = SimpleNamespace(pk="p1") + attachment = SimpleNamespace( + pk="attachment", point=point, kind="file", original_name="secret.pdf", + mime_type="application/pdf", size=250, + ) + with ( + patch("conversations.detail_presentation.ConversationPoint.objects") as points, + patch("conversations.detail_presentation.ConversationPointAttachment.objects") as media, + patch("conversations.detail_presentation.point_visible_to", + return_value=True), + patch("conversations.detail_presentation.can_download_attachment", + return_value=False), + ): + points.filter.return_value.exclude.return_value = Mock() + media.filter.return_value.select_related.return_value.order_by.return_value.__getitem__ = Mock( + return_value=[attachment], + ) + self.assertEqual( + conversation_media_for_profile(viewer, conversation), [], + ) + + def test_group_count_is_only_returned_to_a_manager(self): + viewer = SimpleNamespace(pk="owner") + group = SimpleNamespace(pk="group-1") + conversation = SimpleNamespace(context=SimpleNamespace( + kind="group", group=group, + )) + person = SimpleNamespace(pk="member", full_name="Marie", username="marie") + row = SimpleNamespace(profile=person, profile_id="member") + with ( + patch("conversations.detail_presentation.can_manage_conversation", + return_value=True), + patch("groups.models.GroupMembership.objects") as memberships, + ): + memberships.filter.return_value.count.return_value = 247 + memberships.filter.return_value.select_related.return_value.order_by.return_value.__getitem__ = Mock( + return_value=[row], + ) + data = conversation_members_for_profile(viewer, conversation) + self.assertEqual(data["scope"], "group") + self.assertEqual(data["total_count"], 247) + self.assertEqual(data["people"], [{"label": "Marie", "is_me": False}]) diff --git a/conversations/test_c3_typed_media.py b/conversations/test_c3_typed_media.py new file mode 100644 index 0000000000..f19fe2cd2f --- /dev/null +++ b/conversations/test_c3_typed_media.py @@ -0,0 +1,88 @@ +from types import SimpleNamespace +from unittest.mock import Mock, patch +from contextlib import ExitStack + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from conversations.rich_services import upload_conversation_media + + +class TypedConversationMediaTests(TestCase): + def _context(self): + actor = SimpleNamespace(pk="author") + conversation = SimpleNamespace( + lifecycle="open", + policy=SimpleNamespace(allow_images=True), + ) + uploaded = SimpleNamespace(name="style.png") + point = Mock() + point.attachments.filter.return_value.order_by.return_value.first.return_value = None + attachment = SimpleNamespace(kind="image", content_hash="digest") + patches = [ + patch("conversations.rich_services.can_view_conversation", return_value=True), + patch("conversations.rich_services._validate_represented_context"), + patch("conversations.rich_services.validate_conversation_upload", + return_value=(b"png-data", "image/png", "digest")), + patch("conversations.rich_services.create_point_attachment", + return_value=attachment), + ] + return actor, conversation, uploaded, point, attachment, patches + + def test_question_with_photo_remains_one_structured_point(self): + actor, conversation, uploaded, point, attachment, patches = self._context() + with ExitStack() as stack: + for patcher in patches: + stack.enter_context(patcher) + publish = stack.enter_context(patch( + "conversations.rich_services.publish_composed_point", + return_value=point, + )) + result, media = upload_conversation_media( + actor=actor, conversation=conversation, uploaded_file=uploaded, + kind="image", client_reference="one-intent", + point_kind="question", title="Vous aimez ce style ?", + body="Regardez la photo", + ) + self.assertIs(result, point) + self.assertIs(media, attachment) + self.assertEqual(publish.call_args.kwargs["kind"], "question") + self.assertEqual(publish.call_args.kwargs["client_reference"], "one-intent") + + def test_form_with_attachment_uses_questionnaire_owner(self): + actor, conversation, uploaded, point, _, patches = self._context() + with ExitStack() as stack: + for patcher in patches: + stack.enter_context(patcher) + publish = stack.enter_context(patch( + "conversations.rich_services.publish_conversation_form", + return_value=point, + )) + result, _ = upload_conversation_media( + actor=actor, conversation=conversation, uploaded_file=uploaded, + kind="image", client_reference="form-intent", + point_kind="form_request", title="Documents à préparer", + questions=["Quel est votre nom ?"], audience_id="audience", + ) + self.assertIs(result, point) + self.assertEqual(publish.call_args.kwargs["questions"], ["Quel est votre nom ?"]) + self.assertEqual(publish.call_args.kwargs["client_reference"], "form-intent") + + def test_unknown_or_mixed_structured_targets_fail_closed(self): + actor, conversation, uploaded, _, _, patches = self._context() + with ExitStack() as stack: + for patcher in patches: + stack.enter_context(patcher) + with self.assertRaises(ValidationError): + upload_conversation_media( + actor=actor, conversation=conversation, uploaded_file=uploaded, + kind="image", client_reference="bad-intent", + point_kind="unknown", title="Unknown", + ) + with self.assertRaises(ValidationError): + upload_conversation_media( + actor=actor, conversation=conversation, uploaded_file=uploaded, + kind="image", client_reference="mixed-intent", + point_kind="question", point_id="other-point", + title="Question", + ) diff --git a/conversations/test_c4_rich.py b/conversations/test_c4_rich.py new file mode 100644 index 0000000000..8be4b94e5c --- /dev/null +++ b/conversations/test_c4_rich.py @@ -0,0 +1,337 @@ +"""C4 full owner contract for FormRequest, media and rich Exchange depth.""" +import json +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from django.urls import reverse + +from activities.models import Activity +from authorization.constants import SystemRoleCode +from authorization.services import grant_activity_role +from organizations.models import Organization +from questionnaires.models import FormRequest, QuestionType +from questionnaires.services import ( + add_question, create_form, create_form_version, publish_form_version, + save_response, submit_response, +) + +from .audience_models import ConversationAudienceRuleKind, ConversationAudienceRuleOperation +from .audience_services import add_audience_rule, create_audience_set +from .core_models import ConversationContextKind +from .form_models import ConversationPointFormRequest +from .media_models import ConversationPointAttachment +from .point_models import ConversationPoint, ConversationPointKind, ConversationPointResponseMode +from .point_services import create_point, resolve_point +from .services import activate_participation, ensure_context_conversation + + +User = get_user_model() + + +class ConversationC4RichTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user(username='c4-owner', password='StrongPass2026!') + self.member = User.objects.create_user(username='c4-member', password='StrongPass2026!') + self.other = User.objects.create_user(username='c4-other', password='StrongPass2026!') + self.outsider = User.objects.create_user(username='c4-outsider', password='StrongPass2026!') + self.space = Organization.objects.create(name='C4 Espace', created_by=self.owner) + self.activity = Activity.objects.create(space=self.space, created_by=self.owner, title='C4 Activity') + grant_activity_role( + profile=self.owner, activity=self.activity, + role_code=SystemRoleCode.ACTIVITY_LOCAL_MANAGER, + granted_by=self.owner, source='c4', + ) + self.conversation = ensure_context_conversation( + actor=self.owner, kind=ConversationContextKind.ACTIVITY, + activity=self.activity, + ) + for profile in (self.member, self.other): + activate_participation(actor=self.owner, conversation=self.conversation, profile=profile) + self.audience = create_audience_set( + actor=self.owner, conversation=self.conversation, label='Destinataires', + ) + add_audience_rule( + actor=self.owner, audience_set=self.audience, + operation=ConversationAudienceRuleOperation.INCLUDE, + kind=ConversationAudienceRuleKind.EXPLICIT_PROFILE, profile=self.member, + ) + + def _api(self, name, **kwargs): + return reverse('conversations-api:' + name, kwargs={ + **({'pk': self.conversation.pk} if name in { + 'media-upload', 'form-compose', 'form-options', 'detail', + } else {}), + **kwargs, + }) + + def _form_request(self): + self.client.force_login(self.owner) + url = self._api('form-compose') + return self.client.post(url, data=json.dumps({ + 'title': 'Préparation collective', + 'questions': ['Quel matériel ?', 'Quel horaire ?'], + 'audience_id': str(self.audience.pk), + 'client_reference': 'c4-form-unique', + }), content_type='application/json') + + def test_form_preparation_is_canonical_idempotent_and_returns_to_recipient(self): + first = self._form_request() + self.assertEqual(first.status_code, 200, first.content) + second = self._form_request() + self.assertEqual(second.status_code, 200, second.content) + self.assertEqual(first.json()['point_id'], second.json()['point_id']) + point = ConversationPoint.objects.get(pk=first.json()['point_id']) + self.assertEqual(point.kind, ConversationPointKind.FORM_REQUEST) + self.assertEqual(point.form_request_links.count(), 1) + link = point.form_request_links.get() + self.assertEqual(link.target_profile, self.member) + self.assertIsNone(link.form_request.journey_id) + self.assertEqual(link.form_request.form_version.questions.count(), 2) + self.assertEqual(FormRequest.objects.count(), 1) + self.client.force_login(self.member) + detail = self.client.get(self._api('detail')).json() + row = next(item for section in ('points', 'essential', 'elsewhere') + for item in detail[section] if item['id'] == str(point.pk)) + self.assertEqual(row['form_request']['id'], str(link.form_request_id)) + target = self.client.get( + '/api/v1/questionnaires/requests/%s/' % link.form_request_id + ) + self.assertEqual(target.status_code, 200) + self.client.force_login(self.other) + detail = self.client.get(self._api('detail')).json() + self.assertFalse(any( + item['id'] == str(point.pk) + for section in ('points', 'essential', 'elsewhere') + for item in detail[section] + )) + self.assertEqual(self.client.get( + '/api/v1/questionnaires/requests/%s/' % link.form_request_id + ).status_code, 404) + self.assertEqual( + ConversationPointFormRequest.objects.filter(point=point).count(), 1, + ) + self.client.force_login(self.member) + save_response( + request=link.form_request, actor=self.member, + answers={'question_1': 'Un micro', 'question_2': '15 h'}, + ) + submit_response(request=link.form_request, actor=self.member) + link.form_request.refresh_from_db() + self.assertEqual(link.form_request.status, 'completed') + + def test_form_requires_activity_authority_and_rejects_unrelated_version(self): + self.client.force_login(self.owner) + available = self.client.get(self._api('form-options')) + self.assertEqual(available.status_code, 200) + self.assertTrue(available.json()['can_prepare']) + form = create_form( + actor=self.owner, activity=self.activity, key='existing-c4', title='Formulaire', + ) + version = create_form_version(form=form, actor=self.owner) + add_question( + form_version=version, actor=self.owner, key='topic', + label='Votre sujet ?', position=0, required=True, + question_type=QuestionType.SHORT_TEXT, + ) + publish_form_version(form_version=version, actor=self.owner) + options = self.client.get(self._api('form-options')).json()['results'] + self.assertIn(str(version.pk), [item['id'] for item in options]) + accepted = self.client.post(self._api('form-compose'), json.dumps({ + 'title': 'Répondre', 'form_version_id': str(version.pk), + 'audience_id': str(self.audience.pk), + 'client_reference': 'c4-form-reuse', + }), content_type='application/json') + self.assertEqual(accepted.status_code, 200) + self.client.force_login(self.member) + self.assertFalse(self.client.get(self._api('form-options')).json()['can_prepare']) + denied = self.client.post(self._api('form-compose'), json.dumps({ + 'title': 'Intrusion', 'form_version_id': str(version.pk), + 'audience_id': str(self.audience.pk), + 'client_reference': 'c4-denied', + }), content_type='application/json') + self.assertEqual(denied.status_code, 403) + + def _media(self, *, data=b'consignes privees', filename='consignes.txt', + mime='text/plain', ref='c4-private-media', kind='file', point_id=None): + url = self._api('media-upload') + payload = { + 'kind': kind, 'client_reference': ref, + 'file': SimpleUploadedFile(filename, data, content_type=mime), + } + if point_id: + payload['point_id'] = str(point_id) + return self.client.post(url, payload) + + def test_private_document_upload_download_idempotence_and_content_conflicts(self): + self.client.force_login(self.owner) + first = self._media() + self.assertEqual(first.status_code, 200, first.content) + second = self._media() + self.assertEqual(second.status_code, 200, second.content) + self.assertEqual(first.json()['attachment']['id'], second.json()['attachment']['id']) + self.assertEqual(ConversationPointAttachment.objects.count(), 1) + self.assertEqual(self._media(data=b'autre contenu').status_code, 400) + attachment_id = first.json()['attachment']['id'] + download = reverse('conversations-api:attachment-download', kwargs={'attachment_pk': attachment_id}) + self.client.force_login(self.member) + self.assertEqual(self.client.get(download).status_code, 200) + self.client.force_login(self.outsider) + self.assertEqual(self.client.get(download).status_code, 404) + self.client.force_login(self.owner) + accepted = self.client.get(download) + self.assertEqual(accepted.status_code, 200) + self.assertEqual(accepted['Cache-Control'], 'private, no-store') + self.assertEqual(accepted['X-Content-Type-Options'], 'nosniff') + self.assertEqual(b''.join(accepted.streaming_content), b'consignes privees') + + def test_media_validates_signatures_without_creating_orphan_point(self): + self.client.force_login(self.owner) + point_count = ConversationPoint.objects.count() + denied = self._media( + kind='voice', filename='faux.webm', mime='audio/webm', + data=b'not-really-audio', ref='c4-invalid-audio', + ) + self.assertEqual(denied.status_code, 400) + self.assertEqual(ConversationPoint.objects.count(), point_count) + allowed = self._media( + kind='voice', filename='vocal.wav', mime='audio/wav', + data=b'RIFF\x08\x00\x00\x00WAVE' + b'fmt ' + b'\x00'*24, + ref='c4-valid-voice', + ) + self.assertEqual(allowed.status_code, 200, allowed.content) + self.assertEqual(allowed.json()['attachment']['kind'], 'voice') + + def test_exchange_entries_and_private_attachments_revalidate_visibility(self): + self.client.force_login(self.owner) + add_audience_rule( + actor=self.owner, audience_set=self.audience, + operation=ConversationAudienceRuleOperation.INCLUDE, + kind=ConversationAudienceRuleKind.EXPLICIT_PROFILE, + profile=self.owner, + ) + exchange = create_point( + actor=self.owner, conversation=self.conversation, + kind=ConversationPointKind.EXCHANGE, + response_mode=ConversationPointResponseMode.FREE_TEXT, + title='Questions', visibility_audience=self.audience, + response_audience=self.audience, + response_visibility='respondent_and_authorities', + ) + self.client.force_login(self.member) + first = self.client.post( + reverse('conversations-api:exchange-entries', kwargs={'point_pk': exchange.pk}), + json.dumps({'body': 'Un message privé', 'client_reference': 'c4-entry'}), + content_type='application/json', + ) + self.assertEqual(first.status_code, 200) + attachment = self._media( + kind='file', ref='c4-entry-upload', + point_id=exchange.pk, + ) + self.assertEqual(attachment.status_code, 200) + own = self.client.get(reverse('conversations-api:exchange-entries', kwargs={'point_pk': exchange.pk})) + self.assertEqual(own.status_code, 200) + self.assertEqual(len(own.json()['results']), 2) + self.assertEqual(own.json()['results'][1]['attachments'][0]['id'], attachment.json()['attachment']['id']) + self.client.force_login(self.other) + self.assertEqual(self.client.get( + reverse('conversations-api:exchange-entries', kwargs={'point_pk': exchange.pk}) + ).status_code, 404) + self.assertEqual(self.client.get(reverse( + 'conversations-api:attachment-download', + kwargs={'attachment_pk': attachment.json()['attachment']['id']}, + )).status_code, 404) + self.client.force_login(self.owner) + view = self.client.get(reverse('conversations:exchange-detail', kwargs={'point_pk': exchange.pk})) + self.assertEqual(view.status_code, 200) + self.assertContains(view, 'Un message privé') + + def test_file_response_attaches_to_canonical_response_not_new_point(self): + self.client.force_login(self.owner) + point = create_point( + actor=self.owner, conversation=self.conversation, + kind=ConversationPointKind.REQUEST, + response_mode=ConversationPointResponseMode.FILE, + title='Joindre le document', visibility_audience=self.audience, + response_audience=self.audience, + expected_action_audience=self.audience, + ) + self.client.force_login(self.member) + url = reverse('conversations-api:point-reply-media', kwargs={'point_pk': point.pk}) + def upload(data=b'contenu fourni', ref='c4-point-file'): + return self.client.post(url, { + 'kind': 'file', + 'client_reference': ref, + 'file': SimpleUploadedFile( + 'contenu.txt', data, content_type='text/plain', + ), + }) + first = upload() + self.assertEqual(first.status_code, 200, first.content) + again = upload() + self.assertEqual(again.status_code, 200, again.content) + self.assertEqual(first.json()['response_id'], again.json()['response_id']) + self.assertEqual(first.json()['attachment']['id'], again.json()['attachment']['id']) + self.assertEqual(ConversationPoint.objects.count(), 1) + self.assertEqual(point.responses.count(), 1) + attached = ConversationPointAttachment.objects.get( + pk=first.json()['attachment']['id'], + ) + self.assertEqual(attached.response_id, point.responses.first().pk) + self.assertIsNone(attached.exchange_entry_id) + self.assertEqual(upload(data=b'mauvais fichier').status_code, 400) + + self.client.force_login(self.owner) + resolve_point(actor=self.owner, point=point, summary='Pièce reçue') + self.client.force_login(self.member) + self.assertEqual(upload().status_code, 200) + self.client.force_login(self.outsider) + self.assertEqual(upload(ref='c4-outside').status_code, 404) + + def test_voice_response_requires_signed_audio_and_correct_mode(self): + self.client.force_login(self.owner) + point = create_point( + actor=self.owner, conversation=self.conversation, + kind=ConversationPointKind.QUESTION, + response_mode=ConversationPointResponseMode.VOICE, + title='Répondre en vocal', + visibility_audience=self.audience, + response_audience=self.audience, + expected_action_audience=self.audience, + ) + self.client.force_login(self.member) + url = reverse('conversations-api:point-reply-media', kwargs={'point_pk': point.pk}) + sound = b'RIFF\x08\x00\x00\x00WAVEfmt ' + b'\x00' * 24 + invalid = self.client.post(url, { + 'kind': 'file', 'client_reference': 'c4-voice-mismatch', + 'file': SimpleUploadedFile( + 'fichier.txt', b'bonjour', content_type='text/plain', + ), + }) + self.assertEqual(invalid.status_code, 400) + valid = self.client.post(url, { + 'kind': 'voice', 'client_reference': 'c4-voice-point', + 'file': SimpleUploadedFile( + 'vocal.wav', sound, content_type='audio/wav', + ), + }) + self.assertEqual(valid.status_code, 200, valid.content) + self.assertEqual(point.responses.count(), 1) + self.assertEqual( + ConversationPointAttachment.objects.filter( + point=point, response__isnull=False, + ).count(), 1, + ) + + def test_web_form_and_media_actions_use_real_owners(self): + self.client.force_login(self.owner) + html = self.client.get(reverse('conversations:detail', kwargs={'pk': self.conversation.pk})) + self.assertContains(html, 'Formulaire') + self.assertContains(html, 'conversations-rich-composer.js') + result = self.client.post(reverse('conversations:compose', kwargs={'pk': self.conversation.pk}), { + 'mode': 'form', 'title': 'Formulaire web', + 'questions': 'Votre nom ?', 'audience_id': str(self.audience.pk), + 'client_reference': 'c4-web-form', + }) + self.assertEqual(result.status_code, 302) + self.assertEqual(ConversationPointFormRequest.objects.count(), 1) diff --git a/conversations/test_c5_mature.py b/conversations/test_c5_mature.py new file mode 100644 index 0000000000..8ec06d5a05 --- /dev/null +++ b/conversations/test_c5_mature.py @@ -0,0 +1,607 @@ +"""C5: owner-checked history, search, continuation and personal UX contracts.""" +from django.core.exceptions import ValidationError +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from activities.models import Activity +from authorization.constants import SystemRoleCode +from authorization.services import grant_activity_role +from organizations.models import Organization + +from .audience_models import ConversationAudienceRuleKind, ConversationAudienceRuleOperation +from .audience_services import add_audience_rule, create_audience_set +from .core_models import ConversationContextKind, ConversationHistoryPolicy, ConversationLifecycle +from .point_models import ConversationPointKind, ConversationPointResponseMode +from .point_services import create_exchange_entry, create_point, point_visible_to, supersede_point +from .services import activate_participation, ensure_context_conversation, transition_conversation + + +User = get_user_model() + + +class ConversationC5MatureTests(TestCase): + def setUp(self): + self.manager = User.objects.create_user(username='c5-manager', password='StrongPass2026!') + self.member = User.objects.create_user(username='c5-member', password='StrongPass2026!') + self.other = User.objects.create_user(username='c5-other', password='StrongPass2026!') + self.outside = User.objects.create_user(username='c5-outside', password='StrongPass2026!') + self.space = Organization.objects.create(name='C5 Coordination', created_by=self.manager) + self.activity = Activity.objects.create( + space=self.space, created_by=self.manager, title='C5 Activity', + ) + grant_activity_role( + profile=self.manager, activity=self.activity, + role_code=SystemRoleCode.ACTIVITY_LOCAL_MANAGER, + granted_by=self.manager, source='c5', + ) + self.conversation = ensure_context_conversation( + actor=self.manager, kind=ConversationContextKind.ACTIVITY, + activity=self.activity, + ) + for user in (self.member, self.other): + activate_participation( + actor=self.manager, conversation=self.conversation, profile=user, + ) + + def _point(self, title, **kwargs): + return create_point( + actor=self.manager, conversation=self.conversation, + kind=kwargs.pop('kind', ConversationPointKind.INFORMATION), + title=title, **kwargs, + ) + + def _audience(self, user, label): + audience = create_audience_set( + actor=self.manager, conversation=self.conversation, label=label, + ) + add_audience_rule( + actor=self.manager, audience_set=audience, + operation=ConversationAudienceRuleOperation.INCLUDE, + kind=ConversationAudienceRuleKind.EXPLICIT_PROFILE, + profile=user, + ) + return audience + + def _history(self, *, section='all', limit=25, cursor=None, query=None): + params = {'section': section, 'limit': limit} + if cursor is not None: + params['cursor'] = cursor + if query is not None: + params['q'] = query + return self.client.get( + reverse('conversations-api:point-depth', kwargs={'pk': self.conversation.pk}), + params, + ) + + def test_visual_list_compacts_context_without_changing_owner_title(self): + self.activity.title = ( + 'Marché des coopératives — Lubumbashi — sur les démarches d’accès' + ) + self.activity.save(update_fields=['title', 'updated_at']) + full_title = 'Préparation pratique — ' + self.activity.title + self.conversation.title_override = full_title + self.conversation.save(update_fields=['title_override', 'updated_at']) + self.client.force_login(self.member) + + response = self.client.get(reverse('conversations-api:list')) + self.assertEqual(response.status_code, 200) + row = next(item for item in response.json()['results'] + if item['id'] == str(self.conversation.pk)) + self.assertEqual(row['title'], full_title) # legacy contract stays intact + self.assertEqual(row['card']['title'], 'Marché des coopératives') + self.assertEqual( + row['card']['context_label'], + 'Lubumbashi — sur les démarches d’accès', + ) + self.assertEqual(row['card']['context_kind'], 'activity') + self.conversation.refresh_from_db() + self.assertEqual(self.conversation.title_override, full_title) + # Web must use the exact same presentation, not a verbose duplicate. + page = self.client.get(reverse('conversations:list')) + self.assertEqual(page.status_code, 200) + self.assertContains(page, 'Marché des coopératives') + self.assertNotContains(page, 'Préparation pratique — Marché') + self.assertContains(page, 'Lubumbashi') + search = self.client.get( + reverse('conversations:list'), {'q': 'coopératives'}, + ) + self.assertContains(search, 'Marché des coopératives') + self.assertNotContains(search, 'Préparation pratique — Marché') + + def test_visual_list_media_icons_require_real_visible_owner_content(self): + from .media_models import ConversationPointAttachment + + member_only = self._audience(self.member, 'C5 media member') + other_only = self._audience(self.other, 'C5 media other') + visible = self._point( + 'Photo du dossier', visibility_audience=member_only, + ) + private = self._point( + 'Vocal privé', visibility_audience=other_only, + ) + for point, kind in ((visible, 'image'), (private, 'voice')): + ConversationPointAttachment.objects.create( + point=point, kind=kind, uploaded_by=self.manager, + file='conversations/tests/c5-%s.bin' % kind, + original_name='media-%s.bin' % kind, + mime_type='image/png' if kind == 'image' else 'audio/mp4', + size=2048, content_hash='a' * 64, + ) + self.client.force_login(self.member) + visible_card = self.client.get( + reverse('conversations-api:list') + ).json()['results'][0]['card'] + self.assertIn('image', visible_card['media_kinds']) + self.assertNotIn('voice', visible_card['media_kinds']) + self.assertNotIn('Vocal privé', str(visible_card)) + page = self.client.get(reverse('conversations:list')) + self.assertContains(page, 'Photo') + self.assertNotContains(page, 'Vocal privé') + self.client.force_login(self.other) + other_card = self.client.get( + reverse('conversations-api:list') + ).json()['results'][0]['card'] + self.assertIn('voice', other_card['media_kinds']) + self.assertNotIn('image', other_card['media_kinds']) + + def test_list_does_not_signal_private_response_attachment_to_other_viewers(self): + from .media_models import ConversationPointAttachment + from .point_services import submit_point_response + + point = self._point( + 'Confirmer votre présence', + kind=ConversationPointKind.CONFIRMATION, + response_mode=ConversationPointResponseMode.BOOLEAN, + response_visibility='respondent_and_authorities', + ) + response = submit_point_response( + actor=self.other, point=point, value=True, + ) + ConversationPointAttachment.objects.create( + point=point, response=response, kind='voice', + uploaded_by=self.other, + file='conversations/tests/private-response.bin', + original_name='reponse-vocale.m4a', + mime_type='audio/mp4', size=1200, content_hash='b' * 64, + ) + self.client.force_login(self.member) + member_card = self.client.get( + reverse('conversations-api:list') + ).json()['results'][0]['card'] + self.assertNotIn('voice', member_card['media_kinds']) + self.assertNotIn('reponse-vocale', str(member_card)) + self.client.force_login(self.manager) + manager_card = self.client.get( + reverse('conversations-api:list') + ).json()['results'][0]['card'] + self.assertIn('voice', manager_card['media_kinds']) + + def test_request_local_visibility_cache_cannot_transfer_other_viewers_rights(self): + from .point_services import point_visible_to + + restricted = self._audience(self.member, 'Visible uniquement au membre') + point = self._point('Point réservé', visibility_audience=restricted) + cache = {} + audience_cache = {} + history_cache = {} + self.assertTrue(point_visible_to( + self.member, point, visibility_cache=cache, + audience_cache=audience_cache, history_cache=history_cache, + )) + self.assertFalse(point_visible_to( + self.other, point, visibility_cache=cache, + audience_cache=audience_cache, history_cache=history_cache, + )) + self.assertFalse(point_visible_to( + self.outside, point, visibility_cache=cache, + audience_cache=audience_cache, history_cache=history_cache, + )) + + def test_context_cover_only_when_event_and_activity_publicly_published(self): + from django.utils import timezone + from events.models import Event + from activities.models import ActivityStatus, ActivityVisibility + + event = Event.objects.create( + activity=self.activity, + cover_image='events/covers/public-c5-test.jpg', + ) + self.activity.status = ActivityStatus.PUBLISHED + self.activity.visibility = ActivityVisibility.PUBLIC + self.activity.save(update_fields=['status', 'visibility', 'updated_at']) + self.client.force_login(self.member) + list_url = reverse('conversations-api:list') + draft_card = self.client.get(list_url).json()['results'][0]['card'] + self.assertIsNone(draft_card['context_image_url']) + event.published_at = timezone.now() + event.save(update_fields=['published_at', 'updated_at']) + image_card = self.client.get(list_url).json()['results'][0]['card'] + self.assertTrue(image_card['context_image_url'].startswith('/')) + self.assertIn('public-c5-test.jpg', image_card['context_image_url']) + self.activity.visibility = ActivityVisibility.PRIVATE + self.activity.save(update_fields=['visibility', 'updated_at']) + private_card = self.client.get(list_url).json()['results'][0]['card'] + self.assertIsNone(private_card['context_image_url']) + + def test_long_conversation_152_points_continues_without_duplicate_or_limit_50(self): + for i in range(152): + self._point('Étape %03d' % i) + self.client.force_login(self.member) + seen = set() + cursor = None + pages = 0 + while True: + response = self._history(limit=23, cursor=cursor) + self.assertEqual(response.status_code, 200, response.content) + page = response.json() + ids = [entry['id'] for entry in page['results']] + self.assertEqual(len(ids), len(set(ids))) + self.assertTrue(seen.isdisjoint(ids)) + seen.update(ids) + pages += 1 + cursor = page['next_cursor'] + if cursor is None: + break + self.assertLess(pages, 15) + self.assertEqual(len(seen), 152) + self.assertGreater(pages, 5) + + def test_history_cursor_cannot_replay_for_another_viewer_query_or_filter(self): + self._point('Coordination 1') + self._point('Coordination 2') + self._point('Coordination 3') + self.client.force_login(self.member) + first = self._history(limit=1) + self.assertEqual(first.status_code, 200) + cursor = first.json()['next_cursor'] + self.assertIsNotNone(cursor) + self.assertEqual(self._history(limit=1, cursor=cursor).status_code, 200) + self.assertEqual(self._history(limit=1, cursor=cursor, query='autre').status_code, 400) + self.assertEqual(self._history(limit=2, cursor=cursor).status_code, 400) + self.client.force_login(self.other) + self.assertEqual(self._history(limit=1, cursor=cursor).status_code, 400) + self.client.force_login(self.outside) + self.assertEqual(self._history(limit=1, cursor=cursor).status_code, 404) + + def test_search_reads_only_audience_authorized_points_and_entries(self): + only_member = self._audience(self.member, 'Membre') + only_other = self._audience(self.other, 'Autre') + visible = self._point('Dossier secret membre', visibility_audience=only_member) + private = self._point('Dossier secret autre', visibility_audience=only_other) + exchange = self._point( + 'Échange de détails', + kind=ConversationPointKind.EXCHANGE, + response_mode=ConversationPointResponseMode.FREE_TEXT, + response_visibility='respondent_and_authorities', + ) + create_exchange_entry( + actor=self.member, point=exchange, + body='Coordonnées confidentielles du membre', + ) + self.client.force_login(self.member) + accessible = self._history(query='secret') + self.assertEqual(accessible.status_code, 200) + self.assertEqual( + [entry['id'] for entry in accessible.json()['results']], + [str(visible.pk)], + ) + private_entry = self._history(query='Coordonnées confidentielles') + self.assertEqual( + [entry['id'] for entry in private_entry.json()['results']], + [str(exchange.pk)], + ) + self.client.force_login(self.other) + other = self._history(query='secret') + self.assertEqual( + [entry['id'] for entry in other.json()['results']], + [str(private.pk)], + ) + no_entry = self._history(query='Coordonnées confidentielles') + self.assertEqual(no_entry.json()['results'], []) + + def test_history_since_join_and_rejoin_is_consistent_with_direct_focus(self): + self.conversation.history_policy = ConversationHistoryPolicy.FROM_JOIN + self.conversation.save(update_fields=['history_policy', 'updated_at']) + old = self._point('Avant arrivée') + late = User.objects.create_user(username='c5-late', password='StrongPass2026!') + participation = activate_participation( + actor=self.manager, conversation=self.conversation, profile=late, + ) + joined = participation.joined_at + activate_participation( + actor=self.manager, conversation=self.conversation, profile=late, + ) + participation.refresh_from_db() + self.assertEqual(participation.joined_at, joined) + new = self._point('Après arrivée') + self.assertFalse(point_visible_to(late, old)) + self.assertTrue(point_visible_to(late, new)) + self.client.force_login(late) + page = self._history() + self.assertEqual( + [entry['id'] for entry in page.json()['results']], + [str(new.pk)], + ) + focus = self.client.get( + reverse('conversations-api:detail', kwargs={'pk': self.conversation.pk}), + {'focus_point': str(old.pk)}, + ) + self.assertEqual(focus.status_code, 200) + self.assertIsNone(focus.json()['focused_point']) + self.assertEqual(self._history(query='Avant arrivée').json()['results'], []) + + def test_history_resolved_superseded_and_draft_not_public(self): + old = self._point('Ancien horaire') + newer = self._point('Horaire confirmé', publish=False) + supersede_point(actor=self.manager, old_point=old, new_point=newer) + draft = self._point('Brouillon réservé', publish=False) + self.client.force_login(self.member) + page = self._history(section='history') + self.assertEqual( + [row['id'] for row in page.json()['results']], [str(old.pk)], + ) + all_page = self._history() + self.assertIn(str(newer.pk), [row['id'] for row in all_page.json()['results']]) + self.assertNotIn(str(draft.pk), [row['id'] for row in all_page.json()['results']]) + search = self._history(query='Ancien horaire') + self.assertEqual(search.json()['results'][0]['lifecycle'], 'superseded') + + def test_archived_web_and_api_query_stay_personal_not_shared(self): + self._point('Décision coordonnée') + self.client.force_login(self.member) + action_url = reverse( + 'conversations:personal-state', kwargs={'pk': self.conversation.pk}, + ) + action = self.client.post(action_url, {'action': 'archive'}) + self.assertEqual(action.status_code, 302) + self.conversation.refresh_from_db() + self.assertEqual(self.conversation.lifecycle, ConversationLifecycle.OPEN) + all_list = self.client.get(reverse('conversations-api:list')) + self.assertNotIn( + str(self.conversation.pk), + [row['id'] for row in all_list.json()['results']], + ) + archived = self.client.get( + reverse('conversations-api:list'), {'tab': 'archived', 'q': 'Décision'}, + ) + self.assertEqual(archived.status_code, 200) + self.assertEqual( + [row['id'] for row in archived.json()['results']], + [str(self.conversation.pk)], + ) + web = self.client.get( + reverse('conversations:list'), {'tab': 'archived'}, + ) + self.assertContains(web, 'C5 Activity') + self.client.post(action_url, {'action': 'archive'}) + self.assertIn( + str(self.conversation.pk), + [row['id'] for row in self.client.get( + reverse('conversations-api:list') + ).json()['results']], + ) + + def test_closed_conversation_has_no_stale_attention_or_response_controls(self): + from .attention import point_attention_reason + + expected = self._audience(self.member, 'Réponse attendue') + point = self._point( + 'Confirmer le rendez-vous', + kind=ConversationPointKind.CONFIRMATION, + response_mode=ConversationPointResponseMode.BOOLEAN, + expected_action_audience=expected, + response_audience=expected, + ) + self.assertEqual(point_attention_reason(self.member, point), 'respond') + transition_conversation( + actor=self.manager, conversation=self.conversation, + lifecycle=ConversationLifecycle.CLOSED, + ) + point.conversation.refresh_from_db() + self.assertIsNone(point_attention_reason(self.member, point)) + self.client.force_login(self.member) + detail = self.client.get(reverse( + 'conversations-api:detail', kwargs={'pk': self.conversation.pk}, + )) + self.assertEqual(detail.status_code, 200) + for section in ('points', 'essential', 'elsewhere'): + for item in detail.json()[section]: + if item['id'] == str(point.pk): + self.assertFalse(item['can_respond']) + + def test_closed_rejects_acknowledge_resolve_cancel_and_supersede(self): + from .point_services import ( + acknowledge_point, close_point_responses, resolve_point, + ) + from .lifecycle_services import cancel_point + + point = self._point( + 'À valider avant fermeture', + kind=ConversationPointKind.CONFIRMATION, + response_mode=ConversationPointResponseMode.BOOLEAN, + requires_acknowledgement=True, + ) + successor = self._point('Nouvelle proposition', publish=False) + transition_conversation( + actor=self.manager, conversation=self.conversation, + lifecycle=ConversationLifecycle.CLOSED, + ) + with self.assertRaises(ValidationError): + acknowledge_point(actor=self.member, point=point) + with self.assertRaises(ValidationError): + resolve_point(actor=self.manager, point=point, summary='Non autorisé') + with self.assertRaises(ValidationError): + close_point_responses(actor=self.manager, point=point) + with self.assertRaises(ValidationError): + supersede_point(actor=self.manager, old_point=point, new_point=successor) + with self.assertRaises(ValidationError): + cancel_point(actor=self.manager, point=point) + point.refresh_from_db() + self.assertEqual(point.lifecycle, 'open') + + def test_closed_conversation_read_only_and_history_still_available(self): + point = self._point('État établi') + transition_conversation( + actor=self.manager, conversation=self.conversation, + lifecycle=ConversationLifecycle.CLOSED, + ) + self.client.force_login(self.member) + detail = self.client.get(reverse( + 'conversations-api:detail', kwargs={'pk': self.conversation.pk}, + )) + self.assertEqual(detail.status_code, 200) + self.assertFalse(detail.json()['composer']['can_create']) + rows = detail.json()['points'] + detail.json()['essential'] + detail.json().get('elsewhere', []) + for row in rows: + self.assertFalse(row['can_respond']) + denied_response = self.client.post( + reverse('conversations-api:point-respond', kwargs={'point_pk': point.pk}), + {'value': 'reply after closing'}, + ) + self.assertEqual(denied_response.status_code, 400) + history = self._history() + self.assertEqual( + [item['id'] for item in history.json()['results']], [str(point.pk)], + ) + invalid = self.client.post( + reverse('conversations:personal-state', kwargs={'pk': self.conversation.pk}), + {'action': 'unsupported'}, + ) + self.assertEqual(invalid.status_code, 404) + + def test_replacement_does_not_reveal_hidden_successor_id(self): + member_only = self._audience(self.member, 'Only member') + other_only = self._audience(self.other, 'Only other') + old = self._point('Ancien Point partagé') + hidden_successor = self._point( + 'Nouveau contenu privé', visibility_audience=other_only, + publish=False, + ) + supersede_point( + actor=self.manager, old_point=old, new_point=hidden_successor, + ) + self.client.force_login(self.member) + page = self._history(query='Ancien Point') + self.assertEqual(page.status_code, 200) + row = page.json()['results'][0] + self.assertEqual(row['id'], str(old.pk)) + self.assertIsNone(row['replaced_by_point_id']) + html = self.client.get( + reverse('conversations:history', kwargs={'pk': self.conversation.pk}), + {'section': 'history', 'q': 'Ancien Point'}, + ) + self.assertNotContains(html, str(hidden_successor.pk)) + self.assertNotContains(html, 'Voir le Point actuel') + self.client.force_login(self.other) + other = self._history(query='Ancien Point') + self.assertEqual(other.status_code, 200) + self.assertEqual( + other.json()['results'][0]['replaced_by_point_id'], + str(hidden_successor.pk), + ) + self.assertTrue(point_visible_to(self.other, hidden_successor)) + + def test_response_result_does_not_expose_private_count_to_peer(self): + private_audience = self._audience(self.member, 'Membre répondant') + point = self._point( + 'Confirmation privée', + kind=ConversationPointKind.CONFIRMATION, + response_mode=ConversationPointResponseMode.BOOLEAN, + response_audience=private_audience, + response_visibility='respondent_and_authorities', + ) + from .point_services import submit_point_response + submit_point_response( + actor=self.member, point=point, value=True, + client_reference='c5-respond-private', + ) + self.client.force_login(self.other) + response = self.client.get(reverse( + 'conversations-api:point-depth', + kwargs={'pk': self.conversation.pk}, + ), {'section': 'all', 'q': 'Confirmation privée'}) + self.assertEqual(response.status_code, 200) + result = response.json()['results'][0]['response_status'] + self.assertEqual(result['responded'], False) + self.assertIsNone(result['response_count']) + self.client.force_login(self.member) + own = self._history(query='Confirmation privée').json()['results'][0] + self.assertTrue(own['response_status']['responded']) + self.assertIsNone(own['response_status']['response_count']) + self.client.force_login(self.manager) + manager = self._history(query='Confirmation privée').json()['results'][0] + self.assertEqual(manager['response_status']['response_count'], 1) + + def test_private_resolution_never_leaks_via_history_list_search_or_web(self): + from .point_services import resolve_point + + authority_audience = self._audience(self.manager, 'Résolution des autorités') + point = self._point( + 'Point de décision', + kind=ConversationPointKind.CONFIRMATION, + response_mode=ConversationPointResponseMode.BOOLEAN, + resolution_audience=authority_audience, + ) + resolve_point( + actor=self.manager, point=point, summary='Conclusion réservée aux autorités', + ) + + self.client.force_login(self.member) + search = self._history(query='Conclusion réservée') + self.assertEqual(search.status_code, 200) + self.assertEqual(search.json()['results'], []) + permitted_point = self._history(query='Point de décision') + self.assertEqual(permitted_point.status_code, 200) + self.assertEqual(permitted_point.json()['results'][0]['id'], str(point.pk)) + self.assertFalse( + permitted_point.json()['results'][0]['resolution_summary'] + ) + listing = self.client.get( + reverse('conversations-api:list'), {'q': 'Conclusion réservée'}, + ) + self.assertEqual(listing.status_code, 200) + self.assertEqual(listing.json()['results'], []) + normal_list = self.client.get(reverse('conversations-api:list')) + self.assertNotIn('Conclusion réservée', normal_list.content.decode()) + web = self.client.get( + reverse('conversations:history', kwargs={'pk': self.conversation.pk}), + {'section': 'all', 'q': 'Point de décision'}, + ) + self.assertEqual(web.status_code, 200) + self.assertNotContains(web, 'Conclusion réservée aux autorités') + + self.client.force_login(self.manager) + allowed = self._history(query='Conclusion réservée') + self.assertEqual(allowed.status_code, 200) + self.assertEqual(allowed.json()['results'][0]['id'], str(point.pk)) + self.assertEqual( + allowed.json()['results'][0]['resolution_summary'], + 'Conclusion réservée aux autorités', + ) + + def test_default_point_search_includes_current_open_points(self): + point = self._point('Validation en cours') + self.client.force_login(self.member) + result = self.client.get( + reverse('conversations:history', kwargs={'pk': self.conversation.pk}), + {'q': 'Validation en cours'}, + ) + self.assertEqual(result.status_code, 200) + self.assertContains(result, point.title) + self.assertContains(result, str(point.pk)) + + def test_web_history_search_is_accessible_and_cursor_preserves_filter(self): + for i in range(27): + self._point('Contrôle %02d' % i) + self.client.force_login(self.member) + url = reverse('conversations:history', kwargs={'pk': self.conversation.pk}) + result = self.client.get(url, {'section': 'all', 'q': 'Contrôle'}) + self.assertEqual(result.status_code, 200) + self.assertContains(result, 'Retrouver un Point') + self.assertContains(result, 'Voir les Points suivants') + self.assertContains(result, 'cursor=') + self.assertContains(result, 'q=Contr%C3%B4le') + self.client.force_login(self.outside) + denied = self.client.get(url) + self.assertIn(denied.status_code, (403, 404)) diff --git a/conversations/test_j4_experience.py b/conversations/test_j4_experience.py index 83dda4906f..70e896d899 100644 --- a/conversations/test_j4_experience.py +++ b/conversations/test_j4_experience.py @@ -54,7 +54,7 @@ def test_for_me_lists_attention_not_exchange_volume(self): self.client.force_login(self.member) response = self.client.get(reverse("conversations:list")) self.assertEqual(response.status_code, 200) - self.assertContains(response, "1 pour vous") + self.assertContains(response, "1 à traiter") self.assertContains(response, "J4 Activity") def test_detail_does_not_leak_to_outsider(self): @@ -70,5 +70,5 @@ def test_member_can_respond_from_mature_now_surface(self): follow=True, ) self.assertEqual(response.status_code, 200) - self.assertContains(response, "Tout est en ordre") + self.assertContains(response, "En cours ailleurs") self.assertEqual(self.point.responses.filter(actor=self.member, status="active").count(), 1) diff --git a/conversations/test_j7_contracts.py b/conversations/test_j7_contracts.py index db84fc07ca..903e484f12 100644 --- a/conversations/test_j7_contracts.py +++ b/conversations/test_j7_contracts.py @@ -12,7 +12,7 @@ from .audience_models import ConversationAudienceRuleKind, ConversationAudienceRuleOperation from .audience_services import add_audience_rule, create_audience_set -from .core_models import ConversationContextKind, ConversationInvitationStatus +from .core_models import Conversation, ConversationContextKind, ConversationInvitationStatus from .point_models import ConversationPointKind, ConversationPointResponseMode from .point_services import create_point from .services import ( @@ -102,6 +102,159 @@ def test_api_hides_conversation_from_outsider(self): response = self.client.get(reverse("conversations-api:detail", kwargs={"pk": self.conversation.pk})) self.assertEqual(response.status_code, 404) + def test_c1_list_and_detail_keep_authorized_owner_projection_fields(self): + point = self._question(client_reference="j7-c1-owner-projection") + self.client.force_login(self.member) + + listing = self.client.get(reverse("conversations-api:list")) + self.assertEqual(listing.status_code, 200) + self.assertEqual(listing["Cache-Control"], "private, no-store") + item = next( + item for item in listing.json()["results"] + if item["id"] == str(self.conversation.pk) + ) + for field in ( + "title", "purpose", "context", "attention_count", + "latest_result", "all_clear", "updated_at", + ): + self.assertIn(field, item) + + detail = self.client.get( + reverse("conversations-api:detail", kwargs={"pk": self.conversation.pk}) + ) + self.assertEqual(detail.status_code, 200) + self.assertEqual(detail["Cache-Control"], "private, no-store") + payload = detail.json() + for field in ("purpose", "context", "points", "essential", "personal_state", "updated_at"): + self.assertIn(field, payload) + matching = [row for row in payload["points"] if row["id"] == str(point.pk)] + self.assertEqual(len(matching), 1) + for field in ( + "kind", "importance", "response_mode", "lifecycle", "title", + "body", "options", "section", "attention_reason", "can_respond", + "resolution_summary", "opens_at", "deadline_at", "valid_until", + "published_at", "updated_at", + ): + self.assertIn(field, matching[0]) + + self.client.force_login(self.outsider) + self.assertNotIn( + str(self.conversation.pk), + [row["id"] for row in self.client.get(reverse("conversations-api:list")).json()["results"]], + ) + self.assertEqual( + self.client.get( + reverse("conversations-api:detail", kwargs={"pk": self.conversation.pk}) + ).status_code, + 404, + ) + + def test_c1_updated_after_requires_explicit_timezone(self): + self.client.force_login(self.member) + url = reverse("conversations-api:list") + naive = self.client.get(url, {"updated_after": "2026-11-04T09:00:00"}) + self.assertEqual(naive.status_code, 400) + self.assertIn("updated_after", naive.json()) + + aware = self.client.get(url, {"updated_after": "2026-11-04T09:00:00Z"}) + self.assertEqual(aware.status_code, 200) + self.assertIn("results", aware.json()) + + def test_c2_context_entry_uses_existing_authorized_conversation(self): + before = Conversation.objects.count() + self.client.force_login(self.member) + kwargs = {"kind": "activity", "context_pk": str(self.activity.pk)} + web = self.client.get(reverse("conversations:by-context", kwargs=kwargs)) + self.assertRedirects( + web, reverse("conversations:detail", kwargs={"pk": self.conversation.pk}), + ) + api = self.client.get(reverse("conversations-api:by-context", kwargs=kwargs)) + self.assertEqual(api.status_code, 200) + self.assertEqual(api["Cache-Control"], "private, no-store") + self.assertEqual(api.json()["count"], 1) + self.assertEqual(api.json()["results"][0]["id"], str(self.conversation.pk)) + self.assertEqual(Conversation.objects.count(), before) + + self.client.force_login(self.outsider) + self.assertEqual( + self.client.get(reverse("conversations:by-context", kwargs=kwargs)).status_code, + 404, + ) + response = self.client.get(reverse("conversations-api:by-context", kwargs=kwargs)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 0) + self.assertEqual(response.json()["results"], []) + self.assertFalse(response.json()["can_ensure"]) + self.assertEqual(Conversation.objects.count(), before) + + def test_c2_groups_visible_points_once_with_essential_and_elsewhere(self): + information = create_point( + actor=self.owner, conversation=self.conversation, + kind=ConversationPointKind.INFORMATION, + title="Entrée porte B", importance="important", + ) + elsewhere = create_point( + actor=self.owner, conversation=self.conversation, + kind=ConversationPointKind.QUESTION, + response_mode=ConversationPointResponseMode.FREE_TEXT, + title="Une question ouverte", response_audience=self.audience, + ) + expected = self._question(client_reference="c2-member-expected") + self.client.force_login(self.member) + url = reverse("conversations-api:detail", kwargs={"pk": self.conversation.pk}) + payload = self.client.get(url).json() + groups = ( + payload["points"], payload["essential"], payload["elsewhere"], + ) + ids = [point["id"] for group in groups for point in group] + self.assertEqual(len(ids), len(set(ids))) + self.assertIn(str(information.pk), ids) + self.assertIn(str(elsewhere.pk), ids) + self.assertIn(str(expected.pk), ids) + self.assertIn(str(elsewhere.pk), [point["id"] for point in payload["elsewhere"]]) + + page = self.client.get(reverse("conversations:detail", kwargs={"pk": self.conversation.pk})) + for pk in (information.pk, elsewhere.pk, expected.pk): + self.assertContains(page, f'id="point-{pk}"', count=1) + self.assertContains(page, "En cours ailleurs") + self.assertContains(page, "À vous") + + def test_c2_search_focus_never_reveals_point_outside_audience(self): + restricted = create_audience_set( + actor=self.owner, conversation=self.conversation, label="Auteur seul", + ) + add_audience_rule( + actor=self.owner, audience_set=restricted, + operation=ConversationAudienceRuleOperation.INCLUDE, + kind=ConversationAudienceRuleKind.EXPLICIT_PROFILE, + profile=self.owner, + ) + hidden = create_point( + actor=self.owner, conversation=self.conversation, + kind=ConversationPointKind.INFORMATION, + title="Information réservée strictement", visibility_audience=restricted, + ) + self.client.force_login(self.member) + url = reverse("conversations-api:detail", kwargs={"pk": self.conversation.pk}) + detail = self.client.get(url, {"focus_point": str(hidden.pk)}) + self.assertEqual(detail.status_code, 200) + payload = detail.json() + self.assertIsNone(payload["focused_point"]) + self.assertNotIn( + str(hidden.pk), + [point["id"] for field in ("points", "essential", "elsewhere") for point in payload[field]], + ) + page = self.client.get( + reverse("conversations:detail", kwargs={"pk": self.conversation.pk}), + {"focus": str(hidden.pk)}, + ) + self.assertNotContains(page, "Information réservée strictement") + results = self.client.get( + reverse("conversations-api:list"), {"q": "réservée strictement"}, + ) + self.assertEqual(results.status_code, 200) + self.assertEqual(results.json()["results"], []) + def test_api_response_is_idempotent_by_client_reference(self): point = self._question(client_reference="j7-api-question") self.client.force_login(self.member) diff --git a/conversations/test_point_presentation_author.py b/conversations/test_point_presentation_author.py new file mode 100644 index 0000000000..2b821ed0e4 --- /dev/null +++ b/conversations/test_point_presentation_author.py @@ -0,0 +1,95 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import SimpleTestCase + +from conversations.api_views import _serialize_point + + +def _point(*, author, represented_space=None): + return SimpleNamespace( + pk="point-1", + conversation_id="conversation-1", + conversation=SimpleNamespace(lifecycle="closed"), + kind="information", + response_mode="none", + lifecycle="open", + response_status={}, + allow_response_change=False, + supersedes_id=None, + importance="normal", + title="Préparation", + body="Contenu autorisé", + requires_acknowledgement=False, + opens_at=None, + deadline_at=None, + valid_until=None, + options=SimpleNamespace(all=lambda: []), + published_by=author, + published_by_id=author.pk, + client_reference="client-owned-ref", + represented_space=represented_space, + represented_space_id=(represented_space.pk if represented_space else None), + published_at=None, + updated_at=None, + ) + + +class ConversationPointAuthorProjectionTests(SimpleTestCase): + def _serialize(self, point, viewer): + with ( + patch("conversations.api_views.point_personal_result", return_value={}), + patch("conversations.api_views.visible_attachments_for_profile", return_value=[]), + ): + return _serialize_point(point, profile=viewer, section="history") + + def test_point_has_minimal_author_identity_and_viewer_ownership(self): + person = SimpleNamespace( + pk="user-1", username="marie", phone="private", + email="private@example.test", get_full_name=lambda: "Marie N.", + ) + own = self._serialize(_point(author=person), person) + self.assertEqual( + own["author"], + {"label": "Marie N.", "is_mine": True, "represented_space": False}, + ) + self.assertNotIn("published_by_id", own) + self.assertNotIn("email", str(own)) + self.assertNotIn("phone", str(own)) + + def test_other_author_uses_public_handle_not_private_name(self): + person = SimpleNamespace( + pk="user-1", username="marie", get_full_name=lambda: "Private Name", + ) + viewer = SimpleNamespace(pk="user-2") + row = self._serialize(_point(author=person), viewer) + self.assertEqual(row["author"]["label"], "marie") + self.assertFalse(row["author"]["is_mine"]) + self.assertNotIn("Private Name", str(row)) + + def test_idempotency_reference_is_visible_to_author_only(self): + person = SimpleNamespace( + pk="user-1", username="marie", get_full_name=lambda: "Marie N.", + ) + own = self._serialize(_point(author=person), person) + self.assertEqual(own["client_reference"], "client-owned-ref") + other = self._serialize( + _point(author=person), SimpleNamespace(pk="user-2"), + ) + self.assertIsNone(other["client_reference"]) + + def test_point_represented_space_is_not_a_global_profile(self): + person = SimpleNamespace( + pk="user-1", username="marie", get_full_name=lambda: "Marie N.", + ) + viewer = SimpleNamespace(pk="user-2") + space = SimpleNamespace(pk="space-1", name="Coopérative") + other = self._serialize( + _point(author=person, represented_space=space), viewer, + ) + self.assertEqual( + other["author"], + {"label": "Coopérative", "is_mine": False, "represented_space": True}, + ) + self.assertNotIn("user-1", str(other)) + self.assertNotIn("space-1", str(other)) diff --git a/conversations/urls.py b/conversations/urls.py index 9a7df59647..a9c48a3b88 100644 --- a/conversations/urls.py +++ b/conversations/urls.py @@ -1,8 +1,16 @@ from django.urls import path -from .media_views import serve_attachment +from .media_views import serve_attachment, serve_inline_attachment from .views import ( ConversationDetailView, + ConversationPointHistoryView, + ConversationPersonalStateWebView, + ConversationContextOpenView, + ConversationContextEnsureView, + ConversationComposeView, + ConversationMediaWebView, + ConversationPointMediaWebView, + ConversationExchangeDepthView, ConversationInvitationRespondView, ConversationListView, PointAcknowledgeView, @@ -15,10 +23,19 @@ urlpatterns = [ path("", ConversationListView.as_view(), name="list"), + path("by-context///", ConversationContextOpenView.as_view(), name="by-context"), path("/", ConversationDetailView.as_view(), name="detail"), + path("/history/", ConversationPointHistoryView.as_view(), name="history"), + path("/personal-state/", ConversationPersonalStateWebView.as_view(), name="personal-state"), + path("/compose/", ConversationComposeView.as_view(), name="compose"), + path("/media/", ConversationMediaWebView.as_view(), name="media"), + path("points//", ConversationExchangeDepthView.as_view(), name="exchange-detail"), + path("by-context///ensure/", ConversationContextEnsureView.as_view(), name="context-ensure"), path("invitations//respond/", ConversationInvitationRespondView.as_view(), name="invitation-respond"), path("points//respond/", PointRespondView.as_view(), name="point-respond"), + path("points//reply-media/", ConversationPointMediaWebView.as_view(), name="point-reply-media"), path("points//acknowledge/", PointAcknowledgeView.as_view(), name="point-acknowledge"), path("points//exchange/", PointExchangeView.as_view(), name="point-exchange"), path("attachments//", serve_attachment, name="attachment"), + path("attachments//inline/", serve_inline_attachment, name="attachment-inline"), ] diff --git a/conversations/views.py b/conversations/views.py index d694d2e89d..a7a93b4458 100644 --- a/conversations/views.py +++ b/conversations/views.py @@ -1,22 +1,35 @@ from datetime import datetime +from urllib.parse import urlencode +from uuid import uuid4 from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied, ValidationError -from django.shortcuts import get_object_or_404, redirect +from django.http import Http404, JsonResponse +from rest_framework.exceptions import NotFound +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone from django.views import View from django.views.generic import TemplateView from .core_models import Conversation, ConversationInvitation, ConversationInvitationStatus +from .composing import can_ensure_context_conversation, composer_capabilities, contribute_text, publish_composed_point +from .rich_services import publish_conversation_form, readable_exchange_entries, serialize_attachment, upload_conversation_media, submit_point_media_response +from .services import ensure_context_conversation +from .core_models import ConversationContext, ConversationContextKind +from organizations.models import Organization from .point_models import ConversationPoint, ConversationPointResponseMode from .point_services import acknowledge_point, create_exchange_entry, submit_point_response +from .history import page_points_for_profile from .presentation import ( catch_up_summary, conversation_context_label, conversation_rows_for_profile, + conversations_for_context, essential_points_for_profile, now_points_for_profile, + other_visible_points_for_profile, search_conversation, search_conversations_for_profile, ) @@ -39,7 +52,7 @@ class ConversationListView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - tab = (self.request.GET.get("tab") or "for-me").strip().lower() + tab = (self.request.GET.get("tab") or "all").strip().lower() if tab not in {"for-me", "all", "archived"}: tab = "for-me" q = (self.request.GET.get("q") or "").strip()[:120] @@ -63,11 +76,33 @@ def get_context_data(self, **kwargs): archived=tab == "archived", only_attention=tab == "for-me", ) if not q else [], - "search_results": search_conversations_for_profile(self.request.user, q) if q else [], + "search_results": self._deduplicated_search(q, tab=tab) if q else [], } ) return context + def _deduplicated_search(self, query, *, tab="all"): + visible_attention_ids = ( + { + row.conversation.pk for row in conversation_rows_for_profile( + self.request.user, only_attention=True, limit=300, + ) + } if tab == "for-me" else None + ) + seen = set() + results = [] + for result in search_conversations_for_profile( + self.request.user, query, include_archived=tab == "archived", + ): + if result.conversation.pk in seen: + continue + if (visible_attention_ids is not None and + result.conversation.pk not in visible_attention_ids): + continue + seen.add(result.conversation.pk) + results.append(result) + return results + class ConversationDetailView(ConversationAccessMixin, TemplateView): template_name = "conversations/detail.html" @@ -78,21 +113,343 @@ def get_context_data(self, **kwargs): previous_state = conversation.user_states.filter(profile=self.request.user).first() previous_open = previous_state.last_opened_at if previous_state else None q = (self.request.GET.get("q") or "").strip()[:120] + now_rows = now_points_for_profile(self.request.user, conversation) + seen = {row["point"].pk for row in now_rows} + essential = [ + point for point in essential_points_for_profile(self.request.user, conversation) + if point.pk not in seen + ] + seen.update(point.pk for point in essential) + elsewhere = other_visible_points_for_profile( + self.request.user, conversation, excluded_ids=seen, + ) + seen.update(point.pk for point in elsewhere) + focused_point = None + focus = (self.request.GET.get("focus") or "").strip() + if focus: + try: + candidate = ConversationPoint.objects.get(pk=focus, conversation=conversation) + except (ConversationPoint.DoesNotExist, ValueError, ValidationError): + candidate = None + if candidate is not None: + from .point_services import point_visible_to + if point_visible_to(self.request.user, candidate) and candidate.pk not in seen: + focused_point = candidate context.update( { "conversation": conversation, "context_label": conversation_context_label(conversation, self.request.user), - "now_rows": now_points_for_profile(self.request.user, conversation), - "essential_points": essential_points_for_profile(self.request.user, conversation), + "my_points": [ + row for row in now_rows + if row["section"] == "pour_moi" or row["reason"] == "resolve" + ], + "known_points": [ + row for row in now_rows + if row["section"] != "pour_moi" and row["reason"] != "resolve" + ], + "elsewhere_points": elsewhere, + "essential_points": essential, + "focused_point": focused_point, "q": q, "search_results": search_conversation(self.request.user, conversation, q) if q else [], "catch_up": catch_up_summary(self.request.user, conversation, since=previous_open), + "personal_state": previous_state, + "composer": composer_capabilities(self.request.user, conversation), + "compose_refs": { + key: str(uuid4()) for key in ( + "text", "information", "poll", "confirmation", + "question", "request", "exchange", + "form", "voice", "file", "image", + ) + }, + "compose_error": getattr(self, "compose_error", None), + "compose_draft": getattr(self, "compose_draft", {}), + } ) update_personal_conversation_state(actor=self.request.user, conversation=conversation, opened=True) return context +class ConversationPointHistoryView(ConversationAccessMixin, TemplateView): + """N2 search and older/changed Points, keyset-continuable and ACL-checked.""" + template_name = "conversations/history.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + conversation = self.get_conversation() + query = (self.request.GET.get("q") or "").strip()[:120] + section = (self.request.GET.get("section") or ("all" if query else "history")).strip() + if section not in {"history", "all", "active"}: + section = "history" + error = None + try: + page = page_points_for_profile( + self.request.user, conversation, query=query, + section=section, limit=25, + cursor=self.request.GET.get("cursor") or None, + ) + except ValidationError: + page = {"results": [], "next_cursor": None} + error = "Cette recherche a changé. Recommencez." + params = {"section": section} + if query: + params["q"] = query + if page["next_cursor"]: + params["cursor"] = page["next_cursor"] + context.update({ + "conversation": conversation, + "context_label": conversation_context_label(conversation, self.request.user), + "query": query, "section": section, + "results": page["results"], "next_cursor": page["next_cursor"], + "next_query": urlencode(params), + "history_error": error, + }) + return context + + +class ConversationPersonalStateWebView(ConversationAccessMixin, View): + """Only personal state is changed; no shared Conversation lifecycle.""" + def post(self, request, pk): + conversation = self.get_conversation() + action = (request.POST.get("action") or "").strip() + field = { + "pin": ("pinned", "pinned_at"), + "mute": ("mute", "muted_at"), + "archive": ("archived", "archived_at"), + "hide": ("hidden", "hidden_at"), + "revisit": ("revisit", "revisit_at"), + }.get(action) + if field is None: + raise Http404("Action inconnue.") + state = conversation.user_states.filter(profile=request.user).first() + active = bool(state and getattr(state, field[1])) + try: + update_personal_conversation_state( + actor=request.user, conversation=conversation, + **{field[0]: not active}, + ) + except (ValidationError, PermissionDenied) as exc: + messages.error(request, str(exc)) + return redirect("conversations:detail", pk=conversation.pk) + + +class ConversationContextOpenView(LoginRequiredMixin, View): + login_url = "core:login" + + def get(self, request, kind, context_pk): + try: + candidates = conversations_for_context(request.user, kind, context_pk) + except NotFound as exc: + raise Http404("Conversation introuvable.") from exc + if not candidates: + if can_ensure_context_conversation(request.user, kind, context_pk): + return render( + request, "conversations/context_choices.html", + {"conversations": [], "can_ensure": True, "kind": kind, "context_pk": context_pk}, + ) + raise Http404("Aucune Conversation accessible.") + if len(candidates) == 1: + return redirect("conversations:detail", pk=candidates[0].pk) + return render( + request, "conversations/context_choices.html", + {"conversations": [ + {"conversation": item, "label": conversation_context_label(item, request.user)} + for item in candidates + ]}, + ) + + +class ConversationContextEnsureView(LoginRequiredMixin, View): + login_url = "core:login" + + def post(self, request, kind, context_pk): + if not can_ensure_context_conversation(request.user, kind, context_pk): + raise PermissionDenied("Ouverture non autorisée.") + field = ConversationContext.TARGET_FIELDS[kind][0] + model = ConversationContext._meta.get_field(field).remote_field.model + try: + target = model.objects.get(pk=context_pk) + except (model.DoesNotExist, TypeError, ValueError, ValidationError) as exc: + raise Http404("Contexte introuvable.") from exc + conversation = ensure_context_conversation( + actor=request.user, kind=kind, **{field: target}, + ) + if not can_view_conversation(request.user, conversation): + raise Http404("Conversation introuvable.") + return redirect("conversations:detail", pk=conversation.pk) + + +class ConversationComposeView(ConversationDetailView): + """Failure keeps the submitted draft and reference on the same Web surface.""" + + def post(self, request, pk): + conversation = self.get_conversation() + mode = (request.POST.get("mode") or "text").strip() + ref = request.POST.get("client_reference") + space_raw = request.POST.get("represented_space_id") + represented_space = None + try: + if space_raw: + try: + represented_space = Organization.objects.get(pk=space_raw) + except (Organization.DoesNotExist, ValidationError, ValueError, TypeError) as exc: + raise PermissionDenied("Espace non autorisé.") from exc + if mode == "text": + _, _, point_id = contribute_text( + actor=request.user, conversation=conversation, + body=request.POST.get("body"), + point_id=request.POST.get("point_id") or None, + audience_id=request.POST.get("audience_id") or None, + represented_space=represented_space, client_reference=ref, + ) + elif mode == "form": + questions = [ + line.strip() for line in request.POST.get("questions", "").splitlines() + if line.strip() + ] if not request.POST.get("form_version_id") else None + point = publish_conversation_form( + actor=request.user, conversation=conversation, + title=request.POST.get("title", ""), + form_version_id=request.POST.get("form_version_id") or None, + questions=questions, audience_id=request.POST.get("audience_id") or None, + represented_space=represented_space, + client_reference=ref, + ) + point_id = point.pk + elif mode == "point": + kind = request.POST.get("kind") + options = [ + line.strip() for line in request.POST.get("options", "").splitlines() + if line.strip() + ] if kind == "poll" else [] + point = publish_composed_point( + actor=request.user, conversation=conversation, + kind=kind, + title=request.POST.get("title", ""), + body=request.POST.get("body", ""), + audience_id=request.POST.get("audience_id") or None, + options=options, represented_space=represented_space, + client_reference=ref, + ) + point_id = point.pk + else: + raise ValidationError("Action indisponible.") + except (ValidationError, PermissionDenied) as exc: + self.compose_error = ( + "Vous n’êtes pas autorisé à publier dans cette Conversation." + if isinstance(exc, PermissionDenied) else str(exc) + ) + self.compose_draft = request.POST + return self.get(request, pk=pk) + return redirect( + f"{reverse('conversations:detail', kwargs={'pk': conversation.pk})}" + f"?focus={point_id}#point-{point_id}" + ) + + +class ConversationMediaWebView(ConversationDetailView): + def post(self, request, pk): + conversation = self.get_conversation() + try: + raw_space = request.POST.get("represented_space_id") + represented = Organization.objects.get(pk=raw_space) if raw_space else None + point, attachment = upload_conversation_media( + actor=request.user, conversation=conversation, + uploaded_file=request.FILES.get("file"), + kind=request.POST.get("kind", "file"), + body=request.POST.get("body", ""), + audience_id=request.POST.get("audience_id") or None, + point_id=request.POST.get("point_id") or None, + represented_space=represented, + client_reference=request.POST.get("client_reference"), + ) + except (ValidationError, PermissionDenied, Organization.DoesNotExist, ValueError, TypeError) as exc: + if request.headers.get("x-requested-with") == "XMLHttpRequest": + return JsonResponse({"detail": str(exc)}, status=403 if isinstance(exc, PermissionDenied) else 400) + self.compose_error = "Pièce non envoyée. " + str(exc) + self.compose_draft = request.POST + return self.get(request, pk=pk) + if request.headers.get("x-requested-with") == "XMLHttpRequest": + return JsonResponse({ + "point_id": str(point.pk), "attachment": serialize_attachment(attachment), + }) + return redirect( + f"{reverse('conversations:detail', kwargs={'pk': conversation.pk})}" + f"?focus={point.pk}#point-{point.pk}" + ) + + +class ConversationExchangeDepthView(LoginRequiredMixin, TemplateView): + login_url = "core:login" + template_name = "conversations/exchange.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + point = get_object_or_404( + ConversationPoint.objects.select_related("conversation__context"), pk=self.kwargs["point_pk"], + ) + from .point_services import point_visible_to, point_response_allowed + if not point_visible_to(self.request.user, point) or point.kind != "exchange": + raise Http404("Échange inaccessible.") + try: + entries, cursor = readable_exchange_entries( + self.request.user, point, cursor=self.request.GET.get("cursor"), + ) + except ValidationError as exc: + raise Http404("Curseur invalide.") from exc + context.update({ + "conversation": point.conversation, "point": point, + "entries": entries, "next_cursor": cursor, + "can_contribute": bool(point_response_allowed(self.request.user, point) and + point.conversation.lifecycle == "open" and + point.lifecycle == "open" and + (point.opens_at is None or point.opens_at <= timezone.now()) and + (point.deadline_at is None or point.deadline_at > timezone.now()) and + (point.valid_until is None or point.valid_until > timezone.now())), + "client_reference": str(uuid4()), + }) + return context + + +class ConversationPointMediaWebView(LoginRequiredMixin, View): + login_url = "core:login" + + def post(self, request, point_pk): + point = get_object_or_404( + ConversationPoint.objects.select_related("conversation__context"), pk=point_pk, + ) + if not can_view_conversation(request.user, point.conversation): + raise Http404("Point inaccessible.") + try: + space_id = request.POST.get("represented_space_id") + space = Organization.objects.get(pk=space_id) if space_id else None + response, attachment = submit_point_media_response( + actor=request.user, point=point, + uploaded_file=request.FILES.get("file"), + kind=request.POST.get("kind", "file"), + represented_space=space, + client_reference=request.POST.get("client_reference"), + ) + except (ValidationError, PermissionDenied, Organization.DoesNotExist, ValueError, TypeError) as exc: + if request.headers.get("x-requested-with") == "XMLHttpRequest": + return JsonResponse({"detail": str(exc)}, status=403 if isinstance(exc, PermissionDenied) else 400) + messages.error(request, "Envoi non confirmé : " + str(exc)) + return redirect( + f"{reverse('conversations:detail', kwargs={'pk': point.conversation_id})}" + f"?focus={point.pk}#point-{point.pk}" + ) + if request.headers.get("x-requested-with") == "XMLHttpRequest": + return JsonResponse({ + "point_id": str(point.pk), "response_id": str(response.pk), + "attachment": serialize_attachment(attachment), + }) + return redirect( + f"{reverse('conversations:detail', kwargs={'pk': point.conversation_id})}" + f"?focus={point.pk}#point-{point.pk}" + ) + + class ConversationInvitationRespondView(LoginRequiredMixin, View): login_url = "core:login" @@ -190,4 +547,4 @@ def post(self, request, point_pk): ) except (ValidationError, PermissionDenied) as exc: messages.error(request, str(exc)) - return redirect("conversations:detail", pk=point.conversation_id) + return redirect("conversations:exchange-detail", point_pk=point.pk) diff --git a/core/api/access_projection.py b/core/api/access_projection.py index dc46a3971a..1ce3a6f40d 100644 --- a/core/api/access_projection.py +++ b/core/api/access_projection.py @@ -2,6 +2,7 @@ from django.db.models import Prefetch from django.urls import reverse +from django.utils import timezone from access.models import AccessCredential, AccessStatus, CredentialStatus from access.selectors import access_credential_is_presentable_to @@ -43,6 +44,9 @@ def _primary_place(occurrence): return { "name": link.place.name or None, "locality": link.place.locality or None, + "address_line": link.place.address_line or None, + "access_instructions": link.place.access_instructions or None, + "timezone": link.place.timezone or None, } @@ -247,6 +251,7 @@ def build_personal_accesses_data( def build_personal_access_credential_data(*, profile, access, observed_at=None): + observed_at = observed_at or timezone.now() if not access_credential_is_presentable_to(profile, access, at=observed_at): return None @@ -256,6 +261,24 @@ def build_personal_access_credential_data(*, profile, access, observed_at=None): from access.services import render_access_credential + # Explicit, finite owner permission for device-protected *presentation*. + # The QR still has no offline validation authority: every scan resolves + # Access/AccessCredential/AccessUse against the server's current state. + expiry_candidates = [ + value for value in ( + access.valid_until, + getattr(access.occurrence, "end_at", None) + if access.occurrence_id else None, + ) + if value is not None + ] + offline_until = min(expiry_candidates) if expiry_candidates else None + offline_allowed = ( + credential.credential_type == "qr" + and offline_until is not None + and offline_until > observed_at + ) + return { "access": {"kind": "access", "id": str(access.pk)}, "relationship": ACCESS_RELATION_BENEFICIARY, @@ -265,4 +288,10 @@ def build_personal_access_credential_data(*, profile, access, observed_at=None): "payload": render_access_credential(credential), "issued_at": _iso(credential.issued_at), }, + "offline_presentation": { + "allowed": offline_allowed, + "expires_at": _iso(offline_until) if offline_allowed else None, + "observed_at": _iso(observed_at), + "scope": "display_only", + }, } diff --git a/core/api/detail_projections.py b/core/api/detail_projections.py index 2f1e26614a..d3fae6d066 100644 --- a/core/api/detail_projections.py +++ b/core/api/detail_projections.py @@ -3,7 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.urls import reverse -from journeys.models import RequestStatus +from journeys.models import JourneyStatus, RequestStatus, WorkflowKind from payments.models import PaymentObligationProcessingMode, PaymentObligationStatus from readiness import ReadinessCheckState from services.requirement_services import derive_requirement_consequence @@ -319,6 +319,9 @@ def build_journey_detail(*, journey, readiness, profile, live=None): ) if actionable_form_ids: capabilities.append("complete_form") + if journey.workflow == WorkflowKind.INVITATION and journey.status == JourneyStatus.SUBMITTED: + capabilities.extend(["accept_invitation", "decline_invitation"]) + links["invitation"] = f"/api/v1/me/journeys/{journey.pk}/invitation/" return { "identity": { @@ -460,9 +463,12 @@ def build_access_detail(*, access, profile): occurrence = None if access.occurrence_id: + # Reuse Access' owner projection of planned time/place, not the + # volatile Day-of position or live capacity. + from .access_projection import _occurrence_payload + occurrence = { - "kind": "occurrence", - "id": str(access.occurrence_id), + **(_occurrence_payload(access.occurrence) or {}), "link": f"/api/v1/occurrences/{access.occurrence_id}/", } diff --git a/core/api/detail_urls.py b/core/api/detail_urls.py index 76ae1c8b9e..44896b34d0 100644 --- a/core/api/detail_urls.py +++ b/core/api/detail_urls.py @@ -1,3 +1,4 @@ +from .journey_invitation_api import PersonalJourneyInvitationAPIView from django.urls import path from .detail_views import ( @@ -9,6 +10,7 @@ app_name = "personal-detail-projections" urlpatterns = [ + path("journeys//invitation/", PersonalJourneyInvitationAPIView.as_view(), name="journey-invitation"), path( "journeys//", PersonalJourneyDetailAPIView.as_view(), diff --git a/core/api/detail_views.py b/core/api/detail_views.py index c056a3e4ba..bab6d05820 100644 --- a/core/api/detail_views.py +++ b/core/api/detail_views.py @@ -103,7 +103,9 @@ def get(self, request, pk): self._guard_personal_scope(request) observed_at = timezone.now() access = get_object_or_404( - participant_accesses_visible_to_buyer(request.user), + participant_accesses_visible_to_buyer(request.user) + .select_related("activity", "occurrence") + .prefetch_related("occurrence__place_links__place"), pk=pk, ) return self._response( diff --git a/core/api/journey_invitation_api.py b/core/api/journey_invitation_api.py new file mode 100644 index 0000000000..3ac71fe10a --- /dev/null +++ b/core/api/journey_invitation_api.py @@ -0,0 +1,66 @@ +"""Native beneficiary actions for an existing Journey invitation. + +The canonical Journey transition and Access issuance are shared with Web. +""" +from django.core.exceptions import ValidationError as DomainValidationError +from django.shortcuts import get_object_or_404 +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.participant_actions import participant_accept_invitation, participant_decline_invitation +from journeys.models import Journey, JourneyStatus, WorkflowKind + + +class PersonalJourneyInvitationAPIView(APIView): + permission_classes = [IsAuthenticated] + + def _journey(self, request, pk): + return get_object_or_404( + Journey.objects.filter( + beneficiary=request.user, + workflow=WorkflowKind.INVITATION, + ).select_related("activity", "occurrence"), + pk=pk, + ) + + def _response(self, journey, *, access=None): + can_respond = journey.status == JourneyStatus.SUBMITTED + payload = { + "kind": "journey_invitation", + "journey_id": str(journey.pk), + "status": journey.status, + "capabilities": ["accept", "decline"] if can_respond else [], + "access_id": str(access.pk) if access is not None else None, + } + response = Response(payload) + response["Cache-Control"] = "private, no-store" + return response + + def get(self, request, pk): + journey = self._journey(request, pk) + return self._response(journey) + + def post(self, request, pk): + journey = self._journey(request, pk) + if not isinstance(request.data, dict) or set(request.data) != {"decision"}: + raise ValidationError({"decision": "Indiquez une décision valide."}) + decision = request.data["decision"] + if decision not in {"accept", "decline"}: + raise ValidationError({"decision": "Décision inconnue."}) + # Repeated requests cannot turn an accepted invitation into a refusal. + if decision == "accept" and journey.status == JourneyStatus.CANCELLED: + raise ValidationError({"decision": "Cette invitation a été refusée."}) + if decision == "decline" and journey.status == JourneyStatus.CONFIRMED: + raise ValidationError({"decision": "Cette invitation est déjà acceptée."}) + try: + if decision == "accept": + journey, access = participant_accept_invitation( + journey=journey, actor=request.user, + ) + return self._response(journey, access=access) + journey = participant_decline_invitation(journey=journey, actor=request.user) + return self._response(journey) + except DomainValidationError as exc: + raise ValidationError({"decision": exc.messages}) from exc diff --git a/core/api/mark_views.py b/core/api/mark_views.py index 03fe8fd74f..17da0a9f51 100644 --- a/core/api/mark_views.py +++ b/core/api/mark_views.py @@ -23,16 +23,16 @@ def post(self, request): self._guard_personal_scope(request, include_body=True) payload = request.data if not isinstance(payload, dict): - raise ValidationError("Le corps de la requête doit être un objet.") + raise ValidationError("Le contenu envoyé est invalide.") mark_input = payload.get("input") if not isinstance(mark_input, dict): - raise ValidationError({"input": ["Une entrée Mark structurée est requise."]}) + raise ValidationError({"input": ["Indiquez votre demande."]}) input_kind = str(mark_input.get("kind") or "").strip() value = mark_input.get("value") if not input_kind: - raise ValidationError({"input": ["Le type d'entrée est requis."]}) + raise ValidationError({"input": ["Précisez le type de contenu."]}) if input_kind == "text": value = str(value or "").strip() if not value: @@ -44,11 +44,11 @@ def post(self, request): context = payload.get("context") or {} if not isinstance(context, dict): - raise ValidationError({"context": ["Le contexte doit être un objet."]}) + raise ValidationError({"context": ["Le contexte transmis est invalide."]}) forbidden_context = _FORBIDDEN_CONTEXT_KEYS.intersection(context) if forbidden_context: - raise PermissionDenied("Le contexte d'autorité est résolu par le serveur.") + raise PermissionDenied("Vous ne pouvez pas choisir vos autorisations depuis cette demande.") result = public_mark_result( orchestrate_mark( diff --git a/core/api/me_projection.py b/core/api/me_projection.py index c066acfad2..7be8115afe 100644 --- a/core/api/me_projection.py +++ b/core/api/me_projection.py @@ -68,18 +68,30 @@ def _display_name(profile): return _clean_text(profile.full_name) or _clean_text(profile.username) or str(profile.pk) -def _bounded(queryset, serializer, *, limit): - rows = list(queryset[: limit + 1]) +def _bounded(queryset, serializer, *, limit, offset=0): + rows = list(queryset[offset : offset + limit + 1]) has_more = len(rows) > limit if has_more: rows = rows[:limit] - total = queryset.count() - else: - total = len(rows) + total = queryset.count() return { "count": total, "items": [serializer(row) for row in rows], "has_more": has_more, + "next_offset": offset + len(rows), + } + + +def _family_slice(sources, *, limit, offset, family): + """Keep the root preview unchanged while paging exactly one owner family.""" + if family is not None: + if family not in sources: + raise ValueError("Famille inconnue.") + queryset, serializer = sources[family] + return {family: _bounded(queryset, serializer, limit=limit, offset=offset)} + return { + name: _bounded(queryset, serializer, limit=limit, offset=offset) + for name, (queryset, serializer) in sources.items() } @@ -171,6 +183,10 @@ def _watch_item(row): "status_label": row.get_status_display(), "dossier": dossier, "updated_at": row.updated_at, + "links": { + "web": reverse("discovery:watch-detail", + kwargs={"watch_id": row.pk}), + }, } @@ -212,7 +228,78 @@ def _profile_follow_item(row): } -def build_personal_considerations_data(profile, *, limit=ME_SECTION_LIMIT): +def _decorate_saved_recognition_media(profile, bookmarks): + """Only re-use already-published Discovery RECOGNIZE media in Moi. + + Media is representation, not ownership or permission to share. Keep the + normal private bookmark title even when a public image is inadmissible. + """ + rows = bookmarks.get("items") if isinstance(bookmarks, dict) else None + if not rows: + return + + from accounts.models import UserProfile + from activities.models import Activity, ActivityStatus, ActivityVisibility + from groups.selectors import eligible_activity_ids_for_profile + from organizations.models import SpaceLifecycle + from presentations.media_selectors import public_presentation_media_for_activities + + ids = [row["activity"]["id"] for row in rows if row.get("activity")] + candidates = list( + Activity.objects.filter( + pk__in=ids, + status=ActivityStatus.PUBLISHED, + visibility=ActivityVisibility.PUBLIC, + ).select_related("space") + ) + eligible = set(eligible_activity_ids_for_profile(profile, [a.pk for a in candidates])) + public_people = set( + UserProfile.objects.filter( + user_id__in=[a.owner_profile_id for a in candidates if a.owner_profile_id], + public_profile=True, + searchable=True, + user__is_active=True, + ).values_list("user_id", flat=True) + ) + admitted = [ + activity for activity in candidates + if activity.pk in eligible and ( + (activity.space_id is not None + and activity.space.public_profile and activity.space.searchable + and activity.space.lifecycle == SpaceLifecycle.ACTIVE + and activity.space.verification_status != "suspended") + or + (activity.space_id is None and activity.owner_profile_id in public_people) + ) + ] + published = public_presentation_media_for_activities(admitted) + for row in rows: + activity_id = row["activity"]["id"] + available = published.get(activity_id) or [] + media = next( + (use for use in available + if use.mime_type in {"image/jpeg", "image/png"}), + None, + ) + if media is None: + continue + row["media_bindings"] = [{ + "resource": { + "kind": "presentation_media_use", + "id": media.use_id, + "url": reverse( + "presentations:published-media-file", + kwargs={"use_id": media.use_id}, + ), + "mime_type": media.mime_type, + "alt_text": media.alt_text, + }, + "target": {"kind": "activity", "id": activity_id}, + "purpose": "RECOGNIZE", + }] + + +def build_personal_considerations_data(profile, *, limit=ME_SECTION_LIMIT, offset=0, family=None): """Private facts explicitly chosen by the Profile; no inferred preference.""" interests = ( ProfileInterest.objects.filter(profile=profile, topic__is_active=True) @@ -244,15 +331,23 @@ def build_personal_considerations_data(profile, *, limit=ME_SECTION_LIMIT): .select_related("organizer_profile") .order_by("-followed_at", "id") ) - return { - "interests": _bounded(interests, _interest_item, limit=limit), - "open_to": _bounded(open_to, _open_to_item, limit=limit), - "watches": _bounded(watches, _watch_item, limit=limit), - "bookmarks": _bounded(bookmarks, _bookmark_item, limit=limit), - "followed_spaces": _bounded(followed_spaces, _space_follow_item, limit=limit), - "followed_profiles": _bounded(followed_profiles, _profile_follow_item, limit=limit), - } - + collections = _family_slice( + { + "interests": (interests, _interest_item), + "open_to": (open_to, _open_to_item), + "watches": (watches, _watch_item), + "bookmarks": (bookmarks, _bookmark_item), + "followed_spaces": (followed_spaces, _space_follow_item), + "followed_profiles": (followed_profiles, _profile_follow_item), + }, + limit=limit, + offset=offset, + family=family, + ) + # Same admission on initial 6 items and on every continuation page. + if "bookmarks" in collections: + _decorate_saved_recognition_media(profile, collections["bookmarks"]) + return collections def _authorized_space_item(space): return { @@ -306,12 +401,13 @@ def _group_item(group): "detail": reverse( "personal-projections:group-detail", kwargs={"pk": group.pk}, - ) + ), + "web": reverse("groups:detail", kwargs={"slug": group.slug}), }, } -def build_personal_collectives_data(profile, *, limit=ME_SECTION_LIMIT): +def build_personal_collectives_data(profile, *, limit=ME_SECTION_LIMIT, offset=0, family=None): """Compose relationships without treating membership as authority.""" spaces = authorized_spaces(profile).order_by("name", "id") team_memberships = ( @@ -324,12 +420,16 @@ def build_personal_collectives_data(profile, *, limit=ME_SECTION_LIMIT): .order_by("team__organization__name", "team__name", "id") ) groups = groups_for_profile(profile).order_by("name", "id") - return { - "authorized_spaces": _bounded(spaces, _authorized_space_item, limit=limit), - "teams": _bounded(team_memberships, _team_membership_item, limit=limit), - "groups": _bounded(groups, _group_item, limit=limit), - } - + return _family_slice( + { + "authorized_spaces": (spaces, _authorized_space_item), + "teams": (team_memberships, _team_membership_item), + "groups": (groups, _group_item), + }, + limit=limit, + offset=offset, + family=family, + ) def _asset_item(asset): latest_payload = None @@ -408,7 +508,7 @@ def _credential_item(credential): } -def build_personal_resources_data(profile, *, limit=ME_SECTION_LIMIT): +def build_personal_resources_data(profile, *, limit=ME_SECTION_LIMIT, offset=0, family=None): latest = PersonalAssetVersion.objects.filter(asset_id=OuterRef("pk")).order_by( "-version", "-created_at", @@ -423,14 +523,18 @@ def build_personal_resources_data(profile, *, limit=ME_SECTION_LIMIT): ) .order_by("-updated_at", "id") ) - proofs = proofs_for_profile(profile) + proofs = proofs_for_profile(profile).order_by('-issued_at', 'id') credentials = credentials_for_profile(profile) - return { - "documents": _bounded(assets, _asset_item, limit=limit), - "proofs": _bounded(proofs, _proof_item, limit=limit), - "credentials": _bounded(credentials, _credential_item, limit=limit), - } - + return _family_slice( + { + "documents": (assets, _asset_item), + "proofs": (proofs, _proof_item), + "credentials": (credentials, _credential_item), + }, + limit=limit, + offset=offset, + family=family, + ) def _passport_interest(row): return { @@ -455,22 +559,39 @@ def _passport_open_to(row): } +def _passport_topic_codes(activity): + if activity is None: + return [] + return [ + link.topic.code for link in activity.topic_links.all() + if link.topic.is_active + ] + + def _passport_activity(activity): return { "kind": "activity", "id": str(activity.pk), "title": activity.title, "status": activity.status, + "status_label": activity.get_status_display(), "visibility": activity.visibility, + "topic_codes": _passport_topic_codes(activity), } def _passport_proof(proof): - return _proof_item(proof) + return { + **_proof_item(proof), + "topic_codes": _passport_topic_codes(proof.journey.activity), + } def _passport_credential(credential): - return _credential_item(credential) + return { + **_credential_item(credential), + "topic_codes": _passport_topic_codes(credential.activity), + } def build_personal_passport_data( @@ -482,6 +603,10 @@ def build_personal_passport_data( selected_proof_ids=None, selected_credential_ids=None, selected_sections=None, + excluded_activity_ids=(), + excluded_proof_ids=(), + excluded_credential_ids=(), + excluded_sections=(), ): if variant not in PASSPORT_VARIANTS: raise ValueError("Variante Passeport inconnue.") @@ -494,6 +619,10 @@ def build_personal_passport_data( selected_proof_ids=selected_proof_ids, selected_credential_ids=selected_credential_ids, selected_sections=selected_sections, + excluded_activity_ids=excluded_activity_ids, + excluded_proof_ids=excluded_proof_ids, + excluded_credential_ids=excluded_credential_ids, + excluded_sections=excluded_sections, ) identity = dict(projection.identity) identity["links"] = [ @@ -507,7 +636,14 @@ def build_personal_passport_data( "interests": [_passport_interest(row) for row in projection.interests], "open_to": [_passport_open_to(row) for row in projection.open_to], }, + "collectives": { + "team_affiliations": list(projection.team_affiliations), + "group_affiliations": list(projection.group_affiliations), + "authorized_space_affiliations": list(projection.authorized_space_affiliations), + "active_space_mandates": list(projection.active_space_mandates), + }, "activities": [_passport_activity(row) for row in projection.activities], + "experiences": list(projection.activity_involvements), "established": { "proofs": [_passport_proof(row) for row in projection.proofs], }, diff --git a/core/api/me_views.py b/core/api/me_views.py index f5e54c4610..f819ca1fa0 100644 --- a/core/api/me_views.py +++ b/core/api/me_views.py @@ -1,11 +1,30 @@ from django.utils import timezone +from uuid import UUID +from core.web_throttling import allow_web_request, value_rate_identity +from django.http import Http404, HttpResponse +from datetime import timezone as dt_timezone +from core.branding import render_makolo_qr_png +from sharing.passport_pdf_layout import render_passport_layout +from django.core.exceptions import ValidationError as DjangoValidationError, PermissionDenied as DjangoPermissionDenied +from django.shortcuts import get_object_or_404 +from django.urls import reverse from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from sharing.passport_pdf import passport_pdf_response +from sharing.models import PassportSnapshot, PassportShareSubject +from sharing.passport_documents import ( + passport_verification_token, snapshot_payload_is_intact, + revoke_passport_snapshot, issue_passport_snapshot, +) +from sharing.passport_sharing import create_passport_share, revoke_passport_share, create_direct_passport_share +from accounts.models import UserProfile +from sharing.passport import build_profile_passport, PASSPORT_VARIANTS from core.api.me_projection import ( + build_personal_resources_data, PASSPORT_COMPLETE, build_personal_collectives_data, build_personal_considerations_data, @@ -13,6 +32,9 @@ build_personal_partners_data, ) from core.api.projections import projection_envelope +from topics.models import OpenToKind, ProfileInterest, ProfileOpenTo, Topic +from topics.services import replace_profile_interests, replace_profile_open_to +from django.core.exceptions import ValidationError as DomainValidationError from core.api.z8_projection import ( RESOURCE_DEFAULT_LIMIT, RESOURCE_MAX_LIMIT, @@ -86,6 +108,55 @@ def get(self, request): return self._response(data, observed_at=observed_at) +class PersonalMeTerritoryPreviewAPIView(PersonalProjectionAPIView): + """Bounded continuation of existing private N1 territory collections.""" + + projection_code = "personal.me.preview" + page_size = 24 + max_page_size = 50 + builders = { + "considerations": build_personal_considerations_data, + "collectives": build_personal_collectives_data, + "resources": build_personal_resources_data, + } + + families = { + "considerations": frozenset(( + "interests", "open_to", "watches", "bookmarks", + "followed_spaces", "followed_profiles", + )), + "collectives": frozenset(("authorized_spaces", "teams", "groups")), + "resources": frozenset(("documents", "proofs", "credentials")), + } + + def get(self, request, territory): + self._guard_personal_scope(request) + builder = self.builders.get(territory) + family = request.query_params.get("family") + if builder is None or family not in self.families.get(territory, ()): + raise ValidationError({"family": "Collection inconnue."}) + try: + offset = int(request.query_params.get("offset", 0)) + limit = int(request.query_params.get("limit", self.page_size)) + except (TypeError, ValueError) as exc: + raise ValidationError({"pagination": "Paramètres invalides."}) from exc + if offset < 0 or not 1 <= limit <= self.max_page_size: + raise ValidationError({"pagination": "Plage de chargement invalide."}) + observed_at = timezone.now() + collection = builder(request.user, offset=offset, limit=limit, family=family)[family] + return self._response( + { + "actor": {"type": "profile", "id": str(request.user.pk)}, + "territory": territory, + "family": family, + "offset": offset, + "limit": limit, + "collection": collection, + }, + observed_at=observed_at, + ) + + class PersonalConsiderationsAPIView(PersonalProjectionAPIView): projection_code = "personal.me.considerations" @@ -98,6 +169,95 @@ def get(self, request): ) +class PersonalConsiderationSettingsAPIView(PersonalProjectionAPIView): + """Edit explicit personal preferences through their existing domain services. + + Saving interests cannot infer an openness, Watch, or follow. + """ + + projection_code = "personal.me.settings" + + def get(self, request): + self._guard_personal_scope(request) + query = (request.query_params.get("q") or "").strip() + if len(query) > 100: + raise ValidationError({"q": "Recherche trop longue."}) + return self._response( + self._settings(request.user, query=query), observed_at=timezone.now(), + ) + + def put(self, request): + self._guard_personal_scope(request, include_body=True) + family = request.data.get("family") + values = request.data.get("values") + if family not in {"interests", "open_to"} or not isinstance(values, list) or len(values) > 200: + raise ValidationError({"values": "Sélection invalide."}) + if any(not isinstance(value, str) for value in values): + raise ValidationError({"values": "Sélection invalide."}) + # A client may explicitly choose how newly added declarations are + # disclosed. Omission preserves the documented public-first behavior; + # neither choice changes existing declarations or discoverability. + new_visibility = request.data.get("new_visibility", "public") + if new_visibility not in ("public", "private"): + raise ValidationError({"new_visibility": "Visibilité initiale invalide."}) + try: + if family == "interests": + replace_profile_interests( + profile=request.user, topic_ids=values, + new_visibility=new_visibility, + ) + else: + retained = ProfileOpenTo.objects.filter( + profile=request.user, topic__isnull=True, kind__in=values, + ) + public_kinds = list( + retained.filter(is_public=True).values_list("kind", flat=True) + ) + if new_visibility == "public": + public_kinds.extend( + set(values) - set(retained.values_list("kind", flat=True)) + ) + searchable_kinds = list( + retained.filter(is_searchable=True).values_list("kind", flat=True) + ) + replace_profile_open_to( + profile=request.user, kinds=values, + public_kinds=public_kinds, searchable_kinds=searchable_kinds, + ) + except DomainValidationError as exc: + raise ValidationError(exc.message_dict if hasattr(exc, "message_dict") else {"values": exc.messages}) from exc + return self._response(self._settings(request.user), observed_at=timezone.now()) + + @staticmethod + def _settings(profile, *, query=""): + interests = ProfileInterest.objects.filter(profile=profile).values_list("topic_id", flat=True) + open_to = ProfileOpenTo.objects.filter( + profile=profile, is_active=True, topic__isnull=True, + ).values_list("kind", flat=True) + topics = Topic.objects.filter(is_active=True) + if query: + topics = topics.filter(label__icontains=query) + topics = topics.order_by("label", "code") + return { + "topic_query": query, + "topic_total": topics.count(), + "interests": [str(value) for value in interests], + "open_to": list(open_to), + "selected_topics": [ + {"id": str(row.pk), "label": row.label} + for row in Topic.objects.filter(pk__in=interests).order_by("label", "code") + ], + "topics": [ + {"id": str(row.pk), "label": row.label} + for row in topics[:200] + ], + "open_to_options": [ + {"id": code, "label": label} + for code, label in OpenToKind.choices + ], + } + + class PersonalCollectivesAPIView(PersonalProjectionAPIView): projection_code = "personal.me.collectives" @@ -126,6 +286,10 @@ def get(self, request): selected_proof_ids=request.query_params.getlist("proof"), selected_credential_ids=request.query_params.getlist("credential"), selected_sections=request.query_params.getlist("include"), + excluded_activity_ids=request.query_params.getlist("exclude_activity"), + excluded_proof_ids=request.query_params.getlist("exclude_proof"), + excluded_credential_ids=request.query_params.getlist("exclude_credential"), + excluded_sections=request.query_params.getlist("exclude"), ) except ValueError as exc: raise ValidationError({"variant": str(exc)}) from exc @@ -134,6 +298,344 @@ def get(self, request): return response +def _selected_personal_passport(request): + """One authorized builder for issue and PDF, never two document truths.""" + variant = (request.query_params.get("variant") or PASSPORT_COMPLETE).strip() + if variant not in PASSPORT_VARIANTS: + raise ValidationError({"variant": "Variante inconnue."}) + try: + return build_profile_passport( + request.user, + variant=variant, + topic_codes=request.query_params.getlist("topic"), + selected_activity_ids=( + request.query_params.getlist("activity") if variant == "custom" else None + ), + selected_proof_ids=( + request.query_params.getlist("proof") if variant == "custom" else None + ), + selected_credential_ids=( + request.query_params.getlist("credential") if variant == "custom" else None + ), + selected_sections=( + request.query_params.getlist("include") if variant == "custom" else None + ), + excluded_activity_ids=( + request.query_params.getlist("exclude_activity") if variant == "custom" else () + ), + excluded_proof_ids=( + request.query_params.getlist("exclude_proof") if variant == "custom" else () + ), + excluded_credential_ids=( + request.query_params.getlist("exclude_credential") if variant == "custom" else () + ), + excluded_sections=( + request.query_params.getlist("exclude") if variant == "custom" else () + ), + ) + except ValueError as exc: + raise ValidationError({"variant": str(exc)}) from exc + except DjangoPermissionDenied as exc: + raise ValidationError({"selection": "Sélection non autorisée."}) from exc + + +class PersonalPassportPdfAPIView(PersonalProjectionAPIView): + """Authenticated PDF export also issues one immutable dated document.""" + + projection_code = "personal.me.passport" + + def get(self, request): + self._guard_personal_scope(request) + return passport_pdf_response(request, _selected_personal_passport(request)) + + +class PersonalPassportIssueAPIView(PersonalProjectionAPIView): + """Issue one dated document without requiring the owner to download a PDF.""" + + projection_code = "personal.me.passport.issuance" + + def post(self, request): + self._guard_personal_scope(request, include_body=True) + identity = [value_rate_identity("issuer", str(request.user.pk))] + if not allow_web_request( + request, scope="passport-issue-minute", limit=8, + window_seconds=60, identities=identity, + ): + from rest_framework.exceptions import Throttled + raise Throttled(detail="Trop d'émissions. Réessayez plus tard.") + projection = _selected_personal_passport(request) + if not projection.has_content: + raise ValidationError({"selection": "Aucun contenu à émettre."}) + snapshot = issue_passport_snapshot(projection) + return self._response({ + "id": str(snapshot.pk), + "document_id": snapshot.document_id, + "variant": snapshot.variant, + "generated_at": snapshot.generated_at.isoformat(), + "verification_path": reverse( + "sharing:passport-verify", + kwargs={"token": passport_verification_token(snapshot)}, + ), + }, observed_at=timezone.now()) + + +class PersonalPassportHistoryAPIView(PersonalProjectionAPIView): + """Issued snapshots for the authenticated Profile, never other subjects.""" + projection_code = "personal.me.passport.history" + + def get(self, request): + self._guard_personal_scope(request) + try: + offset = int(request.query_params.get("offset", "0")) + except (TypeError, ValueError) as exc: + raise ValidationError({"offset": "Entier attendu."}) from exc + if offset < 0: + raise ValidationError({"offset": "Décalage négatif interdit."}) + queryset = PassportSnapshot.objects.filter(profile=request.user).prefetch_related( + "passport_share_subjects__envelope", + ).order_by( + "-generated_at", "-pk", + ) + count = queryset.count() + entries = list(queryset[offset : offset + 20]) + observed_at = timezone.now() + return self._response( + { + "count": count, + "items": [ + { + "id": str(row.pk), + "document_id": row.document_id, + "variant": row.variant, + "generated_at": row.generated_at.isoformat(), + "revoked": row.is_revoked, + "integrity_ok": snapshot_payload_is_intact(row), + "active_shares": [ + { + "id": str(grant.envelope_id), + "expires_at": ( + grant.envelope.expires_at.isoformat() + if grant.envelope.expires_at else None + ), + } + for grant in row.passport_share_subjects.all() + if not row.is_revoked and grant.envelope.is_active_at() + ], + "verification_path": reverse( + "sharing:passport-verify", + kwargs={"token": passport_verification_token(row)}, + ), + } + for row in entries + ], + "has_more": offset + len(entries) < count, + "next_offset": offset + len(entries), + }, + observed_at=observed_at, + ) + + +class PersonalPassportVersionAPIView(PersonalProjectionAPIView): + """Read one exact issued payload without going through the live builder.""" + projection_code = "personal.me.passport.version" + + def get(self, request, snapshot_id): + self._guard_personal_scope(request) + snapshot = get_object_or_404( + PassportSnapshot, pk=snapshot_id, profile=request.user, + ) + if not snapshot_payload_is_intact(snapshot): + raise Http404("Version non intègre.") + return self._response( + { + "id": str(snapshot.pk), + "document_id": snapshot.document_id, + "revoked": snapshot.is_revoked, + "variant": snapshot.variant, + "generated_at": snapshot.generated_at.isoformat(), + "payload": snapshot.payload, + "verification_path": reverse( + "sharing:passport-verify", + kwargs={"token": passport_verification_token(snapshot)}, + ), + }, + observed_at=timezone.now(), + ) + + +class PersonalPassportVersionPdfAPIView(PersonalProjectionAPIView): + """Export an existing immutable snapshot; never issue a second version.""" + projection_code = "personal.me.passport.version" + + def get(self, request, snapshot_id): + self._guard_personal_scope(request) + snapshot = get_object_or_404( + PassportSnapshot, pk=snapshot_id, profile=request.user, + ) + if not snapshot_payload_is_intact(snapshot): + raise Http404("Document non intègre.") + verify_url = request.build_absolute_uri(reverse( + "sharing:passport-verify", + kwargs={"token": passport_verification_token(snapshot)}, + )) + issued = snapshot.generated_at.astimezone(dt_timezone.utc) + content = render_passport_layout( + snapshot.payload, + { + "document_id": snapshot.document_id, + "issued_at": issued.strftime("%d/%m/%Y · %H:%M UTC"), + "variant": { + "complete": "Complet", + "public": "Public", + "thematic": "Thématique", + "custom": "Personnalisé", + }.get(snapshot.variant, snapshot.variant), + "identifier": snapshot.public_identifier, + }, + verify_url, + qr_png=render_makolo_qr_png(verify_url, branded=True, box_size=5), + ) + response = HttpResponse(content, content_type="application/pdf") + response["Content-Disposition"] = ( + f'attachment; filename="passeport-makolo-{snapshot.document_id}.pdf"' + ) + response["Cache-Control"] = "private, no-store" + response["X-Content-Type-Options"] = "nosniff" + return response + + +class PersonalPassportShareAPIView(PersonalProjectionAPIView): + """Sharing grant for one existing issued document; never issues a new one.""" + projection_code = "personal.me.passport.share" + + def post(self, request, snapshot_id): + self._guard_personal_scope(request, include_body=True) + identity = [value_rate_identity("issuer", str(request.user.pk))] + if not ( + allow_web_request( + request, scope="passport-share-minute", limit=12, + window_seconds=60, identities=identity, + ) + and allow_web_request( + request, scope="passport-share-hour", limit=60, + window_seconds=3600, identities=identity, + ) + ): + from rest_framework.exceptions import Throttled + raise Throttled(detail="Trop de partages. Réessayez plus tard.") + snapshot = get_object_or_404( + PassportSnapshot, pk=snapshot_id, profile=request.user, + ) + try: + days = int(request.data.get("days", 7)) + envelope, url = create_passport_share( + snapshot=snapshot, actor=request.user, days=days, + ) + except (TypeError, ValueError, DjangoValidationError) as exc: + raise ValidationError( + {"days": "Partage impossible : durée invalide ou document indisponible."} + ) from exc + except DjangoPermissionDenied as exc: + raise Http404("Partage non autorisé.") from exc + return self._response({ + "share_id": str(envelope.pk), + "share_url": url, + "expires_at": envelope.expires_at.isoformat(), + "document_id": snapshot.document_id, + }, observed_at=timezone.now()) + + +class PersonalPassportDirectShareAPIView(PersonalProjectionAPIView): + """Deliver an existing dated Passport to one recipient-bound Makolo account.""" + projection_code = "personal.me.passport.delivery" + + def post(self, request, snapshot_id): + self._guard_personal_scope(request, include_body=True) + recipient_id = request.data.get("recipient_id") + if not isinstance(recipient_id, str): + raise ValidationError({"recipient_id": "Destinataire requis."}) + try: + recipient_uuid = UUID(recipient_id) + except ValueError as exc: + raise ValidationError({"recipient_id": "Identifiant incorrect."}) from exc + recipient = get_object_or_404( + UserProfile.objects.select_related("user").filter( + user__is_active=True, searchable=True, + ), + pk=recipient_uuid, + ) + actor_id = str(request.user.pk) + if not ( + allow_web_request( + request, scope="passport-direct-minute", limit=12, + window_seconds=60, + identities=[value_rate_identity("sender", actor_id)], + ) + and allow_web_request( + request, scope="passport-direct-pair", limit=8, + window_seconds=3600, + identities=[value_rate_identity( + "pair", f"{actor_id}:{recipient.pk}", + )], + ) + ): + from rest_framework.exceptions import Throttled + raise Throttled(detail="Trop de partages.") + snapshot = get_object_or_404( + PassportSnapshot, pk=snapshot_id, profile=request.user, + ) + try: + delivery = create_direct_passport_share( + snapshot=snapshot, actor=request.user, recipient=recipient, + ) + except (DjangoValidationError, DjangoPermissionDenied, ValueError) as exc: + raise ValidationError({"passport": "Partage indisponible."}) from exc + return self._response({ + "state": "delivered", + "delivery_id": str(delivery.pk), + "recipient_id": str(recipient.pk), + "document_id": snapshot.document_id, + }, observed_at=timezone.now()) + + +class PersonalPassportVersionRevokeAPIView(PersonalProjectionAPIView): + """Owner-only withdrawal of one issued snapshot and its active grants.""" + + projection_code = "personal.me.passport.version" + + def post(self, request, snapshot_id): + self._guard_personal_scope(request, include_body=True) + snapshot = get_object_or_404( + PassportSnapshot, pk=snapshot_id, profile=request.user, + ) + revoked = revoke_passport_snapshot(snapshot=snapshot, actor=request.user) + return self._response({ + "id": str(revoked.pk), + "document_id": revoked.document_id, + "revoked": revoked.is_revoked, + }, observed_at=timezone.now()) + + +class PersonalPassportShareRevokeAPIView(PersonalProjectionAPIView): + """Revoke only a Sharing grant belonging to the current Profile.""" + + projection_code = "personal.me.passport.share" + + def post(self, request, share_id): + self._guard_personal_scope(request, include_body=True) + grant = get_object_or_404( + PassportShareSubject.objects.select_related("envelope"), + envelope_id=share_id, snapshot__profile=request.user, + ) + revoked = revoke_passport_share( + envelope=grant.envelope, actor=request.user, + ) + return self._response({ + "share_id": str(revoked.pk), + "revoked": not revoked.is_active_at(), + }, observed_at=timezone.now()) + + class PersonalResourcesAPIView(PersonalProjectionAPIView): projection_code = "personal.me.resources" diff --git a/core/api/passport_visibility_views.py b/core/api/passport_visibility_views.py new file mode 100644 index 0000000000..4f3c88253a --- /dev/null +++ b/core/api/passport_visibility_views.py @@ -0,0 +1,67 @@ +"""Private, owner-scoped configuration for Passport presentation audiences.""" +import uuid + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework.exceptions import ValidationError +from topics.models import ProfileInterest, ProfileOpenTo + +from accounts.models import ( + PROFILE_VISIBILITY_FIELDS, + ProfileVisibilityTargetKind as Target, +) +from accounts.profile_visibility import replace_profile_visibility +from accounts.profile_visibility_projection import build_profile_visibility_settings +from core.api.me_views import PersonalProjectionAPIView + + +class PersonalPassportVisibilityAPIView(PersonalProjectionAPIView): + projection_code = "personal.me.passport.visibility" + + def get(self, request): + self._guard_personal_scope(request) + return self._response(build_profile_visibility_settings( + request.user, compact=request.query_params.get('compact') == '1', + ), observed_at=timezone.now()) + + def put(self, request): + self._guard_personal_scope(request, include_body=True) + kind = request.data.get("target_kind") + field_key = request.data.get("field_key", "") + target_id = request.data.get("target_id") + audiences = request.data.get("audiences") + if kind not in Target.values or not isinstance(audiences, list): + raise ValidationError({"target_kind": "Cible ou audiences invalides."}) + if kind != Target.FIELD: + try: + target_id = uuid.UUID(str(target_id)) + except (ValueError, TypeError, AttributeError) as exc: + raise ValidationError({"target_id": "Identifiant invalide."}) from exc + if kind == Target.FIELD: + if target_id is not None or field_key not in PROFILE_VISIBILITY_FIELDS: + raise ValidationError({"field_key": "Information non publiable."}) + target = {} + elif kind == Target.INTEREST: + if field_key or not target_id: + raise ValidationError({"target_id": "Intérêt requis."}) + target = {"interest": get_object_or_404( + ProfileInterest, pk=target_id, profile=request.user, + )} + else: + if field_key or not target_id: + raise ValidationError({"target_id": "Déclaration requise."}) + target = {"open_to": get_object_or_404( + ProfileOpenTo, pk=target_id, profile=request.user, + )} + try: + replace_profile_visibility( + owner=request.user, target_kind=kind, field_key=field_key, + audiences=audiences, **target, + ) + except (DjangoValidationError, ValueError, TypeError) as exc: + detail = exc.message_dict if hasattr(exc, "message_dict") else ( + exc.messages if hasattr(exc, "messages") else [str(exc)] + ) + raise ValidationError({"audiences": detail}) from exc + return self._response(build_profile_visibility_settings(request.user), observed_at=timezone.now()) diff --git a/core/api/personal_projections.py b/core/api/personal_projections.py index 2c374dfa8b..d749959b5c 100644 --- a/core/api/personal_projections.py +++ b/core/api/personal_projections.py @@ -1,6 +1,8 @@ from __future__ import annotations import hashlib +from datetime import date, datetime +from zoneinfo import ZoneInfo from django.core import signing from django.db import models @@ -38,6 +40,9 @@ from core.read_models import build_personal_ongoing_read_model +NOW_LIMIT = 12 +NOW_CONTINUATION_SALT = "makolo.personal.now.v1" + ONGOING_LIMIT = 18 ONGOING_CONTINUATION_SALT = "makolo.personal.ongoing.v1" @@ -389,7 +394,8 @@ def _decorate_now_semantics( "knowledge_context": { "provenance": [source], "freshness": { - "state": "fresh", + # The projection timestamp does not establish owner-data freshness. + "state": "unknown", "observed_at": _iso(observed_at), }, "knowledge_state": "known", @@ -409,7 +415,24 @@ def _decorate_now_semantics( ) -def build_personal_now_projection(profile, *, observed_at=None): +def now_continuation_offset(token, *, profile): + """Return the next owner-scoped page; a token never selects the actor.""" + if not token: + return 0 + payload = signing.loads( + token, + salt=NOW_CONTINUATION_SALT, + max_age=60 * 60, + ) + if not isinstance(payload, dict) or payload.get("viewer") != str(profile.pk): + raise ValueError("Continuation belongs to another viewer.") + offset = payload.get("offset") + if type(offset) is not int or offset < NOW_LIMIT or offset % NOW_LIMIT: + raise ValueError("Invalid Now continuation offset.") + return offset + + +def build_personal_now_projection(profile, *, observed_at=None, continuation_offset=0): observed_at = observed_at or timezone.now() result, metadata = resolve_mature_home_contextual_actions( profile, @@ -459,14 +482,92 @@ def build_personal_now_projection(profile, *, observed_at=None): for _, item in items if item["source"]["kind"] == "journey" } - occurrence_by_journey = { - str(pk): occurrence_id - for pk, occurrence_id in Journey.objects.filter( - pk__in=journey_ids, - beneficiary=profile, - ).values_list("pk", "occurrence_id") - if occurrence_id is not None - } + occurrence_by_journey = {} + if journey_ids: + occurrence_by_journey = { + str(pk): occurrence_id + for pk, occurrence_id in Journey.objects.filter( + pk__in=journey_ids, + beneficiary=profile, + ).values_list("pk", "occurrence_id") + if occurrence_id is not None + } + # Public recognition media is owned by Presentation and bound to an + # eligible Activity. It must not reveal private Journey attachments. + public_recognition_by_journey = {} + if journey_ids: + from accounts.models import UserProfile + from activities.models import Activity, ActivityStatus, ActivityVisibility, Occurrence + from groups.selectors import eligible_activity_ids_for_profile + from organizations.models import SpaceLifecycle + from presentations.media_selectors import public_presentation_media_for_activities + + linked = { + str(pk): activity_id + for pk, activity_id in Journey.objects.filter( + pk__in=journey_ids, beneficiary=profile + ).values_list("pk", "activity_id") + if activity_id is not None + } + candidates = list(Activity.objects.filter( + pk__in=set(linked.values()), + status=ActivityStatus.PUBLISHED, + visibility=ActivityVisibility.PUBLIC, + ).select_related("space")) + eligible = set(eligible_activity_ids_for_profile(profile, [a.pk for a in candidates])) + public_owners = set(UserProfile.objects.filter( + user_id__in=[a.owner_profile_id for a in candidates if a.owner_profile_id], + public_profile=True, searchable=True, user__is_active=True, + ).values_list("user_id", flat=True)) + allowed = [ + a for a in candidates + if a.pk in eligible and ( + ( + a.space_id is not None + and a.space.public_profile + and a.space.searchable + and a.space.lifecycle == SpaceLifecycle.ACTIVE + and a.space.verification_status != "suspended" + ) or (a.space_id is None and a.owner_profile_id in public_owners) + ) + ] + if allowed: + occurrence_ids = set(occurrence_by_journey.values()) + occurrences = Occurrence.objects.filter( + pk__in=occurrence_ids, + activity_id__in=[activity.pk for activity in allowed], + ) + public_media = public_presentation_media_for_activities( + allowed, occurrences=occurrences, + ) + for journey_id, activity_id in linked.items(): + occurrence_id = occurrence_by_journey.get(journey_id) + images = [ + media for media in public_media.get(str(activity_id), []) + if (media.occurrence_id is None or + media.occurrence_id == str(occurrence_id)) + and media.mime_type in {"image/jpeg", "image/png"} + ] + images.sort(key=lambda media: ( + media.occurrence_id != str(occurrence_id), + media.position, + media.use_id, + )) + if images: + media = images[0] + public_recognition_by_journey[journey_id] = { + "resource_ref": f"presentation_media_use:{media.use_id}", + "target": "subject", + "purpose": "recognize", + "kind": "image", + "mime_type": media.mime_type, + "label": media.alt_text, + "url": reverse("presentations:published-media-file", + kwargs={"use_id": media.use_id}), + "authorized": True, + "presentation_rank": "primary", + } + # These artifacts belong to their Journeys, not to Presentation. # Only documents already inside a Journey owned by this profile are # exposed. The download endpoint independently rechecks authorization. @@ -546,9 +647,12 @@ def build_personal_now_projection(profile, *, observed_at=None): if "open_day_of" not in item["capabilities"]: item["capabilities"].append("open_day_of") _decorate_now_semantics(action, item, observed_at=observed_at) - media = artifact_media_by_journey.get(journey_id) - if media: - item["media_bindings"] = list(media) + media = artifact_media_by_journey.get(journey_id) or [] + recognition = public_recognition_by_journey.get(journey_id) + if recognition or media: + item["media_bindings"] = ( + ([recognition] if recognition else []) + list(media) + ) serialized_items = [item for _, item in items] for composed in dossier_dependency_now_situations(profile, observed_at=observed_at): @@ -570,7 +674,13 @@ def build_personal_now_projection(profile, *, observed_at=None): serialized_items.append(composed) else: serialized_items[replace_at] = composed + # Page only after owner admission, permissions and composition. + if type(continuation_offset) is not int or continuation_offset < 0: + raise ValueError("Invalid Now continuation offset.") is_empty = not serialized_items + page = serialized_items[continuation_offset : continuation_offset + NOW_LIMIT + 1] + has_more = len(page) > NOW_LIMIT + page = page[:NOW_LIMIT] profile_id = str(profile.pk) return { "surface": "now_me", @@ -584,8 +694,20 @@ def build_personal_now_projection(profile, *, observed_at=None): "actor_attention_state": "calm" if is_empty else "active", "capabilities": [], "handoffs": [], - "items": serialized_items, - "continuation": None, + "items": page, + "coverage_state": "partial" if has_more else "complete", + "continuation": ( + None if is_empty else { + "state": "more" if has_more else "end", + "token": ( + signing.dumps( + {"viewer": profile_id, "offset": continuation_offset + NOW_LIMIT}, + salt=NOW_CONTINUATION_SALT, + compress=True, + ) if has_more else None + ), + } + ), "terminal": { "state": "empty" if is_empty else "ok", # Human wording remains Presentation; the server owns the state. @@ -762,8 +884,29 @@ def _journey_ongoing_item(journey, readiness): } -def _access_ongoing_item(access): +def _access_ongoing_item(access, *, profile): + # Access is a different owner, but can continue the same personal Journey. + # Never carry this identity across a transfer to another beneficiary. + journey = access.journey if access.journey_id else None + ticket = getattr(access, "ticket", None) + order = ticket.order if ticket is not None else None + waitlist = getattr(order, "waitlist_entry", None) if order is not None else None + waitlist_anchor = ( + {"kind": "waitlist", "id": str(waitlist.pk)} + if waitlist is not None and waitlist.user_id == profile.pk + else None + ) + journey_anchor = ( + {"kind": "journey", "id": str(journey.pk)} + if journey is not None and journey.beneficiary_id == profile.pk + else None + ) + anchor = waitlist_anchor or journey_anchor return { + "continuity_identity_anchor": anchor, + "continuity_related_basis": [ + basis for basis in (waitlist_anchor, journey_anchor) if basis is not None + ], "kind": "access", "source": {"kind": "access", "id": str(access.pk)}, "state": "available", @@ -780,6 +923,10 @@ def _access_ongoing_item(access): else None ), "timing": _occurrence_timing(access.occurrence), + "access_window": { + "valid_from": _iso(access.valid_from) if access.valid_from else None, + "valid_until": _iso(access.valid_until) if access.valid_until else None, + }, "place": _occurrence_place(access.occurrence), "capabilities": ["open_access"] + (["open_day_of"] if access.occurrence_id else []), "links": { @@ -844,7 +991,13 @@ def _dossier_ongoing_item(dossier, *, readiness): "source": {"kind": "dossier", "id": str(dossier.pk)}, "state": state, "title": dossier.title, - "summary": "Cet objectif composé continue.", + "summary": { + ReadinessStatus.BLOCKED: "Une condition reste à régler avant de poursuivre cet objectif.", + ReadinessStatus.ACTION_REQUIRED: "Une action de votre part peut faire avancer cet objectif.", + ReadinessStatus.WAITING: "La suite de cet objectif est en attente. Vous n’avez rien à faire pour l’instant.", + ReadinessStatus.READY: "Ce dossier est prêt pour la prochaine étape.", + ReadinessStatus.COMPLETE: "Cet objectif est terminé.", + }.get(readiness.status, "Retrouvez les éléments et les démarches liés à cet objectif."), "ready": [], "actor_interventions": interventions, "continuation": continuation, @@ -1055,11 +1208,343 @@ def _funding_ongoing_item(funding): def _ongoing_identity(item): - source = item["source"] + source = item.get("continuity_identity_anchor") or item["source"] material = f'{source["kind"]}:{source["id"]}' return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32] +def _ongoing_n1_when(timing): + """Humanized owner date, never an internal timestamp.""" + raw = timing.get("start_at") or timing.get("start_date") or timing.get("deadline_date") + if not isinstance(raw, str) or not raw: + return None + try: + if "T" in raw or " " in raw: + instant = datetime.fromisoformat(raw.replace("Z", "+00:00")) + zone = timing.get("timezone") + if zone and instant.tzinfo is not None: + instant = instant.astimezone(ZoneInfo(zone)) + return instant.strftime("%d/%m/%Y · %H:%M") + return date.fromisoformat(raw).strftime("%d/%m/%Y") + except (TypeError, ValueError, OverflowError, KeyError): + return None + + +def _ongoing_n1(item): + """A small, additive display projection shared by Web and mobile.""" + blocker = item.get("blocker") + interventions = item.get("actor_interventions") or [] + continuation = item.get("continuation") or {} + if blocker: + cue, tone = "Une étape bloquée", "attention" + elif interventions: + cue, tone = "De votre côté", "action" + elif continuation.get("state") == "waiting": + cue, tone = "En attente", "calm" + elif item.get("state") == "available": + cue, tone = "Accès disponible", "ready" + else: + cue, tone = None, "calm" + title = item.get("title") or "" + summary = item.get("summary") or "" + next_item = item.get("next") or {} + supporting = next_item.get("title") + if supporting and supporting.strip().casefold() in { + title.strip().casefold(), summary.strip().casefold() + }: + supporting = None + place = item.get("place") or {} + return { + "cue": cue, + "tone": tone, + "supporting": supporting, + "when_label": _ongoing_n1_when(item.get("timing") or {}), + "place_label": place.get("name") or place.get("locality"), + } + + +def _attach_ongoing_recognition_media(entries, items, *, viewer): + """Batch resolve published media against the same authorization as delivery.""" + from accounts.models import UserProfile + from activities.models import Activity, ActivityStatus, ActivityVisibility, Occurrence + from groups.selectors import eligible_activity_ids_for_profile + from organizations.models import SpaceLifecycle + from presentations.media_selectors import public_presentation_media_for_activities + from tickets.models import Ticket, TicketType + from discovery.models import ActivityBookmark + from discovery.search import public_occurrences_for_activities + from groups.selectors import filter_queryset_by_activity_group_eligibility + + # Ticket offers and transfers are personal pursuits of Event Activities. + # Resolve their owner links in batches rather than per displayed row. + type_ids = { + entry.value.ticket_type_id + for entry in entries if entry.kind == "waitlist" + } + ticket_ids = { + entry.value.ticket_id + for entry in entries if entry.kind == "transfer" + } + type_activities = { + str(ticket_type.pk): ticket_type.event.activity_id + for ticket_type in TicketType.objects.filter( + pk__in=type_ids + ).select_related("event") + } if type_ids else {} + ticket_activities = { + str(ticket.pk): ticket.event.activity_id + for ticket in Ticket.objects.filter( + pk__in=ticket_ids + ).select_related("event") + } if ticket_ids else {} + + references = [] + activity_ids = set() + occurrence_ids = set() + for entry in entries: + if entry.kind == "waitlist": + activity_id = type_activities.get(str(entry.value.ticket_type_id)) + occurrence_id = None + elif entry.kind == "transfer": + activity_id = ticket_activities.get(str(entry.value.ticket_id)) + occurrence_id = None + elif entry.kind in {"journey", "access", "funding"}: + activity_id = getattr(entry.value, "activity_id", None) + occurrence_id = getattr(entry.value, "occurrence_id", None) + else: + references.append((None, None)) + continue + references.append((str(activity_id) if activity_id else None, + str(occurrence_id) if occurrence_id else None)) + if activity_id: + activity_ids.add(activity_id) + if occurrence_id: + occurrence_ids.add(occurrence_id) + if not activity_ids: + return + + activities = list( + Activity.objects.filter( + pk__in=activity_ids, + status=ActivityStatus.PUBLISHED, + visibility=ActivityVisibility.PUBLIC, + ).select_related("space") + ) + group_eligible = set( + eligible_activity_ids_for_profile(viewer, [a.pk for a in activities]) + ) + public_profiles = set( + UserProfile.objects.filter( + user_id__in=[a.owner_profile_id for a in activities if a.owner_profile_id], + public_profile=True, searchable=True, user__is_active=True, + ).values_list("user_id", flat=True) + ) + allowed = [ + a for a in activities + if a.pk in group_eligible and ( + ( + a.space_id is not None + and a.space.public_profile and a.space.searchable + and a.space.lifecycle == SpaceLifecycle.ACTIVE + and a.space.verification_status != "suspended" + ) or ( + a.space_id is None and a.owner_profile_id in public_profiles + ) + ) + ] + if not allowed: + return + occurrences = Occurrence.objects.filter( + pk__in=occurrence_ids, activity_id__in=[a.pk for a in allowed] + ) + media_by_activity = public_presentation_media_for_activities( + allowed, occurrences=occurrences + ) + # Share Discovery's published PUBLIC resources; private owner documents + # must use their own authorized endpoints and are never inferred here. + from discovery.api.composition import _activity_resource_previews + resources_by_activity = _activity_resource_previews([a.pk for a in allowed]) + # Reuse Discovery's public admission, not just a published Activity flag. + # An Ongoing entry never transfers authority to share or save its owner. + discoverable = { + str(occurrence.activity_id) + for occurrence in filter_queryset_by_activity_group_eligibility( + public_occurrences_for_activities([a.pk for a in allowed]), + viewer, + ) + } + family_by_activity = {activity_id: "activity" for activity_id in discoverable} + allowed_ids = {str(activity.pk) for activity in allowed} + # Some canonical services and fundings have no public Occurrence. They + # still exist in Discovery, but only its own selector may admit them. + from discovery.api.composition import detail_candidate + for entry, (activity_id, _) in zip(entries, references): + if not activity_id or activity_id not in allowed_ids: + continue + if activity_id in family_by_activity: + continue + if entry.kind == "funding": + candidates = ("funding_activity",) + elif entry.kind in {"journey", "access"}: + candidates = ("service_activity",) + else: + continue + for family in candidates: + if detail_candidate(family, activity_id, profile=viewer) is not None: + family_by_activity[activity_id] = family + break + saved_ids = { + str(activity_id) + for activity_id in ActivityBookmark.objects.filter( + user=viewer, activity_id__in=family_by_activity.keys() + ).values_list("activity_id", flat=True) + } + for item, (activity_id, occurrence_id) in zip(items, references): + if not activity_id: + continue + admissible = [ + media for media in media_by_activity.get(activity_id, []) + if media.occurrence_id is None or media.occurrence_id == occurrence_id + ] + admissible.sort(key=lambda media: ( + media.occurrence_id != occurrence_id, media.position, media.use_id + )) + family = family_by_activity.get(activity_id) + if family: + # Mutation and Sharing independently reauthorize this family at + # execution time. A presentation reference is never a mandate. + saved = activity_id in saved_ids + item["n1"]["possibility"] = { + "family": family, + "id": activity_id, + "saved_state": "saved" if saved else "not_saved", + "capabilities": ["unsave" if saved else "save", "share"], + "links": { + "saved": reverse( + "discovery_api:item-saved", + kwargs={"family": family, "item_id": activity_id}, + ), + "share": reverse( + "sharing_api:possibility", + kwargs={"family": family, "item_id": activity_id}, + ), + }, + } + item["resources"] = list(resources_by_activity.get(activity_id, [])) + item["media_bindings"] = [ + { + "resource": { + "kind": "presentation_media_use", + "id": media.use_id, + "url": reverse("presentations:published-media-file", + kwargs={"use_id": media.use_id}), + "mime_type": media.mime_type, + "alt_text": media.alt_text, + }, + "target": {"kind": "ongoing_continuity", "id": item["id"]}, + "purpose": "RECOGNIZE", + } + for media in admissible[:3] + if media.mime_type in {"image/jpeg", "image/png"} + ] + + +def _ongoing_temporal_facts(item): + """Owner-established human dates only, never technical timestamps.""" + facts = [] + timing = item.get("timing") or {} + source = item["source"] + if timing.get("start_at") or timing.get("start_date"): + facts.append({ + "kind": "occurrence_start", + "label": "Début", + "at": timing.get("start_at"), + "date": timing.get("start_date"), + "timezone": timing.get("timezone"), + "source": item.get("occurrence") or source, + }) + next_item = item.get("next") or {} + due_at = (next_item.get("timing") or {}).get("due_at") + if due_at: + facts.append({ + "kind": "next_due", + "label": next_item.get("title") or "Échéance", + "at": due_at, + "source": source, + }) + expires_at = timing.get("offer_expires_at") or timing.get("expires_at") + if expires_at: + facts.append({ + "kind": "decision_expires" if timing.get("offer_expires_at") else "expires", + "label": "Répondre avant" if timing.get("offer_expires_at") else "Expiration", + "at": expires_at, + "source": source, + }) + access_window = item.get("access_window") or {} + for field, label in ( + ("valid_from", "Accès utilisable dès"), + ("valid_until", "Accès valable jusqu'au"), + ): + if access_window.get(field): + facts.append({ + "kind": field, + "label": label, + "at": access_window[field], + "source": source, + }) + seen = {(fact.get("at"), fact.get("date")) for fact in facts} + for intervention in item.get("actor_interventions") or []: + window = intervention.get("timing") or {} + for field in ("due_at", "expires_at"): + at = window.get(field) + if not at or (at, None) in seen: + continue + facts.append({ + "kind": "intervention_due", + "label": intervention.get("title") or "Échéance", + "at": at, + "source": source, + }) + seen.add((at, None)) + return facts + + +def _attach_ongoing_private_journey_resources(items, *, viewer): + """Project metadata only when the beneficiary may export the artifact. + + No raw file URL, private storage key, QR, or secret enters this read model. + Export warnings and authorization remain owned by JourneyArtifact. + """ + from journeys.collaboration_models import JourneyArtifact + from sharing.document_services import can_export_journey_artifact + + journey_ids = { + item["source"]["id"] + for item in items + if item["source"]["kind"] == "journey" + } + if not journey_ids: + return + grouped = {} + for artifact in ( + JourneyArtifact.objects.filter(journey_id__in=journey_ids) + .select_related("journey") + .order_by("journey_id", "title", "-version", "id") + ): + if not can_export_journey_artifact(viewer, artifact, "download").allowed: + continue + grouped.setdefault(str(artifact.journey_id), []).append({ + "kind": "journey_artifact", + "title": artifact.title, + "mime_type": artifact.mime_type, + "description": artifact.get_status_display(), + "owner": {"kind": "journey", "id": str(artifact.journey_id)}, + }) + for item in items: + if item["source"]["kind"] == "journey": + item["resources"].extend(grouped.get(item["source"]["id"], [])) + + def _decorate_ongoing_semantics(item, *, observed_at): """Add Mature continuity semantics without becoming an owner domain.""" @@ -1077,7 +1562,10 @@ def _decorate_ongoing_semantics(item, *, observed_at): { "id": identity, "continuity_identity": identity, - "continuity_basis": [source], + "continuity_basis": [ + *(item.get("continuity_related_basis") or []), + source, + ], "human_context": item["title"], "synthesis": item["summary"], # C0 human aliases remain available beside the structured Mature @@ -1130,9 +1618,11 @@ def _decorate_ongoing_semantics(item, *, observed_at): "knowledge_state": "known", }, "relation_projections": [], - "temporal_facts": [], + "temporal_facts": _ongoing_temporal_facts(item), "actions": list(item.get("actor_interventions", [])), "media_bindings": [], + "resources": [], + "n1": _ongoing_n1(item), "owner_depth": { "source": source, "links": { @@ -1152,17 +1642,20 @@ def _decorate_ongoing_semantics(item, *, observed_at): ) -def ongoing_continuation_offset(token): +def ongoing_continuation_offset(token, *, profile): + """Decode an opaque, viewer-bound continuation. Never select a Profile.""" if not token: return 0 payload = signing.loads( token, salt=ONGOING_CONTINUATION_SALT, - max_age=60 * 60 * 24, + max_age=60 * 60, ) - offset = int(payload["offset"]) - if offset < 0: - raise ValueError("Invalid continuation offset.") + if not isinstance(payload, dict) or payload.get("viewer") != str(profile.pk): + raise ValueError("Continuation belongs to another viewer.") + offset = payload.get("offset") + if type(offset) is not int or offset < ONGOING_LIMIT or offset % ONGOING_LIMIT: + raise ValueError("Invalid Ongoing continuation offset.") return offset @@ -1192,7 +1685,7 @@ def build_personal_ongoing_projection( if entry.kind == "journey": items.append(_journey_ongoing_item(entry.value, entry.readiness)) elif entry.kind == "access": - items.append(_access_ongoing_item(entry.value)) + items.append(_access_ongoing_item(entry.value, profile=profile)) elif entry.kind == "dossier": items.append( _dossier_ongoing_item( @@ -1214,6 +1707,9 @@ def build_personal_ongoing_projection( for item in items: _decorate_ongoing_semantics(item, observed_at=observed_at) + _attach_ongoing_recognition_media(entries, items, viewer=profile) + _attach_ongoing_private_journey_resources(items, viewer=profile) + is_empty = not items profile_id = str(profile.pk) return { @@ -1240,7 +1736,7 @@ def build_personal_ongoing_projection( "state": "more" if has_more else "end", "token": ( signing.dumps( - {"offset": continuation_offset + ONGOING_LIMIT}, + {"offset": continuation_offset + ONGOING_LIMIT, "viewer": profile_id}, salt=ONGOING_CONTINUATION_SALT, compress=True, ) diff --git a/core/api/personal_views.py b/core/api/personal_views.py index 79f97443c0..1ac97f6241 100644 --- a/core/api/personal_views.py +++ b/core/api/personal_views.py @@ -7,6 +7,7 @@ from core.api.personal_projections import ( build_personal_ongoing_projection, build_personal_now_projection, + now_continuation_offset, ongoing_continuation_offset, ) @@ -17,7 +18,20 @@ class PersonalNowAPIView(PersonalProjectionAPIView): def get(self, request): self._guard_personal_scope(request) observed_at = timezone.now() - data = build_personal_now_projection(request.user, observed_at=observed_at) + try: + offset = now_continuation_offset( + request.query_params.get("continuation"), + profile=request.user, + ) + except (signing.BadSignature, KeyError, TypeError, ValueError) as exc: + raise ValidationError( + {"continuation": "Cette suite de résultats n'est plus disponible."} + ) from exc + data = build_personal_now_projection( + request.user, + observed_at=observed_at, + continuation_offset=offset, + ) return self._response(data, observed_at=observed_at) @@ -29,7 +43,8 @@ def get(self, request): observed_at = timezone.now() try: offset = ongoing_continuation_offset( - request.query_params.get("continuation") + request.query_params.get("continuation"), + profile=request.user, ) except (signing.BadSignature, KeyError, TypeError, ValueError) as exc: raise ValidationError( diff --git a/core/api/platform_urls.py b/core/api/platform_urls.py index 2ee873ac71..275f3e33d1 100644 --- a/core/api/platform_urls.py +++ b/core/api/platform_urls.py @@ -1,6 +1,10 @@ from django.urls import path from interoperability.api_views import PlatformInteroperabilityAPIView +from intelligence.connection_api import ( + PlatformIntelligenceConnectionsAPIView, PlatformIntelligenceConnectionAPIView, +) + from .platform_views import PlatformCapabilitiesAPIView @@ -10,4 +14,6 @@ urlpatterns = [ path("capabilities/", PlatformCapabilitiesAPIView.as_view(), name="capabilities"), path("interoperability/", PlatformInteroperabilityAPIView.as_view(), name="interoperability"), + path("intelligence/connections/", PlatformIntelligenceConnectionsAPIView.as_view(), name="intelligence-connections"), + path("intelligence/connections//", PlatformIntelligenceConnectionAPIView.as_view(), name="intelligence-connection"), ] diff --git a/core/api/test_native_invitation.py b/core/api/test_native_invitation.py new file mode 100644 index 0000000000..a3e2ade08d --- /dev/null +++ b/core/api/test_native_invitation.py @@ -0,0 +1,105 @@ +import json +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from access.models import Access +from activities.models import Activity, ActivityStatus, Occurrence, OccurrenceStatus +from journeys.models import Journey, JourneyStatus, WorkflowKind + + +class NativeInvitationTests(TestCase): + def setUp(self): + users = get_user_model() + self.beneficiary = users.objects.create_user( + username="invitation-native-beneficiary", + email="invitation-native-beneficiary@example.test", + password="test-pass-2026", + ) + self.other = users.objects.create_user( + username="invitation-native-other", + email="invitation-native-other@example.test", + password="test-pass-2026", + ) + self.activity = Activity.objects.create( + owner_profile=self.other, created_by=self.other, + title="Invitation à un atelier", + status=ActivityStatus.PUBLISHED, + ) + self.occurrence = Occurrence.objects.create( + activity=self.activity, + start_at=timezone.now() + timedelta(days=3), + end_at=timezone.now() + timedelta(days=3, hours=2), + status=OccurrenceStatus.SCHEDULED, + ) + self.journey = Journey.objects.create( + initiated_by=self.other, + beneficiary=self.beneficiary, + activity=self.activity, + occurrence=self.occurrence, + workflow=WorkflowKind.INVITATION, + status=JourneyStatus.SUBMITTED, + ) + self.url = reverse( + "personal-detail-projections:journey-invitation", + kwargs={"pk": self.journey.pk}, + ) + + def decide(self, decision): + return self.client.post( + self.url, + data=json.dumps({"decision": decision}), + content_type="application/json", + ) + + def test_unauthenticated_and_foreign_profiles_cannot_decide(self): + self.assertIn(self.client.get(self.url).status_code, (401, 403)) + self.client.force_login(self.other) + self.assertEqual(self.client.get(self.url).status_code, 404) + self.assertEqual(self.decide("accept").status_code, 404) + self.assertEqual(self.journey.status, JourneyStatus.SUBMITTED) + self.assertEqual(Access.objects.count(), 0) + + def test_accept_is_idempotent_and_issues_access_once(self): + self.client.force_login(self.beneficiary) + self.assertEqual( + set(self.client.get(self.url).json()["capabilities"]), + {"accept", "decline"}, + ) + first = self.decide("accept") + self.assertEqual(first.status_code, 200) + self.assertEqual(first.json()["status"], "confirmed") + self.assertIsNotNone(first.json()["access_id"]) + second = self.decide("accept") + self.assertEqual(second.status_code, 200) + self.assertEqual(second.json()["access_id"], first.json()["access_id"]) + self.assertEqual(Access.objects.filter(journey=self.journey).count(), 1) + self.assertEqual(self.client.get(self.url).json()["capabilities"], []) + self.assertEqual(self.decide("decline").status_code, 400) + + def test_decline_is_idempotent_and_never_issues_access(self): + self.client.force_login(self.beneficiary) + self.assertEqual(self.decide("decline").json()["status"], "cancelled") + self.assertEqual(self.decide("decline").json()["status"], "cancelled") + self.assertEqual(self.decide("accept").status_code, 400) + self.assertEqual(Access.objects.filter(journey=self.journey).count(), 0) + + def test_rejects_non_invitation_journey(self): + self.journey.workflow = WorkflowKind.REGISTRATION + self.journey.save() + self.client.force_login(self.beneficiary) + self.assertEqual(self.client.get(self.url).status_code, 404) + self.assertEqual(self.decide("accept").status_code, 404) + + def test_rejects_unknown_or_incomplete_decision(self): + self.client.force_login(self.beneficiary) + self.assertEqual(self.decide("apply").status_code, 400) + result = self.client.post( + self.url, data=json.dumps({"decision": "accept", "is_admin": True}), + content_type="application/json", + ) + self.assertEqual(result.status_code, 400) + self.assertEqual(Access.objects.count(), 0) diff --git a/core/api/urls.py b/core/api/urls.py index 6da307ff60..689abd81c5 100644 --- a/core/api/urls.py +++ b/core/api/urls.py @@ -5,15 +5,31 @@ from .history_views import PersonalHistoryAPIView from .search_views import PersonalSearchAPIView from interoperability.api_views import PersonalInteroperabilityAPIView +from intelligence.connection_api import ( + ProfileIntelligenceConnectionsAPIView, ProfileIntelligenceConnectionAPIView, +) + from .day_of_views import PersonalOccurrenceDayOfAPIView, PersonalOccurrenceLiveAPIView from .me_views import ( PersonalCollectivesAPIView, PersonalConsiderationsAPIView, + PersonalConsiderationSettingsAPIView, PersonalMeAPIView, + PersonalMeTerritoryPreviewAPIView, PersonalPartnersAPIView, PersonalPassportAPIView, + PersonalPassportPdfAPIView, + PersonalPassportIssueAPIView, + PersonalPassportHistoryAPIView, + PersonalPassportVersionAPIView, + PersonalPassportVersionPdfAPIView, + PersonalPassportShareAPIView, + PersonalPassportDirectShareAPIView, + PersonalPassportShareRevokeAPIView, + PersonalPassportVersionRevokeAPIView, PersonalResourcesAPIView, ) +from .passport_visibility_views import PersonalPassportVisibilityAPIView from .personal_views import PersonalNowAPIView, PersonalOngoingAPIView from .now_media_views import PersonalNowJourneyArtifactMediaAPIView from .z9_views import PersonalPartnerDetailAPIView @@ -29,6 +45,7 @@ urlpatterns = [ path("", PersonalMeAPIView.as_view(), name="me"), + path("preview//", PersonalMeTerritoryPreviewAPIView.as_view(), name="me-preview"), path("now/", PersonalNowAPIView.as_view(), name="now"), path( "now/media/journey-artifacts//", @@ -41,6 +58,8 @@ path("search/", PersonalSearchAPIView.as_view(), name="search"), path("orders//", PersonalCommerceOrderDetailAPIView.as_view(), name="commerce-order-detail"), path("interoperability/", PersonalInteroperabilityAPIView.as_view(), name="interoperability"), + path("intelligence/connections/", ProfileIntelligenceConnectionsAPIView.as_view(), name="intelligence-connections"), + path("intelligence/connections//", ProfileIntelligenceConnectionAPIView.as_view(), name="intelligence-connection"), path( "occurrences//day-of/", PersonalOccurrenceDayOfAPIView.as_view(), @@ -56,6 +75,7 @@ PersonalAccessCredentialAPIView.as_view(), name="access-credential", ), + path("considerations/settings/", PersonalConsiderationSettingsAPIView.as_view(), name="consideration-settings"), path( "considerations/", PersonalConsiderationsAPIView.as_view(), @@ -66,6 +86,16 @@ PersonalCollectivesAPIView.as_view(), name="collectives", ), + path("passport/visibility/", PersonalPassportVisibilityAPIView.as_view(), name="passport-visibility"), + path("passport/pdf/", PersonalPassportPdfAPIView.as_view(), name="passport-pdf"), + path("passport/issue/", PersonalPassportIssueAPIView.as_view(), name="passport-issue"), + path("passport/history/", PersonalPassportHistoryAPIView.as_view(), name="passport-history"), + path("passport/history//", PersonalPassportVersionAPIView.as_view(), name="passport-version"), + path("passport/history//pdf/", PersonalPassportVersionPdfAPIView.as_view(), name="passport-version-pdf"), + path("passport/history//share/", PersonalPassportShareAPIView.as_view(), name="passport-share"), + path("passport/history//deliver/", PersonalPassportDirectShareAPIView.as_view(), name="passport-direct-delivery"), + path("passport/history//revoke/", PersonalPassportVersionRevokeAPIView.as_view(), name="passport-version-revoke"), + path("passport/shares//revoke/", PersonalPassportShareRevokeAPIView.as_view(), name="passport-share-revoke"), path( "passport/", PersonalPassportAPIView.as_view(), diff --git a/core/api/z8_projection.py b/core/api/z8_projection.py index d496536dd4..561511f6d2 100644 --- a/core/api/z8_projection.py +++ b/core/api/z8_projection.py @@ -332,6 +332,10 @@ def build_personal_passport_depth_data( selected_proof_ids=None, selected_credential_ids=None, selected_sections=None, + excluded_activity_ids=(), + excluded_proof_ids=(), + excluded_credential_ids=(), + excluded_sections=(), ): data = build_personal_passport_data( profile, @@ -341,6 +345,10 @@ def build_personal_passport_depth_data( selected_proof_ids=selected_proof_ids, selected_credential_ids=selected_credential_ids, selected_sections=selected_sections, + excluded_activity_ids=excluded_activity_ids, + excluded_proof_ids=excluded_proof_ids, + excluded_credential_ids=excluded_credential_ids, + excluded_sections=excluded_sections, ) public_enabled = profile_has_public_passport(profile) variant_visibility = { @@ -354,10 +362,18 @@ def build_personal_passport_depth_data( "presentation_web": reverse("sharing:passport-me"), } if public_enabled: - links["public_web"] = reverse( - "sharing:passport-profile", - kwargs={"profile_id": profile.pk}, + from sharing.models import PublicIdentifier + + identifier = ( + PublicIdentifier.objects.filter(profile=profile) + .values_list("identifier", flat=True) + .first() ) + if identifier: + links["public_web"] = reverse( + "public-passport", + kwargs={"identifier": identifier}, + ) data["projection"] = { "variant": data["variant"], "available_variants": list(PASSPORT_VARIANTS), @@ -376,6 +392,33 @@ def build_personal_passport_depth_data( "select_projection", "present", ] + from sharing.passport import profile_passport_topic_options + data["topic_options"] = [ + {"code": row.code, "label": row.label} + for row in profile_passport_topic_options(profile) + ] + # Owner-only personal detail. Do not put this into the canonical Passport + # identity: public/thematic/custom variants and shareable documents must + # never inherit private account coordinates by composition. + if variant == PASSPORT_COMPLETE: + data["private_details"] = data["identity"].pop("owner_details", {}) + if variant == PASSPORT_COMPLETE: + # The owner receives one read-model, including disclosure decisions + # evaluated by the server. Flutter can remove facts locally without + # requesting each variant. This metadata is never sent to a visitor. + public = build_personal_passport_data(profile, variant=PASSPORT_PUBLIC) + data["composition"] = { + "schema_version": 1, + "public_identity": public["identity"], + "public_ids": { + "interests": [row["id"] for row in public["declared"]["interests"]], + "open_to": [row["id"] for row in public["declared"]["open_to"]], + "activities": [row["id"] for row in public["activities"]], + "experiences": [row["id"] for row in public["experiences"]], + "proofs": [row["id"] for row in public["established"]["proofs"]], + "credentials": [row["id"] for row in public["issued"]["credentials"]], + }, + } data["links"] = links return data diff --git a/core/home_presentation.py b/core/home_presentation.py index f79ca9122e..96e022a337 100644 --- a/core/home_presentation.py +++ b/core/home_presentation.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import timedelta from zoneinfo import ZoneInfo @@ -42,6 +42,8 @@ from tickets.models import TransferStatus, WaitlistStatus from tickets.selectors import get_ticket_transfers_visible_to, get_waitlist_entries_visible_to +from presentations.product_language import vocabulary_for + from .participant_presentation import occurrence_timing from .participant_selectors import ACTIVE_JOURNEY_STATUSES, participant_active_accesses @@ -67,7 +69,7 @@ class HomeActionMeta: @dataclass(frozen=True, slots=True) class HomeActionItem: - identity: ContextualActionIdentity + identity: ContextualActionIdentity = field(repr=False) context_label: str source_label: str action_label: str @@ -176,7 +178,7 @@ def _journey_actions(profile, *, observed_at, metadata): temporal_actions, HomeActionMeta( context_label=journey.activity.title, - source_label="Occurrence", + source_label=vocabulary_for(activity=journey.activity).occurrence_noun, fallback_url=fallback_url, ), ) diff --git a/core/home_views.py b/core/home_views.py index d4db58f317..2422f3ab8c 100644 --- a/core/home_views.py +++ b/core/home_views.py @@ -2,13 +2,15 @@ from uuid import UUID from django.contrib.auth.mixins import LoginRequiredMixin +from django.core import signing +from django.http import HttpResponseBadRequest from django.urls import NoReverseMatch, reverse from django.utils.dateparse import parse_datetime from django.utils.formats import date_format from django.utils import timezone from django.views.generic import TemplateView -from core.api.personal_projections import build_personal_now_projection +from core.api.personal_projections import build_personal_now_projection, now_continuation_offset def _deadline_label(timing): @@ -55,28 +57,7 @@ def _consequence_text(consequence): def _now_topology(item): - """Only render owner-provided presentation semantics, never infer monitoring.""" - media = item.get("media_bindings") or () - if any( - isinstance(binding, dict) - and binding.get("authorized") is True - and binding.get("resource_ref") - and ( - binding.get("presentation_rank") == "primary" - or binding.get("purpose") in {"understand", "establish", "act"} - ) - for binding in media - ): - return "media" - actions = item.get("business_actions") or () - if any( - isinstance(action, dict) - and action.get("capability") - and action.get("label") - and action.get("interaction_depth") in {"direct_now", "focused"} - for action in actions - ): - return "action" + """Select a presentation geometry, never infer missing owner facts.""" members = item.get("relation_members") or () relations = item.get("relations") or () response = item.get("response") or {} @@ -89,13 +70,12 @@ def _now_topology(item): isinstance(relation, dict) and isinstance(relation.get("kind"), str) and relation["kind"].strip() + and isinstance(relation.get("member_ids"), (list, tuple)) and len({ - value for value in (relation.get("member_ids") or ()) + value for value in relation["member_ids"] if isinstance(value, str) and value in member_ids }) >= 2 for relation in relations - if isinstance(relation, dict) - and isinstance(relation.get("member_ids"), (list, tuple)) ) if ( len(member_ids) >= 2 @@ -105,41 +85,84 @@ def _now_topology(item): and response.get("type") ): return "composition" + media = item.get("media_bindings") or () + admitted_urls = {media_use["url"] for media_use in _now_inline_media(item)} + if any( + isinstance(binding, dict) + and binding.get("authorized") is True + and binding.get("resource_ref") + and binding.get("url") in admitted_urls + and ( + binding.get("presentation_rank") == "primary" + or binding.get("purpose") in {"understand", "establish", "act"} + ) + for binding in media + ): + return "media" + if any( + isinstance(action, dict) + and action.get("capability") + and action.get("label") + and action.get("interaction_depth") in {"direct_now", "focused"} + for action in (item.get("business_actions") or ()) + ): + return "action" if response.get("type") in {"wait", "monitor", "waiting"}: return "waiting" return "meaning" def _now_inline_media(item): - """Expose only the first-party resource-view endpoint; never raw storage URLs.""" - allowed_prefix = "/api/v1/me/now/media/journey-artifacts/" + """Allow only authorized first-party Journey files and published media uses.""" + from uuid import UUID + + private_prefix = "/api/v1/me/now/media/journey-artifacts/" + public_prefix = "/presentation/media/uses/" result = [] for binding in item.get("media_bindings") or (): if not isinstance(binding, dict) or binding.get("authorized") is not True: continue - if not binding.get("resource_ref"): - continue + ref = binding.get("resource_ref") url = binding.get("url") - if not isinstance(url, str) or not url.startswith(allowed_prefix): + if not isinstance(ref, str) or not isinstance(url, str): continue - path, separator, query = url.partition("?") - resource_id = path[len(allowed_prefix):].strip("/") - if not resource_id or "/" in resource_id: - continue - from uuid import UUID - - try: - UUID(resource_id) - except (ValueError, AttributeError): - continue - if separator and query != "view=text": + if not url.startswith("/") or url.startswith("//") or "#" in url: continue + path, separator, query = url.partition("?") kind = binding.get("kind") if kind not in {"image", "pdf", "document", "audio", "video"}: continue - original_url = binding.get("download_url") - if not isinstance(original_url, str) or original_url != path: + if ref.startswith("journey_artifact:") and path.startswith(private_prefix): + identifier = path[len(private_prefix):].strip("/") + try: + UUID(identifier) + except (ValueError, AttributeError): + continue + if path != private_prefix + identifier + "/" or ref != "journey_artifact:" + identifier: + continue + if (kind == "document" and (not separator or query != "view=text")) or ( + kind != "document" and separator + ): + continue + original_url = binding.get("download_url") + if not isinstance(original_url, str) or original_url != path: + original_url = url + elif ref.startswith("presentation_media_use:") and path.startswith(public_prefix): + identifier = ref[len("presentation_media_use:"):] + try: + UUID(identifier) + except (ValueError, AttributeError): + continue + if ( + kind != "image" + or binding.get("purpose") != "recognize" + or path != public_prefix + identifier + "/file/" + or separator + ): + continue original_url = url + else: + continue result.append({ "url": url, "download_url": original_url, @@ -245,9 +268,13 @@ def _web_now_item(item): action_label=response.get("label") or item.get("title") or "Ouvrir", summary=item.get("summary") or "", state=item.get("state_meaning") or "", + subject=_display_value(item.get("subject"), "label", "name"), + delta=_display_value(item.get("delta"), "meaning", "label"), + exit_condition=_display_value(item.get("exit_condition"), "label", "meaning"), + knowledge_state=_display_value(item.get("knowledge_context"), "knowledge_state"), why_now=_display_value(item.get("why_now"), "meaning"), consequence=_consequence_text(item.get("consequence")), - turn=_display_value(item.get("turn"), "label", "type"), + turn=_display_value(item.get("turn"), "label"), response_type=response.get("type") or "", url=_handoff_url(item), status_label="", @@ -313,6 +340,7 @@ def _now_web_context(data): primary_attention=primary_attention, primary_action=None, action_items=tuple(projected[1:]), + page_items=tuple(projected), knowledge_items=(), upcoming=(), all_clear=is_calm, @@ -323,6 +351,7 @@ def _now_web_context(data): terminal_message=terminal.get("message") or "", selection_reason=selection.get("reason") or "", continuation_state=continuation_state, + continuation_token=(continuation.get("token") if isinstance(continuation, dict) else None), ) @@ -330,12 +359,33 @@ class MatureParticipantHomeView(LoginRequiredMixin, TemplateView): template_name = "core/participant_home.html" login_url = "core:login" + def get_template_names(self): + if ( + self.request.GET.get("continuation") + and self.request.headers.get("X-Requested-With") == "XMLHttpRequest" + ): + return ["core/_now_page_fragment.html"] + return super().get_template_names() + + def get(self, request, *args, **kwargs): + try: + self._now_offset = now_continuation_offset( + request.GET.get("continuation"), + profile=request.user, + ) + except (signing.BadSignature, KeyError, ValueError, TypeError): + return HttpResponseBadRequest("Suite de résultats indisponible.") + response = super().get(request, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + return response + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) observed_at = timezone.now() data = build_personal_now_projection( self.request.user, observed_at=observed_at, + continuation_offset=getattr(self, "_now_offset", 0), ) context["home"] = _now_web_context(data) return context diff --git a/core/interoperability_views.py b/core/interoperability_views.py index b7b430b64c..27a82ffb43 100644 --- a/core/interoperability_views.py +++ b/core/interoperability_views.py @@ -1,14 +1,20 @@ from __future__ import annotations +import uuid + +from django.contrib import messages +from django.http import Http404 +from django.shortcuts import redirect +from django.urls import reverse + from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic import TemplateView +from intelligence.connection_web import connection_form_context, handle_connection_form +from intelligence.models import ProviderScope +from notifications.push import revoke_push_endpoint_by_id -from interoperability.presentation import ( - present_action, - present_connection, - present_extension, - present_provider, -) +from interoperability.catalog_presentation import connection_experience_context +from interoperability.presentation import present_connection from interoperability.profile_projection import build_profile_interoperability_payload @@ -20,32 +26,37 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) payload = build_profile_interoperability_payload(self.request.user) + context.update(connection_form_context()) + context["interoperability"] = payload + context["connections"] = [ + present_connection(row) for row in payload.get("connections", []) + ] context.update( - { - "interoperability": payload, - "providers": [ - present_provider(provider) - for provider in payload.get("providers", []) - if provider.get("available") - ], - "connections": [ - present_connection(connection) - for connection in payload.get("connections", []) - ], - "authorized_actions": [ - present_action(action) - for action in payload.get("actions", []) - if action.get("authorized") - ], - "extensions": [ - present_extension(extension) - for extension in payload.get("extensions", []) - if extension.get("available") and extension.get("enabled") - ], - } + connection_experience_context( + self.request, + offers=context["connection_offers"], + connections=context["connections"], + ) ) + context["connections_context_label"] = "Moi" + context["connections_return_url"] = reverse("core:participant-me") + context["connections_return_label"] = "Retour à Moi" return context + def post(self, request, *args, **kwargs): + if request.POST.get("operation") == "revoke_push": + try: + endpoint_id = uuid.UUID(request.POST.get("endpoint_id", "")) + except (ValueError, TypeError): + raise Http404() + if not revoke_push_endpoint_by_id( + user=request.user, endpoint_id=endpoint_id, + ): + raise Http404() + messages.success(request, "Cet appareil ne recevra plus les notifications push.") + return redirect(reverse("core:participant-connections")) + return handle_connection_form(request, scope=ProviderScope.PROFILE) + def render_to_response(self, context, **response_kwargs): response = super().render_to_response(context, **response_kwargs) response["Cache-Control"] = "private, no-store" diff --git a/core/mark_orchestration.py b/core/mark_orchestration.py index bc15ae1dc4..921d713932 100644 --- a/core/mark_orchestration.py +++ b/core/mark_orchestration.py @@ -933,8 +933,8 @@ def orchestrate_mark(*, profile, input_kind, value, context=None): situation=situation, intent="personal_scope", message=( - "Le Makolo Mark personnel agit uniquement pour le Profile connecté. " - "Le contexte ne peut pas accorder une autorité supplémentaire." + "Cette demande concerne uniquement votre profil. " + "Elle ne permet pas d’obtenir de nouvelles autorisations." ), result={ "state": "forbidden", @@ -948,8 +948,8 @@ def orchestrate_mark(*, profile, input_kind, value, context=None): intent="ingest_input", code="input_modality_not_exposed", message=( - "Le runtime Mark actuel ne possède pas encore un intake générique " - f"pour la modalité « {input_kind} »." + "Ce type de contenu ne peut pas être traité ici : " + f"« {input_kind} »." ), ) @@ -986,9 +986,9 @@ def orchestrate_mark(*, profile, input_kind, value, context=None): intent="conserve_personal_asset", code="file_ingestion_not_exposed", message=( - "Makolo sait gérer les ressources personnelles existantes, " - "mais le Mark n'expose pas encore un intake de fichier canonique. " - "Aucun PersonalAsset vide n'a été créé." + "Ajoutez le document depuis vos ressources. " + "" + "Aucun fichier n’a été enregistré." ), links={"resources": reverse("personal-projections:resources")}, handoff=_handoff("personal_assets", "resources"), @@ -1253,8 +1253,8 @@ def orchestrate_mark(*, profile, input_kind, value, context=None): if journey["state"] == "resolved" and journey["result"].get("kind") == "journey": journey["state"] = "unsupported" journey["message"] = ( - "Le Mark ne possède pas de capability Payment propriétaire " - "confirmée pour cette démarche. Il n'a initié aucun paiement." + "Le paiement ne peut pas être effectué depuis cette demande. " + "Aucun paiement n’a été initié." ) journey["result"] = { "state": "unsupported", @@ -1288,8 +1288,8 @@ def orchestrate_mark(*, profile, input_kind, value, context=None): intent="set_interest", code="interest_mutation_not_exposed", message=( - "Le Mark ne transforme pas une phrase en Interest sans contrat " - "de mutation explicite. Aucun Interest n'a été créé." + "Ajoutez cet intérêt depuis votre profil. " + "Aucun centre d’intérêt n’a été enregistré." ), links={"considerations": reverse("personal-projections:considerations")}, handoff=_handoff("profile", "considerations"), @@ -1309,9 +1309,9 @@ def orchestrate_mark(*, profile, input_kind, value, context=None): intent="create_reality", code="creation_or_publication_not_exposed", message=( - "Le Mark comprend l'intention de faire exister quelque chose, " - "mais aucun owner-domain creation contract stable n'est exposé " - "ici. Rien n'a été créé ni publié." + "La création ou la publication n’est pas disponible ici. " + "" + "Rien n’a été créé ni publié." ), ) @@ -1326,7 +1326,7 @@ def orchestrate_mark(*, profile, input_kind, value, context=None): situation=situation, intent="unsupported_action", code="capability_not_available", - message="Makolo ne possède pas actuellement cette capacité.", + message="Cette action n’est pas disponible.", ) if _contains( @@ -1377,8 +1377,8 @@ def orchestrate_mark(*, profile, input_kind, value, context=None): intent="clarify_intent", code="what_consequence", summary=( - "Makolo a besoin d'une seule précision pour savoir quelle " - "conséquence utile produire à partir de cela." + "Précisez " + "ce que vous souhaitez faire." ), ) @@ -1387,7 +1387,7 @@ def orchestrate_mark(*, profile, input_kind, value, context=None): intent="unknown", code="what_do_you_mean", summary=( - "Makolo ne peut pas encore relier cette demande à une capacité " - "réellement disponible. Précisez ce que vous voulez obtenir." + "Impossible de déterminer l’action souhaitée. " + "Précisez votre demande." ), ) diff --git a/core/mature_experience_views.py b/core/mature_experience_views.py index 078755b4b1..e6dc03f65c 100644 --- a/core/mature_experience_views.py +++ b/core/mature_experience_views.py @@ -1,6 +1,8 @@ from __future__ import annotations from django.contrib.auth.mixins import LoginRequiredMixin +from django.core import signing +from django.http import HttpResponseBadRequest from django.db import models from django.shortcuts import redirect from django.urls import reverse @@ -36,8 +38,12 @@ from trust.selectors import proofs_for_profile from .participant_selectors import participant_active_accesses, participant_active_journeys -from core.api.personal_projections import build_personal_ongoing_projection -from core.api.me_projection import build_personal_me_data +from core.api.personal_projections import ( + build_personal_ongoing_projection, + _ongoing_n1_when, + ongoing_continuation_offset, +) +from core.api.me_projection import build_personal_me_data, build_personal_considerations_data from .participant_presentation import occurrence_timing from .participant_views import HOME_READINESS_CANDIDATE_LIMIT, _access_card, _primary_place @@ -182,60 +188,137 @@ class MatureParticipantOngoingView(FragmentTemplateMixin, LoginRequiredMixin, Te fragment_template_name = "core/participant_ongoing_fragment.html" login_url = "core:login" + def get_template_names(self): + if ( + self.request.GET.get("continuation") + and self.request.headers.get("X-Makolo-Continuation") == "1" + ): + return ["core/_ongoing_page_fragment.html"] + return super().get_template_names() + + def get(self, request, *args, **kwargs): + token = request.GET.get("continuation") + if token: + try: + ongoing_continuation_offset(token, profile=request.user) + except (signing.BadSignature, KeyError, ValueError, TypeError): + return HttpResponseBadRequest("Suite de résultats indisponible.") + response = super().get(request, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + return response + @staticmethod def _web_item(item): + """N1 web uses exactly the API's semantic and media projection.""" + n1 = item.get("n1") or {} + media = next( + ( + binding.get("resource") or {} + for binding in item.get("media_bindings") or [] + if binding.get("purpose") == "RECOGNIZE" + and (binding.get("resource") or {}).get("url") + ), + {}, + ) links = item.get("links") or {} - blocker = item.get("blocker") - interventions = item.get("actor_interventions") or [] - continuation = item.get("continuation") - ready = item.get("ready") or [] - next_item = item.get("next") - - summary = item.get("summary") or "Cette réalité continue." - if blocker: - tone = "attention" - elif interventions: - tone = "action" - elif continuation and continuation.get("state") == "waiting": - tone = "calm" - elif ready: - tone = "ready" - else: - tone = "calm" - - next_action = "" - if interventions: - next_action = interventions[0].get("title") or "" - elif next_item: - next_action = next_item.get("title") or "" - - timing = item.get("timing") or {} - compact_timing = "" - for key in ("start_at", "start_date", "due_at", "deadline_date", "expires_at"): - value = timing.get(key) - if value: - compact_timing = value - break - + labels = { + "journey": "Continuer la démarche", + "access": "Voir mon accès", + "dossier": "Ouvrir le dossier", + "project": "Voir le projet", + "waitlist": "Suivre ma liste d’attente", + "transfer": "Voir le transfert", + "payment": "Suivre le paiement", + "funding": "Gérer le financement", + } return { - "kind": item.get("kind"), - "title": item.get("title") or "", - "summary": summary, - "tone": tone, - "next_action": next_action, - "timing": {"compact_label": compact_timing} if compact_timing else None, - "place": item.get("place"), - "url": links.get("web") or links.get("detail") or "#", + "primary_label": labels.get(item.get("kind"), "Voir la suite"), + "id": item.get("id"), + "title": item.get("human_context") or item.get("title") or "", + "summary": item.get("synthesis") or item.get("summary") or "", + "tone": n1.get("tone") or "calm", + "cue": n1.get("cue"), + "next_action": n1.get("supporting"), + "when": n1.get("when_label"), + "place": n1.get("place_label"), + "image_url": media.get("url"), + "image_alt": media.get("alt_text") or "", + "media_gallery": [ + binding["resource"] + for binding in item.get("media_bindings") or [] + if binding.get("purpose") == "RECOGNIZE" + and isinstance(binding.get("resource"), dict) + and binding["resource"].get("mime_type") in {"image/jpeg", "image/png"} + and binding["resource"].get("url") + ], + "resources": [ + resource for resource in item.get("resources") or [] + if isinstance(resource, dict) and resource.get("title") + ], + "temporal_facts": [ + { + **fact, + "display_when": _ongoing_n1_when({ + "start_at": fact.get("at"), + "start_date": fact.get("date"), + "timezone": fact.get("timezone"), + }), + } + for fact in item.get("temporal_facts") or [] + if isinstance(fact, dict) and (fact.get("at") or fact.get("date")) + ], + "url": links.get("web"), + "possibility": n1.get("possibility"), + "can_save": bool(n1.get("possibility") and ( + {"save", "unsave"} & set( + n1["possibility"].get("capabilities") or [] + ) + )), + "can_share": bool(n1.get("possibility") and + "share" in (n1["possibility"].get("capabilities") or [])), + "details": tuple( + (label, tuple(value for value in entries if isinstance(value, str) and value.strip())) + for label, values in ( + ("Déjà réglé", item.get("settled") or []), + ("De votre côté", item.get("my_side") or []), + ("En attente", item.get("elsewhere") or []), + ("Préparé par Makolo", item.get("makolo") or []), + ("À venir", item.get("system_or_time") or []), + ("À suivre", item.get("next_items") or []), + ("À relier", item.get("relation_projections") or []), + ("Résultat", [item["outcome"]] if item.get("outcome") else []), + ("Ce qui bloque", [item["blocker"]] if item.get("blocker") else []), + ("À vérifier", [item["unknown"]] if item.get("unknown") else []), + ) + if (entries := [ + entry if isinstance(entry, str) + else entry.get("label") or entry.get("title") or entry.get("summary") + if isinstance(entry, dict) else None + for entry in values + ]) + and any(isinstance(value, str) and value.strip() for value in entries) + ), } def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = self.request.user observed_at = get_request_context(self.request).observed_at + try: + offset = ongoing_continuation_offset( + self.request.GET.get("continuation"), + profile=profile, + ) + except (signing.BadSignature, KeyError, TypeError, ValueError): + offset = 0 data = build_personal_ongoing_projection( profile, observed_at=observed_at, + continuation_offset=offset, ) + context["ongoing_next_token"] = (data.get("continuation") or {}).get("token") + context["ongoing_end_reached"] = (data.get("continuation") or {}).get("state") == "end" + context["ongoing_page_offset"] = offset items = data.get("items", []) ongoing_items = [self._web_item(item) for item in items] kinds = {item.get("kind") for item in items} @@ -250,107 +333,67 @@ def get_context_data(self, **kwargs): class MatureParticipantMeView(LoginRequiredMixin, TemplateView): + """Web N1 consumes the same bounded private projection as Flutter.""" + template_name = "core/participant_me.html" login_url = "core:login" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = self.request.user - context["me_projection"] = build_personal_me_data( + me_projection = build_personal_me_data( profile=profile, request=self.request, ) + context["me_projection"] = me_projection + context["passport_url"] = reverse("sharing:passport-me") - interests = list( - ProfileInterest.objects.filter(profile=profile) - .select_related("topic") - .order_by("topic__label", "created_at")[:ME_PREVIEW_LIMIT] - ) - open_to = list( - ProfileOpenTo.objects.filter(profile=profile, is_active=True) - .select_related("topic") - .order_by("kind", "created_at")[:ME_PREVIEW_LIMIT] - ) - watches = list( - DiscoveryWatch.objects.filter(owner=profile) - .select_related("dossier") - .order_by("-updated_at", "id")[:ME_PREVIEW_LIMIT] - ) - bookmarks = list( - ActivityBookmark.objects.filter(user=profile) - .select_related("activity") - .order_by("-created_at")[:ME_PREVIEW_LIMIT] - ) - followed_spaces = list( - OrganizationFollow.objects.filter(user=profile) - .select_related("organization") - .order_by("-followed_at")[:ME_PREVIEW_LIMIT] - ) - followed_profiles = list( - ProfileFollow.objects.filter(user=profile) - .select_related("organizer_profile") - .order_by("-followed_at")[:ME_PREVIEW_LIMIT] - ) - team_memberships = list( - TeamMembership.objects.filter(user=profile, status=TeamMembershipStatus.ACTIVE) - .select_related("team__organization") - .order_by("team__organization__name", "team__name")[:ME_PREVIEW_LIMIT] - ) - spaces = list(authorized_spaces(profile)[:ME_PREVIEW_LIMIT]) - groups = list(groups_for_profile(profile)[:ME_PREVIEW_LIMIT]) - resources = list(personal_assets_for_controller(profile)[:ME_PREVIEW_LIMIT]) - credentials = list(credentials_for_profile(profile)[:ME_PREVIEW_LIMIT]) - proofs = list(proofs_for_profile(profile)[:ME_PREVIEW_LIMIT]) - + support = me_projection.get("support") or {} support_links = [] - recognition_account = account_for_profile(profile) - if recognition_account is not None or redemptions_requiring_beneficiary_response(profile).exists(): + if (support.get("recognition") or {}).get("available"): support_links.append( { "label": "Reconnaissance", - "detail": "Voir ce que vos contributions ont rendu disponible.", "url": reverse("recognition:dashboard"), } ) - if get_accounts_visible_to(profile).filter(user=profile).exists() or get_subscriptions_visible_to(profile).filter(user=profile).exists(): + if (support.get("loyalty") or {}).get("available"): support_links.append( { "label": "Mes avantages", - "detail": "Retrouver vos relations de fidélité organisation par organisation.", "url": reverse("loyalty:dashboard"), } ) - personal_partners = list( - Partner.objects.filter(user=profile, status=PartnerStatus.ACTIVE) - .select_related("organization") - .order_by("organization__name", "name")[:ME_PREVIEW_LIMIT] - ) - for partner in personal_partners: - support_links.append( - { - "label": partner.organization.name, - "detail": "Ma relation partenaire", - "url": reverse("partners:my-detail", kwargs={"pk": partner.pk}), - } + + # Partner details remain an owner route, not an API link or a + # second copy of the partnership in the N1 projection. + if (support.get("partners") or {}).get("available"): + personal_partners = ( + Partner.objects.filter(user=profile, status=PartnerStatus.ACTIVE) + .select_related("organization") + .order_by("organization__name", "name")[:ME_PREVIEW_LIMIT] ) + for partner in personal_partners: + support_links.append( + { + "label": partner.organization.name, + "url": reverse("partners:my-detail", kwargs={"pk": partner.pk}), + } + ) + + context["support_links"] = support_links + return context + +class MatureParticipantConsiderationsView(LoginRequiredMixin, TemplateView): + """Web entry to the same owner-managed personal choices as native Flutter.""" + + template_name = "core/participant_me_considerations.html" + login_url = "core:login" - context.update( - { - "interests": interests, - "open_to": open_to, - "watches": watches, - "bookmarks": bookmarks, - "followed_spaces": followed_spaces, - "followed_profiles": followed_profiles, - "team_memberships": team_memberships, - "authorized_spaces": spaces, - "my_groups": groups, - "resources": resources, - "credentials": credentials, - "proofs": proofs, - "passport_url": reverse("sharing:passport-me"), - "support_links": support_links, - } + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["choices"] = build_personal_considerations_data( + self.request.user, limit=24, ) return context diff --git a/core/participant_action_presentation.py b/core/participant_action_presentation.py index fa2711db74..ecbc05b9c9 100644 --- a/core/participant_action_presentation.py +++ b/core/participant_action_presentation.py @@ -3,7 +3,7 @@ JOURNEY_HEADLINES = { ReadinessStatus.BLOCKED: "La suite est bloquée.", - ReadinessStatus.ACTION_REQUIRED: "Il reste quelque chose à faire.", + ReadinessStatus.ACTION_REQUIRED: "Une action est nécessaire.", ReadinessStatus.WAITING: "Ça suit son cours.", ReadinessStatus.READY: "Tout est prêt.", ReadinessStatus.COMPLETE: "Démarche terminée.", @@ -12,13 +12,13 @@ JOURNEY_SUMMARIES = { ReadinessStatus.BLOCKED: "Un point doit être réglé avant de continuer.", ReadinessStatus.ACTION_REQUIRED: "Il reste une action de votre côté pour avancer.", - ReadinessStatus.WAITING: "Vous avez fait votre part. Rien à faire pour le moment.", + ReadinessStatus.WAITING: "En attente d’une suite. Aucune action nécessaire actuellement.", ReadinessStatus.READY: "Vous n’avez rien d’autre à préparer pour l’instant.", ReadinessStatus.COMPLETE: "Cette démarche est terminée.", } PHASE_LABELS = { - "before": "Avant de partir", + "before": "Avant le début", "arrival": "Il est temps d’y aller", "live": "Ce qui compte maintenant", "after": "Cette activité est terminée", @@ -34,7 +34,7 @@ def journey_action_presentation(*, journey, readiness, live=None): "phase": phase, "eyebrow": PHASE_LABELS[phase], "label": live["next_action"]["label"], - "cta": "Ouvrir l’action en cours" if phase in {"arrival", "live"} else "Voir l’occurrence", + "cta": "Ouvrir l’action en cours" if phase in {"arrival", "live"} else "Voir les détails", } elif live and phase == "before" and readiness.status == ReadinessStatus.READY: handoff = { @@ -60,10 +60,10 @@ def _access_presentation(rows): if not rows: return {"state": "not_required", "label": "Aucun accès à présenter", "detail": "Vous n’avez pas d’accès distinct à présenter ici."} if any(row["usable"] for row in rows): - return {"state": "ready", "label": "Votre accès est prêt", "detail": "Vous pouvez l’utiliser pour cette occurrence."} + return {"state": "ready", "label": "Votre accès est prêt", "detail": "Vous pouvez présenter cet accès au moment prévu."} if any(row["status"] == "pending" for row in rows): - return {"state": "waiting", "label": "Votre accès est en préparation", "detail": "Rien à faire de votre côté pour le moment."} - return {"state": "unavailable", "label": "Votre accès n’est pas disponible", "detail": "Ouvrez la démarche avant de vous déplacer."} + return {"state": "waiting", "label": "Votre accès est en préparation", "detail": "Aucune action nécessaire actuellement."} + return {"state": "unavailable", "label": "Votre accès n’est pas disponible", "detail": "Consultez votre démarche pour vérifier ce qui manque avant de vous déplacer."} def _placement_presentation(payload): @@ -81,7 +81,7 @@ def _placement_presentation(payload): def _queue_presentation(rows): if not rows: - return {"state": "none", "label": "Pas de file en cours", "detail": "Vous n’attendez pas votre tour pour le moment.", "rows": ()} + return {"state": "none", "label": "Pas de file en cours", "detail": "Aucun passage en attente.", "rows": ()} called = next((row for row in rows if row["status"] == "called"), None) if called: return {"state": "called", "label": "C’est votre tour", "detail": called["label"], "rows": rows} diff --git a/core/platform_web_views.py b/core/platform_web_views.py index 7ad690bd03..64c93bf15d 100644 --- a/core/platform_web_views.py +++ b/core/platform_web_views.py @@ -18,7 +18,11 @@ from authorization.services import can from core.platform_presentation import platform_modules_for from interoperability.presentation import present_connection +from intelligence.connection_web import connection_form_context, handle_connection_form +from intelligence.models import ProviderScope + from interoperability.projections import build_interoperability_payload +from interoperability.domain_integrations import platform_domain_integrations from operations.product_overview import build_product_operations_overview from operations.selectors import ( get_operations_audit_logs, get_operations_incidents, @@ -213,6 +217,8 @@ class PlatformInteroperabilityView(PlatformView): heading = "Interopérabilité" def get_context_data(self, **kwargs): + if not can(self.request.user, PermissionCode.PLATFORM_MANAGE): + raise PermissionDenied("Autorité Platform requise.") context = super().get_context_data(**kwargs) from intelligence.interoperability import ( platform_provider_connections, @@ -229,9 +235,20 @@ def get_context_data(self, **kwargs): self_link="/api/v1/platform/interoperability/", actor=self.request.user, authority_context="platform", + domain_integrations=platform_domain_integrations(self.request.user), ) + context["domain_integrations"] = context["interoperability"]["domain_integrations"] + context.update(connection_form_context()) context["connections"] = [present_connection(row) for row in connections] return context + def post(self, request, *args, **kwargs): + if not can(request.user, PermissionCode.PLATFORM_MANAGE): + raise PermissionDenied("Autorité Platform requise.") + return handle_connection_form( + request, scope=ProviderScope.PLATFORM, + ) + + diff --git a/core/read_models/personal_ongoing.py b/core/read_models/personal_ongoing.py index e682c64dcf..a135125b28 100644 --- a/core/read_models/personal_ongoing.py +++ b/core/read_models/personal_ongoing.py @@ -78,7 +78,7 @@ def build_personal_ongoing_read_model( .filter(occurrence__isnull=False) .select_related(None) .prefetch_related(None) - .select_related("activity", "occurrence") + .select_related("activity", "occurrence", "journey", "ticket__order__waitlist_entry") .prefetch_related("occurrence__place_links__place") .order_by("occurrence__start_date", "occurrence__start_time", "id")[ : budget.remaining diff --git a/core/test_demo_seed.py b/core/test_demo_seed.py index 5488f71d89..3974827ac2 100644 --- a/core/test_demo_seed.py +++ b/core/test_demo_seed.py @@ -1,10 +1,12 @@ from django.core.management import call_command -from django.test import TestCase, override_settings +from django.test import SimpleTestCase, TestCase, override_settings from accounts.models import User +from activities.models import Activity from authorization.constants import PermissionCode from authorization.services import can from demo_seed.beta import BETA_PERSONAS +from demo_seed.mobile_mature_universe import _activity_copy, _demo_title, _opportunity_summary from demo_seed.beta_validation import assert_beta_scenario_coverage from demo_seed.task22_extension import T22_PERSONAS from events.models import Event @@ -46,6 +48,12 @@ def test_beta_seed_uses_semantic_personas_and_scenario_contract(self): self.assertTrue(admin.is_superuser) self.assertTrue(admin.check_password(self.password)) self.assertTrue(participant.check_password(self.password)) + event = Activity.objects.get(slug="beta-event-free") + transport = Activity.objects.get(slug="beta-transport-lub-kol") + self.assertEqual(event.title, "Atelier communauté gratuit") + self.assertEqual(transport.title, "Lubumbashi → Kolwezi") + self.assertFalse(event.title.startswith(("Exemple — ", "[DEMO]"))) + self.assertFalse(transport.title.startswith(("Exemple — ", "[DEMO]"))) def test_beta_seed_reconciles_guarded_status_snapshots_on_rerun(self): call_command( @@ -145,3 +153,55 @@ def test_beta_persona_addresses_are_stable_and_fictitious(self): }, ) self.assertTrue(all(email.endswith(".test") for email in set(BETA_PERSONAS.values()) | set(T22_PERSONAS.values()))) + + +class DemoProductLanguageTests(SimpleTestCase): + def test_mobile_scenarios_are_identified_without_erasing_their_title(self): + self.assertEqual( + _demo_title("Festival musical à Lubumbashi"), + "Festival musical à Lubumbashi", + ) + self.assertEqual( + _demo_title("Exemple — Festival musical à Lubumbashi"), + "Festival musical à Lubumbashi", + ) + + def test_mobile_activity_copy_is_contextual_not_one_generic_formula(self): + spec = { + "title": "Atelier de prise de parole", + "geo": "Lubumbashi", + } + event_short, event_detail = _activity_copy(spec, "event") + transport_short, transport_detail = _activity_copy( + {"title": "Lubumbashi → Kolwezi", "geo": "Lubumbashi→Kolwezi"}, + "transport", + ) + self.assertIn("participation", event_short) + self.assertIn("Atelier de prise de parole", event_detail) + self.assertIn("départ", transport_short) + self.assertIn("Lubumbashi → Kolwezi", transport_detail) + self.assertNotEqual(event_detail, transport_detail) + self.assertNotIn("horaire confirmé", event_detail.lower()) + + def test_fictitious_opportunity_copy_varies_by_actual_scenario(self): + base = {"title": "Formation professionnelle", "geo": "Lubumbashi"} + engaged = _opportunity_summary({**base, "relation": "engaged"}) + ineligible = _opportunity_summary({**base, "relation": "not_eligible"}) + self.assertNotEqual(engaged, ineligible) + self.assertIn("dossier déjà commencé", engaged) + self.assertIn("vérifier les critères", ineligible) + self.assertIn("fictifs", engaged) + self.assertIn("aucune candidature réelle", engaged) + self.assertNotIn("Alain", engaged) + + def test_public_demo_opportunity_does_not_claim_a_personal_journey_or_real_offer(self): + summary = _opportunity_summary({ + "title": "Formation professionnelle", + "relation": "engaged", + "state": "ongoing", + "geo": "Lubumbashi", + }) + self.assertIn("exemples de Makolo", summary) + self.assertIn("fictifs", summary) + self.assertNotIn("Alain", summary) + self.assertNotIn("actuellement ouverte", summary) diff --git a/core/test_lp5_language_sources.py b/core/test_lp5_language_sources.py new file mode 100644 index 0000000000..0004da3f7a --- /dev/null +++ b/core/test_lp5_language_sources.py @@ -0,0 +1,57 @@ +"""Targeted LP5 protection: dynamic copy follows facts and provenance.""" + +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +from django.test import SimpleTestCase + +from core.api.personal_projections import _dossier_ongoing_item +from core.participant_action_presentation import _access_presentation +from readiness import ReadinessStatus +from discovery.api.composition import _assessment, _opportunity_source_previews + + +class ServerProductLanguageTests(SimpleTestCase): + def test_live_access_advice_does_not_expose_occurrence(self): + ready = _access_presentation([{"usable": True, "status": "valid"}]) + self.assertEqual(ready["state"], "ready") + self.assertEqual(ready["detail"], "Vous pouvez présenter cet accès au moment prévu.") + self.assertNotIn("occurrence", ready["detail"].lower()) + + not_ready = _access_presentation([{"usable": False, "status": "revoked"}]) + self.assertEqual(not_ready["state"], "unavailable") + self.assertIn("vérifier ce qui manque avant de vous déplacer", not_ready["detail"]) + + def test_discovery_unknown_relation_remains_unknown_not_a_denial(self): + facts = _assessment({ + "availability": {"state": "unknown"}, + "personal_relation": {"state": "unknown"}, + }) + self.assertEqual([item["state"] for item in facts], ["unknown", "unknown"]) + self.assertIn("pas encore confirmé", facts[1]["label"]) + self.assertNotIn("impossible", facts[1]["label"].lower()) + + def test_opportunity_source_is_not_automatically_endorsed(self): + opportunity_id = uuid4() + source = SimpleNamespace( + pk=uuid4(), + opportunity_id=opportunity_id, + source_name="Catalogue indiqué", + url="https://example.org/catalogue/", + ) + with patch("opportunities.models.OpportunitySource.objects.filter") as query: + query.return_value.order_by.return_value = [source] + previews = _opportunity_source_previews([opportunity_id]) + shown = previews[str(opportunity_id)][0] + self.assertEqual(shown["title"], "Catalogue indiqué") + self.assertEqual(shown["external_url"], "https://example.org/catalogue/") + self.assertEqual(shown["description"], "") + + def test_dossier_summary_follows_owner_readiness_without_a_parallel_status(self): + dossier = SimpleNamespace(pk=uuid4(), title="Dossier formation", deadline=None) + ready = SimpleNamespace(status=ReadinessStatus.READY, primary_next_action=None) + item = _dossier_ongoing_item(dossier, readiness=ready) + self.assertEqual(item["summary"], "Ce dossier est prêt pour la prochaine étape.") + self.assertEqual(item["state"], ReadinessStatus.READY.value) + self.assertNotIn("objectif composé", item["summary"]) diff --git a/core/test_mature_personal_experience.py b/core/test_mature_personal_experience.py index ab7889dbc0..c611fcaaef 100644 --- a/core/test_mature_personal_experience.py +++ b/core/test_mature_personal_experience.py @@ -20,7 +20,7 @@ def setUp(self): def test_primary_personal_destinations_render(self): expectations = { "core:participant-home": "Tout est en ordre. ✓", - "core:participant-ongoing": "Ce qui continue", + "core:participant-ongoing": "

En cours

", "core:participant-me": "Passeport Makolo", "core:makolo-mark": "Qu’est-ce que vous avez en tête ?", } diff --git a/core/test_me_n1_web.py b/core/test_me_n1_web.py new file mode 100644 index 0000000000..fc046f54d1 --- /dev/null +++ b/core/test_me_n1_web.py @@ -0,0 +1,178 @@ +"""N1 Web must render the same owner-projected personal capital as mobile.""" +from django.contrib.auth import get_user_model +from django.contrib.staticfiles.storage import staticfiles_storage +from django.test import TestCase +from django.urls import reverse +from topics.models import ProfileInterest, ProfileOpenTo, OpenToKind, Topic +from activities.models import Activity, ActivityStatus +from discovery.models import ActivityBookmark + + +class PersonalMeN1WebTests(TestCase): + def setUp(self): + self.profile = get_user_model().objects.create_user( + username="me_n1_preview", + first_name="Amina", last_name="Test", + ) + self.url = reverse("core:participant-me") + + def test_identity_and_empty_territories_remain_visible(self): + self.client.force_login(self.profile) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'data-mk-surface="me"') + self.assertContains(response, "Amina Test") + self.assertContains(response, "Passeport Makolo") + self.assertContains(response, "Ce qui compte pour moi") + self.assertContains(response, "Mes collectifs") + self.assertContains(response, "Mes accès") + self.assertContains(response, "Mes ressources") + self.assertContains(response, "Aucune ressource pour le moment.") + self.assertNotContains(response, "Profil complété à") + self.assertNotContains(response, "AccessCredential") + + def test_web_n1_uses_an_explicit_stylesheet_and_preserves_owner_links(self): + self.client.force_login(self.profile) + response = self.client.get(self.url) + self.assertContains(response, staticfiles_storage.url("css/me-n1.css")) + self.assertContains(response, reverse("core:participant-accesses")) + self.assertContains(response, reverse("personal_assets:list")) + self.assertContains(response, reverse("sharing:passport-me")) + + def test_anonymous_view_is_not_public(self): + response = self.client.get(self.url) + self.assertNotEqual(response.status_code, 200) + + def test_choices_hub_and_avatar_are_genuine_owner_handoffs(self): + self.client.force_login(self.profile) + root = self.client.get(self.url) + self.assertContains(root, reverse("core:participant-me-choices")) + self.assertContains(root, reverse("account:profile")) + self.assertContains(root, "Mon historique") + self.assertNotContains(root, "Vous avez tout exploré") + hub = self.client.get(reverse("core:participant-me-choices")) + self.assertEqual(hub.status_code, 200) + self.assertContains(hub, reverse("account:interests")) + self.assertContains(hub, reverse("account:open-to")) + self.assertContains(hub, reverse("discovery:bookmarks")) + self.assertContains(hub, reverse("discovery:watch-list")) + + def test_moi_root_shows_three_saved_items_and_real_total(self): + self.client.force_login(self.profile) + for index in range(5): + activity = Activity.objects.create( + owner_profile=self.profile, + created_by=self.profile, + title=f"Possibilité échantillon {index}", + status=ActivityStatus.PUBLISHED, + ) + ActivityBookmark.objects.create(user=self.profile, activity=activity) + root = self.client.get(self.url) + self.assertEqual(root.status_code, 200) + self.assertContains(root, "Voir les 5 enregistrés") + self.assertEqual(root.content.count(b'class="mk-me-n1-saved-title"'), 3) + + def test_consideration_settings_edits_only_the_authenticated_profile(self): + other = get_user_model().objects.create_user(username="me_n1_unrelated") + topic = Topic.objects.create(code="me-topic-for-settings", label="Musique") + self.client.force_login(self.profile) + url = reverse("personal-projections:consideration-settings") + before = self.client.get(url) + self.assertEqual(before.status_code, 200) + self.assertEqual(before.json()["meta"]["projection"], "personal.me.settings") + changed = self.client.put(url, + data='{"family":"interests","values":["%s"]}' % topic.pk, + content_type="application/json") + self.assertEqual(changed.status_code, 200) + self.assertTrue(ProfileInterest.objects.filter( + profile=self.profile, topic=topic).exists()) + self.assertFalse(ProfileInterest.objects.filter(profile=other).exists()) + enabled = self.client.put(url, + data='{"family":"open_to","values":["%s"]}' % OpenToKind.MENTOR, + content_type="application/json") + self.assertEqual(enabled.status_code, 200) + self.assertTrue(ProfileOpenTo.objects.filter( + profile=self.profile, kind=OpenToKind.MENTOR).exists()) + rejected = self.client.put(url, + data='{"family":"interests","values":[],"profile_id":"other"}', + content_type="application/json") + self.assertEqual(rejected.status_code, 400) + self.assertTrue(ProfileInterest.objects.filter( + profile=self.profile, topic=topic).exists()) + + def test_preview_pages_are_private_and_bounded(self): + preview = reverse("personal-projections:me-preview", kwargs={"territory": "considerations"}) + self.assertNotEqual(self.client.get(preview).status_code, 200) + self.client.force_login(self.profile) + response = self.client.get(preview, {"family": "interests", "offset": "6", "limit": "24"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["meta"]["projection"], "personal.me.preview") + data = response.json()["data"] + self.assertEqual(data["territory"], "considerations") + self.assertEqual(data["offset"], 6) + self.assertEqual(data["family"], "interests") + self.assertEqual(data["actor"]["id"], str(self.profile.pk)) + self.assertEqual(data["collection"]["items"], []) + self.assertEqual(response["Cache-Control"], "private, no-store") + + def test_preview_rejects_scope_override_and_unbounded_requests(self): + self.client.force_login(self.profile) + preview = reverse("personal-projections:me-preview", kwargs={"territory": "resources"}) + self.assertEqual(self.client.get(preview, {"family": "documents", "profile_id": "other"}).status_code, 400) + self.assertEqual(self.client.get(preview, {"family": "documents", "offset": -1}).status_code, 400) + self.assertEqual(self.client.get(preview, {"family": "documents", "limit": 1000}).status_code, 400) + self.assertEqual(self.client.get(preview, {"family": "unknown"}).status_code, 400) + self.assertEqual( + self.client.get(reverse("personal-projections:me-preview", + kwargs={"territory": "unknown"})).status_code, 400 + ) + + def test_every_declared_interest_is_seen_once_across_continuations(self): + topics = [ + Topic.objects.create(code=f"me-topic-{n:02d}", label=f"Sujet {n:02d}") + for n in range(13) + ] + for topic in topics: + ProfileInterest.objects.create(profile=self.profile, topic=topic) + self.client.force_login(self.profile) + root = self.client.get(reverse("personal-projections:me")).json()["data"] + first = root["considerations"]["interests"] + self.assertEqual(first["count"], 13) + self.assertEqual(first["next_offset"], 6) + self.assertTrue(first["has_more"]) + preview = reverse("personal-projections:me-preview", + kwargs={"territory": "considerations"}) + ids = [row["id"] for row in first["items"]] + offset = first["next_offset"] + for _ in range(10): + response = self.client.get(preview, { + "family": "interests", "offset": offset, "limit": 3, + }) + self.assertEqual(response.status_code, 200) + data = response.json()["data"] + self.assertEqual(data["actor"]["id"], str(self.profile.pk)) + self.assertEqual(data["family"], "interests") + page = data["collection"] + ids.extend(row["id"] for row in page["items"]) + self.assertEqual(page["next_offset"], offset + len(page["items"])) + offset = page["next_offset"] + if not page["has_more"]: + break + else: + self.fail("Pagination did not reach the real end") + self.assertEqual(len(ids), 13) + self.assertEqual(len(set(ids)), 13) + self.assertEqual(offset, 13) + + def test_other_persons_interests_never_appear_on_my_continuation(self): + other = get_user_model().objects.create_user( + username="me_n1_other", first_name="Autre", last_name="profil", + ) + topic = Topic.objects.create(code="me-private-other", label="Sujet privé") + ProfileInterest.objects.create(profile=other, topic=topic) + self.client.force_login(self.profile) + preview = reverse("personal-projections:me-preview", + kwargs={"territory": "considerations"}) + response = self.client.get(preview, {"family": "interests", "offset": 0}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["collection"]["items"], []) diff --git a/core/test_n2_focus_contract.py b/core/test_n2_focus_contract.py new file mode 100644 index 0000000000..088097aa6c --- /dev/null +++ b/core/test_n2_focus_contract.py @@ -0,0 +1,76 @@ +"""N2 is a native composition of already-authorized N1 facts, not a new owner.""" +import json +from pathlib import Path + +from django.template.loader import render_to_string +from django.test import SimpleTestCase + +from core.home_views import _now_web_context +from core.mature_experience_views import MatureParticipantOngoingView + + +FIXTURE = Path(__file__).resolve().parent.parent / "mobile/test/fixtures/now_s1_s5_contract.json" + + +class NowN2GoldenContractTests(SimpleTestCase): + def test_all_five_now_topologies_produce_human_n2(self): + fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) + expected = ("meaning", "media", "action", "waiting", "composition") + rendered = [] + for item, topology in zip(fixture["items"], expected, strict=True): + projected = _now_web_context({ + **fixture, "items": [item] + }).primary_attention + self.assertEqual(projected.topology, topology) + html = render_to_string( + "core/_now_n2_focus.html", {"item": projected} + ) + self.assertIn(item["human_context"], html) + self.assertNotIn("source_domain", html) + self.assertNotIn("context_type", html) + rendered.append(html) + self.assertIn("Pièce autorisée", rendered[1]) + self.assertIn("En attente", rendered[3]) + self.assertIn("Rendez-vous A", rendered[4]) + self.assertIn("Rendez-vous B", rendered[4]) + + def test_now_n2_does_not_invent_an_action_or_private_share(self): + home = _now_web_context({ + "items": [{ + "id": "private", + "human_context": "Demande personnelle", + "state_meaning": "Une réponse est attendue.", + "response": {"type": "wait"}, + "business_actions": [], + "links": {}, + }], + "selection": {"state": "ready"}, + "freshness": {"state": "fresh"}, + "terminal": {"state": "ok"}, + }) + html = render_to_string( + "core/_now_n2_focus.html", {"item": home.primary_attention} + ) + self.assertIn("En attente", html) + self.assertNotIn('data-now-direct', html) + self.assertNotIn("Partager", html) + + def test_ongoing_n2_reuses_only_owner_projection_and_its_handoff(self): + item = MatureParticipantOngoingView._web_item({ + "id": "ongoing-private", + "kind": "journey", + "source": {"kind": "journey", "id": "11111111-2222-4333-8444-555555555555"}, + "title": "Visa Canada", + "summary": "Une réponse est attendue.", + "settled": [{"title": "Formulaire transmis"}], + "my_side": [{"title": "Fournir le certificat"}], + "elsewhere": [{"summary": "Examen par le consulat"}], + "next_items": [{"title": "Biométrie"}], + "links": {}, + }) + html = render_to_string("core/_ongoing_n2_focus.html", {"item": item}) + for phrase in ("Visa Canada", "Formulaire transmis", + "Fournir le certificat", "Examen par le consulat", "Biométrie"): + self.assertIn(phrase, html) + self.assertNotIn("Ouvrir la démarche", html) + self.assertNotIn("Partager", html) diff --git a/core/test_now_continuation.py b/core/test_now_continuation.py new file mode 100644 index 0000000000..633c9d8ec5 --- /dev/null +++ b/core/test_now_continuation.py @@ -0,0 +1,110 @@ +"""Now N1 pagination: owner admission precedes slicing; cursors are private.""" +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +from django.core import signing +from django.test import SimpleTestCase +from django.utils import timezone + +from core.api.personal_projections import ( + NOW_LIMIT, + build_personal_now_projection, + _decorate_now_semantics, + now_continuation_offset, +) + + +class NowContinuationTests(SimpleTestCase): + def setUp(self): + self.profile = SimpleNamespace(pk=uuid4()) + self.other = SimpleNamespace(pk=uuid4()) + + def _projection(self, *, count=NOW_LIMIT * 2 + 3, offset=0): + examples = [ + { + "id": f"personal-now-{i}", + "key": f"personal-now-{i}", + "source": {"kind": "dossier", "id": str(uuid4())}, + "dimension": "adaptation", + "kind": "dossier.dependency", + "business_actions": [], + "human_context": f"Situation {i}", + } + for i in range(count) + ] + with ( + patch( + "core.api.personal_projections.resolve_mature_home_contextual_actions", + return_value=(SimpleNamespace(actions=[]), {}), + ), + patch( + "core.api.personal_projections.dossier_dependency_now_situations", + return_value=examples, + ), + ): + return build_personal_now_projection( + self.profile, + observed_at=timezone.now(), + continuation_offset=offset, + ) + + def test_pages_are_bounded_and_terminal_only_after_last_item(self): + first = self._projection() + self.assertEqual(len(first["items"]), NOW_LIMIT) + self.assertEqual(first["continuation"]["state"], "more") + self.assertEqual(first["coverage_state"], "partial") + self.assertEqual(first["items"][0]["id"], "personal-now-0") + + second_offset = now_continuation_offset( + first["continuation"]["token"], profile=self.profile + ) + self.assertEqual(second_offset, NOW_LIMIT) + second = self._projection(offset=second_offset) + self.assertEqual(len(second["items"]), NOW_LIMIT) + self.assertEqual(second["continuation"]["state"], "more") + third_offset = now_continuation_offset( + second["continuation"]["token"], profile=self.profile + ) + last = self._projection(offset=third_offset) + self.assertEqual(len(last["items"]), 3) + self.assertEqual(last["continuation"], {"state": "end", "token": None}) + self.assertEqual(last["coverage_state"], "complete") + + def test_cursor_cannot_be_used_by_another_profile(self): + first = self._projection() + with self.assertRaises(ValueError): + now_continuation_offset( + first["continuation"]["token"], profile=self.other + ) + + def test_bad_cursor_does_not_become_page_one(self): + with self.assertRaises(signing.BadSignature): + now_continuation_offset("invalid", profile=self.profile) + + def test_empty_owner_projection_is_the_only_calm_case(self): + empty = self._projection(count=0) + self.assertEqual(empty["selection"]["state"], "empty") + self.assertEqual(empty["actor_attention_state"], "calm") + self.assertIsNone(empty["continuation"]) + self.assertEqual(empty["terminal"]["state"], "empty") + + def test_s1_owner_freshness_is_not_inferred_from_projection_time(self): + action = SimpleNamespace( + reason_codes=(), + actionability=SimpleNamespace(value="needs_attention"), + summary="Une décision est attendue.", + label="Consulter", + kind="journey.needs_attention", + ) + item = { + "source": {"kind": "journey", "id": str(uuid4())}, + "dimension": "adaptation", + "links": {}, + "timing": {}, + } + _decorate_now_semantics(action, item, observed_at=timezone.now()) + self.assertEqual(item["knowledge_context"]["freshness"]["state"], "unknown") + self.assertEqual(item["consequence"]["state"], "unknown") + self.assertIsNone(item["consequence"]["effect"]) + self.assertEqual(item["turn"]["type"], "none") diff --git a/core/test_now_ongoing_n1_templates.py b/core/test_now_ongoing_n1_templates.py new file mode 100644 index 0000000000..9746a7c6e4 --- /dev/null +++ b/core/test_now_ongoing_n1_templates.py @@ -0,0 +1,213 @@ +"""Presentation tests: both personal N1 streams reuse their owner projections.""" +from django.template.loader import render_to_string +from django.test import SimpleTestCase + +from core.home_views import _now_web_context, _now_inline_media, _now_topology +from core.mature_experience_views import MatureParticipantOngoingView + + +class PersonalN1TemplateTests(SimpleTestCase): + def test_now_fragment_exposes_server_cursor_and_complete_unit(self): + situation = { + "id": "nown1-one", + "key": "nown1-one", + "human_context": "Inscription", + "title": "Votre inscription", + "state_meaning": "Un document est à vérifier.", + "summary": "Un document est à vérifier.", + "response": {"type": "understand", "label": "Vérifier"}, + "why_now": {"meaning": "Une pièce est demandée."}, + "consequence": {"state": "unknown", "effect": None}, + "source": {"kind": "journey", "id": "11111111-2222-4333-8444-555555555555"}, + } + projection = { + "items": [situation], + "selection": {"state": "ready"}, + "freshness": {"state": "fresh"}, + "actor_attention_state": "active", + "terminal": {"state": "ok"}, + "continuation": {"state": "more", "token": "opaque-test-next"}, + } + home = _now_web_context(projection) + html = render_to_string("core/_now_page_fragment.html", {"home": home}) + self.assertIn('data-next-token="opaque-test-next"', html) + self.assertIn('data-continuation-state="more"', html) + self.assertIn('data-now-identity="nown1-one"', html) + self.assertIn("Voir plus", html) + self.assertIn("Voir moins", html) + self.assertIn("Un document est à vérifier.", html) + + def test_now_public_recognition_media_requires_exact_published_binding(self): + use_id = "11111111-2222-4333-8444-555555555555" + url = f"/presentation/media/uses/{use_id}/file/" + valid = { + "resource_ref": f"presentation_media_use:{use_id}", + "target": "subject", + "purpose": "recognize", + "kind": "image", + "url": url, + "label": "Véhicule", + "authorized": True, + "presentation_rank": "primary", + } + self.assertEqual(_now_inline_media({"media_bindings": [valid]})[0]["url"], url) + for altered in ( + {**valid, "authorized": False}, + {**valid, "url": "https://example.test" + url}, + {**valid, "url": url + "?token=secret"}, + {**valid, "resource_ref": "journey_artifact:" + use_id}, + ): + self.assertFalse(_now_inline_media({"media_bindings": [altered]})) + + def test_now_s2_renders_published_recognition_image(self): + use_id = "11111111-2222-4333-8444-555555555555" + situation = { + "id": "now-image", + "key": "now-image", + "human_context": "Voyage Kolwezi", + "state_meaning": "Le départ est à préparer.", + "response": {"type": "prepare", "label": "Préparer"}, + "media_bindings": [{ + "resource_ref": f"presentation_media_use:{use_id}", + "target": "subject", "purpose": "recognize", "kind": "image", + "url": f"/presentation/media/uses/{use_id}/file/", + "authorized": True, "presentation_rank": "primary", + "label": "Véhicule de la liaison", + }], + } + self.assertEqual(_now_topology(situation), "media") + home = _now_web_context({ + "items": [situation], "selection": {"state": "ready"}, + "freshness": {"state": "fresh"}, + "actor_attention_state": "active", + "terminal": {"state": "ok"}, + }) + html = render_to_string("core/_now_page_fragment.html", {"home": home}) + self.assertIn("mk-now-unit__hero", html) + self.assertIn(f"/presentation/media/uses/{use_id}/file/", html) + + def test_now_web_s5_preserves_relation_and_semantic_fields(self): + situation = { + "id": "now-composition", + "key": "now-composition", + "human_context": "Réservations", + "state_meaning": "Deux rendez-vous se chevauchent.", + "subject": {"label": "Disponibilité"}, + "delta": {"meaning": "Le second horaire est confirmé."}, + "exit_condition": {"label": "Un créneau est libéré."}, + "why_now": {"meaning": "Les horaires se recouvrent."}, + "consequence": {"state": "known", "effect": "Un seul rendez-vous est possible."}, + "response": {"type": "decide", "label": "Choisir"}, + "relation_members": [ + {"id": "a", "label": "Rendez-vous A"}, + {"id": "b", "label": "Rendez-vous B"}, + ], + "relations": [{"kind": "conflict", "member_ids": ["a", "b"], + "summary": "Même créneau."}], + "media_bindings": [{ + "resource_ref": "presentation_media_use:11111111-2222-4333-8444-555555555555", + "target": "subject", "purpose": "recognize", "kind": "image", + "url": "/presentation/media/uses/11111111-2222-4333-8444-555555555555/file/", + "authorized": True, "presentation_rank": "primary", + }], + } + self.assertEqual(_now_topology(situation), "composition") + home = _now_web_context({ + "items": [situation], + "selection": {"state": "ready"}, + "freshness": {"state": "fresh"}, + "actor_attention_state": "active", + "terminal": {"state": "ok"}, + }) + html = render_to_string("core/_now_page_fragment.html", {"home": home}) + self.assertIn("Disponibilité", html) + self.assertIn("Le second horaire est confirmé.", html) + self.assertIn("Rendez-vous A", html) + + def test_ongoing_fragment_has_single_unit_and_server_continuity(self): + item = { + "id": "ongoing-1", + "source": {"kind": "journey", "id": "11111111-2222-4333-8444-555555555555"}, + "kind": "journey", + "title": "Inscription", + "human_context": "Inscription", + "summary": "Une réponse est attendue.", + "synthesis": "Une réponse est attendue.", + "settled": [{"title": "Dossier remis"}], + "my_side": [{"title": "Valider le reçu"}], + "elsewhere": [{"summary": "L'organisme répondra."}], + "next_items": [], + "links": {"web": "/me/journeys/11111111-2222-4333-8444-555555555555/"}, + "n1": {"tone": "calm", "cue": "En attente"}, + } + projected = MatureParticipantOngoingView._web_item(item) + html = render_to_string( + "core/_ongoing_page_fragment.html", + { + "ongoing_items": [projected], + "ongoing_next_token": "cursor", + }, + ) + self.assertIn('data-next-token="cursor"', html) + self.assertEqual(html.count('data-ongoing-unit'), 1) + self.assertIn("Dossier remis", html) + self.assertIn("Valider le reçu", html) + self.assertIn("Voir moins", html) + self.assertIn("Continuer la démarche", html) + self.assertIn("Enregistrer", html) + self.assertIn("Partager", html) + + def test_published_discovery_owner_reuses_n1_save_and_sharing(self): + activity_id = "11111111-2222-4333-8444-555555555555" + item = { + "id": "ongoing-public", + "kind": "journey", + "title": "Visite guidée", + "summary": "Départ confirmé.", + "links": {"web": "/me/ongoing/visite/"}, + "n1": { + "possibility": { + "family": "activity", + "id": activity_id, + "saved_state": "not_saved", + "capabilities": ["save", "share"], + "links": { + "saved": ( + f"/api/v1/discovery/items/activity/{activity_id}/saved/" + ), + "share": ( + f"/api/v1/sharing/possibilities/activity/{activity_id}/" + ), + }, + }, + }, + } + projected = MatureParticipantOngoingView._web_item(item) + html = render_to_string("core/_ongoing_unit.html", {"item": projected}) + self.assertIn("data-ongoing-save", html) + self.assertIn("data-makolo-share", html) + self.assertIn("Enregistrer", html) + self.assertIn("Continuer la démarche", html) + self.assertIn("Partager", html) + + def test_private_continuity_does_not_gain_public_share_or_save(self): + item = { + "id": "private", + "kind": "journey", + "title": "Démarche personnelle", + "summary": "À préparer.", + "settled": [], + "my_side": [], + "elsewhere": [], + "next_items": [], + "links": {}, + } + projected = MatureParticipantOngoingView._web_item(item) + html = render_to_string( + "core/_ongoing_unit.html", {"item": projected} + ) + self.assertIn("Partager", html) + self.assertIn("Enregistrer", html) + self.assertIn("mk-ongoing-unit__action-disabled", html) + self.assertNotIn("data-makolo-share", html) + self.assertNotIn("data-ongoing-save", html) diff --git a/core/test_ongoing_continuation.py b/core/test_ongoing_continuation.py new file mode 100644 index 0000000000..bfaa3c6e57 --- /dev/null +++ b/core/test_ongoing_continuation.py @@ -0,0 +1,115 @@ +"""Ongoing N1 server pagination: bounded, viewer-bound and terminal only at end.""" +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +from django.core import signing +from django.test import SimpleTestCase +from django.utils import timezone + +from core.api.personal_projections import ( + ONGOING_LIMIT, + ONGOING_CONTINUATION_SALT, + build_personal_ongoing_projection, + ongoing_continuation_offset, +) +from core.read_models.personal_ongoing import PersonalOngoingEntry + + +class OngoingContinuationTests(SimpleTestCase): + def setUp(self): + self.profile = SimpleNamespace(pk=uuid4()) + self.other = SimpleNamespace(pk=uuid4()) + + def _projection(self, *, total=ONGOING_LIMIT * 2 + 3, offset=0): + entries = [ + PersonalOngoingEntry( + kind="project", value=SimpleNamespace(number=i), + ) + for i in range(total) + ] + + def select(_profile, *, observed_at, limit, include_personal_funding): + self.assertTrue(include_personal_funding) + self.assertLessEqual(limit, offset + ONGOING_LIMIT + 1) + return tuple(entries[:limit]) + + def serialize(entry): + return { + "kind": "project", + "source": {"kind": "project", "id": f"test-project-{entry.number}"}, + "title": f"Projet {entry.number}", + "summary": "Une suite existe.", + "state": "active", + "ready": [], + "actor_interventions": [], + "continuation": None, + "blocker": None, + "next": None, + "timing": {}, + "place": None, + "capabilities": [], + "links": {}, + } + + with ( + patch("core.api.personal_projections.build_personal_ongoing_read_model", + side_effect=select), + patch("core.api.personal_projections.pass_ongoing_candidates_through_molongo", + side_effect=lambda rows: rows), + patch("core.api.personal_projections._project_ongoing_item", + side_effect=serialize), + patch("core.api.personal_projections._attach_ongoing_recognition_media"), + ): + return build_personal_ongoing_projection( + self.profile, + observed_at=timezone.now(), + continuation_offset=offset, + ) + + def test_server_pages_are_bounded_and_exhausted_truthfully(self): + first = self._projection() + self.assertEqual(len(first["items"]), ONGOING_LIMIT) + self.assertEqual(first["continuation"]["state"], "more") + self.assertEqual(first["coverage_state"], "partial") + second_offset = ongoing_continuation_offset( + first["continuation"]["token"], profile=self.profile, + ) + self.assertEqual(second_offset, ONGOING_LIMIT) + second = self._projection(offset=second_offset) + self.assertEqual(len(second["items"]), ONGOING_LIMIT) + third_offset = ongoing_continuation_offset( + second["continuation"]["token"], profile=self.profile, + ) + last = self._projection(offset=third_offset) + self.assertEqual(len(last["items"]), 3) + self.assertEqual(last["continuation"], {"state": "end", "token": None}) + self.assertEqual(last["coverage_state"], "complete") + identities = [row["id"] for page in (first, second, last) for row in page["items"]] + self.assertEqual(len(identities), len(set(identities))) + + def test_signed_cursor_cannot_cross_profile(self): + first = self._projection() + with self.assertRaises(ValueError): + ongoing_continuation_offset( + first["continuation"]["token"], profile=self.other, + ) + + def test_signed_offset_must_be_positive_aligned_integer(self): + for offset in (-1, 0, 1, ONGOING_LIMIT + 1, "18", True): + token = signing.dumps( + {"offset": offset, "viewer": str(self.profile.pk)}, + salt=ONGOING_CONTINUATION_SALT, + ) + with self.subTest(offset=offset), self.assertRaises(ValueError): + ongoing_continuation_offset(token, profile=self.profile) + + def test_corrupt_cursor_is_not_retreated_as_first_page(self): + with self.assertRaises(signing.BadSignature): + ongoing_continuation_offset("invalid", profile=self.profile) + + def test_empty_owner_has_no_n1_units(self): + empty = self._projection(total=0) + self.assertEqual(empty["items"], []) + self.assertEqual(empty["continuation"]["state"], "end") + self.assertEqual(empty["selection"]["state"], "empty") diff --git a/core/test_ongoing_n1_semantics.py b/core/test_ongoing_n1_semantics.py new file mode 100644 index 0000000000..d65b111892 --- /dev/null +++ b/core/test_ongoing_n1_semantics.py @@ -0,0 +1,100 @@ +"""OngoingMe N1: owner-backed cues and shared Web/Flutter display contract.""" +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +from django.test import SimpleTestCase +from django.utils import timezone + +from core.api.personal_projections import ( + _attach_ongoing_recognition_media, + _decorate_ongoing_semantics, + _ongoing_n1_when, +) +from core.mature_experience_views import MatureParticipantOngoingView + + +class OngoingN1SemanticsTests(SimpleTestCase): + def item(self, **overrides): + item = { + "kind": "journey", + "source": {"kind": "journey", "id": "fixture-journey"}, + "state": "waiting", + "title": "Visa Canada", + "summary": "Demande en examen.", + "ready": [], + "actor_interventions": [], + "continuation": {"state": "waiting", "summary": "Le consulat examine la demande."}, + "blocker": None, + "next": {"title": "Biométrie"}, + "timing": {"start_date": "2026-11-15"}, + "place": {"name": "Centre de biométrie"}, + "capabilities": [], + "links": {"web": "/me/ongoing/sample/"}, + } + item.update(overrides) + _decorate_ongoing_semantics(item, observed_at=timezone.now()) + return item + + def test_calm_wait_and_web_parity(self): + item = self.item() + self.assertEqual(item["n1"]["cue"], "En attente") + self.assertEqual(item["n1"]["tone"], "calm") + self.assertEqual(item["n1"]["when_label"], "15/11/2026") + self.assertEqual(item["n1"]["supporting"], "Biométrie") + self.assertEqual(item["media_bindings"], []) + web = MatureParticipantOngoingView._web_item(item) + self.assertEqual(web["title"], item["human_context"]) + self.assertEqual(web["summary"], item["synthesis"]) + self.assertEqual(web["cue"], item["n1"]["cue"]) + + def test_personal_action_is_not_urgent_or_blocked(self): + item = self.item( + actor_interventions=[{"title": "Fournir un document"}], + continuation=None, + ) + self.assertEqual(item["n1"]["cue"], "De votre côté") + self.assertEqual(item["n1"]["tone"], "action") + + def test_blocker_is_local_to_one_step(self): + item = self.item(blocker={"title": "Pièce manquante"}) + self.assertEqual(item["n1"]["cue"], "Une étape bloquée") + + def test_no_fabricated_next_date_or_media(self): + item = self.item( + next=None, timing={}, place=None, continuation=None, state="active" + ) + self.assertIsNone(item["n1"]["cue"]) + self.assertIsNone(item["n1"]["when_label"]) + self.assertIsNone(item["n1"]["supporting"]) + self.assertEqual(item["media_bindings"], []) + + def test_human_time_obeys_source_timezone(self): + self.assertEqual( + _ongoing_n1_when({ + "start_at": "2026-11-15T14:30:00+00:00", + "timezone": "Africa/Kinshasa", + }), + "15/11/2026 · 15:30", + ) + self.assertIsNone(_ongoing_n1_when({"start_at": "bad timestamp"})) + + def test_private_activity_does_not_gain_share_save_or_media(self): + entry = SimpleNamespace( + kind="journey", + value=SimpleNamespace(activity_id=uuid4(), occurrence_id=None), + ) + item = { + "id": "private-continuity", + "n1": {"cue": None}, + "media_bindings": [], + } + with patch("activities.models.Activity.objects.filter") as candidates: + candidates.return_value.select_related.return_value = [] + _attach_ongoing_recognition_media( + [entry], [item], + viewer=SimpleNamespace(pk=uuid4()), + ) + self.assertNotIn("possibility", item["n1"]) + self.assertEqual(item["media_bindings"], []) + diff --git a/core/test_w8_personal_interoperability.py b/core/test_w8_personal_interoperability.py index df3f57dced..2618dacf22 100644 --- a/core/test_w8_personal_interoperability.py +++ b/core/test_w8_personal_interoperability.py @@ -1,4 +1,4 @@ -from django.test import RequestFactory, TestCase +from django.test import RequestFactory, SimpleTestCase, TestCase from django.urls import resolve, reverse from accounts.models import User, UserProfile @@ -90,16 +90,18 @@ def test_empty_state_is_clean_and_does_not_invent_providers(self): self.assertEqual(response.status_code, 200) self.assertEqual(response["Cache-Control"], "private, no-store") - self.assertContains(response, "Aucune connexion pour le moment.") - self.assertContains( - response, - "Aucun service n’est encore disponible pour votre Profil.", - ) + self.assertContains(response, "Services à découvrir") + self.assertContains(response, "Mes connexions") + self.assertContains(response, "Tavily") self.assertNotContains(response, "Marketplace") + self.assertNotContains(response, "dans cet environnement") + self.assertNotContains(response, "Explorez les services disponibles pour commencer") + self.assertNotContains(response, "Notifications sur mes appareils") + self.assertNotContains(response, "Paiement de démonstration") self.assertNotContains(response, "Connecter") self.assertNotContains(response, "Gmail") self.assertNotContains(response, "Google Calendar") - self.assertNotContains(response, "OpenAI") + self.assertContains(response, "OpenAI API") def test_profile_connections_are_humanized_and_secret_free(self): own = self._connection( @@ -137,12 +139,14 @@ def test_profile_connections_are_humanized_and_secret_free(self): ) self.client.force_login(self.user) - response = self.client.get(reverse("core:participant-connections")) - html = response.content.decode("utf-8") + url = reverse("core:participant-connections") + response = self.client.get(f"{url}?tab=mine") + detail = self.client.get(f"{url}?connection={own.pk}") + html = detail.content.decode("utf-8") self.assertContains(response, "Service personnel") self.assertContains(response, "Connecté et disponible") - self.assertContains(response, "Génération de texte") + self.assertContains(detail, "Génération de texte") self.assertContains(response, "Service désactivé") self.assertContains(response, "Désactivé") self.assertContains(response, "Service dégradé") @@ -161,6 +165,83 @@ def test_profile_connections_are_humanized_and_secret_free(self): self.assertNotIn("test-model", html) self.assertNotIn("openai_compatible", html) self.assertNotIn("text_generate", html) + self.assertNotContains(response, "Extension déclarée") + self.assertNotContains(response, "Mode démonstration uniquement") + + def test_real_discovery_handoff_only_for_usable_personal_research_connection(self): + own = self._connection( + name="Recherche utilisable", + scope=ProviderScope.PROFILE, + profile=self.profile, + ) + own.routes.update(capability=IntelligenceCapability.WEB_RESEARCH.value) + other = self._connection( + name="Recherche désactivée", + scope=ProviderScope.PROFILE, + profile=self.profile, + enabled=False, + ) + other.routes.update(capability=IntelligenceCapability.WEB_RESEARCH.value) + foreign = self._connection( + name="Recherche autre Profil", + scope=ProviderScope.PROFILE, + profile=self.other_profile, + ) + foreign.routes.update(capability=IntelligenceCapability.WEB_RESEARCH.value) + self.client.force_login(self.user) + url = reverse("core:participant-connections") + response = self.client.get(f"{url}?tab=mine") + detail = self.client.get(f"{url}?connection={own.pk}") + disabled = self.client.get(f"{url}?connection={other.pk}") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Recherche utilisable") + self.assertContains(response, "Recherche désactivée") + self.assertNotContains(response, "Recherche autre Profil") + self.assertContains(detail, "Explorer dans Découvrir", count=1) + self.assertContains(detail, reverse("discovery:home")) + self.assertNotContains(disabled, "Explorer dans Découvrir") + + def test_catalogue_filters_and_n3_use_owner_offers_only(self): + from unittest.mock import patch + + self.client.force_login(self.user) + url = reverse("core:participant-connections") + response = self.client.get(f"{url}?tab=discover&cap=web_research&q=Tavily") + self.assertContains(response, "Tavily") + self.assertNotContains(response, "Exa") + self.assertNotContains(response, "OpenAI API") + + with patch.dict( + "os.environ", + {"INTELLIGENCE_CREDENTIAL_MASTER_KEY": "test-key-for-display-only"}, + ): + detail = self.client.get(f"{url}?offer=tavily") + self.assertContains(detail, 'name="provider" value="tavily"') + self.assertContains(detail, 'name="api_key"') + self.assertContains(detail, 'name="consent"') + self.assertNotContains(detail, 'name="model"') + + unknown = self.client.get(f"{url}?offer=unregistered-provider") + self.assertNotContains(unknown, 'name="provider" value="unregistered-provider"') + + def test_foreign_connection_cannot_be_opened_via_n3_query(self): + own = self._connection( + name="Ma connexion contrôlée", + scope=ProviderScope.PROFILE, + profile=self.profile, + ) + foreign = self._connection( + name="Connexion d'un autre profil", + scope=ProviderScope.PROFILE, + profile=self.other_profile, + ) + self.client.force_login(self.user) + url = reverse("core:participant-connections") + detail = self.client.get(f"{url}?connection={own.pk}") + self.assertContains(detail, "Ma connexion contrôlée") + foreign_detail = self.client.get(f"{url}?connection={foreign.pk}") + self.assertNotContains(foreign_detail, "Connexion d'un autre profil") + self.assertNotContains(foreign_detail, str(foreign.pk)) def test_space_and_platform_connections_never_leak_into_personal_page(self): space = Organization.objects.create( @@ -199,3 +280,69 @@ def test_personal_primary_navigation_remains_five_destinations(self): for label in ("Maintenant", "Découvrir", "Makolo", "En cours", "Moi"): self.assertIn(f"{label}", mobile) self.assertNotIn(">Connexions<", mobile) + + +class C2InteroperabilityPresentationContractTests(SimpleTestCase): + def test_connection_with_no_active_capability_is_not_described_as_usable(self): + from interoperability.presentation import connection_status_label + + self.assertEqual( + connection_status_label( + { + "enabled": True, + "connected": True, + "usable": False, + "health": "healthy", + "capabilities": [], + } + ), + "Connecté · aucun usage disponible", + ) + + def test_c3_explanations_do_not_grant_authority(self): + from interoperability.presentation import present_connection + + base = { + "display_name": "Profil service", + "connected": True, + "enabled": True, + "status": "connected", + "health": "healthy", + "usable": False, + "capabilities": [], + } + no_capability = present_connection(base) + self.assertEqual(no_capability["experience_state"], "no_capability") + self.assertEqual( + no_capability["experience_message"], + "Aucun usage disponible pour cette connexion.", + ) + self.assertNotIn("execute_url", no_capability) + self.assertNotIn("connect_url", no_capability) + disabled = present_connection({**base, "enabled": False, "usable": True}) + self.assertEqual(disabled["experience_state"], "disabled") + unavailable = present_connection({**base, "health": "unavailable"}) + self.assertEqual(unavailable["experience_state"], "unavailable") + usable = present_connection({ + **base, "usable": True, "capabilities": ["text_generate"], + }) + self.assertEqual(usable["experience_state"], "usable") + self.assertEqual( + usable["experience_message"], "Ce service est prêt à être utilisé." + ) + self.assertNotIn("execute_url", usable) + + def test_action_metadata_does_not_promise_an_execution_link(self): + from interoperability.presentation import present_action + + row = present_action( + { + "code": "notifications.send", + "available": True, + "authorized": True, + "requires_connection": False, + } + ) + self.assertEqual(row["status_label"], "Capacité disponible dans Makolo") + self.assertNotIn("execute_url", row) + self.assertNotIn("connect_url", row) diff --git a/core/test_z2_personal_projections.py b/core/test_z2_personal_projections.py index 567ffc48e2..eda998739f 100644 --- a/core/test_z2_personal_projections.py +++ b/core/test_z2_personal_projections.py @@ -15,6 +15,7 @@ from core.api.personal_projections import ( build_personal_now_projection, build_personal_ongoing_projection, + _ongoing_temporal_facts, ) @@ -425,3 +426,34 @@ def test_waiting_is_ongoing_not_now_and_offer_is_both(self): and action["confirmation_required"] is True for action in decision["business_actions"] )) + + +class OngoingTemporalFactsTests(TestCase): + def test_only_owner_human_dates_are_projected(self): + item = { + "source": {"kind": "journey", "id": "j1"}, + "occurrence": {"kind": "occurrence", "id": "o1"}, + "timing": { + "start_date": "2026-11-12", + "timezone": "Africa/Kinshasa", + "updated_at": "2026-10-26T10:00:00Z", + }, + "next": { + "title": "Remettre le document", + "timing": {"due_at": "2026-11-10T13:00:00Z"}, + }, + } + facts = _ongoing_temporal_facts(item) + self.assertEqual([fact["kind"] for fact in facts], [ + "occurrence_start", "next_due", + ]) + self.assertEqual(facts[0]["source"]["kind"], "occurrence") + self.assertEqual(facts[1]["label"], "Remettre le document") + self.assertNotIn("updated_at", str(facts)) + + def test_no_technical_timestamp_becomes_calendar_fact(self): + self.assertEqual(_ongoing_temporal_facts({ + "source": {"kind": "project", "id": "p1"}, + "timing": {"created_at": "2026-10-20"}, + "next": None, + }), []) diff --git a/core/test_z4_me_capital_api.py b/core/test_z4_me_capital_api.py index 7c5d29dcd0..88df64afb1 100644 --- a/core/test_z4_me_capital_api.py +++ b/core/test_z4_me_capital_api.py @@ -1,4 +1,6 @@ from django.test import TestCase +from types import SimpleNamespace +from unittest.mock import patch from rest_framework.test import APIClient @@ -74,6 +76,95 @@ def test_considerations_keep_explicit_concepts_separate(self): self.assertEqual(data["watches"]["items"], []) self.assertNotIn("criteria", str(data["watches"])) + def test_bookmarked_media_reuses_published_recognition_only(self): + from activities.models import ActivityVisibility + + activity = Activity.objects.create( + owner_profile=self.user, + created_by=self.user, + title="Formation reconnue", + status=ActivityStatus.PUBLISHED, + visibility=ActivityVisibility.PUBLIC, + ) + ActivityBookmark.objects.create(user=self.user, activity=activity) + media = SimpleNamespace( + use_id="ea1a8fe1-3549-4ad1-86a2-bad932f2a235", + mime_type="image/jpeg", + alt_text="Salle de formation", + ) + with patch( + "presentations.media_selectors.public_presentation_media_for_activities", + return_value={str(activity.pk): [media]}, + ): + response = self.client.get("/api/v1/me/") + self.assertEqual(response.status_code, 200) + row = response.json()["data"]["considerations"]["bookmarks"]["items"][0] + binding = row["media_bindings"][0] + self.assertEqual(binding["purpose"], "RECOGNIZE") + self.assertEqual(binding["target"], {"kind": "activity", "id": str(activity.pk)}) + self.assertEqual(binding["resource"]["mime_type"], "image/jpeg") + self.assertEqual(binding["resource"]["alt_text"], "Salle de formation") + self.assertIn("/media/uses/", binding["resource"]["url"]) + self.assertNotIn("file_path", str(binding)) + + def test_saved_media_is_also_available_on_bounded_continuation(self): + from activities.models import ActivityVisibility + + activities = [] + for index in range(7): + activity = Activity.objects.create( + owner_profile=self.user, + created_by=self.user, + title=f"Possibilité {index}", + status=ActivityStatus.PUBLISHED, + visibility=ActivityVisibility.PUBLIC, + ) + ActivityBookmark.objects.create(user=self.user, activity=activity) + activities.append(activity) + oldest = activities[0] + media = SimpleNamespace( + use_id="ea1a8fe1-3549-4ad1-86a2-bad932f2a235", + mime_type="image/png", + alt_text="Lieu de la possibilité", + ) + with patch( + "presentations.media_selectors.public_presentation_media_for_activities", + return_value={str(oldest.pk): [media]}, + ): + root = self.client.get("/api/v1/me/") + page = self.client.get( + "/api/v1/me/preview/considerations/", + {"family": "bookmarks", "offset": 6, "limit": 24}, + ) + self.assertEqual(root.status_code, 200) + self.assertEqual(page.status_code, 200) + initial = root.json()["data"]["considerations"]["bookmarks"] + self.assertEqual(initial["next_offset"], 6) + self.assertTrue(initial["has_more"]) + continued = page.json()["data"]["collection"] + self.assertEqual(len(continued["items"]), 1) + self.assertFalse(continued["has_more"]) + row = continued["items"][0] + self.assertEqual(row["activity"]["id"], str(oldest.pk)) + self.assertEqual(row["media_bindings"][0]["purpose"], "RECOGNIZE") + + def test_private_bookmark_does_not_reveal_media(self): + from activities.models import ActivityVisibility + + activity = Activity.objects.create( + owner_profile=self.user, + created_by=self.user, + title="Contenu privé", + status=ActivityStatus.PUBLISHED, + visibility=ActivityVisibility.PRIVATE, + ) + ActivityBookmark.objects.create(user=self.user, activity=activity) + response = self.client.get("/api/v1/me/") + self.assertEqual(response.status_code, 200) + row = response.json()["data"]["considerations"]["bookmarks"]["items"][0] + self.assertEqual(row["activity"]["title"], "Contenu privé") + self.assertNotIn("media_bindings", row) + def test_team_membership_is_not_projected_as_authority(self): organization = Organization.objects.create( name="Collectif Z4", @@ -157,12 +248,159 @@ def test_passport_reuses_canonical_projection_and_respects_variant_privacy(self) "personal.me.passport", ) self.assertEqual(complete.json()["data"]["variant"], "complete") + self.assertIn("private_details", complete.json()["data"]) + self.assertEqual( + complete.json()["data"]["private_details"]["username"], + self.user.username, + ) self.assertEqual( complete.json()["data"]["declared"]["interests"][0]["topic"]["label"], "Sujet privé", ) self.assertEqual(public.json()["data"]["variant"], "public") + self.assertNotIn("private_details", public.json()["data"]) self.assertEqual(public.json()["data"]["declared"]["interests"], []) + for variant in ("thematic", "custom"): + response = self.client.get(f"/api/v1/me/passport/?variant={variant}") + self.assertEqual(response.status_code, 200) + self.assertNotIn("private_details", response.json()["data"]) + + def test_personalized_passport_api_requires_explicit_disclosure_selection(self): + self.user.bio = "Description privée pour mon Passeport" + self.user.metadata = {"avatar_url": "/media/private-personal-avatar.jpg"} + self.user.save(update_fields=["bio", "metadata", "updated_at"]) + + identity_only = self.client.get("/api/v1/me/passport/?variant=custom") + self.assertEqual(identity_only.status_code, 200) + empty = identity_only.json()["data"] + self.assertEqual(empty["identity"]["avatar_url"], "") + self.assertEqual(empty["identity"]["bio"], "") + self.assertEqual(empty["activities"], []) + self.assertNotIn("private_details", empty) + + deliberate = self.client.get( + "/api/v1/me/passport/?variant=custom&include=bio&include=avatar", + ) + self.assertEqual(deliberate.status_code, 200) + selected = deliberate.json()["data"] + self.assertEqual( + selected["identity"]["bio"], "Description privée pour mon Passeport", + ) + self.assertEqual( + selected["identity"]["avatar_url"], "/media/private-personal-avatar.jpg", + ) + self.assertNotIn("private_details", selected) + + def test_passport_collective_context_is_owner_only_and_not_authority(self): + organization = Organization.objects.create( + name="Collectif CV Z4", created_by=self.other, + ) + team = Team.objects.create( + organization=organization, name="Équipe CV Z4", is_active=True, + ) + TeamMembership.objects.create( + team=team, user=self.user, status=TeamMembershipStatus.ACTIVE, + ) + + complete = self.client.get("/api/v1/me/passport/") + self.assertEqual(complete.status_code, 200) + data = complete.json()["data"] + self.assertEqual( + data["collectives"]["team_affiliations"], + [{"team": "Équipe CV Z4", "space": "Collectif CV Z4"}], + ) + self.assertEqual(data["collectives"]["active_space_mandates"], []) + + for variant in ("public", "thematic", "custom"): + response = self.client.get(f"/api/v1/me/passport/?variant={variant}") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json()["data"]["collectives"]["team_affiliations"], [], + ) + self.assertEqual( + response.json()["data"]["collectives"]["active_space_mandates"], [], + ) + + def test_dated_history_api_never_reads_another_profiles_snapshots(self): + from sharing.passport import build_profile_passport + from sharing.passport_documents import issue_passport_snapshot + + mine = issue_passport_snapshot(build_profile_passport(self.user, variant="complete")) + another = issue_passport_snapshot(build_profile_passport(self.other, variant="complete")) + listing = self.client.get("/api/v1/me/passport/history/") + self.assertEqual(listing.status_code, 200) + self.assertEqual(listing.json()["data"]["count"], 1) + self.assertEqual(listing.json()["data"]["items"][0]["id"], str(mine.pk)) + self.assertEqual(listing["Cache-Control"], "private, no-store") + one = self.client.get(f"/api/v1/me/passport/history/{mine.pk}/") + self.assertEqual(one.status_code, 200) + self.assertEqual(one.json()["data"]["document_id"], mine.document_id) + denied = self.client.get(f"/api/v1/me/passport/history/{another.pk}/") + self.assertEqual(denied.status_code, 404) + + def test_issued_passport_share_api_is_owner_scoped_without_reissuance(self): + from sharing.models import PassportSnapshot + from sharing.passport import build_profile_passport + from sharing.passport_documents import issue_passport_snapshot + + mine = issue_passport_snapshot(build_profile_passport( + self.user, variant="custom", selected_sections=("bio",), + )) + foreign = issue_passport_snapshot(build_profile_passport( + self.other, variant="custom", selected_sections=("bio",), + )) + before = PassportSnapshot.objects.count() + result = self.client.post( + f"/api/v1/me/passport/history/{mine.pk}/share/", + {"days": 7}, format="json", + ) + self.assertEqual(result.status_code, 200) + self.assertIn("passport/shared/", result.json()["data"]["share_url"]) + self.assertEqual(PassportSnapshot.objects.count(), before) + self.assertEqual(result["Cache-Control"], "private, no-store") + + denied = self.client.post( + f"/api/v1/me/passport/history/{foreign.pk}/share/", + {"days": 7}, format="json", + ) + self.assertEqual(denied.status_code, 404) + + complete = issue_passport_snapshot(build_profile_passport( + self.user, variant="complete", + )) + invalid = self.client.post( + f"/api/v1/me/passport/history/{complete.pk}/share/", + {"days": 7}, format="json", + ) + self.assertEqual(invalid.status_code, 400) + + def test_passport_pdf_is_authenticated_and_issues_verifiable_snapshot(self): + from sharing.models import PassportSnapshot + + topic = Topic.objects.create(code="z4-pdf", label="Culture") + ProfileInterest.objects.create(profile=self.user, topic=topic, is_public=True) + response = self.client.get("/api/v1/me/passport/pdf/?variant=complete") + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/pdf") + self.assertTrue(response.content.startswith(b"%PDF")) + self.assertEqual(response["Cache-Control"], "private, no-store") + snapshot = PassportSnapshot.objects.get(profile=self.user) + self.assertEqual(snapshot.variant, "complete") + self.assertIn("Culture", str(snapshot.payload["interests"])) + + self.client.force_authenticate(user=None) + anonymous = self.client.get("/api/v1/me/passport/pdf/") + self.assertIn(anonymous.status_code, (401, 403)) + self.assertEqual(PassportSnapshot.objects.count(), 1) + + def test_passport_pdf_rejects_invalid_custom_selection(self): + topic = Topic.objects.create(code="z4-pdf-private", label="Sujet") + ProfileInterest.objects.create(profile=self.user, topic=topic) + response = self.client.get( + "/api/v1/me/passport/pdf/?variant=custom&activity=" + "00000000-0000-0000-0000-000000000001&include=interests" + ) + self.assertEqual(response.status_code, 403) def test_partner_projection_is_personal_and_minimally_disclosed(self): organization = Organization.objects.create( diff --git a/core/test_z6_day_of_api.py b/core/test_z6_day_of_api.py index 56351dba3e..70c9d80327 100644 --- a/core/test_z6_day_of_api.py +++ b/core/test_z6_day_of_api.py @@ -192,6 +192,61 @@ def test_access_is_composed_directly_but_credential_secret_stays_in_protected_de self.assertNotIn(render_access_credential(self.credential), rendered) self.assertNotIn("payload", rendered.lower()) + def test_credential_offline_presentation_is_finite_and_beneficiary_only(self): + path = f"/api/v1/me/accesses/{self.access.pk}/credential/" + self.client.force_authenticate(self.participant) + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Cache-Control"], "private, no-store") + policy = response.json()["data"]["offline_presentation"] + self.assertEqual(policy["scope"], "display_only") + self.assertTrue(policy["allowed"]) + self.assertEqual( + policy["expires_at"], + min(self.access.valid_until, self.occurrence.end_at).isoformat(), + ) + self.assertNotIn("payload", policy) + self.client.force_authenticate(self.outsider) + self.assertEqual(self.client.get(path).status_code, 404) + + def test_open_ended_right_does_not_grant_device_qr_retention(self): + self.access.valid_until = None + self.access.save(update_fields=["valid_until", "updated_at"]) + self.occurrence.end_at = None + self.occurrence.save(update_fields=["end_at", "updated_at"]) + self.client.force_authenticate(self.participant) + response = self.client.get( + f"/api/v1/me/accesses/{self.access.pk}/credential/" + ) + self.assertEqual(response.status_code, 200) + policy = response.json()["data"]["offline_presentation"] + self.assertFalse(policy["allowed"]) + self.assertIsNone(policy["expires_at"]) + + def test_access_summary_and_detail_retain_practical_day_j_instructions(self): + self.client.force_authenticate(self.participant) + listing = self.client.get("/api/v1/me/accesses/") + self.assertEqual(listing.status_code, 200) + row = listing.json()["data"]["items"][0] + self.assertEqual( + row["occurrence"]["place"]["access_instructions"], + "Présentez-vous à l’accueil.", + ) + self.assertEqual( + row["occurrence"]["place"]["address_line"], + "Avenue du Départ", + ) + self.assertNotIn("payload", str(row)) + detail = self.client.get( + f"/api/v1/me/accesses/{self.access.pk}/" + ) + self.assertEqual(detail.status_code, 200) + place = detail.json()["data"]["occurrence"]["place"] + self.assertEqual( + place["access_instructions"], "Présentez-vous à l’accueil." + ) + self.assertEqual(place["address_line"], "Avenue du Départ") + def test_active_occurrence_hands_off_to_existing_operations_live(self): self.occurrence.start_at = self.now - timedelta(minutes=5) self.occurrence.end_at = self.now + timedelta(hours=2) diff --git a/core/tests.py b/core/tests.py index 297b96d634..de39eb094f 100644 --- a/core/tests.py +++ b/core/tests.py @@ -34,8 +34,8 @@ def test_public_home_is_available_without_authentication(self): response = self.client.get(reverse("core:home")) self.assertEqual(response.status_code, 200) - self.assertContains(response, "Faites avancer ce qui compte.") - self.assertContains(response, "Trouvez ce que vous voulez vivre, faire ou obtenir.") + self.assertContains(response, "Ça vous intéresserait") + self.assertContains(response, "Trouvez des activités, des services, des occasions") self.assertContains(response, "Découvrir") self.assertContains(response, "Espaces") self.assertContains(response, "Créer un compte") diff --git a/core/urls.py b/core/urls.py index 741df6f8e3..6a40725d4a 100644 --- a/core/urls.py +++ b/core/urls.py @@ -6,7 +6,7 @@ from .transverse_search_web import PersonalSearchWebView from .interoperability_views import PersonalConnectionsView from .m8c_participant_views import ParticipantJourneyDetailView, ParticipantOccurrenceLiveView -from .mature_experience_views import MakoloMarkView, MatureParticipantMeView, MatureParticipantOngoingView +from .mature_experience_views import MakoloMarkView, MatureParticipantMeView, MatureParticipantOngoingView, MatureParticipantConsiderationsView from .participant_views import ( ParticipantAccessDetailView, ParticipantAccessListView, @@ -30,6 +30,7 @@ path("me/", MatureParticipantHomeView.as_view(), name="participant-home"), path("me/ongoing/", MatureParticipantOngoingView.as_view(), name="participant-ongoing"), path("me/moi/", MatureParticipantMeView.as_view(), name="participant-me"), + path("me/moi/choix/", MatureParticipantConsiderationsView.as_view(), name="participant-me-choices"), path("me/connections/", PersonalConnectionsView.as_view(), name="participant-connections"), path("mark/", MakoloMarkView.as_view(), name="makolo-mark"), path("me/journeys/", ParticipantJourneyListView.as_view(), name="participant-journeys"), diff --git a/demo_seed/beta.py b/demo_seed/beta.py index 84f7feb97e..ee61887e1c 100644 --- a/demo_seed/beta.py +++ b/demo_seed/beta.py @@ -84,10 +84,10 @@ def _users(ctx): def _space(key, name, owner, *, archetype=SpaceArchetype.GENERIC): org = upsert(Organization, f"beta-{key}", defaults={ - "name": name, "slug": f"beta-{key}", "archetype": archetype, "description": f"Espace fictif {name}.", "contact_email": f"contact.beta.{key}@makolo.test", + "name": name, "slug": f"beta-{key}", "archetype": archetype, "description": f"Coordination des activités et des services de {name}.", "contact_email": f"contact.beta.{key}@makolo.test", "country": "CD", "city": "Lubumbashi", "public_profile": True, "verification_status": "verified", "created_by": owner, }) - team = upsert(Team, f"beta-{key}", defaults={"organization": org, "name": "Équipe bêta", "is_default": True, "is_active": True}) + team = upsert(Team, f"beta-{key}", defaults={"organization": org, "name": "Équipe de coordination", "is_default": True, "is_active": True}) return org, team @@ -107,7 +107,7 @@ def _place(key, name, locality, lat, lon, owner): def _event(ctx, key, space, manager, category, place, title, days, hour, *, status=ActivityStatus.PUBLISHED, visibility=ActivityVisibility.PUBLIC, capacity=80, price=None, mode=PaymentMode.NONE): activity = upsert(Activity, f"beta-event-{key}", defaults={ "space": space, "created_by": manager, "title": title, "slug": f"beta-event-{key}", - "short_description": f"Scénario bêta — {title}", "description": "Événement fictif pour la bêta Makolo.", + "short_description": f"Rencontre autour de {title.lower()}.", "description": "Consultez le programme, les horaires et les modalités de participation.", "status": status, "visibility": visibility, }) start = _at(ctx, days, hour) @@ -138,13 +138,13 @@ def _event(ctx, key, space, manager, category, place, title, days, hour, *, stat }) ticket_type = upsert(TicketType, f"beta-event-{key}", defaults={ "event": event, "offer": offer, "capacity_pool": pool, "name": "Standard", "slug": "standard", - "description": "Projection Event du tarif canonique.", "is_public": True, + "description": "Informations et modalités associées à cette catégorie de billet.", "is_public": True, }) return {"activity": activity, "occurrence": occ, "event": event, "pool": pool, "offer": offer, "ticket_type": ticket_type} def _events(ctx, users, space, places): - category = upsert(EventCategory, "beta", defaults={"name": "Communauté bêta", "slug": "beta", "is_active": True}) + category = upsert(EventCategory, "beta", defaults={"name": "Communauté locale", "slug": "beta", "is_active": True}) w = _weekend(ctx) specs = [ ("today", "Rencontre Makolo aujourd’hui", 0, 20, 60, "0.00", PaymentMode.NONE, ActivityStatus.PUBLISHED, ActivityVisibility.PUBLIC, "lub"), @@ -176,13 +176,13 @@ def _transport(ctx, users, space, places): TransportRouteStop.objects.update_or_create(route=route, position=2, defaults={"place": destination, "boarding_allowed": False, "alighting_allowed": True}) activity = upsert(Activity, f"beta-transport-{key}", defaults={ "space": space, "created_by": users["transport_operator"], "title": route.name, "slug": f"beta-transport-{key}", - "short_description": "Trajet routier fictif de la bêta Makolo.", "description": "Transport canonique indépendant d’Events.", + "short_description": "Trajet routier avec départs programmés.", "description": "Informations sur le parcours, le départ et les modalités d’accès.", "status": ActivityStatus.PUBLISHED, "visibility": ActivityVisibility.PUBLIC, }) service = upsert(TransportService, f"beta-{key}", defaults={"activity": activity, "route": route, "mode": "road"}) route_data[key] = (route, activity, service, origin) - coach = upsert(Vehicle, "beta-coach", defaults={"space": space, "label": "Autocar bêta 40", "vehicle_type": "bus", "passenger_capacity": 40, "active": True}) - mini = upsert(Vehicle, "beta-mini", defaults={"space": space, "label": "Minibus bêta 18", "vehicle_type": "minibus", "passenger_capacity": 18, "active": True}) + coach = upsert(Vehicle, "beta-coach", defaults={"space": space, "label": "Autocar 40 places", "vehicle_type": "bus", "passenger_capacity": 40, "active": True}) + mini = upsert(Vehicle, "beta-mini", defaults={"space": space, "label": "Minibus 18 places", "vehicle_type": "minibus", "passenger_capacity": 18, "active": True}) w = _weekend(ctx) specs = [ ("history", "lub-kol", -7, 8, coach, 40, "20", PaymentMode.UPFRONT), ("today", "lub-kol", 0, 18, coach, 40, "20", PaymentMode.UPFRONT), @@ -291,7 +291,7 @@ def _flows(ctx, users, event_space, transport_space, events, trips): def _scanner(ctx, users, events, trips): scanner = users["scanner"] grant_activity_role(profile=scanner, activity=events["paid"]["activity"], role=SystemRoleCode.ACTIVITY_SCANNER, granted_by=users["space_admin"], source="makolo-beta") - gate = upsert(EventAccessGate, "beta-gate", defaults={"event": events["paid"]["event"], "name": "Entrée bêta", "slug": "entree-beta", "is_active": True, "created_by": users["event_manager"]}) + gate = upsert(EventAccessGate, "beta-gate", defaults={"event": events["paid"]["event"], "name": "Entrée principale", "slug": "entree-beta", "is_active": True, "created_by": users["event_manager"]}) upsert(ScannerAssignment, "beta-event", defaults={"activity": events["paid"]["activity"], "occurrence": events["paid"]["occurrence"], "event": events["paid"]["event"], "agent": scanner, "assigned_by": users["event_manager"], "access_gate": gate, "label": "Entrée bêta", "is_active": True, "valid_from": ctx.as_of - timedelta(days=1), "valid_until": events["paid"]["occurrence"].end_at + timedelta(hours=1)}) grant_activity_role(profile=scanner, activity=trips["weekend"]["activity"], role=SystemRoleCode.ACTIVITY_SCANNER, granted_by=users["space_admin"], source="makolo-beta") upsert(ScannerAssignment, "beta-transport", defaults={"activity": trips["weekend"]["activity"], "occurrence": trips["weekend"]["occurrence"], "agent": scanner, "assigned_by": users["transport_operator"], "label": "Embarquement bêta", "is_active": True, "valid_from": ctx.as_of - timedelta(days=1), "valid_until": trips["weekend"]["occurrence"].end_at + timedelta(hours=1)}) @@ -312,20 +312,20 @@ def _notifications(ctx, users): def _console_data(ctx, users, space, paid): p = users["participant"] contacts = [] - for key, user, email, name, consent in [("participant", p, p.email, p.full_name, MarketingConsent.UNKNOWN), ("subscribed", None, "beta.contact.subscribed@makolo.test", "Contact abonné bêta", MarketingConsent.SUBSCRIBED), ("unsubscribed", None, "beta.contact.unsubscribed@makolo.test", "Contact désabonné bêta", MarketingConsent.UNSUBSCRIBED)]: + for key, user, email, name, consent in [("participant", p, p.email, p.full_name, MarketingConsent.UNKNOWN), ("subscribed", None, "beta.contact.subscribed@makolo.test", "Contact abonné", MarketingConsent.SUBSCRIBED), ("unsubscribed", None, "beta.contact.unsubscribed@makolo.test", "Contact désabonné bêta", MarketingConsent.UNSUBSCRIBED)]: contacts.append(upsert(CRMContact, f"beta-{key}", defaults={"organization": space, "user": user, "email": email, "name": name, "source": ContactSource.MANUAL, "marketing_consent": consent, "first_seen_at": ctx.as_of - timedelta(days=20), "last_seen_at": ctx.as_of, "metadata": {"seed": "makolo-beta"}})) - audience = upsert(Audience, "beta-active", defaults={"organization": space, "name": "Participants bêta actifs", "description": "L’appartenance ne vaut pas consentement marketing.", "status": AudienceStatus.ACTIVE, "created_by": users["marketing"]}) + audience = upsert(Audience, "beta-active", defaults={"organization": space, "name": "Participants actifs", "description": "L’appartenance ne vaut pas consentement marketing.", "status": AudienceStatus.ACTIVE, "created_by": users["marketing"]}) upsert(AudienceMember, "beta-participant", defaults={"audience": audience, "profile": p, "source": AudienceMemberSource.MANUAL}) - promo = upsert(Promotion, "beta-active", defaults={"organization": space, "name": "Bêta 10%", "description": "Promotion fictive active.", "discount_type": DiscountType.PERCENT, "discount_value": Decimal("10"), "starts_at": ctx.as_of - timedelta(days=2), "ends_at": ctx.as_of + timedelta(days=10), "max_redemptions": 50, "is_active": True, "created_by": users["marketing"]}) + promo = upsert(Promotion, "beta-active", defaults={"organization": space, "name": "Réduction de 10 %", "description": "Réduction applicable pendant la période annoncée.", "discount_type": DiscountType.PERCENT, "discount_value": Decimal("10"), "starts_at": ctx.as_of - timedelta(days=2), "ends_at": ctx.as_of + timedelta(days=10), "max_redemptions": 50, "is_active": True, "created_by": users["marketing"]}) upsert(PromotionCode, "beta-active", defaults={"promotion": promo, "code": "BETA10", "starts_at": ctx.as_of - timedelta(days=2), "ends_at": ctx.as_of + timedelta(days=10), "is_active": True, "created_by": users["marketing"]}) upsert(PromotionTargeting, "beta-active", defaults={"promotion": promo, "activity": paid["activity"], "audience": audience}) upsert(PromotionOffer, "beta-active", defaults={"promotion": promo, "offer": paid["offer"], "source": "canonical"}) - upsert(Promotion, "beta-expired", defaults={"organization": space, "name": "Bêta expirée", "discount_type": DiscountType.PERCENT, "discount_value": Decimal("5"), "starts_at": ctx.as_of - timedelta(days=30), "ends_at": ctx.as_of - timedelta(days=1), "is_active": False, "created_by": users["marketing"]}) - group = upsert(Group, "beta-community", defaults={"name": "Communauté bêta Events", "slug": "beta-event-community", "description": "Groupe fictif de l’Espace.", "space": space, "created_by": users["event_manager"], "status": GroupStatus.ACTIVE, "visibility": GroupVisibility.SPACE}) + upsert(Promotion, "beta-expired", defaults={"organization": space, "name": "Promotion achevée", "discount_type": DiscountType.PERCENT, "discount_value": Decimal("5"), "starts_at": ctx.as_of - timedelta(days=30), "ends_at": ctx.as_of - timedelta(days=1), "is_active": False, "created_by": users["marketing"]}) + group = upsert(Group, "beta-community", defaults={"name": "Communauté des événements", "slug": "beta-event-community", "description": "Échanges autour des rencontres et de leur organisation.", "space": space, "created_by": users["event_manager"], "status": GroupStatus.ACTIVE, "visibility": GroupVisibility.SPACE}) for key in ["participant", "marketing", "event_manager"]: upsert(GroupMembership, f"beta-{key}", defaults={"group": group, "profile": users[key], "status": GroupMembershipStatus.ACTIVE, "source": GroupMembershipSource.MANUAL, "joined_at": ctx.as_of - timedelta(days=7)}) - upsert(AutomationRule, "beta-disabled", defaults={"space": space, "activity": paid["activity"], "name": "Rappel billet bêta (désactivé)", "trigger_event_type": DomainEventType.ACCESS_ISSUED, "conditions": {}, "action_kind": DomainAutomationActionKind.NOTIFICATION, "action_config": {"recipient": "beneficiary", "title": "Billet disponible", "message": "Votre billet Makolo est disponible.", "category": "ticket", "queue_email": False}, "is_active": False, "created_by": users["event_manager"]}) - for key, status, title, resolution in [("open", IncidentStatus.OPEN, "Incident bêta ouvert", ""), ("investigating", IncidentStatus.INVESTIGATING, "Incident bêta en cours", ""), ("resolved", IncidentStatus.RESOLVED, "Incident bêta résolu", "Résolution fictive validée.")]: + upsert(AutomationRule, "beta-disabled", defaults={"space": space, "activity": paid["activity"], "name": "Rappel de billet (inactif)", "trigger_event_type": DomainEventType.ACCESS_ISSUED, "conditions": {}, "action_kind": DomainAutomationActionKind.NOTIFICATION, "action_config": {"recipient": "beneficiary", "title": "Billet disponible", "message": "Votre billet Makolo est disponible.", "category": "ticket", "queue_email": False}, "is_active": False, "created_by": users["event_manager"]}) + for key, status, title, resolution in [("open", IncidentStatus.OPEN, "Orientation à clarifier", ""), ("investigating", IncidentStatus.INVESTIGATING, "Accès en cours de réorganisation", ""), ("resolved", IncidentStatus.RESOLVED, "Signalisation corrigée", "Résolution fictive validée.")]: upsert(OperationsIncident, f"beta-{key}", defaults={"title": title, "category": IncidentCategory.SUPPORT, "severity": IncidentSeverity.LOW, "status": status, "organization": space, "activity": paid["activity"], "occurrence": paid["occurrence"], "description": "Incident fictif pour Operations.", "resolution": resolution, "opened_by": users["staff"], "assigned_to": users["staff"], "detected_at": ctx.as_of - timedelta(days=2), "resolved_at": ctx.as_of - timedelta(days=1) if status == IncidentStatus.RESOLVED else None, "metadata": {"seed": "makolo-beta"}}) ctx.contacts, ctx.promotions = contacts, [promo] for label, count in [("beta_contacts", 3), ("beta_audiences", 1), ("beta_promotions", 2), ("beta_groups", 1), ("beta_automation_rules", 1), ("beta_operations_incidents", 3)]: ctx.add(label, count) @@ -352,7 +352,7 @@ def seed_beta(ctx: SeedContext) -> None: places = {"lub": _place("lub", "Centre Makolo Lubumbashi", "Lubumbashi", "-11.664700", "27.479400", users["space_admin"]), "kol": _place("kol", "Agence Makolo Kolwezi", "Kolwezi", "-10.716700", "25.466700", users["space_admin"]), "lik": _place("lik", "Maison Makolo Likasi", "Likasi", "-10.981400", "26.733300", users["space_admin"]), "text": _place("text", "Lieu sans coordonnées — bêta", "Lubumbashi", None, None, users["space_admin"])} for space, keys in [(event_space, ["lub", "kol", "lik", "text"]), (transport_space, ["lub", "kol"])]: for i, key in enumerate(keys): SpacePlace.objects.update_or_create(organization=space, place=places[key], role=SpacePlaceRole.SERVICE_POINT, defaults={"public_label": places[key].name, "is_primary": i == 0, "is_public": True, "is_active": True, "position": i}) - upsert(Zone, "beta-nearby", defaults={"name": "Autour de Lubumbashi — bêta", "zone_type": ZoneType.RADIUS, "country_code": "CD", "administrative_area": "Haut-Katanga", "locality": "Lubumbashi", "center_place": places["lub"], "radius_m": 25000, "is_active": True, "created_by": users["space_admin"]}) + upsert(Zone, "beta-nearby", defaults={"name": "Autour de Lubumbashi", "zone_type": ZoneType.RADIUS, "country_code": "CD", "administrative_area": "Haut-Katanga", "locality": "Lubumbashi", "center_place": places["lub"], "radius_m": 25000, "is_active": True, "created_by": users["space_admin"]}) events = _events(ctx, users, event_space, places) trips = _transport(ctx, users, transport_space, places) _flows(ctx, users, event_space, transport_space, events, trips) diff --git a/demo_seed/events_commerce.py b/demo_seed/events_commerce.py index 7baa8da47f..e3a70469ab 100644 --- a/demo_seed/events_commerce.py +++ b/demo_seed/events_commerce.py @@ -123,7 +123,7 @@ def seed_events_and_commerce(ctx: SeedContext) -> None: categories[slug] = upsert(EventCategory, slug, defaults={ "name": name, "slug": slug, - "description": f"Événements de démonstration Makolo : {name}.", + "description": f"Organisation et coordination d’événements : {name}.", "is_active": True, }) @@ -142,7 +142,7 @@ def seed_events_and_commerce(ctx: SeedContext) -> None: }) venues.append(venue) online = upsert(EventVenue, "online", defaults={ - "name": "Makolo Live Room — Démo", + "name": "Makolo Live Room", "kind": VenueKind.ONLINE, "address": "", "city": "", @@ -193,10 +193,10 @@ def seed_events_and_commerce(ctx: SeedContext) -> None: "venue": venue, "title": title, "slug": f"demo-{year}-{i+1:02d}-{stable_token(title, 6)}", - "short_description": f"{title} : une expérience Makolo pensée pour la communauté de {city}.", + "short_description": f"Rencontre à {city} autour de {title.lower()}.", "description": ( - f"{title} réunit participants, intervenants et partenaires autour d'une expérience " - f"réaliste de démonstration. Billetterie, communication, accès et suivi sont gérés dans Makolo." + f"Retrouvez le programme de {title}, les horaires et les modalités de participation " + f"pour cette rencontre à {city}." ), "status": status, "visibility": visibility, @@ -374,7 +374,7 @@ def seed_events_and_commerce(ctx: SeedContext) -> None: "idempotency_key": f"demo-payment-{order_seq:06d}", "checkout_url": "", "failure_code": "DECLINED_DEMO" if payment_status == PaymentStatus.FAILED else "", - "failure_message": "Paiement de démonstration refusé par le sandbox." if payment_status == PaymentStatus.FAILED else "", + "failure_message": "Le règlement n’a pas pu aboutir." if payment_status == PaymentStatus.FAILED else "", "metadata": {"seed": "makolo-demo", "channel": "web"}, "processed_at": created + timedelta(minutes=5) if payment_status not in {PaymentStatus.PENDING, PaymentStatus.PROCESSING} else None, "succeeded_at": created + timedelta(minutes=5) if payment_status in {PaymentStatus.SUCCEEDED, PaymentStatus.REFUNDED} else None, @@ -501,7 +501,7 @@ def _seed_scanner(ctx: SeedContext) -> None: "event": event, "name": gate_name, "slug": f"gate-{j+1}", - "description": f"{gate_name} — scénario de démonstration.", + "description": f"Point de passage : {gate_name}.", "is_active": True, "throughput_target_per_minute": 25 if j == 0 else 12, "warning_rejection_rate": 25, @@ -524,7 +524,7 @@ def _seed_scanner(ctx: SeedContext) -> None: "is_active": True, "valid_from": event.start_at - timedelta(hours=2), "valid_until": event.end_at + timedelta(hours=1), - "notes": "Affectation de démonstration.", + "notes": "Affectation au poste prévu pour cette session.", }) backdate(assignment, created_at=event.start_at - timedelta(days=7), updated_at=event.start_at - timedelta(days=1)) assignments.append(assignment) diff --git a/demo_seed/mobile_mature_universe.py b/demo_seed/mobile_mature_universe.py index f212a66f94..cba83b19db 100644 --- a/demo_seed/mobile_mature_universe.py +++ b/demo_seed/mobile_mature_universe.py @@ -378,24 +378,46 @@ def _occurrence_status(spec: dict) -> str: return OccurrenceStatus.SCHEDULED +def _demo_title(title: str) -> str: + """Restore natural titles from older seed snapshots without changing identity.""" + return title.removeprefix("Exemple — ").strip() + + def _activity_copy(spec: dict, vertical: str) -> tuple[str, str]: - place = spec.get("geo") or "lieu à confirmer" - relation = RELATION_LABELS.get(spec.get("relation"), spec.get("relation", "à suivre")) - state = STATE_LABELS.get(spec.get("state"), spec.get("state", "à suivre")) - intros = { - "event": "Un rendez-vous culturel, professionnel ou communautaire", - "transport": "Un déplacement inscrit dans les habitudes de mobilité d’Alain", - "service": "Un service utile à une démarche personnelle ou professionnelle", - "funding": "Une possibilité de financement ou de prise en charge", + """Contextual editorial copy; the seed's technical provenance remains separate.""" + title = _demo_title(spec["title"]) + place = spec.get("geo") + short_labels = { + "event": "Dates et repères de participation", + "transport": "Parcours et informations de départ", + "service": "Étapes et ressources de la démarche", + "funding": "Objectif et modalités du financement", + } + short = short_labels.get(vertical, "Informations et modalités pratiques") + if place: + short = f"{short} · {place}" + descriptions = { + "event": ( + f"Pour « {title} », prenez connaissance des dates et des informations " + "pratiques disponibles afin de préparer votre participation." + ), + "transport": ( + f"Pour préparer « {title} », retrouvez le parcours, le départ et les " + "indications de voyage lorsqu’elles sont précisées." + ), + "service": ( + f"« {title} » rassemble les informations utiles pour comprendre cette " + "démarche, repérer les ressources associées et envisager la suite." + ), + "funding": ( + f"Autour de « {title} », découvrez l’objectif de cette initiative et " + "examinez les modalités annoncées avant d’envisager une contribution." + ), } - intro = intros.get(vertical, "Une possibilité utile") - short = f"{intro}, à {place}. Situation actuelle : {state}." - description = ( - f"{spec['title']} fait partie du parcours d’Alain Kabeya. " - f"Cette réalité est localisée ou rattachée à {place} et apparaît comme {relation}. " - f"Les dates, accès, ressources et prochaines étapes associés sont disponibles dans Makolo." + return short, descriptions.get( + vertical, + f"Retrouvez les informations et les ressources associées à « {title} ».", ) - return short[:320], description def _activity(spec: dict, *, vertical: str, space: Organization, actor: User) -> Activity: @@ -406,7 +428,7 @@ def _activity(spec: dict, *, vertical: str, space: Organization, actor: User) -> defaults={ "space": space, "created_by": actor, - "title": spec["title"], + "title": _demo_title(spec["title"]), "slug": f"mobile-mature-{spec['id'].lower()}", "short_description": short_description, "description": description, @@ -423,7 +445,7 @@ def _occurrence(ctx: SeedContext, spec: dict, *, activity: Activity, place: Plac f"mobile-mature-occ-{spec['id'].lower()}", defaults={ "activity": activity, - "label": spec["title"][:180], + "label": _demo_title(spec["title"])[:180], "start_at": start, "end_at": start + timedelta(hours=2 + index % 4), "timezone": place.timezone if place else "Africa/Lubumbashi", @@ -774,11 +796,21 @@ def _seed_funding(ctx, specs, *, space, actor, places, activities, occurrences): def _opportunity_summary(spec: dict) -> str: - relation = RELATION_LABELS.get(spec.get("relation"), "à explorer") - state = STATE_LABELS.get(spec.get("state"), "à suivre") + """Describe the synthetic scenario, not a real user's private journey.""" + situation = { + "engaged": "Le scénario permet d’explorer les étapes d’un dossier déjà commencé.", + "saved": "Il montre comment conserver une piste et revenir examiner ses conditions.", + "watched": "Il illustre le suivi des changements avant de prendre une décision.", + "not_eligible": "Il invite à vérifier les critères avant de préparer une candidature.", + "rejected": "Il permet de comprendre les suites possibles après un refus.", + "planned": "Il aide à organiser les questions à résoudre avant une candidature.", + "draft": "Il permet de préparer les informations nécessaires sans les transmettre.", + "discover_only": "Il aide à explorer les informations avant de décider de se lancer.", + }.get(spec.get("relation"), "Il permet d’examiner les informations et de préparer une suite.") return ( - f"{spec['title']} est une possibilité {relation} dans le parcours d’Alain. " - f"Elle est rattachée à {spec.get('geo') or 'un contexte à confirmer'} et apparaît actuellement comme {state}." + f"« {spec['title']} » fait partie des exemples de Makolo. {situation} " + "Les dates, les conditions et les résultats du scénario sont fictifs ; " + "aucune candidature réelle n’est ouverte par cette fiche." ) @@ -793,13 +825,13 @@ def _seed_opportunities(ctx, specs, *, curator, primary): defaults={ "opportunity": opportunity, "version": 1, - "title": spec["title"], + "title": _demo_title(spec["title"]), "summary": _opportunity_summary(spec), "issuer_name": ISSUERS[index % len(ISSUERS)], "opens_at": pivot - timedelta(days=30), "deadline_at": pivot + (timedelta(days=90) if spec["state"] not in TERMINAL_STATES else timedelta(days=1)), "timezone": "Africa/Lubumbashi", - "application_instructions": "Vérifier les conditions, préparer les pièces utiles et suivre la candidature depuis Makolo.", + "application_instructions": "Cet exemple vous permet de découvrir la préparation d’une candidature. Aucun dossier n’est transmis à un organisme réel.", "remote_allowed": index % 3 == 0, "created_by": curator, }, @@ -811,16 +843,16 @@ def _seed_opportunities(ctx, specs, *, curator, primary): f"mobile-mature-opportunity-source-{spec['id']}", defaults={ "opportunity": opportunity, - "source_type": OpportunitySourceType.OFFICIAL if public_url else OpportunitySourceType.AGGREGATOR, - "source_name": PUBLIC_SOURCE_LABELS.get(spec["provenance"], directory_name), + "source_type": OpportunitySourceType.USER_SUPPLIED, + "source_name": f"Référence indicative · {PUBLIC_SOURCE_LABELS.get(spec['provenance'], directory_name)}", "url": public_url or directory_url, "external_reference": f"mobile-mature:{spec['id']}", "is_primary": True, "status": OpportunitySourceStatus.ACTIVE, "discovered_at": ctx.as_of - timedelta(days=30), "last_checked_at": ctx.as_of - timedelta(days=1), - "verified_at": ctx.as_of - timedelta(days=1) if public_url else None, - "verified_by": curator if public_url else None, + "verified_at": None, + "verified_by": None, }, ) if revision.published_at is None: @@ -842,11 +874,10 @@ def _seed_obtentions(ctx, specs, *, space, actor, primary, places, activities, o existing = create_obtention( actor=actor, space=space, - title=spec["title"], - short_description=f"{spec['title']} — {RELATION_LABELS.get(spec.get('relation'), 'à suivre')}.", + title=_demo_title(spec["title"]), + short_description="Modalités de mise à disposition", description=( - f"{spec['title']} fait partie des choses qu’Alain souhaite acheter, louer, emprunter ou recevoir. " - f"Makolo suit la remise effective ou le droit d’usage, indépendamment du paiement." + "Consultez les conditions de mise à disposition et les étapes de la démarche." ), targets=[{"title": spec["title"], "quantity": "1", "unit": "unité"}], modes=[MODE_CYCLE[index % len(MODE_CYCLE)]], @@ -857,6 +888,12 @@ def _seed_obtentions(ctx, specs, *, space, actor, primary, places, activities, o existing.activity.slug = f"mobile-mature-{spec['id'].lower()}" existing.activity.save(update_fields=["slug", "updated_at"]) activity = existing.activity + # Existing seed-owned Obtention records must also regain the visible + # demonstration marker on a deterministic reseed. No real offer is touched. + expected_title = _demo_title(spec["title"]) + if activity.title != expected_title: + activity.title = expected_title + activity.save(update_fields=["title", "updated_at"]) # Keep Obtention enterable until the beneficiary Journey has been # created through the canonical service. Historical closure is applied # after personal relations are seeded. diff --git a/demo_seed/mobile_mature_validation.py b/demo_seed/mobile_mature_validation.py index 6800a0a758..0606124e6d 100644 --- a/demo_seed/mobile_mature_validation.py +++ b/demo_seed/mobile_mature_validation.py @@ -5,7 +5,7 @@ from funding.models import FundingDetails from journeys.models import Journey from obtention.models import ObtentionDetails -from opportunities.models import OpportunitySource +from opportunities.models import OpportunityRevision, OpportunitySource, OpportunitySourceType from preparation.models import ActivityResource, ResourceKind from services.models import ServiceDetails from transport.models import TransportService @@ -57,6 +57,14 @@ def assert_mobile_mature_demo_coverage() -> dict[str, int]: raise MobileMatureDemoValidationError("Le seed mature doit exposer au moins 150 Resources contextuelles.") if counts["mobile_mature_file_resources"] < 50: raise MobileMatureDemoValidationError("Le seed mature doit contenir un corpus média/fichier significatif.") + if activities.filter(title__startswith="Exemple — ").exists() or activities.filter(title__startswith="[DEMO]").exists(): + raise MobileMatureDemoValidationError("Une Activity de démonstration expose un marqueur technique dans son titre.") + synthetic_sources = OpportunitySource.objects.filter(external_reference__startswith="mobile-mature:") + if synthetic_sources.exclude(source_type=OpportunitySourceType.USER_SUPPLIED).exists() or synthetic_sources.filter(verified_at__isnull=False).exists(): + raise MobileMatureDemoValidationError("Une référence de démonstration ne doit pas certifier une offre fictive.") + synthetic_revisions = OpportunityRevision.objects.filter(opportunity__sources__external_reference__startswith="mobile-mature:") + if synthetic_revisions.filter(title__startswith="Exemple — ").exists() or synthetic_revisions.filter(title__startswith="[DEMO]").exists(): + raise MobileMatureDemoValidationError("Une candidature de démonstration expose un marqueur technique dans son titre.") if counts["mobile_mature_journeys"] < 30: raise MobileMatureDemoValidationError("Le persona principal doit posséder un historique/présent riche de Journeys.") for year in (2025, 2026, 2027): diff --git a/demo_seed/obtention.py b/demo_seed/obtention.py index cc575460ca..9402ee0f1a 100644 --- a/demo_seed/obtention.py +++ b/demo_seed/obtention.py @@ -24,9 +24,9 @@ def _space(owner): Organization, "beta-obtention", defaults={ - "name": "Makolo Beta Obtention", + "name": "Espace des démarches et ressources", "slug": "beta-obtention", - "description": "Espace fictif pour démontrer la verticale Obtention.", + "description": "Accès aux démarches, ressources et informations de mise à disposition.", "contact_email": "contact.beta.obtention@makolo.test", "country": "CD", "city": "Lubumbashi", @@ -52,6 +52,17 @@ def _ensure_obtention(*, actor, space, slug, title, targets, modes, result_label .first() ) if existing is not None: + # This slug belongs to the deterministic beta seed, not to a real offer. + expected_title = title + updates = {"title": expected_title} + for name in ("short_description", "description"): + if name in kwargs: + updates[name] = kwargs[name] + changed = [name for name, value in updates.items() if getattr(existing.activity, name) != value] + if changed: + for name in changed: + setattr(existing.activity, name, updates[name]) + existing.activity.save(update_fields=[*changed, "updated_at"]) return existing return create_obtention( actor=actor, @@ -102,7 +113,7 @@ def seed_obtention(ctx: SeedContext) -> None: slug="kit-scolaire-a-retirer", title="Kit scolaire à retirer", short_description="Recevoir un kit scolaire complet à Lubumbashi.", - description="Scénario bêta d’attribution réelle, indépendant du paiement.", + description="Préparation d’un kit de fournitures scolaires et coordination des étapes de retrait.", targets=[ {"title": "Sac scolaire", "quantity": "1", "unit": "pièce"}, {"title": "Cahiers", "quantity": "10", "unit": "pièces"}, @@ -169,7 +180,7 @@ def seed_obtention(ctx: SeedContext) -> None: slug="projecteur-4k", title="Projecteur 4K", short_description="Acheter ou louer un projecteur pour une activité.", - description="Scénario bêta montrant plusieurs modes sans créer de sous-verticale Achat/Location.", + description="Consultez les modalités de disponibilité du projecteur, à l’achat ou à la location.", targets=[{"title": "Projecteur 4K", "quantity": "1", "unit": "pièce"}], modes=[ObtentionModeCode.BUY, ObtentionModeCode.RENT], result_label="Projecteur effectivement obtenu", diff --git a/demo_seed/transport.py b/demo_seed/transport.py index 9e2913fdbe..699da48ffa 100644 --- a/demo_seed/transport.py +++ b/demo_seed/transport.py @@ -109,9 +109,9 @@ def seed_transport(ctx: SeedContext) -> None: defaults={ "space": space, "created_by": manager, - "title": "Lubumbashi → Kolwezi", - "short_description": "Trajet routier Mulykap", - "description": "Service routier régulier entre Lubumbashi et Kolwezi.", + "title": "Navette Lubumbashi → Kolwezi", + "short_description": "Départs routiers entre Lubumbashi et Kolwezi.", + "description": "Consultez les départs, les points d’embarquement et les modalités de réservation pour ce parcours.", "status": ActivityStatus.PUBLISHED, "visibility": ActivityVisibility.PUBLIC, }, diff --git a/discovery/api/composition.py b/discovery/api/composition.py index 7c85109ac3..010e77224b 100644 --- a/discovery/api/composition.py +++ b/discovery/api/composition.py @@ -1,5 +1,9 @@ from __future__ import annotations +import hashlib +import json +from urllib.parse import urlsplit + from dataclasses import dataclass from types import SimpleNamespace @@ -24,7 +28,6 @@ present_service_card, ) from discovery.intent import DiscoveryIntent, resolve_discovery_intent -from discovery.intelligence import interpret_with_intelligence from discovery.models import ActivityBookmark from discovery.presentation import aggregate_discovery_items, build_discovery_item from discovery.search import ( @@ -38,6 +41,7 @@ public_service_discovery_items, ) +from .media_bindings import apply_recognition_media from .projections import ( project_funding_possibility, project_occurrence_possibility, @@ -48,8 +52,31 @@ DISCOVERY_API_PAGE_SIZE = 20 DISCOVERY_API_MAX_PAGE_SIZE = 50 -DISCOVERY_RESOURCE_PREVIEW_LIMIT = 6 + DISCOVERY_CONTINUATION_SALT = "makolo.discovery.items.v1" +# Keep all public resource identities visible without embedding every full +# document in an N1 collection. Full TEXT lives at its owner read endpoint. +DISCOVERY_RESOURCE_INLINE_CHARS = 1400 +DISCOVERY_RESOURCE_ACTIVITY_INLINE_BUDGET = 8400 + + +def _safe_public_http_url(raw): + if not isinstance(raw, str) or raw != raw.strip() or any( + char in raw for char in "\r\n\t" + ): + return None + try: + parts = urlsplit(raw) + if ( + parts.scheme.lower() not in {"http", "https"} + or not parts.hostname + or parts.username is not None + or parts.password is not None + ): + return None + except ValueError: + return None + return raw @dataclass(frozen=True) @@ -65,7 +92,40 @@ def _authenticated(profile) -> bool: return bool(getattr(profile, "is_authenticated", False)) -def parse_page_params(params) -> tuple[int, int]: +def _continuation_context(params, *, profile=None) -> str: + """Bind a cursor to the exact request criteria and authenticated viewer. + + Page and continuation are navigation controls, not exploration criteria. + No client-provided profile or authority is ever trusted. + """ + if hasattr(params, "lists"): + criteria = { + key: sorted(str(value) for value in values) + for key, values in params.lists() + if key not in {"page", "continuation", "include_spatial_index"} + } + else: + criteria = { + str(key): [str(value)] + for key, value in params.items() + if key not in {"page", "continuation", "include_spatial_index"} + } + viewer = ( + str(profile.pk) + if _authenticated(profile) + else None + ) + canonical = json.dumps( + {"criteria": criteria, "viewer": viewer}, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def parse_page_params(params, *, profile=None) -> tuple[int, int]: + """A request without parameters is valid; a cursor must match its context.""" try: continuation = params.get("continuation") if continuation: @@ -74,6 +134,13 @@ def parse_page_params(params) -> tuple[int, int]: salt=DISCOVERY_CONTINUATION_SALT, max_age=60 * 60 * 24, ) + if ( + not isinstance(cursor, dict) + or cursor.get("context") != _continuation_context( + params, profile=profile + ) + ): + raise ValueError("Contexte de continuation incompatible.") page = max(int(cursor["page"]), 1) else: page = max(int(params.get("page") or 1), 1) @@ -82,7 +149,7 @@ def parse_page_params(params) -> tuple[int, int]: DISCOVERY_API_MAX_PAGE_SIZE, ) except (signing.BadSignature, KeyError, TypeError, ValueError) as exc: - raise ValidationError("Pagination invalide.") from exc + raise ValidationError("Pagination invalide ou continuation expiree.") from exc return page, page_size @@ -134,8 +201,10 @@ def compose_discovery(params, *, profile=None) -> DiscoveryComposition: legitimate end of results. """ + # Ordinary GET exploration must not make implicit paid external API calls. + # User-invoked POST /discovery/clarify/ resolves optional Intelligence; + # returned structured criteria can then be searched deterministically. intent = resolve_discovery_intent(params) - intent = interpret_with_intelligence(intent, profile=profile) search_params = intent.to_search_params() vertical = intent.vertical @@ -201,10 +270,26 @@ def _saved_opportunity_ids(profile, opportunity_ids) -> set[str]: } -def _activity_resource_previews(activity_ids) -> dict[str, list[dict]]: +def _activity_resource_previews(activity_ids, *, profile=None, occurrence_scope=None) -> dict[str, list[dict]]: + """Expose authorized resource metadata, with a bounded inline TEXT sample. + + Preparation retains all document content, publication and reader authority. + Discovery keeps *every* admitted resource identity, without a silent + six-item or other presentation cutoff; long text is opened via read_url. + """ if not activity_ids: return {} - from preparation.models import ActivityResource, ResourceKind, ResourceStatus, ResourceVisibility + from preparation.models import ( + ActivityResource, ResourceKind, ResourceStatus, ResourceVisibility, + ) + from preparation.services import can_view_resource + from groups.selectors import eligible_activity_ids_for_profile + + # Preserve the Preparation authority check for every resource, but resolve + # its Group gate for this viewer and admitted Activity set in one query. + eligible_ids = { + str(value) for value in eligible_activity_ids_for_profile(profile, activity_ids) + } rows = ( ActivityResource.objects.filter( @@ -212,14 +297,40 @@ def _activity_resource_previews(activity_ids) -> dict[str, list[dict]]: status=ResourceStatus.PUBLISHED, visibility=ResourceVisibility.PUBLIC, ) + .select_related("activity", "activity__space", "activity__owner_profile") .order_by("activity_id", "title", "-version", "id") ) result: dict[str, list[dict]] = {} + remaining_by_activity = {} + access_by_context = {} for resource in rows: activity_id = str(resource.activity_id) - bucket = result.setdefault(activity_id, []) - if len(bucket) >= DISCOVERY_RESOURCE_PREVIEW_LIMIT: + # A public occurrence-specific Resource for an unrelated session is + # not a fact of the currently presented variant. + if occurrence_scope is not None and resource.occurrence_id is not None: + if str(resource.occurrence_id) not in occurrence_scope.get(activity_id, set()): + continue + # For published/public resources, delivery authority depends on the + # Activity, viewer and occurrence context (not on the file type). + # Cache that check, but never cache it across viewers or requests. + access_key = (activity_id, str(resource.occurrence_id) if resource.occurrence_id else None) + if access_key not in access_by_context: + access_by_context[access_key] = can_view_resource( + profile, resource, eligible_activity_ids=eligible_ids, + ) + if not access_by_context[access_key]: continue + bucket = result.setdefault(activity_id, []) + text = resource.text_content if resource.kind == ResourceKind.TEXT else None + truncated = False + if text is not None: + remaining = remaining_by_activity.get( + activity_id, DISCOVERY_RESOURCE_ACTIVITY_INLINE_BUDGET, + ) + excerpt = text[: min(DISCOVERY_RESOURCE_INLINE_CHARS, remaining)] + truncated = len(excerpt) < len(text) + remaining_by_activity[activity_id] = remaining - len(excerpt) + text = excerpt bucket.append( { "id": str(resource.pk), @@ -228,10 +339,20 @@ def _activity_resource_previews(activity_ids) -> dict[str, list[dict]]: "kind": resource.kind, "mime_type": resource.mime_type or None, "external_url": ( - resource.external_url + _safe_public_http_url(resource.external_url) if resource.kind == ResourceKind.URL else None ), + "read_url": ( + reverse( + "preparation:resource-text", + kwargs={"resource_id": resource.pk}, + ) + if resource.kind == ResourceKind.TEXT + else None + ), + "text_content": text, + "text_truncated": truncated, "download_url": ( reverse( "preparation:resource-download", @@ -261,22 +382,142 @@ def _opportunity_source_previews(opportunity_ids) -> dict[str, list[dict]]: for source in sources: opportunity_id = str(source.opportunity_id) bucket = result.setdefault(opportunity_id, []) - if len(bucket) >= DISCOVERY_RESOURCE_PREVIEW_LIMIT: - continue bucket.append( { "id": str(source.pk), "title": source.source_name, - "description": "Source publique de référence.", + "description": "", "kind": "url", "mime_type": None, - "external_url": source.url, + "external_url": _safe_public_http_url(source.url), "download_url": None, } ) return result +def _public_occurrence_facts_for_rows(rows) -> tuple[dict[str, list[dict]], dict[str, list[dict]]]: + """Resolve public occurrence-place roles for selected Activity candidates. + + Only Occurrences already admitted by Discovery can be inspected here. + Reuse the same place-publication rule as Discovery's primary place. + The bounded wire contains locations, not inferred routes or coordinates. + """ + from django.core.exceptions import ObjectDoesNotExist + from discovery.presentation import _space_place_is_public + + selected = {} + for family, _, candidate in rows: + if family != "activity": + continue + selected[str(candidate.activity_id)] = set(candidate.matching_occurrence_ids) + + if not selected: + return {}, {} + activity_ids = list(selected) + occurrence_ids = {oid for ids in selected.values() for oid in ids} + from activities.models import ActivityStatus, ActivityVisibility, Occurrence, OccurrenceStatus + + # Exact identities have already passed the viewer-aware Discovery selector. + # Only load place relations here, not Commerce/Capacity prefetches. + queryset = ( + Occurrence.objects.filter( + pk__in=occurrence_ids, + activity_id__in=activity_ids, + activity__status=ActivityStatus.PUBLISHED, + activity__visibility=ActivityVisibility.PUBLIC, + status=OccurrenceStatus.SCHEDULED, + ) + .exclude(activity__space__verification_status="suspended") + .select_related("activity", "activity__space", "activity__transport_service__route") + .prefetch_related( + "place_links__place", + "activity__space__space_places", + "activity__transport_service__route__stops__place", + ) + .order_by("start_date", "start_time", "id") + ) + facts = {activity_id: [] for activity_id in activity_ids} + variants = {activity_id: [] for activity_id in activity_ids} + for occurrence in queryset: + activity_id = str(occurrence.activity_id) + if str(occurrence.pk) not in selected.get(activity_id, set()): + continue + if len(variants[activity_id]) < 12: + variants[activity_id].append({ + "occurrence": {"kind": "occurrence", "id": str(occurrence.pk)}, + "timing": { + "kind": occurrence.timing_kind, + "start_at": occurrence.start_at.isoformat() if occurrence.start_at else None, + "end_at": occurrence.end_at.isoformat() if occurrence.end_at else None, + "start_date": occurrence.start_date.isoformat() if occurrence.start_date else None, + "end_date": occurrence.end_date.isoformat() if occurrence.end_date else None, + "start_time": occurrence.start_time.isoformat() if occurrence.start_time else None, + "end_time": occurrence.end_time.isoformat() if occurrence.end_time else None, + "timezone": occurrence.timezone, + }, + }) + bucket = facts[activity_id] + if len(bucket) >= 12: + continue + for link in occurrence.place_links.all(): + place = link.place + if not place.is_active or not _space_place_is_public(occurrence.activity, place): + continue + bucket.append({ + "occurrence": {"kind": "occurrence", "id": str(occurrence.pk)}, + "role": link.role, + "place": { + "id": str(place.pk), + "name": place.name, + "locality": place.locality, + "latitude": float(place.latitude) if place.latitude is not None else None, + "longitude": float(place.longitude) if place.longitude is not None else None, + }, + }) + if len(bucket) >= 12: + break + + # Transport route stops are canonical multi-place facts even when an + # Occurrence has no explicit place_links. Their original positions + # determine departure/stopover/arrival; never reclassify after hiding + # a non-public stop. + if len(bucket) >= 12: + continue + try: + service = occurrence.activity.transport_service + except ObjectDoesNotExist: + service = None + if service is None: + continue + stops = list(service.route.stops.all()) + for index, stop in enumerate(stops): + place = stop.place + if not place.is_active or not _space_place_is_public(occurrence.activity, place): + continue + role = ( + "stop" + if len(stops) == 1 + else "departure" if index == 0 + else "arrival" if index == len(stops) - 1 + else "stopover" + ) + bucket.append({ + "occurrence": {"kind": "occurrence", "id": str(occurrence.pk)}, + "role": role, + "place": { + "id": str(place.pk), + "name": place.name, + "locality": place.locality, + "latitude": float(place.latitude) if place.latitude is not None else None, + "longitude": float(place.longitude) if place.longitude is not None else None, + }, + }) + if len(bucket) >= 12: + break + return facts, variants + + def _assessment(projection: dict) -> list[dict]: facts = [] availability = projection["availability"]["state"] @@ -341,13 +582,134 @@ def _assessment(projection: dict) -> list[dict]: { "state": "unknown", "code": "personal_relation_not_resolved", - "label": "Votre situation n’est pas encore déterminée ici", + "label": "Nous n’avons pas encore confirmé si cette possibilité est liée à une démarche ou à un accès vous concernant.", "provenance": "personal_relation", } ) return facts +def _public_owner_facts(activity_ids, opportunity_ids) -> tuple[dict, dict]: + """Batch owner identity and date facts without inferring ownership from created_by.""" + from accounts.models import UserProfile + from activities.models import Activity + from opportunities.models import Opportunity + from organizations.models import SpaceLifecycle + + activities = list( + Activity.objects.filter(pk__in=activity_ids) + .select_related("space", "owner_profile") + ) + profile_ids = [ + activity.owner_profile_id + for activity in activities + if activity.owner_profile_id is not None + ] + public_profiles = set( + UserProfile.objects.filter( + user_id__in=profile_ids, + public_profile=True, + searchable=True, + user__is_active=True, + ).values_list("user_id", flat=True) + ) + activity_facts = {} + for activity in activities: + owner = None + if activity.space_id is not None: + space = activity.space + if ( + space.public_profile + and space.searchable + and space.lifecycle == SpaceLifecycle.ACTIVE + and space.verification_status != "suspended" + ): + owner = { + "display_name": space.name, + "identity": {"kind": "space", "id": str(space.pk)}, + "representation": {"name": space.name, "avatar_url": None}, + "context": {"city": space.city, "country": space.country}, + "links": { + "public": reverse( + "organizer_public:detail", + kwargs={"slug": space.slug}, + ), + "native": f"/spaces/{space.slug}", + }, + "capabilities": ["view_public"], + } + elif activity.owner_profile_id in public_profiles: + person = activity.owner_profile + name = person.full_name or person.username + owner = { + "display_name": name, + "identity": {"kind": "profile", "id": str(person.pk)}, + "representation": { + "name": name, + "avatar_url": person.avatar.url if person.avatar else None, + }, + "context": {}, + "links": { + "public": reverse( + "account:public-profile", + kwargs={"profile_id": person.pk}, + ), + "native": f"/people/{person.pk}", + }, + "capabilities": ["view_public"], + } + activity_facts[str(activity.pk)] = { + "owner": owner, + "date": { + "kind": "added_at", + "label": "Ajouté le", + "value": activity.created_at.isoformat(), + }, + } + + opportunity_dates = { + str(pk): published_at.isoformat() if published_at else None + for pk, published_at in Opportunity.objects.filter( + pk__in=opportunity_ids, + ).values_list("pk", "published_at") + } + return activity_facts, opportunity_dates + + +def _service_handoffs(activity_ids, *, profile=None) -> dict[str, dict]: + """A Service start is prevalidated and executed by Services, not Discovery.""" + if not activity_ids: + return {} + from services.views import public_services + + authenticated = _authenticated(profile) + # The Activity has already passed public Discovery admission. Guests may + # see the existence of a start path, never acquire the start capability. + # Services remains the authority and revalidates upon authenticated entry. + return { + str(service.activity_id): { + "state": "owner_api", + "owner": "services", + "capabilities": ["start"] if authenticated else ["start_after_auth"], + "links": { + "start": reverse( + "services-api:start", kwargs={"pk": service.pk}, + ), + }, + "resource": {"kind": "service", "id": str(service.pk)}, + "presentation": ( + {} if authenticated else { + "capability": "start", + "label": "Commencer", + "enabled": False, + "requires_auth": True, + } + ), + } + for service in public_services().filter(activity_id__in=activity_ids) + } + + def _event_handoffs(activity_ids, *, profile=None) -> dict[str, dict]: if not activity_ids: return {} @@ -423,6 +785,8 @@ def sold_out(ticket_type): "orders": reverse("ticket-orders-list"), } public_types = types_by_event.get(event.pk, []) + if public_types: + links["native"] = f"/events/{event.slug}/tickets" if authenticated and any( sold_out(ticket_type) for ticket_type in public_types @@ -434,17 +798,153 @@ def sold_out(ticket_type): "owner": "events", "capabilities": capabilities, "links": links, + "resource": { + "kind": "event", + "id": str(event.pk), + "slug": event.slug, + }, + } + return result + + +def _obtention_handoffs(rows) -> dict[str, dict]: + """Native Obtention routes only for admitted published Activity candidates.""" + from obtention.selectors import public_obtentions + + admitted = { + str(candidate.activity_id) + for family, _, candidate in rows + if family == "activity" + } + if not admitted: + return {} + return { + str(obtention.activity_id): { + "state": "owner_api", + "owner": "obtention", + "capabilities": ["start_journey"], + "links": { + "detail": reverse("obtention_api:detail", kwargs={"pk": obtention.pk}), + "start_journey": reverse("obtention_api:journey-create", kwargs={"pk": obtention.pk}), + "native": f"/obtention/{obtention.pk}/choose", + }, + "resource": {"kind": "obtention", "id": str(obtention.pk)}, + } + for obtention in public_obtentions().filter(activity_id__in=admitted) + } + + +def _funding_handoffs(rows) -> dict[str, dict]: + """Use already-admitted Funding candidates and their owner service.""" + from funding.selectors import funding_accepts_contributions + + handoffs = {} + for family, _, candidate in rows: + if family != "funding_activity": + continue + funding = candidate["funding"] + if not funding_accepts_contributions(funding): + continue + handoffs[str(candidate["activity_id"])] = { + "state": "owner_api", + "owner": "funding", + "capabilities": ["contribute"], + "links": { + "detail": reverse("funding_api:public-detail", kwargs={"pk": funding.pk}), + "contribute": reverse("funding_api:contribute", kwargs={"pk": funding.pk}), + "native": f"/funding/{funding.pk}/contribute", + }, + "resource": {"kind": "funding", "id": str(funding.pk)}, + } + return handoffs + + +def _public_commerce_handoffs(activity_ids, *, profile=None): + """Owner-side offer admission; never infer a booking from a description.""" + if not activity_ids: + return {} + from activities.models import Activity + from commerce.models import Offer, OfferStatus + from django.http import Http404 + from commerce.native_activity_checkout_api import ( + activity_offer_choices, public_activity_for_commerce, + ) + # Discovery may contain many non-Commerce possibilities. Preselect the + # owner-backed candidates before the more expensive capacity checks. + offer_activity_ids = set( + Offer.objects.filter( + activity_id__in=activity_ids, + status=OfferStatus.ACTIVE, + ).values_list("activity_id", flat=True) + ) + if not offer_activity_ids: + return {} + result = {} + for activity in Activity.objects.filter(pk__in=offer_activity_ids).exclude( + event_vertical__isnull=False + ): + try: + public_activity_for_commerce(profile, activity.pk) + except Http404: + continue + if not activity_offer_choices(activity): + continue + result[str(activity.pk)] = { + "state": "owner_api", + "owner": "commerce", + "capabilities": ["inspect_offers"], + "resource": {"kind": "activity", "id": str(activity.pk)}, + "links": { + "offers": reverse("commerce_api:activity-checkout", kwargs={"activity_id": activity.pk}), + "native": f"/commerce/activities/{activity.pk}/checkout", + }, } return result -def _handoff_for_projection(projection: dict, *, event_handoffs=None) -> dict: +def _personal_engagement_handoffs(activity_ids, *, profile=None): + """Reuse the personal owner's confirmed Journey/Commerce relationships.""" + if not _authenticated(profile) or not activity_ids: + return {} + from commerce.native_checkout_api import personal_commerce_handoffs + + return personal_commerce_handoffs(activity_ids, actor=profile) + + +def _handoff_for_projection( + projection: dict, *, event_handoffs=None, service_handoffs=None, + funding_handoffs=None, obtention_handoffs=None, + personal_handoffs=None, commerce_handoffs=None, +) -> dict: resource = projection["identity"]["resource"] family = projection["identity"]["family"] + if family in {"activity", "service_activity", "funding_activity"} and personal_handoffs: + handoff = personal_handoffs.get(resource["id"]) + if handoff is not None: + occurrence = projection["identity"].get("occurrence") + candidate_occurrence_id = occurrence.get("id") if isinstance(occurrence, dict) else None + if not handoff.get("occurrence_id") or handoff["occurrence_id"] == candidate_occurrence_id: + return handoff + if family == "service_activity" and service_handoffs: + handoff = service_handoffs.get(resource["id"]) + if handoff is not None: + return handoff if family == "activity" and event_handoffs: handoff = event_handoffs.get(resource["id"]) if handoff is not None: return handoff + if family == "activity" and obtention_handoffs: + handoff = obtention_handoffs.get(resource["id"]) + if handoff is not None: + return handoff + if family == "funding_activity" and funding_handoffs: + handoff = funding_handoffs.get(resource["id"]) + if handoff is not None: + return handoff + if family == "activity" and commerce_handoffs: + handoff = commerce_handoffs.get(resource["id"]) + if handoff is not None: + return handoff return { "state": "owner_contract", "owner": { @@ -457,11 +957,174 @@ def _handoff_for_projection(projection: dict, *, event_handoffs=None) -> dict: } +def _primary_action_for_projection(projection: dict) -> dict: + """Select navigation from an owner-verified handoff, never from a title or price. + + Selection does not execute, reserve, charge or authorize anything. The + destination owner must re-check eligibility and permission on entry. + """ + engagement = projection["engagement"] + capabilities = set(engagement.get("capabilities") or ()) + links = engagement.get("links") or {} + resource = engagement.get("resource") or {} + presentation = engagement.get("presentation") or {} + if ( + engagement.get("owner") == "commerce" + and engagement.get("state") == "owner_api" + and resource.get("kind") == "activity" + and isinstance(resource.get("id"), str) + and "inspect_offers" in capabilities + and links.get("native") == f"/commerce/activities/{resource['id']}/checkout" + and links.get("offers") + ): + return { + "code": "inspect_offers", + "label": "Choisir une offre", + "icon": "shopping-bag", + "destination": {"kind": "native", "path": links["native"]}, + } + if ( + engagement.get("owner") == "commerce" + and engagement.get("state") == "owner_api" + and resource.get("kind") == "commerce_order" + and isinstance(resource.get("id"), str) + and "resume_order" in capabilities + and links.get("native") == f"/commerce/orders/{resource['id']}" + ): + return { + "code": "resume_order", + "label": "Ma commande", + "icon": "receipt", + "destination": {"kind": "native", "path": links["native"]}, + } + if ( + engagement.get("owner") == "commerce" + and engagement.get("state") == "owner_api" + and resource.get("kind") == "journey" + and isinstance(resource.get("id"), str) + and "checkout" in capabilities + and links.get("native") == f"/commerce/journeys/{resource['id']}/checkout" + and links.get("checkout") + ): + return { + "code": "checkout", + "label": "Choisir une offre", + "icon": "shopping-bag", + "destination": {"kind": "native", "path": links["native"]}, + } + if ( + engagement.get("owner") == "journeys" + and engagement.get("state") == "owner_api" + and resource.get("kind") == "journey" + and isinstance(resource.get("id"), str) + and "continue" in capabilities + and links.get("native") == f"/journeys/{resource['id']}" + ): + return { + "code": "continue", + "label": "Continuer", + "icon": "arrow", + "destination": {"kind": "native", "path": links["native"]}, + } + if ( + engagement.get("owner") == "services" + and engagement.get("state") == "owner_api" + and resource.get("kind") == "service" + and resource.get("id") + and ( + ( + "start" in capabilities + and presentation.get("capability") == "start" + and presentation.get("enabled") is True + ) + or ( + "start_after_auth" in capabilities + and presentation.get("requires_auth") is True + and presentation.get("capability") == "start" + ) + ) + and links.get("start") + ): + return { + "code": "start", + "label": presentation.get("label") or "Commencer", + "icon": "start", + "destination": { + "kind": "native", + "path": f"/services/{resource['id']}/start", + }, + } + native = links.get("native") + if ( + engagement.get("owner") == "events" + and engagement.get("state") == "owner_api" + and resource.get("kind") == "event" + and "inspect_offers" in capabilities + and isinstance(native, str) + and native.startswith("/events/") + and native.endswith("/tickets") + ): + return { + "code": "inspect_offers", + "label": "Voir les billets", + "icon": "ticket", + "destination": {"kind": "native", "path": native}, + } + if ( + engagement.get("owner") == "funding" + and engagement.get("state") == "owner_api" + and resource.get("kind") == "funding" + and isinstance(resource.get("id"), str) + and "contribute" in capabilities + and isinstance(native, str) + and native == f"/funding/{resource['id']}/contribute" + and links.get("contribute") + ): + return { + "code": "contribute", + "label": "Contribuer", + "icon": "heart-handshake", + "destination": {"kind": "native", "path": native}, + } + if ( + engagement.get("owner") == "obtention" + and engagement.get("state") == "owner_api" + and resource.get("kind") == "obtention" + and isinstance(resource.get("id"), str) + and "start_journey" in capabilities + and isinstance(native, str) + and native == f"/obtention/{resource['id']}/choose" + and links.get("start_journey") + ): + return { + "code": "start_journey", + "label": "Obtenir", + "icon": "package-check", + "destination": {"kind": "native", "path": native}, + } + # Web uses the owner's presentation link independently. Discovery's + # primary_action is for a verified in-app native route or N2 detail. + return { + "code": "detail", + "label": "Détails", + "icon": "arrow", + "destination": { + "kind": "detail", + "path": projection["links"]["detail"], + }, + } + + def _finalize_projection( projection: dict, *, card=None, event_handoffs=None, + service_handoffs=None, + funding_handoffs=None, + obtention_handoffs=None, + personal_handoffs=None, + commerce_handoffs=None, description="", ) -> dict: identity = projection["identity"] @@ -513,19 +1176,50 @@ def _finalize_projection( "label": getattr(participant_state, "label", ""), "secondary_label": getattr(participant_state, "secondary_label", ""), } + # Bind only owner-resolved public imagery already carried by the + # canonical representation; a document is not a visual cover. + image_url = projection["representation"].get("image_url") + if image_url: + projection["media_bindings"] = [{ + "resource": {"url": image_url}, + "target": dict(identity["resource"]), + "purpose": "RECOGNIZE", + }] projection["assessment"] = _assessment(projection) projection["engagement"] = _handoff_for_projection( projection, event_handoffs=event_handoffs, + service_handoffs=service_handoffs, + funding_handoffs=funding_handoffs, + obtention_handoffs=obtention_handoffs, + personal_handoffs=personal_handoffs, + commerce_handoffs=commerce_handoffs, ) if card is not None and card.actions.primary is not None: primary = card.actions.primary + # A Web CTA may remain navigable without a matching owner API. + # Do not advertise executable authority from presentation alone. + executable = primary.code in projection["engagement"]["capabilities"] + owner_presentation = projection["engagement"].get("presentation") or {} + requires_auth = bool(owner_presentation.get("requires_auth")) projection["engagement"]["presentation"] = { - "label": primary.label, - "capability": primary.code, - "enabled": bool(primary.enabled), + "label": ( + owner_presentation.get("label") if requires_auth + else primary.label + ), + "capability": ( + owner_presentation.get("capability") if requires_auth + else primary.code + ), + "enabled": bool(not requires_auth and primary.enabled and executable), + "requires_auth": requires_auth, "web": primary.url, + "web_enabled": bool(primary.enabled and primary.url), } + # One server-selected central action; old engagement fields remain stable. + projection["engagement"]["primary_action"] = _primary_action_for_projection( + projection + ) if description: projection["detail"] = {"description": description} return projection @@ -546,6 +1240,51 @@ def project_rows(rows, *, profile=None) -> list[dict]: for family, _, candidate in rows if family == "opportunity" } + from commerce.offer_price_facts import offer_price_facts_many + + price_contexts = { + ( + str(candidate["activity_id"] if isinstance(candidate, dict) else candidate.activity_id), + str(candidate.occurrence_id) if family == "activity" else None, + ) + for family, _, candidate in rows + if family in {"activity", "service_activity"} + } + owner_prices = offer_price_facts_many(contexts=price_contexts) + # The collection and the existing detail API must expose the same + # public description. Bulk-load it for the selected page only. + from activities.models import Activity + + # Resolve published visual uses for this admitted page, in one bounded query. + # A document Resource is never promoted into a cover. + from groups.selectors import eligible_activity_ids_for_profile + from presentations.media_models import PresentationMediaPurpose + from presentations.media_selectors import public_presentation_media_for_activities + + admitted_activity_ids = eligible_activity_ids_for_profile(profile, activity_ids) + presentation_activities = list(Activity.objects.filter(pk__in=admitted_activity_ids)) + candidate_occurrence_ids = { + candidate.occurrence_id + for family, _, candidate in rows + if family == "activity" and candidate.occurrence_id is not None + } + from activities.models import Occurrence + presentation_occurrences = list(Occurrence.objects.filter( + pk__in=candidate_occurrence_ids, + activity_id__in=admitted_activity_ids, + )) + presentation_uses = public_presentation_media_for_activities( + presentation_activities, + occurrences=presentation_occurrences, + purpose=PresentationMediaPurpose.RECOGNIZE, + profile=profile, + ) + descriptions_by_activity = { + str(activity_id): description + for activity_id, description in Activity.objects.filter( + pk__in=activity_ids + ).values_list("pk", "description") + } saved_activity_ids = _saved_activity_ids(profile, activity_ids) saved_opportunity_ids = _saved_opportunity_ids(profile, opportunity_ids) can_save = _authenticated(profile) @@ -553,8 +1292,25 @@ def project_rows(rows, *, profile=None) -> list[dict]: activity_ids, profile=profile, ) - activity_resources = _activity_resource_previews(activity_ids) + service_handoffs = _service_handoffs(activity_ids, profile=profile) + funding_handoffs = _funding_handoffs(rows) + obtention_handoffs = _obtention_handoffs(rows) + personal_handoffs = _personal_engagement_handoffs(activity_ids, profile=profile) + commerce_handoffs = _public_commerce_handoffs(activity_ids, profile=profile) + owner_facts, opportunity_dates = _public_owner_facts( + activity_ids, opportunity_ids, + ) + resource_occurrence_scope = {activity_id: set() for activity_id in activity_ids} + for family, _, candidate in rows: + if family == "activity": + resource_occurrence_scope[str(candidate.activity_id)].update( + str(value) for value in candidate.matching_occurrence_ids + ) + activity_resources = _activity_resource_previews( + activity_ids, profile=profile, occurrence_scope=resource_occurrence_scope, + ) opportunity_resources = _opportunity_source_previews(opportunity_ids) + public_spatial_facts, occurrence_variants = _public_occurrence_facts_for_rows(rows) projections = [] for family, _, candidate in rows: @@ -612,12 +1368,84 @@ def project_rows(rows, *, profile=None) -> list[dict]: ) else: continue + description = ( + candidate.get("summary") or "" + if family == "opportunity" + else descriptions_by_activity.get( + str(candidate["activity_id"] if isinstance(candidate, dict) else candidate.activity_id), + "", + ) + ) finalized = _finalize_projection( projection, card=card, event_handoffs=event_handoffs, + service_handoffs=service_handoffs, + funding_handoffs=funding_handoffs, + obtention_handoffs=obtention_handoffs, + personal_handoffs=personal_handoffs, + commerce_handoffs=commerce_handoffs, + description=description, ) + if family == "opportunity": + issuer_name = (candidate.get("issuer_name") or "").strip() + finalized["owner"] = ( + { + "display_name": issuer_name, + "identity": {"kind": "external_issuer"}, + "representation": {"name": issuer_name, "avatar_url": None}, + "context": {}, + "links": {}, + "capabilities": [], + } + if issuer_name else None + ) + published = opportunity_dates.get(str(candidate["opportunity_id"])) + finalized["date"] = ( + {"kind": "published_at", "label": "Publiée le", "value": published} + if published else None + ) + else: + facts = owner_facts.get( + str(candidate["activity_id"] if isinstance(candidate, dict) else candidate.activity_id), + {}, + ) + finalized["owner"] = facts.get("owner") + finalized["date"] = facts.get("date") + if family in {"activity", "service_activity", "funding_activity"}: + current_activity_id = str( + candidate["activity_id"] if isinstance(candidate, dict) else candidate.activity_id + ) + current_occurrence_id = ( + str(candidate.occurrence_id) + if family == "activity" and candidate.occurrence_id is not None + else None + ) + apply_recognition_media( + finalized, + presentation_uses.get(current_activity_id, ()), + occurrence_id=current_occurrence_id, + ) + if family in {"activity", "service_activity"}: + key = ( + str(candidate["activity_id"] if isinstance(candidate, dict) else candidate.activity_id), + str(candidate.occurrence_id) if family == "activity" else None, + ) + owner_price = owner_prices.get(key) + # Commerce is authoritative even when there is *no* applicable + # Offer or multiple incompatible currencies. Never retain a + # presentation-derived minimum in an owner 'unknown' state. + if owner_price is not None: + finalized["price"] = owner_price.as_dict() resource = finalized["identity"]["resource"] + if family == "activity": + finalized["spatial_facts"] = public_spatial_facts.get( + str(candidate.activity_id), [] + ) + if candidate.matching_count > 1: + finalized["variants"] = occurrence_variants.get( + str(candidate.activity_id), [] + ) if resource["kind"] == "activity": finalized["resources"] = activity_resources.get(resource["id"], []) elif resource["kind"] == "opportunity": @@ -628,14 +1456,189 @@ def project_rows(rows, *, profile=None) -> list[dict]: return projections +def spatial_index_from_rows(rows) -> dict: + """Minimal public geographic presentation of already-admitted candidates. + + The owners decide admission and public occurrence places. Coordinates + are facts, not authority or a client-generated ranking. Keep every + mappable candidate without imposing the old Map endpoint's 100-point cap. + """ + located_rows = [ + (family, key, candidate) + for family, key, candidate in rows + if family == "activity" + ] + facts_by_activity, _ = _public_occurrence_facts_for_rows(located_rows) + possibilities = [] + for family, key, candidate in located_rows: + primary = candidate.to_map_dict() + facts = facts_by_activity.get(str(candidate.activity_id), []) + located_facts = [ + fact for fact in facts + if fact.get("place", {}).get("latitude") is not None + and fact.get("place", {}).get("longitude") is not None + ] + if primary is None and not located_facts: + continue + place = primary["place"] if primary is not None else located_facts[0]["place"] + # Match the canonical N1/N2 place representation when the admitted + # candidate has a primary place. In particular, preserve its Place + # identity and server-computed distance; the small map wire must not + # invent or strip either fact. Fall back to already-public spatial + # facts only when the candidate has no primary place. + if candidate.place is not None: + place = { + "id": candidate.place.id, + "name": candidate.place.name, + "locality": candidate.place.locality, + "latitude": candidate.place.latitude, + "longitude": candidate.place.longitude, + "distance_km": candidate.distance_km, + } + if not located_facts: + located_facts = [{"role": "primary", "place": place}] + possibilities.append({ + "identity": { + "family": family, + "candidate_key": key, + "resource": { + "kind": "activity", + "id": str(candidate.activity_id), + }, + "occurrence": { + "kind": "occurrence", + "id": str(candidate.occurrence_id), + } if candidate.occurrence_id is not None else None, + "revision": None, + }, + "representation": { + "title": candidate.title, + # Already-published owner summary, limited to recognition: + # full descriptions/resources remain available via N2. + "summary": (candidate.summary or "")[:160], + "kind": candidate.vertical, + "eyebrow": candidate.eyebrow, + "image_url": candidate.image_url, + }, + # Owner identity belongs to the publication selector and is + # populated only in compact_spatial_discovery_projection after + # the same Profile/Space privacy gate used by N1. + "owner": None, + "timing": { + "kind": candidate.timing_kind, + "start_at": candidate.start_at.isoformat() + if candidate.start_at else None, + "start_date": candidate.start_date.isoformat() + if candidate.start_date else None, + "start_time": candidate.start_time.isoformat() + if candidate.start_time else None, + "timezone": candidate.timezone, + }, + "place": place, + "spatial_facts": located_facts, + "capabilities": ["view"], + "links": { + "detail": reverse( + "discovery_api:item-detail", + kwargs={"family": family, "item_id": candidate.activity_id}, + ), + }, + }) + return { + "count": len(possibilities), + "total_results": len(rows), + "results": possibilities, + } + + + +def compact_spatial_discovery_projection(rows, *, profile=None) -> dict: + """Small spatial representation of the already-admitted Discovery field. + + Preserve every published spatial role, place and canonical identity. The + first N1 page remains enriched; opening a compact-only candidate requests + its authorized owner detail on demand instead of treating the pin as N2. + """ + spatial = spatial_index_from_rows(rows) + # Activity.operator_display_name may include a non-public creator name. + # Reuse N1's public owner selector, then disclose only the approved + # recognition fields; never disclose hidden Profile/Space identities. + activity_ids = { + item["identity"]["resource"]["id"] for item in spatial["results"] + } + owners, _ = _public_owner_facts(activity_ids, []) + for item in spatial["results"]: + owner = owners.get(item["identity"]["resource"]["id"], {}).get("owner") + if owner is not None: + item["owner"] = { + "display_name": owner["display_name"], + "representation": { + "avatar_url": (owner.get("representation") or {}).get( + "avatar_url" + ), + }, + } + return { + **spatial, + "detail_available": False, + "viewer": ( + {"type": "profile", "id": str(profile.pk)} + if _authenticated(profile) else None + ), + } + + +def spatial_discovery_projection(rows, *, profile=None) -> dict: + """Compose canonical public location facts with viewer-authorized N2 facts. + + The spatial index owns place publication and roles, while project_rows + owns presentation, relevance, prices, permissions and actions. Neither + responsibility is reimplemented in a Map-specific contract. + """ + spatial = spatial_index_from_rows(rows) + by_key = { + item["identity"]["candidate_key"]: item + for item in spatial["results"] + } + admitted = [ + (family, key, candidate) + for family, key, candidate in rows + if key in by_key + ] + projected = project_rows(admitted, profile=profile) + results = [] + for owner_item in projected: + key = owner_item["identity"]["candidate_key"] + location = by_key[key] + item = dict(owner_item) + current = item.get("place") + if not isinstance(current, dict) or ( + current.get("latitude") is None or current.get("longitude") is None + ): + item["place"] = location["place"] + item["spatial_facts"] = location["spatial_facts"] + results.append(item) + return { + "count": len(results), + "total_results": spatial["total_results"], + "detail_available": True, + "viewer": ( + {"type": "profile", "id": str(profile.pk)} + if _authenticated(profile) else None + ), + "results": results, + } + def paginated_projection( params, *, profile=None, include_internal_rows=False, ) -> dict: + # Reject invalid/expired or cross-viewer continuations before executing + # the expensive owner search and aggregation. + page, page_size = parse_page_params(params, profile=profile) composition = compose_discovery(params, profile=profile) - page, page_size = parse_page_params(params) start = (page - 1) * page_size end = start + page_size page_rows = composition.rows[start:end] @@ -648,9 +1651,12 @@ def paginated_projection( has_exploration = bool( composition.intent.raw_text or composition.intent.constraints ) - is_empty = not results + # Empty *page* is not an empty *field*: an out-of-range page must not + # claim that the current criteria have no matching possibilities. + field_empty = count == 0 + page_exhausted = count > 0 and not results selection_reason = None - if is_empty: + if field_empty: selection_reason = "no_match" if has_exploration else "no_current_proposal" payload = { "surface": "discover_me" if authenticated else "discover_public", @@ -666,10 +1672,13 @@ def paginated_projection( ), "freshness": { "state": "fresh", + # Generation time, not proof that each owner fact was revalidated. "observed_at": observed_at.isoformat(), + "generated_at": observed_at.isoformat(), + "fact_freshness": "not_asserted", }, "selection": { - "state": "empty" if is_empty else "ready", + "state": "empty" if field_empty else "ready", "reason": selection_reason, }, "capabilities": [], @@ -698,11 +1707,24 @@ def paginated_projection( # `items` is the common C0 collection alias. "results": results, "items": results, + # One first-page acquisition also supplies the geographic presentation + # of this same admitted field. No second owner search for Carte. + **({ + "spatial_index": ( + compact_spatial_discovery_projection( + composition.rows, profile=profile, + ) + if params.get("include_spatial_index") == "compact" + else spatial_discovery_projection( + composition.rows, profile=profile, + ) + ), + } if page == 1 and params.get("include_spatial_index") in {"1", "compact"} else {}), "continuation": { "state": "more" if has_next else "end", "token": ( signing.dumps( - {"page": page + 1}, + {"page": page + 1, "context": _continuation_context(params, profile=profile)}, salt=DISCOVERY_CONTINUATION_SALT, compress=True, ) @@ -719,7 +1741,7 @@ def paginated_projection( } }, "terminal": { - "state": "empty" if is_empty else "ok", + "state": ("empty" if field_empty else "end_of_field" if page_exhausted else "ok"), "message": None, }, } @@ -776,28 +1798,8 @@ def detail_projection(family, item_id, *, profile=None) -> dict | None: profile=profile, )[0] - if family == "activity": - from activities.models import Activity - - activity = Activity.objects.filter(pk=item_id).only("description").first() - description = activity.description if activity is not None else "" - elif family == "service_activity": - from services.models import ServiceDetails - - service = ( - ServiceDetails.objects.filter(activity_id=item_id) - .select_related("activity") - .only("activity__description") - .first() - ) - description = service.activity.description if service is not None else "" - elif family == "funding_activity": - description = item["funding"].activity.description - else: - description = item.get("summary") or "" - - if description: - projection["detail"] = {"description": description} + # The collection already carries the same authorized detail facts. + # This endpoint stays available for refresh and direct links. return projection diff --git a/discovery/api/media_bindings.py b/discovery/api/media_bindings.py new file mode 100644 index 0000000000..e4b41f2537 --- /dev/null +++ b/discovery/api/media_bindings.py @@ -0,0 +1,48 @@ +"""Pure Discovery composition of Presentation-owned media-use references. + +The caller has already run the canonical admission and batch Presentation selector. +This module does not own files, publication or permissions. +""" +from django.urls import reverse + + +def apply_recognition_media(projection, media_uses, *, occurrence_id=None): + """Mutate a finalized projection from a bounded authorized media-use collection.""" + admissible = [ + media for media in media_uses + if media.occurrence_id is None or media.occurrence_id == occurrence_id + ] + if occurrence_id is not None: + admissible.sort(key=lambda media: ( + media.occurrence_id != occurrence_id, + media.position, + media.use_id, + )) + admissible = admissible[:6] + explicit = [ + { + "resource": { + "kind": "presentation_media_use", + "id": media.use_id, + "url": reverse("presentations:published-media-file", kwargs={"use_id": media.use_id}), + "mime_type": media.mime_type, + "alt_text": media.alt_text, + }, + "target": dict(projection["identity"]["resource"]), + "purpose": "RECOGNIZE", + } + for media in admissible + ] + if not explicit: + return projection + urls = {binding["resource"]["url"] for binding in explicit} + projection["media_bindings"] = [ + *explicit, + *[ + binding for binding in projection.get("media_bindings", []) + if binding["resource"].get("url") not in urls + ], + ] + projection["representation"]["image_url"] = explicit[0]["resource"]["url"] + projection["representation"]["kind"] = "image" + return projection diff --git a/discovery/api/projections.py b/discovery/api/projections.py index 6636dfc091..49cac0a733 100644 --- a/discovery/api/projections.py +++ b/discovery/api/projections.py @@ -147,6 +147,24 @@ def _availability(state: str, *, remaining=None) -> dict: } +def _occurrence_temporal_facts(timing: Mapping) -> list[dict]: + """Prefer qualified instants; expose date/time only when no instant exists.""" + facts = [] + for prefix in ("start", "end"): + instant = timing.get(f"{prefix}_at") + keys = (f"{prefix}_at",) if instant is not None else ( + f"{prefix}_date", f"{prefix}_time" + ) + for key in keys: + if timing.get(key) is not None: + facts.append({ + "kind": key, + "value": timing[key], + "timezone": timing["timezone"], + }) + return facts + + def _existing_relation_action(card, participant): if participant is None or participant.participant_state in {None, "", "none"}: return None @@ -178,6 +196,9 @@ def build_possibility_projection( capabilities: Iterable[str], links: Mapping[str, str | None], provenance_resources: Iterable[Mapping[str, str]], + variants: Iterable[Mapping] = (), + temporal_facts: Iterable[Mapping] = (), + spatial_facts: Iterable[Mapping] = (), ) -> dict: """Compose one Z3 possibility projection from already-resolved domain facts. @@ -221,6 +242,13 @@ def build_possibility_projection( "capabilities": _capabilities(capabilities), "links": _links(links), "provenance": {"resources": provenance}, + # Empty collections mean nothing is exposed, not proof that no + # relation, variant, or media exists anywhere in the owner domain. + "variants": [dict(value) for value in variants], + "relations": [], + "temporal_facts": [dict(value) for value in temporal_facts], + "spatial_facts": [dict(value) for value in spatial_facts], + "media_bindings": [], } @@ -286,6 +314,15 @@ def project_occurrence_possibility( {"kind": "activity", "id": item.activity_id}, {"kind": "occurrence", "id": item.occurrence_id}, ), + variants=( + {"occurrence": _resource("occurrence", occurrence_id)} + for occurrence_id in item.matching_occurrence_ids[:12] + ) if item.matching_count > 1 else (), + temporal_facts=_occurrence_temporal_facts(timing), + spatial_facts=( + ({"role": "primary", "place": dict(place)},) + if place is not None else () + ), ) @@ -365,6 +402,14 @@ def project_funding_possibility( {"kind": "activity", "id": item["activity_id"]}, {"kind": "funding", "id": funding.pk}, ), + temporal_facts=( + {"kind": field, "value": value.isoformat()} + for field, value in ( + ("opens_at", funding.opens_at), + ("closes_at", funding.closes_at), + ) + if value is not None + ), ) @@ -400,4 +445,12 @@ def project_opportunity_possibility( {"kind": "opportunity", "id": item["opportunity_id"]}, {"kind": "opportunity_revision", "id": item["revision_id"]}, ), + temporal_facts=( + {"kind": field, "value": value} + for field, value in ( + ("opens_at", item.get("opens_at")), + ("deadline_at", item.get("deadline_at")), + ) + if value is not None + ), ) diff --git a/discovery/api/test_c5_owner_actions.py b/discovery/api/test_c5_owner_actions.py new file mode 100644 index 0000000000..cad37dddd4 --- /dev/null +++ b/discovery/api/test_c5_owner_actions.py @@ -0,0 +1,289 @@ +"""C5: user-invoked Discovery actions consume the C4 scoped Intelligence channels.""" +import json +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from accounts.models import UserProfile +from intelligence.connection_management import create_connection +from intelligence.models import ProviderScope + + +class _Reply: + def __init__(self, value): + self.value = value + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps(self.value).encode("utf-8") + + +@patch.dict("os.environ", {"INTELLIGENCE_CREDENTIAL_MASTER_KEY": "test-only-c5-key"}) +class DiscoveryConnectedActionsTests(TestCase): + def setUp(self): + User = get_user_model() + self.actor = User.objects.create_user( + username="c5-actor", password="test-password", + ) + self.other = User.objects.create_user( + username="c5-other", password="test-password", + ) + self.profile = UserProfile.objects.create(user=self.actor) + for user in (self.actor, self.other): + for scope in ("discovery_clarify", "discovery_research"): + cache.delete(f"throttle_{scope}_{user.pk}") + self.api = APIClient() + self.api.force_authenticate(self.actor) + + def _connect(self, provider, *, model=""): + return create_connection( + actor=self.actor, scope=ProviderScope.PROFILE, + payload={ + "provider": provider, "name": f"C5 {provider}", + "api_key": f"TEST-C5-KEY-{provider}", + "model": model, "consent": True, + }, + ) + + def test_clarify_requires_owner_and_valid_payload(self): + self.api.force_authenticate(user=None) + response = self.api.post("/api/v1/discovery/clarify/", { + "criteria": {"q": "quelque chose de calme"}, + }, format="json") + self.assertIn(response.status_code, {401, 403}) + self.api.force_authenticate(self.actor) + for invalid in ( + {"criteria": {"q": "test", "profile_id": str(self.other.pk)}}, + {"criteria": {"q": "x" * 121}}, + {"criteria": "pas une recherche"}, + ): + response = self.api.post( + "/api/v1/discovery/clarify/", invalid, format="json", + ) + self.assertEqual(response.status_code, 400, response.data) + + def test_clarify_uses_only_own_openai_route_and_canonicalizes_output(self): + self._connect("openai", model="test-model") + other_client = APIClient() + other_client.force_authenticate(self.other) + unrelated = other_client.post("/api/v1/discovery/clarify/", { + "criteria": {"q": "quelque chose de calme avec mes amis"}, + }, format="json") + self.assertEqual(unrelated.status_code, 200) + self.assertEqual(unrelated.data["data"]["status"], "unavailable") + + with patch("intelligence.providers.openai_compatible._open_url", + return_value=_Reply({ + "choices": [{ + "message": {"content": json.dumps({ + "vertical": "event", "place": "", + "when": "tomorrow", "period": "evening", + "price": "", "text": "calme", + })}, + }], + })) as outbound: + response = self.api.post("/api/v1/discovery/clarify/", { + "criteria": {"q": "quelque chose de calme avec mes amis"}, + }, format="json") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["meta"]["projection"], "discovery.clarification") + self.assertEqual(response["Cache-Control"], "private, no-store") + self.assertEqual(response.data["data"]["status"], "applied") + self.assertEqual(response.data["data"]["query"]["vertical"], "event") + self.assertEqual(response.data["data"]["query"]["when"], "tomorrow") + self.assertTrue(outbound.called) + serialized = str(response.data) + self.assertNotIn("TEST-C5-KEY", serialized) + self.assertNotIn("provider_key", serialized) + self.assertFalse( + any(row["source"] == "default" + for row in response.data["data"]["constraints"]) + ) + + def test_explicit_filters_never_trigger_paid_provider(self): + self._connect("openai", model="test-model") + with patch("intelligence.providers.openai_compatible._open_url") as network: + response = self.api.post("/api/v1/discovery/clarify/", { + "criteria": {"q": "gospel", "vertical": "event"}, + }, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["status"], "already_structured") + self.assertEqual(response.data["data"]["query"]["vertical"], "event") + network.assert_not_called() + + def test_normal_discovery_get_never_triggers_external_provider(self): + self._connect("openai", model="test-model") + with patch("intelligence.providers.openai_compatible._open_url") as network: + response = self.api.get( + "/api/v1/discovery/items/", + {"q": "quelque chose de calme avec mes amis"}, + ) + self.assertEqual(response.status_code, 200) + network.assert_not_called() + + def test_web_research_keeps_only_https_source_backed_pointers(self): + self._connect("tavily") + with patch("intelligence.providers.tavily_web._open_url", + return_value=_Reply({"results": [ + { + "title": "Document public", + "url": "https://example.org/public", + "content": "Une piste publique à vérifier.", + }, + { + "title": "Local", + "url": "https://127.0.0.1/internal", + "content": "A rejeter.", + }, + ]})): + response = self.api.post( + "/api/v1/discovery/research/", + {"q": "bourse en ingénierie"}, format="json", + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.assertEqual(response.data["meta"]["projection"], "discovery.source-leads") + self.assertEqual(len(response.data["data"]["candidates"]), 1) + lead = response.data["data"]["candidates"][0] + self.assertEqual(lead["sources"][0]["url"], "https://example.org/public") + self.assertNotIn("TEST-C5-KEY", str(response.data)) + self.assertNotIn("127.0.0.1", str(response.data)) + + def test_web_research_non_owner_cannot_use_personal_key(self): + self._connect("exa") + self.api.force_authenticate(self.other) + with patch("intelligence.providers.exa_web._open_url") as network: + result = self.api.post( + "/api/v1/discovery/research/", + {"q": "formation informatique"}, format="json", + ) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.data["data"]["status"], "unavailable") + network.assert_not_called() + + def test_external_provider_failure_exposes_only_safe_reason(self): + from intelligence.exceptions import ProviderUnavailable + + self._connect("tavily") + for technical, public in ( + ("invalid_credentials", "invalid_credentials"), + ("rate_limited", "rate_limited"), + ("network_unavailable", "network_unavailable"), + ): + with self.subTest(technical=technical): + with patch( + "intelligence.providers.tavily_web._open_url", + side_effect=ProviderUnavailable(technical), + ): + response = self.api.post("/api/v1/discovery/research/", { + "q": "formation professionnelle", + }, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["status"], "unavailable") + self.assertEqual(response.data["data"]["reason"], public) + self.assertNotIn("TEST-C5-KEY", str(response.data)) + + def test_source_research_web_requires_csrf(self): + from django.test import Client + client = Client(enforce_csrf_checks=True) + client.force_login(self.actor) + response = client.post(reverse("discovery:research"), { + "q": "formation informatique", + }) + self.assertEqual(response.status_code, 403) + + def test_web_clarification_preserves_location_context(self): + self.client.force_login(self.actor) + url = reverse("discovery:clarify") + response = self.client.post(url, { + "q": "formation à distance", + "lat": "-11.66", "lon": "27.48", "radius_km": "25", + }) + self.assertEqual(response.status_code, 302) + self.assertIn("lat=-11.66", response["Location"]) + self.assertIn("lon=27.48", response["Location"]) + self.assertIn("radius_km=25", response["Location"]) + + def test_external_research_rejects_foreign_profile_selector(self): + self._connect("tavily") + response = self.api.post("/api/v1/discovery/research/", { + "q": "formation", "profile_id": str(self.other.pk), + }, format="json") + self.assertEqual(response.status_code, 400) + + def test_invalid_openai_output_cannot_invent_discovery_filters(self): + self._connect("openai", model="test-model") + with patch("intelligence.providers.openai_compatible._open_url", + return_value=_Reply({ + "choices": [{ + "message": {"content": json.dumps({ + "vertical": "service", "place": "Atlantis", + "when": "tomorrow", "period": "", + "price": "", "text": "inventé", + })}, + }], + })): + response = self.api.post("/api/v1/discovery/clarify/", { + "criteria": {"q": "quelque chose de calme avec mes amis"}, + }, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["status"], "unchanged") + self.assertEqual(response.data["data"]["query"]["q"], + "quelque chose de calme avec mes amis") + self.assertNotIn("place", response.data["data"]["query"]) + + def test_clarify_throttle_bounds_repeated_user_initiated_calls(self): + # No provider needed: the budget applies before any external network. + for _ in range(12): + result = self.api.post("/api/v1/discovery/clarify/", { + "criteria": {"q": "quelque chose de calme avec mes amis"}, + }, format="json") + self.assertEqual(result.status_code, 200, result.data) + blocked = self.api.post("/api/v1/discovery/clarify/", { + "criteria": {"q": "quelque chose de calme avec mes amis"}, + }, format="json") + self.assertEqual(blocked.status_code, 429) + + def test_spatial_search_does_not_reinterpret_coordinates(self): + self._connect("openai", model="test-model") + with patch("intelligence.providers.openai_compatible._open_url") as network: + result = self.api.post("/api/v1/discovery/clarify/", { + "criteria": { + "q": "un lieu calme", + "lat": "-11.66", "lon": "27.48", "radius_km": "25", + }, + }, format="json") + self.assertEqual(result.status_code, 200) + self.assertEqual(result.data["data"]["query"]["lat"], "-11.66") + self.assertEqual(result.data["data"]["query"]["radius_km"], "25") + self.assertEqual(result.data["data"]["status"], "already_structured") + network.assert_not_called() + + + def test_web_source_action_renders_only_sourced_leads_in_query_context(self): + self._connect("tavily") + self.client.force_login(self.actor) + with patch("intelligence.providers.tavily_web._open_url", + return_value=_Reply({"results": [{ + "title": "Référence externe", + "url": "https://example.org/reference", + "content": "Une information à contrôler.", + }]})): + response = self.client.post( + reverse("discovery:research"), {"q": "bourse de recherche"}, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Référence externe") + self.assertContains(response, "https://example.org/reference") + self.assertContains(response, "pistes fournies par un service externe") + self.assertNotContains(response, "TEST-C5-KEY") + self.assertEqual(response["Cache-Control"], "private, no-store") diff --git a/discovery/api/test_c8_interop_journey.py b/discovery/api/test_c8_interop_journey.py new file mode 100644 index 0000000000..f90698a0a0 --- /dev/null +++ b/discovery/api/test_c8_interop_journey.py @@ -0,0 +1,124 @@ +"""C8 complete owner journey: connect -> use -> rotate/disable -> revoke. + +Provider HTTP is mocked. A real provider and billable request are NOT assumed. +""" +import json +from unittest.mock import patch + +from django.test import TestCase +from rest_framework.test import APIClient + +from accounts.models import User, UserProfile +from intelligence.models import ProviderConnection, ProviderCredential + + +class _ProviderReply: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps({ + "results": [{ + "title": "Public opportunity reference", + "url": "https://example.org/reference", + "content": "Public information requiring verification.", + }], + }).encode("utf-8") + + +@patch.dict( + "os.environ", + {"INTELLIGENCE_CREDENTIAL_MASTER_KEY": "c8-e2e-only-master-key"}, +) +class C8OwnerInteropJourneyTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user( + username="c8-real-journey", password="test", + ) + UserProfile.objects.create(user=self.owner) + self.api = APIClient() + self.api.force_authenticate(self.owner) + self.root = "/api/v1/me/intelligence/connections/" + self.research = "/api/v1/discovery/research/" + + def _research_with(self, *, key): + with patch( + "intelligence.providers.tavily_web._open_url", + return_value=_ProviderReply(), + ) as outbound: + result = self.api.post( + self.research, {"q": "bourse de recherche"}, format="json", + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual(result["Cache-Control"], "private, no-store") + self.assertEqual(result.data["data"]["status"], "completed") + self.assertEqual( + result.data["data"]["candidates"][0]["sources"][0]["url"], + "https://example.org/reference", + ) + sent = outbound.call_args.args[0] + self.assertEqual(sent.get_header("Authorization"), f"Bearer {key}") + self.assertNotIn(key, str(result.data)) + + def test_lifecycle_and_real_discovery_consumption_share_one_owner(self): + created = self.api.post(self.root, { + "provider": "tavily", + "name": "Mon exploration", + "api_key": "C8-TEST-KEY-ORIGINAL", + "consent": True, + }, format="json") + self.assertEqual(created.status_code, 201, created.data) + identity = created.data["id"] + detail = self.root + identity + "/" + self.assertNotIn("C8-TEST-KEY-ORIGINAL", str(created.data)) + self.assertIn(identity, [ + row["id"] for row in + self.api.get("/api/v1/me/interoperability/").data["connections"] + ]) + self._research_with(key="C8-TEST-KEY-ORIGINAL") + + disabled = self.api.patch( + detail, {"enabled": False}, format="json", + ) + self.assertEqual(disabled.status_code, 200) + self.assertFalse(disabled.data["usable"]) + with patch("intelligence.providers.tavily_web._open_url") as network: + unavailable = self.api.post( + self.research, {"q": "bourse de recherche"}, format="json", + ) + self.assertEqual(unavailable.status_code, 200) + self.assertEqual(unavailable.data["data"]["status"], "unavailable") + network.assert_not_called() + + enabled = self.api.patch(detail, {"enabled": True}, format="json") + self.assertEqual(enabled.status_code, 200) + self.assertEqual(enabled.data["health"], "unknown") + rotated = self.api.patch( + detail, {"api_key": "C8-TEST-KEY-ROTATED"}, format="json", + ) + self.assertEqual(rotated.status_code, 200) + self.assertNotIn("C8-TEST-KEY-ROTATED", str(rotated.data)) + self._research_with(key="C8-TEST-KEY-ROTATED") + + removed = self.api.delete(detail) + self.assertEqual(removed.status_code, 204) + self.assertFalse( + ProviderConnection.objects.filter(pk=identity).exists() + ) + self.assertFalse( + ProviderCredential.objects.filter(connection_id=identity).exists() + ) + with patch("intelligence.providers.tavily_web._open_url") as network: + after = self.api.post( + self.research, {"q": "bourse de recherche"}, format="json", + ) + self.assertEqual(after.status_code, 200) + self.assertEqual(after.data["data"]["status"], "unavailable") + network.assert_not_called() + self.assertNotIn(identity, [ + row["id"] for row in + self.api.get("/api/v1/me/interoperability/").data["connections"] + ]) diff --git a/discovery/api/test_c8_operational_events.py b/discovery/api/test_c8_operational_events.py new file mode 100644 index 0000000000..bcc3893d00 --- /dev/null +++ b/discovery/api/test_c8_operational_events.py @@ -0,0 +1,49 @@ +"""C8 operational observations: useful statuses, never search text or keys.""" +from django.test import TestCase +from rest_framework.test import APIClient + +from accounts.models import User, UserProfile + + +class C8SafeExternalActionLoggingTests(TestCase): + def setUp(self): + self.actor = User.objects.create_user( + username="c8-telemetry", password="test", + ) + UserProfile.objects.create(user=self.actor) + self.client = APIClient() + self.client.force_authenticate(self.actor) + + def test_no_provider_research_is_diagnostic_without_query_leak(self): + private_query = "C8-PRIVATE-QUERY-NOT-TO-LOG" + with self.assertLogs("makolo.discovery", level="INFO") as events: + result = self.client.post( + "/api/v1/discovery/research/", + {"q": private_query}, format="json", + ) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.data["data"]["status"], "unavailable") + self.assertEqual(len(events.records), 1) + row = events.records[0] + self.assertEqual(row.makolo_event, "discovery.external_action") + self.assertEqual(row.action, "research") + self.assertEqual(row.status, "unavailable") + self.assertEqual(row.reason, "connection_unavailable") + self.assertNotIn(private_query, str(row.__dict__)) + self.assertNotIn("api_key", str(row.__dict__)) + self.assertNotIn("provider_token", str(row.__dict__)) + + def test_clarification_status_has_no_raw_user_intent(self): + private_query = "quelque chose de calme avec mes amis C8-SECRET-TEXT" + with self.assertLogs("makolo.discovery", level="INFO") as events: + result = self.client.post( + "/api/v1/discovery/clarify/", + {"criteria": {"q": private_query}}, format="json", + ) + self.assertEqual(result.status_code, 200) + self.assertIn( + result.data["data"]["status"], + {"unavailable", "already_structured", "unchanged"}, + ) + self.assertEqual(events.records[0].action, "clarify") + self.assertNotIn(private_query, str(events.records[0].__dict__)) diff --git a/discovery/api/test_c8_recovery_remainders.py b/discovery/api/test_c8_recovery_remainders.py new file mode 100644 index 0000000000..38fbb7f486 --- /dev/null +++ b/discovery/api/test_c8_recovery_remainders.py @@ -0,0 +1,84 @@ +"""C8 remainder: explicit Discovery failures preserve actionable, safe categories.""" +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from accounts.models import User, UserProfile +from intelligence.connection_management import create_connection +from intelligence.exceptions import ProviderUnavailable +from intelligence.models import ProviderScope + + +@patch.dict("os.environ", {"INTELLIGENCE_CREDENTIAL_MASTER_KEY": "c8-recovery-test-only"}) +class DiscoveryOwnerRecoveryTests(TestCase): + def setUp(self): + self.actor = User.objects.create_user( + username="c8-recovery-actor", password="test", + ) + UserProfile.objects.create(user=self.actor) + for scope in ("discovery_research", "discovery_clarify"): + cache.delete(f"throttle_{scope}_{self.actor.pk}") + self.client = APIClient() + self.client.force_authenticate(self.actor) + + def test_absent_connection_proposes_owner_configuration_not_fake_provider_outage(self): + response = self.client.post( + "/api/v1/discovery/research/", + {"q": "formation scientifique"}, format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["status"], "unavailable") + self.assertEqual(response.data["data"]["reason"], "connection_unavailable") + self.assertEqual(response["Cache-Control"], "private, no-store") + self.client.force_login(self.actor) + web = self.client.post( + reverse("discovery:research"), + {"q": "formation scientifique"}, + ) + self.assertEqual(web.status_code, 200) + self.assertContains(web, "Gérer mes connexions") + + def test_quota_and_network_errors_are_not_mislabelled_key_repair(self): + create_connection( + actor=self.actor, scope=ProviderScope.PROFILE, + payload={ + "provider": "tavily", + "name": "C8 Research", + "api_key": "SECRET-C8-DO-NOT-ECHO", + "consent": True, + }, + ) + for technical, expected in ( + ("rate_limited", "rate_limited"), + ("network_unavailable", "network_unavailable"), + ("invalid_credentials", "invalid_credentials"), + ): + with self.subTest(technical=technical): + with patch( + "intelligence.providers.tavily_web._open_url", + side_effect=ProviderUnavailable(technical), + ): + response = self.client.post( + "/api/v1/discovery/research/", + {"q": "formation scientifique"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["reason"], expected) + self.assertNotIn("SECRET-C8-DO-NOT-ECHO", str(response.data)) + with patch( + "intelligence.providers.tavily_web._open_url", + side_effect=ProviderUnavailable(technical), + ): + page = self.client.post( + reverse("discovery:research"), + {"q": "formation scientifique"}, + ) + self.assertEqual(page.status_code, 200) + self.assertEqual( + "Gérer mes connexions" in page.content.decode("utf-8"), + technical == "invalid_credentials", + ) diff --git a/discovery/api/test_mature_discovery.py b/discovery/api/test_mature_discovery.py index 3e8ca09aa7..deb7606d09 100644 --- a/discovery/api/test_mature_discovery.py +++ b/discovery/api/test_mature_discovery.py @@ -11,11 +11,14 @@ ActivityStatus, ActivityVisibility, Occurrence, + OccurrencePlace, + OccurrencePlaceRole, OccurrenceStatus, ) from discovery.models import ActivityBookmark, DiscoveryWatch from events.models import Event from funding.models import FundingDetails +from geography.models import Place, SpacePlace from groups.models import ( ActivityGroupEligibility, ActivityGroupEligibilityStatus, @@ -32,8 +35,10 @@ OpportunitySave, ) from organizations.models import Organization +from preparation.models import ActivityResource, ResourceKind, ResourceStatus, ResourceVisibility from services.models import OpportunityPolicy, ServiceDetails, ServiceKind from topics.models import ProfileInterest +from transport.models import TransportRoute, TransportRouteStop, TransportService User = get_user_model() @@ -172,6 +177,369 @@ def test_collection_is_multi_family_and_has_no_ranking_contract(self): ) self.assertIsNone(opportunity["identity"]["occurrence"]) + def test_one_discovery_acquisition_carries_map_and_reusable_n2(self): + place = Place.objects.create( + name="Z3 carte unifiée", + locality="Lubumbashi", + latitude="-11.664700", + longitude="27.479400", + ) + OccurrencePlace.objects.create( + occurrence=self.event_occurrence, + place=place, + role=OccurrencePlaceRole.PRIMARY, + ) + self.client.force_login(self.participant) + first = self._items( + q="Z3", page_size=1, include_spatial_index="1" + )["data"] + self.assertEqual(len(first["results"]), 1) + index = first["spatial_index"] + self.assertTrue(index["detail_available"]) + self.assertEqual(index["total_results"], first["count"]) + self.assertEqual(index["viewer"]["id"], str(self.participant.pk)) + event = next( + row for row in index["results"] + if row["identity"]["resource"]["id"] == str(self.event_activity.pk) + ) + self.assertEqual(event["place"]["latitude"], -11.6647) + self.assertEqual(event["place"]["longitude"], 27.4794) + detail = self.client.get(event["links"]["detail"]).json()["data"]["item"] + self.assertEqual(event["identity"], detail["identity"]) + self.assertEqual(event["detail"], detail["detail"]) + + # Presentation options do not affect signed server-owned navigation. + second = self._items( + q="Z3", + page=2, + page_size=1, + continuation=first["continuation"]["token"], + )["data"] + self.assertEqual(second["page"], 2) + self.assertNotIn("spatial_index", second) + + def test_compact_spatial_keeps_every_location_and_requests_details_on_demand(self): + place = Place.objects.create( + name="Z3 carte compacte", + locality="Lubumbashi", + latitude="-11.664700", + longitude="27.479400", + ) + OccurrencePlace.objects.create( + occurrence=self.event_occurrence, + place=place, + role=OccurrencePlaceRole.PRIMARY, + ) + meeting = Place.objects.create( + name="Z3 point de rencontre", + locality="Lubumbashi", + latitude="-11.667000", + longitude="27.470000", + ) + OccurrencePlace.objects.create( + occurrence=self.event_occurrence, + place=meeting, + role=OccurrencePlaceRole.MEETING_POINT, + ) + self.client.force_login(self.participant) + compact = self._items( + q="Z3", page_size=1, include_spatial_index="compact", + )["data"] + full = self._items( + q="Z3", page_size=1, include_spatial_index="1", + )["data"] + index = compact["spatial_index"] + reference = full["spatial_index"] + self.assertFalse(index["detail_available"]) + self.assertTrue(reference["detail_available"]) + self.assertEqual(index["viewer"], reference["viewer"]) + self.assertEqual(index["count"], reference["count"]) + self.assertEqual(index["total_results"], compact["count"]) + self.assertEqual(compact["results"], full["results"]) + self.assertEqual( + [item["identity"] for item in index["results"]], + [item["identity"] for item in reference["results"]], + ) + for compact_item, full_item in zip( + index["results"], reference["results"], + ): + self.assertEqual(compact_item["place"], full_item["place"]) + self.assertEqual( + compact_item["spatial_facts"], full_item["spatial_facts"], + ) + self.assertEqual( + compact_item["representation"]["title"], + full_item["representation"]["title"], + ) + self.assertEqual( + compact_item["links"]["detail"], + full_item["links"]["detail"], + ) + self.assertIn("kind", compact_item["representation"]) + self.assertIn("eyebrow", compact_item["representation"]) + self.assertIn("image_url", compact_item["representation"]) + self.assertIn("summary", compact_item["representation"]) + self.assertLessEqual( + len(compact_item["representation"]["summary"]), 160, + ) + self.assertIn("owner", compact_item) + self.assertIn("timing", compact_item) + # The pin is NOT a cached authorized detail; it must be enriched + # by the canonical detail owner endpoint when it is selected. + self.assertNotIn("detail", compact_item) + self.assertNotIn("resources", compact_item) + self.assertNotIn("engagement", compact_item) + event = next( + item for item in index["results"] + if item["identity"]["resource"]["id"] == str(self.event_activity.pk) + ) + self.assertEqual( + {fact["role"] for fact in event["spatial_facts"]}, + {"primary", "meeting_point"}, + ) + self.assertEqual(event["representation"]["kind"], "event") + self.assertTrue(event["owner"]["display_name"]) + self.assertEqual( + event["timing"]["start_at"], + self.event_occurrence.start_at.isoformat(), + ) + owner_detail = self.client.get(event["links"]["detail"]) + self.assertEqual(owner_detail.status_code, 200) + self.assertEqual( + owner_detail.json()["data"]["item"]["identity"], + event["identity"], + ) + second = self._items( + q="Z3", page_size=1, page=2, + continuation=compact["continuation"]["token"], + )["data"] + self.assertNotIn("spatial_index", second) + + def test_collection_includes_existing_detail_description(self): + for profile in (None, self.participant): + if profile is None: + self.client.logout() + else: + self.client.force_login(profile) + results = self._items(q="Z3", page_size=20)["data"]["results"] + self.assertEqual(len(results), 4) + for row in results: + self.assertTrue(row["links"]["detail"].startswith("/api/v1/")) + detail_response = self.client.get(row["links"]["detail"]) + self.assertEqual(detail_response.status_code, 200) + detailed_row = detail_response.json()["data"]["item"] + self.assertEqual(row.get("detail"), detailed_row.get("detail")) + self.assertEqual(row["identity"], detailed_row["identity"]) + self.assertTrue(row["detail"]["description"]) + + def test_collection_exposes_native_api_references_without_web_authority(self): + from django.urls import resolve + + for viewer in (None, self.participant): + if viewer is None: + self.client.logout() + else: + self.client.force_login(viewer) + rows = self._items(q="Z3", page_size=20)["data"]["results"] + for row in rows: + detail = row["links"]["detail"] + saved = row["links"]["saved"] + self.assertEqual(resolve(detail).url_name, "item-detail") + self.assertEqual(resolve(saved).url_name, "item-saved") + self.assertTrue(detail.startswith("/api/v1/")) + self.assertTrue(saved.startswith("/api/v1/")) + self.assertIn("view", row["capabilities"]) + self.assertEqual("save" in row["capabilities"], viewer is not None) + engagement = row["engagement"] + if engagement["state"] == "owner_contract": + self.assertEqual(engagement["links"], {}) + self.assertEqual(engagement["capabilities"], []) + primary = engagement.get("presentation") + if primary is not None: + self.assertFalse(primary["enabled"]) + + def test_public_service_exposes_login_continuation_not_start_authority(self): + self.client.logout() + rows = self._items(q="Z3 accompagnement", page_size=20)["data"]["results"] + service = next(row for row in rows if row["identity"]["family"] == "service_activity") + engagement = service["engagement"] + self.assertEqual(engagement["owner"], "services") + self.assertEqual(engagement["state"], "owner_api") + self.assertEqual(engagement["capabilities"], ["start_after_auth"]) + self.assertNotIn("start", engagement["capabilities"]) + self.assertTrue(engagement["presentation"]["requires_auth"]) + self.assertFalse(engagement["presentation"]["enabled"]) + self.assertEqual(engagement["primary_action"]["code"], "start") + self.assertEqual( + engagement["primary_action"]["destination"], + {"kind": "native", "path": f"/services/{self.service.pk}/start"}, + ) + # No private personal relation or start authorization leaks to guests. + self.assertNotIn("journey", engagement) + + self.client.force_login(self.participant) + personal_rows = self._items( + q="Z3 accompagnement", page_size=20 + )["data"]["results"] + personal = next( + row for row in personal_rows + if row["identity"]["family"] == "service_activity" + ) + self.assertIn("start", personal["engagement"]["capabilities"]) + self.assertNotIn("start_after_auth", personal["engagement"]["capabilities"]) + + def test_wire_exposes_only_known_owner_facts_and_empty_collections(self): + rows = self._items(q="Z3", page_size=20)["data"]["results"] + by_family = {row["identity"]["family"]: row for row in rows} + self.assertEqual( + set(by_family), {"activity", "service_activity", "funding_activity", "opportunity"} + ) + for row in rows: + for key in ("variants", "relations", "temporal_facts", "spatial_facts", "media_bindings"): + self.assertIsInstance(row[key], list) + self.assertEqual(row["relations"], []) + self.assertEqual(row["media_bindings"], []) + activity = by_family["activity"] + self.assertEqual(activity["variants"], []) + self.assertEqual(activity["spatial_facts"], []) + self.assertTrue(activity["temporal_facts"]) + self.assertEqual(by_family["service_activity"]["temporal_facts"], []) + self.assertEqual(by_family["service_activity"]["spatial_facts"], []) + self.assertEqual( + {fact["kind"] for fact in by_family["funding_activity"]["temporal_facts"]}, + {"opens_at", "closes_at"}, + ) + self.assertEqual( + {fact["kind"] for fact in by_family["opportunity"]["temporal_facts"]}, + {"opens_at", "deadline_at"}, + ) + + def test_multiple_occurrences_have_canonical_bounded_variant_references(self): + second = self._occurrence( + self.event_activity, + start=self.now + timedelta(days=3), + ) + event = next( + row for row in self._items(q="Z3 concert")["data"]["results"] + if row["identity"]["family"] == "activity" + ) + self.assertEqual( + {variant["occurrence"]["id"] for variant in event["variants"]}, + {str(self.event_occurrence.pk), str(second.pk)}, + ) + self.assertEqual( + {variant["occurrence"]["kind"] for variant in event["variants"]}, + {"occurrence"}, + ) + # The API may serialize UTC while fixtures retain their local offset. + # Compare instants, not their ISO-8601 textual representations. + from datetime import datetime + + self.assertEqual( + { + datetime.fromisoformat(variant["timing"]["start_at"]) + for variant in event["variants"] + }, + {self.event_occurrence.start_at, second.start_at}, + ) + self.assertEqual(event["identity"]["resource"]["id"], str(self.event_activity.pk)) + + def test_spatial_facts_include_public_occurrence_place_roles(self): + first = Place.objects.create(name="Z3 lieu principal", locality="Lubumbashi") + second = Place.objects.create(name="Z3 point de rencontre", locality="Lubumbashi") + OccurrencePlace.objects.create( + occurrence=self.event_occurrence, place=first, + role=OccurrencePlaceRole.PRIMARY, + ) + OccurrencePlace.objects.create( + occurrence=self.event_occurrence, place=second, + role=OccurrencePlaceRole.MEETING_POINT, + ) + event = next( + row for row in self._items(q="Z3 concert")["data"]["results"] + if row["identity"]["family"] == "activity" + ) + self.assertEqual( + {fact["role"] for fact in event["spatial_facts"]}, + {"primary", "meeting_point"}, + ) + self.assertEqual( + {fact["place"]["id"] for fact in event["spatial_facts"]}, + {str(first.pk), str(second.pk)}, + ) + self.assertEqual(event["place"]["id"], str(first.pk)) + self.assertTrue(all( + fact["occurrence"]["id"] == str(self.event_occurrence.pk) + for fact in event["spatial_facts"] + )) + + def test_private_space_place_is_not_exposed_by_spatial_facts(self): + private_place = Place.objects.create( + name="Z3 lieu privé", locality="Lubumbashi" + ) + public_place = Place.objects.create( + name="Z3 lieu public", locality="Lubumbashi" + ) + SpacePlace.objects.create( + organization=self.space, place=private_place, is_public=False + ) + OccurrencePlace.objects.create( + occurrence=self.event_occurrence, + place=private_place, + role=OccurrencePlaceRole.PRIMARY, + ) + OccurrencePlace.objects.create( + occurrence=self.event_occurrence, + place=public_place, + role=OccurrencePlaceRole.MEETING_POINT, + ) + for profile in (None, self.participant): + if profile is None: + self.client.logout() + else: + self.client.force_login(profile) + event = next( + row for row in self._items(q="Z3 concert")["data"]["results"] + if row["identity"]["family"] == "activity" + ) + self.assertIsNone(event["place"]) + self.assertEqual( + [fact["place"]["id"] for fact in event["spatial_facts"]], + [str(public_place.pk)], + ) + + def test_transport_spatial_facts_preserve_route_roles_and_privacy(self): + activity = self._activity("Z3 trajet") + self._occurrence(activity) + route = TransportRoute.objects.create(space=self.space, name="Z3 route") + places = [ + Place.objects.create(name=name, locality="Lubumbashi") + for name in ( + "Z3 départ", "Z3 escale publique", "Z3 arrêt privé", "Z3 arrivée" + ) + ] + for position, place in enumerate(places, start=1): + TransportRouteStop.objects.create( + route=route, place=place, position=position + ) + SpacePlace.objects.create( + organization=self.space, place=places[2], is_public=False + ) + TransportService.objects.create(activity=activity, route=route) + row = next( + row for row in self._items(q="Z3 trajet")["data"]["results"] + if row["identity"]["resource"]["id"] == str(activity.pk) + ) + self.assertEqual( + [(fact["role"], fact["place"]["id"]) for fact in row["spatial_facts"]], + [ + ("departure", str(places[0].pk)), + ("stopover", str(places[1].pk)), + ("arrival", str(places[3].pk)), + ], + ) + self.assertEqual(row["identity"]["family"], "activity") + def test_web_and_api_use_the_same_canonical_discovery_field(self): params = {"q": "Z3", "page_size": 20} api = self._items(**params)["data"] @@ -210,6 +578,175 @@ def test_collection_empty_is_valid_and_pagination_is_bounded(self): {"state": "empty", "message": None}, ) + def test_out_of_range_page_is_end_of_field_not_no_match(self): + payload = self._items(q="Z3", page=999, page_size=1)["data"] + self.assertGreater(payload["count"], 0) + self.assertEqual(payload["results"], []) + self.assertEqual(payload["items"], []) + self.assertEqual(payload["selection"], {"state": "ready", "reason": None}) + self.assertEqual(payload["terminal"]["state"], "end_of_field") + self.assertFalse(payload["has_next"]) + self.assertEqual(payload["continuation"]["state"], "end") + self.assertIsNone(payload["continuation"]["token"]) + + def test_generation_timestamp_does_not_assert_owner_fact_freshness(self): + data = self._items(q="Z3")["data"] + self.assertEqual( + data["freshness"]["generated_at"], + data["freshness"]["observed_at"], + ) + self.assertEqual(data["freshness"]["fact_freshness"], "not_asserted") + + def test_public_resources_are_not_capped_at_six_and_web_discloses_all(self): + for index in range(1, 9): + ActivityResource.objects.create( + activity=self.event_activity, + key=f"document-{index:02d}", + title=f"Document {index:02d}", + kind=ResourceKind.TEXT, + text_content=f"Contenu public {index}", + visibility=ResourceVisibility.PUBLIC, + status=ResourceStatus.PUBLISHED, + created_by=self.owner, + published_at=self.now, + ) + ActivityResource.objects.create( + activity=self.event_activity, + key="private-document", + title="Document privé", + kind=ResourceKind.TEXT, + text_content="Ne pas rendre public", + visibility=ResourceVisibility.RESTRICTED, + status=ResourceStatus.PUBLISHED, + created_by=self.owner, + published_at=self.now, + ) + + rows = self._items(q="Z3 concert")["data"]["results"] + item = next( + row for row in rows + if row["identity"]["resource"]["id"] == str(self.event_activity.pk) + ) + self.assertEqual( + [entry["title"] for entry in item["resources"]], + [f"Document {index:02d}" for index in range(1, 9)], + ) + self.assertNotIn("Document privé", str(item)) + + web = self.client.get(reverse("discovery:home"), {"q": "Z3 concert"}) + self.assertEqual(web.status_code, 200) + self.assertContains(web, "Voir les 8 ressources") + self.assertContains(web, "Document 08") + self.assertNotContains(web, "Document privé") + + def test_public_owner_object_comes_from_logical_activity_owner(self): + data = self._items(q="Z3 concert")["data"] + item = next( + row for row in data["results"] + if row["identity"]["family"] == "activity" + ) + owner = item["owner"] + self.assertEqual(owner["identity"], { + "kind": "space", + "id": str(self.space.pk), + }) + self.assertEqual(owner["representation"]["name"], self.space.name) + self.assertEqual(owner["links"]["native"], f"/spaces/{self.space.slug}") + self.assertEqual(owner["capabilities"], ["view_public"]) + self.assertEqual(item["date"]["kind"], "added_at") + self.assertEqual( + item["date"]["value"], + self.event_activity.created_at.isoformat(), + ) + + def test_private_owner_is_not_promoted_to_public_navigation(self): + self.space.public_profile = False + self.space.save(update_fields=["public_profile"]) + item = next( + row for row in self._items(q="Z3 concert")["data"]["results"] + if row["identity"]["family"] == "activity" + ) + self.assertIsNone(item["owner"]) + self.assertNotIn("created_by", item) + + # A public Activity location must not promote its non-public Space + # or creator to the compact map index. + place = Place.objects.create( + name="Z3 lieu visible", + latitude="-11.66", + longitude="27.48", + ) + OccurrencePlace.objects.create( + occurrence=self.event_occurrence, + place=place, + role=OccurrencePlaceRole.PRIMARY, + ) + spatial = self._items( + q="Z3 concert", include_spatial_index="compact", + )["data"]["spatial_index"]["results"] + event = next( + row for row in spatial + if row["identity"]["resource"]["id"] == str(self.event_activity.pk) + ) + self.assertIsNone(event["owner"]) + self.assertNotIn("created_by", event) + + def test_opportunity_issuer_is_external_without_makolo_identity(self): + item = next( + row for row in self._items(q="Z3 bourse")["data"]["results"] + if row["identity"]["family"] == "opportunity" + ) + self.assertEqual(item["owner"]["display_name"], "Fondation Z3") + self.assertEqual(item["owner"]["identity"], {"kind": "external_issuer"}) + self.assertFalse(item["owner"]["links"]) + self.assertEqual(item["date"]["kind"], "published_at") + self.assertEqual( + item["date"]["value"], self.opportunity.published_at.isoformat() + ) + + def test_authenticated_service_start_refers_to_service_not_activity(self): + self.client.force_login(self.participant) + item = next( + row for row in self._items(q="Z3 accompagnement")["data"]["results"] + if row["identity"]["family"] == "service_activity" + ) + handoff = item["engagement"] + self.assertEqual(handoff["state"], "owner_api") + self.assertEqual( + handoff["resource"], {"kind": "service", "id": str(self.service.pk)} + ) + self.assertIn("start", handoff["capabilities"]) + self.assertEqual( + handoff["links"]["start"], + reverse("services-api:start", kwargs={"pk": self.service.pk}), + ) + self.assertTrue(handoff["presentation"]["enabled"]) + self.assertEqual(handoff["primary_action"]["code"], "start") + self.assertEqual( + handoff["primary_action"]["destination"], + {"kind": "native", "path": f"/services/{self.service.pk}/start"}, + ) + + def test_web_primary_action_is_not_an_unbacked_owner_capability(self): + data = self._items(q="Z3 accompagnement")["data"] + service = next( + item for item in data["results"] + if item["identity"]["family"] == "service_activity" + ) + handoff = service["engagement"] + self.assertEqual(handoff["state"], "owner_contract") + self.assertEqual(handoff["capabilities"], []) + presentation = handoff["presentation"] + self.assertEqual(presentation["capability"], "start") + self.assertFalse(presentation["enabled"]) + self.assertTrue(presentation["web_enabled"]) + self.assertTrue(presentation["web"]) + self.assertEqual(handoff["primary_action"]["code"], "detail") + self.assertEqual( + handoff["primary_action"]["destination"]["path"], + service["links"]["detail"], + ) + def test_authenticated_collection_exposes_discover_me_contract_and_cursor(self): self.client.force_login(self.participant) @@ -547,6 +1084,11 @@ def test_event_exposes_real_owner_api_handoff_without_creating_journey(self): handoff["links"]["orders"], reverse("ticket-orders-list"), ) + self.assertEqual(handoff["primary_action"]["code"], "inspect_offers") + self.assertEqual( + handoff["primary_action"]["destination"], + {"kind": "native", "path": f"/events/{self.event.slug}/tickets"}, + ) self.assertEqual(Journey.objects.count(), before) def test_unknown_is_not_rewritten_as_impossible(self): @@ -573,3 +1115,223 @@ def test_map_uses_same_viewer_and_does_not_expose_personal_relation(self): "secondary_label", ): self.assertNotIn(forbidden, serialized) + + def test_spatial_map_shares_discovery_identity_and_public_geography(self): + from geography.models import Place + from activities.models import OccurrencePlace, OccurrencePlaceRole + + located = Place.objects.create( + name="Z3 localisation", locality="Lubumbashi", + latitude="-11.664700", longitude="27.479400", + ) + OccurrencePlace.objects.create( + occurrence=self.event_occurrence, + place=located, + role=OccurrencePlaceRole.PRIMARY, + ) + collection = self._items(q="Z3 concert")["data"]["results"] + expected = next( + row for row in collection + if row["identity"]["resource"]["id"] == str(self.event_activity.pk) + ) + + response = self.client.get( + reverse("discovery_api:map"), {"q": "Z3 concert", "spatial": "1"} + ) + self.assertEqual(response.status_code, 200, response.content) + data = response.json() + selected = next( + row for row in data["results"] + if row["identity"]["resource"]["id"] == str(self.event_activity.pk) + ) + self.assertEqual(selected["identity"], { + "family": expected["identity"]["family"], + "candidate_key": expected["identity"]["candidate_key"], + "resource": expected["identity"]["resource"], + "occurrence": expected["identity"]["occurrence"], + }) + self.assertEqual(selected["place"]["latitude"], -11.6647) + self.assertEqual(selected["place"]["longitude"], 27.4794) + self.assertEqual(selected["spatial_facts"][0]["role"], "primary") + self.assertEqual( + selected["links"]["detail"], expected["links"]["detail"] + ) + self.assertNotIn("personal_relation", selected) + self.assertNotIn("accesscredential", str(data).lower()) + + # Existing non-opt-in endpoint remains backward compatible. + legacy = self.client.get(reverse("discovery_api:map"), {"q": "Z3 concert"}) + self.assertEqual(legacy.status_code, 200) + self.assertIn("activity_id", legacy.json()["results"][0]) + + def test_service_and_funding_admission_reuse_owner_publication_rules(self): + from organizations.models import SpaceLifecycle + + service_path = reverse( + "discovery_api:item-detail", + kwargs={"family": "service_activity", "item_id": self.service_activity.pk}, + ) + funding_path = reverse( + "discovery_api:item-detail", + kwargs={"family": "funding_activity", "item_id": self.funding_activity.pk}, + ) + for field, hidden in ( + ("verification_status", "suspended"), + ("lifecycle", SpaceLifecycle.ARCHIVED), + ("public_profile", False), + ("searchable", False), + ): + with self.subTest(field=field): + original = getattr(self.space, field) + setattr(self.space, field, hidden) + self.space.save(update_fields=[field]) + rows = self._items(q="Z3")["data"]["results"] + ids = {row["identity"]["resource"]["id"] for row in rows} + self.assertNotIn(str(self.service_activity.pk), ids) + self.assertNotIn(str(self.funding_activity.pk), ids) + self.assertEqual(self.client.get(service_path).status_code, 404) + self.assertEqual(self.client.get(funding_path).status_code, 404) + setattr(self.space, field, original) + self.space.save(update_fields=[field]) + recovered = self._items(q="Z3")["data"]["results"] + recovered_ids = { + row["identity"]["resource"]["id"] for row in recovered + } + self.assertIn(str(self.service_activity.pk), recovered_ids) + self.assertIn(str(self.funding_activity.pk), recovered_ids) + + def test_public_resource_metadata_obeys_preparation_owner_gate(self): + resource = ActivityResource.objects.create( + activity=self.event_activity, + key="owner-sensitive-document", + title="Document publication conditionnelle", + kind=ResourceKind.TEXT, + text_content="Informations publiques seulement si le porteur publie", + visibility=ResourceVisibility.PUBLIC, + status=ResourceStatus.PUBLISHED, + created_by=self.owner, + published_at=self.now, + ) + initial = self._items(q="Z3 concert")["data"]["results"] + event = next(row for row in initial if row["identity"]["family"] == "activity") + self.assertIn(str(resource.pk), str(event["resources"])) + + # The Activity can remain in the older public Event field even if its + # owner's public profile was retracted. Preparation then denies the + # Resource reader, so Discovery must not embed either body or metadata. + self.space.public_profile = False + self.space.save(update_fields=["public_profile"]) + later = self._items(q="Z3 concert")["data"]["results"] + event = next(row for row in later if row["identity"]["family"] == "activity") + self.assertIsNone(event["owner"]) + self.assertEqual(event["resources"], []) + self.assertNotIn("owner-sensitive-document", str(event)) + + def test_opportunity_resource_rejects_unsafe_legacy_external_url(self): + from opportunities.models import OpportunitySource, OpportunitySourceType + + source = OpportunitySource.objects.create( + opportunity=self.opportunity, + source_type=OpportunitySourceType.OFFICIAL, + source_name="Source ancienne", + url="https://example.test/official", + ) + OpportunitySource.objects.filter(pk=source.pk).update( + url="javascript:alert(1)" + ) + rows = self._items(q="Z3 bourse")["data"]["results"] + opportunity = next( + item for item in rows + if item["identity"]["family"] == "opportunity" + ) + resource = next( + item for item in opportunity["resources"] + if item["id"] == str(source.pk) + ) + self.assertIsNone(resource["external_url"]) + self.assertNotIn("javascript:alert", str(resource)) + + def test_invalid_cursor_and_exploration_return_api_validation_errors(self): + self.client.force_login(self.participant) + first = self._items(q="Z3", page_size=1)["data"] + cursor = first["continuation"]["token"] + self.assertIsNotNone(cursor) + + endpoint = reverse("discovery_api:items") + for params in ( + {"q": "Z3 changed", "page_size": 1, "continuation": cursor}, + {"q": "Z3", "page_size": 2, "continuation": cursor}, + {"q": "Z3", "page_size": 1, "continuation": cursor + "tampered"}, + {"q": "Z3", "page_size": "not-an-int"}, + {"q": "Z3", "when": "invalid-temporal-preset"}, + ): + with self.subTest(params=params): + response = self.client.get(endpoint, params) + self.assertEqual(response.status_code, 400, response.content) + + self.client.force_login(self.outsider) + rejected = self.client.get( + endpoint, {"q": "Z3", "page_size": 1, "continuation": cursor}, + ) + self.assertEqual(rejected.status_code, 400, rejected.content) + + bad_map = self.client.get( + reverse("discovery_api:map"), + {"when": "invalid-temporal-preset"}, + ) + self.assertEqual(bad_map.status_code, 400, bad_map.content) + + def test_collection_price_uses_commerce_unknown_and_exact_owner_truth(self): + from commerce.models import Offer, OfferStatus, PaymentMode + + service = next( + row for row in self._items(q="Z3 accompagnement")["data"]["results"] + if row["identity"]["family"] == "service_activity" + ) + self.assertEqual(service["price"]["state"], "unknown") + self.assertEqual(service["price"]["reason"], "no_applicable_offer") + self.assertIsNone(service["price"]["minimum"]) + + Offer.objects.create( + activity=self.service_activity, name="Accompagnement au dossier", + unit_price=Decimal("15.00"), currency="USD", + status=OfferStatus.ACTIVE, payment_mode=PaymentMode.UPFRONT, + ) + updated = next( + row for row in self._items(q="Z3 accompagnement")["data"]["results"] + if row["identity"]["family"] == "service_activity" + ) + self.assertEqual(updated["price"]["state"], "exact") + self.assertEqual(updated["price"]["minimum"], "15.00") + self.assertEqual(updated["price"]["currency"], "USD") + + Offer.objects.create( + activity=self.service_activity, name="Autre devise", + unit_price=Decimal("1000.00"), currency="CDF", + status=OfferStatus.ACTIVE, payment_mode=PaymentMode.UPFRONT, + ) + mixed = next( + row for row in self._items(q="Z3 accompagnement")["data"]["results"] + if row["identity"]["family"] == "service_activity" + ) + self.assertEqual(mixed["price"]["state"], "unknown") + self.assertEqual(mixed["price"]["reason"], "mixed_currencies") + self.assertIsNone(mixed["price"]["minimum"]) + self.assertIsNone(mixed["price"]["currency"]) + self.assertEqual(mixed["price"]["offer_count"], 2) + + def test_expired_continuation_is_rejected_as_validation_not_server_error(self): + from unittest.mock import patch + from django.core import signing + + self.client.force_login(self.participant) + with patch( + "discovery.api.composition.signing.loads", + side_effect=signing.SignatureExpired("expired"), + ): + response = self.client.get( + reverse("discovery_api:items"), + {"q": "Z3", "page_size": 1, "continuation": "expired"}, + ) + self.assertEqual(response.status_code, 400, response.content) + self.assertIn("discovery", response.json()) diff --git a/discovery/api/test_media_bindings.py b/discovery/api/test_media_bindings.py new file mode 100644 index 0000000000..7b795706a3 --- /dev/null +++ b/discovery/api/test_media_bindings.py @@ -0,0 +1,74 @@ +import uuid + +from django.test import SimpleTestCase +from django.urls import reverse + +from presentations.media_selectors import ResolvedPresentationMedia + +from .media_bindings import apply_recognition_media + + +class DiscoveryPresentationMediaBindingTests(SimpleTestCase): + def setUp(self): + self.activity_id = str(uuid.uuid4()) + self.occurrence_id = str(uuid.uuid4()) + + def _projection(self): + return { + "identity": {"resource": {"kind": "activity", "id": self.activity_id}}, + "representation": {"kind": "route", "image_url": "/media/existing-event.png"}, + "media_bindings": [ + { + "resource": {"url": "/media/existing-event.png"}, + "target": {"kind": "activity", "id": self.activity_id}, + "purpose": "RECOGNIZE", + } + ], + } + + def _media(self, *, occurrence_id=None, position=0): + return ResolvedPresentationMedia( + use_id=str(uuid.uuid4()), asset_id=str(uuid.uuid4()), + activity_id=self.activity_id, occurrence_id=occurrence_id, + purpose="recognize", mime_type="image/png", + alt_text="Lieu de cette possibilité", position=position, + ) + + def test_public_media_populates_image_and_binding_from_same_use(self): + media = self._media() + projection = apply_recognition_media(self._projection(), [media]) + url = reverse("presentations:published-media-file", kwargs={"use_id": media.use_id}) + self.assertEqual(projection["representation"]["image_url"], url) + self.assertEqual(projection["media_bindings"][0]["resource"]["url"], url) + self.assertEqual(projection["media_bindings"][0]["purpose"], "RECOGNIZE") + self.assertEqual(projection["media_bindings"][0]["target"]["id"], self.activity_id) + self.assertEqual(projection["media_bindings"][0]["resource"]["alt_text"], media.alt_text) + self.assertEqual(projection["media_bindings"][1]["resource"]["url"], "/media/existing-event.png") + + def test_occurrence_specific_media_never_leaks_to_other_occurrence(self): + common = self._media(position=5) + scoped = self._media(occurrence_id=self.occurrence_id, position=9) + another = self._media(occurrence_id=str(uuid.uuid4()), position=0) + result = apply_recognition_media( + self._projection(), [common, another, scoped], + occurrence_id=self.occurrence_id, + ) + self.assertEqual(result["media_bindings"][0]["resource"]["id"], scoped.use_id) + self.assertEqual(result["media_bindings"][1]["resource"]["id"], common.use_id) + self.assertNotIn( + another.use_id, + [row["resource"].get("id") for row in result["media_bindings"]], + ) + + def test_no_explicit_media_preserves_existing_fallback(self): + original = self._projection() + result = apply_recognition_media(original, []) + self.assertEqual(result["representation"]["kind"], "route") + self.assertEqual(result["representation"]["image_url"], "/media/existing-event.png") + self.assertEqual(len(result["media_bindings"]), 1) + + def test_projection_bounds_explicit_gallery(self): + result = apply_recognition_media(self._projection(), [ + self._media(position=index) for index in range(18) + ]) + self.assertEqual(len(result["media_bindings"]), 7) diff --git a/discovery/api/test_personal_primary_actions.py b/discovery/api/test_personal_primary_actions.py new file mode 100644 index 0000000000..6436c903ba --- /dev/null +++ b/discovery/api/test_personal_primary_actions.py @@ -0,0 +1,136 @@ +from django.test import SimpleTestCase + +from discovery.api.composition import _primary_action_for_projection + + +class PersonalDiscoveryPrimaryActionTests(SimpleTestCase): + def _projection(self, engagement): + return { + "links": {"detail": "/api/v1/discovery/items/activity/known/"}, + "engagement": engagement, + } + + def test_commerce_checkout_uses_verified_journey(self): + engagement = { + "owner": "commerce", "state": "owner_api", + "capabilities": ["checkout"], + "resource": {"kind": "journey", "id": "journey-id"}, + "links": {"checkout": "/api/v1/commerce/journeys/journey-id/checkout/", + "native": "/commerce/journeys/journey-id/checkout"}, + } + action = _primary_action_for_projection(self._projection(engagement)) + self.assertEqual(action["code"], "checkout") + self.assertEqual(action["destination"]["path"], + "/commerce/journeys/journey-id/checkout") + + def test_direct_activity_offer_action_is_owner_verified(self): + engagement = { + "owner": "commerce", "state": "owner_api", + "capabilities": ["inspect_offers"], + "resource": {"kind": "activity", "id": "activity-id"}, + "links": { + "native": "/commerce/activities/activity-id/checkout", + "offers": "/api/v1/commerce/activities/activity-id/checkout/", + }, + } + action = _primary_action_for_projection(self._projection(engagement)) + self.assertEqual(action["code"], "inspect_offers") + self.assertEqual(action["label"], "Choisir une offre") + self.assertEqual(action["destination"], { + "kind": "native", "path": "/commerce/activities/activity-id/checkout", + }) + + def test_existing_order_takes_priority_over_new_checkout(self): + engagement = { + "owner": "commerce", "state": "owner_api", + "capabilities": ["resume_order"], + "resource": {"kind": "commerce_order", "id": "order-id"}, + "links": {"native": "/commerce/orders/order-id"}, + } + self.assertEqual( + _primary_action_for_projection(self._projection(engagement))["code"], + "resume_order", + ) + + def test_existing_journey_can_continue(self): + engagement = { + "owner": "journeys", "state": "owner_api", + "capabilities": ["continue"], + "resource": {"kind": "journey", "id": "journey-id"}, + "links": {"native": "/journeys/journey-id"}, + } + self.assertEqual( + _primary_action_for_projection(self._projection(engagement))["code"], + "continue", + ) + + def test_guest_service_start_is_visible_without_execution_permission(self): + engagement = { + "owner": "services", + "state": "owner_api", + "capabilities": ["start_after_auth"], + "resource": {"kind": "service", "id": "service-id"}, + "links": {"start": "/api/v1/services/service-id/start/"}, + "presentation": { + "capability": "start", + "label": "Commencer", + "enabled": False, + "requires_auth": True, + }, + } + action = _primary_action_for_projection(self._projection(engagement)) + self.assertEqual(action["code"], "start") + self.assertEqual(action["destination"], { + "kind": "native", + "path": "/services/service-id/start", + }) + self.assertNotIn("start", engagement["capabilities"]) + self.assertFalse(engagement["presentation"]["enabled"]) + + def test_guest_service_start_rejects_forged_auth_handoff(self): + for engagement in ( + { + "owner": "services", + "state": "owner_contract", + "capabilities": ["start_after_auth"], + "resource": {"kind": "service", "id": "service-id"}, + "links": {"start": "/api/v1/services/service-id/start/"}, + "presentation": { + "capability": "start", "requires_auth": True, + }, + }, + { + "owner": "services", + "state": "owner_api", + "capabilities": [], + "resource": {"kind": "service", "id": "service-id"}, + "links": {"start": "/api/v1/services/service-id/start/"}, + "presentation": { + "capability": "start", "requires_auth": True, + }, + }, + ): + with self.subTest(engagement=engagement): + action = _primary_action_for_projection(self._projection(engagement)) + self.assertEqual(action["code"], "detail") + + def test_untrusted_or_unavailable_owner_action_falls_back_to_detail(self): + for engagement in [ + { + "owner": "commerce", "state": "owner_contract", + "capabilities": ["checkout"], + "resource": {"kind": "journey", "id": "journey-id"}, + "links": {"native": "/commerce/journeys/journey-id/checkout"}, + }, + { + "owner": "commerce", "state": "owner_api", + "capabilities": [], + "resource": {"kind": "commerce_order", "id": "order-id"}, + "links": {"native": "/commerce/orders/order-id"}, + }, + ]: + with self.subTest(engagement=engagement): + self.assertEqual( + _primary_action_for_projection(self._projection(engagement))["code"], + "detail", + ) diff --git a/discovery/api/test_projections.py b/discovery/api/test_projections.py index 2dfe7bbd82..bb925d420d 100644 --- a/discovery/api/test_projections.py +++ b/discovery/api/test_projections.py @@ -14,6 +14,7 @@ RepresentationPresentation, ) +from .composition import _primary_action_for_projection from .projections import ( project_funding_possibility, project_occurrence_possibility, @@ -125,6 +126,8 @@ def test_occurrence_projection_keeps_canonical_identity_and_resolved_relation(se candidate_key=f"activity:{activity_id}", activity_id=activity_id, occurrence_id=occurrence_id, + matching_count=1, + matching_occurrence_ids=[occurrence_id], timing_kind="exact", start_date=(now + timedelta(days=3)).date(), start_time=timezone.localtime(now + timedelta(days=3)).time().replace(tzinfo=None), @@ -241,7 +244,7 @@ def test_funding_projection_keeps_unresolved_personal_relation_unknown(self): item = { "candidate_key": f"funding_activity:{activity_id}", "activity_id": activity_id, - "funding": SimpleNamespace(pk=funding_id), + "funding": SimpleNamespace(pk=funding_id, opens_at=None, closes_at=None), } card = _card(candidate_key=item["candidate_key"], activity_id=activity_id) @@ -326,3 +329,87 @@ def test_projection_exposes_facts_not_frontend_interpretation_or_global_ranking( self.assertNotIn("availability_label", keys) self.assertNotIn("label", projection["personal_relation"]) self.assertNotIn("access_credential", keys) + + +class DiscoveryPrimaryActionHandoffTests(SimpleTestCase): + def test_web_only_owner_action_is_not_promoted_to_native(self): + projection = { + "links": {"detail": "/api/v1/discovery/items/activity/example/"}, + "engagement": { + "owner": "activities", + "state": "owner_contract", + "capabilities": ["reserve"], + "links": {}, + "presentation": { + "capability": "reserve", + "label": "Réserver", + "web": "/events/example/reserve/", + "web_enabled": True, + }, + }, + } + action = _primary_action_for_projection(projection) + self.assertEqual(action["code"], "detail") + self.assertEqual(action["destination"]["kind"], "detail") + + def test_funding_action_points_to_owner_native_route(self): + funding_id = str(uuid.uuid4()) + projection = { + "links": {"detail": "/api/v1/discovery/items/funding_activity/example/"}, + "engagement": { + "owner": "funding", + "state": "owner_api", + "capabilities": ["contribute"], + "resource": {"kind": "funding", "id": funding_id}, + "links": { + "native": f"/funding/{funding_id}/contribute", + "contribute": f"/api/v1/funding/{funding_id}/contributions/", + }, + }, + } + action = _primary_action_for_projection(projection) + self.assertEqual(action["code"], "contribute") + self.assertEqual( + action["destination"], + {"kind": "native", "path": f"/funding/{funding_id}/contribute"}, + ) + + def test_obtention_action_uses_existing_native_journey_owner(self): + obtention_id = str(uuid.uuid4()) + projection = { + "links": {"detail": "/api/v1/discovery/items/activity/example/"}, + "engagement": { + "owner": "obtention", + "state": "owner_api", + "capabilities": ["start_journey"], + "resource": {"kind": "obtention", "id": obtention_id}, + "links": { + "native": f"/obtention/{obtention_id}/choose", + "start_journey": f"/api/v1/obtention/{obtention_id}/journeys/", + }, + }, + } + action = _primary_action_for_projection(projection) + self.assertEqual(action["code"], "start_journey") + self.assertEqual( + action["destination"], + {"kind": "native", "path": f"/obtention/{obtention_id}/choose"}, + ) + + def test_mismatched_funding_route_falls_back_to_detail(self): + projection = { + "links": {"detail": "/detail/"}, + "engagement": { + "owner": "funding", + "state": "owner_api", + "capabilities": ["contribute"], + "resource": {"kind": "funding", "id": str(uuid.uuid4())}, + "links": { + "native": "/funding/other/contribute", + "contribute": "/api/v1/funding/other/contributions/", + }, + }, + } + self.assertEqual( + _primary_action_for_projection(projection)["code"], "detail" + ) diff --git a/discovery/api/test_resource_previews.py b/discovery/api/test_resource_previews.py new file mode 100644 index 0000000000..54dcb4cf30 --- /dev/null +++ b/discovery/api/test_resource_previews.py @@ -0,0 +1,148 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.urls import reverse + +from accounts.models import UserProfile +from django.test import TestCase +from django.utils import timezone + +from activities.models import Activity, ActivityStatus, ActivityVisibility, Occurrence +from preparation.models import ActivityResource, ResourceKind, ResourceStatus, ResourceVisibility + +from .composition import ( + DISCOVERY_RESOURCE_INLINE_CHARS, + DISCOVERY_RESOURCE_ACTIVITY_INLINE_BUDGET, + _activity_resource_previews, + _safe_public_http_url, +) + + +class DiscoveryDocumentaryPreviewTests(TestCase): + def setUp(self): + owner = get_user_model().objects.create_user( + username="discover-doc-preview", email="preview@example.test", + password="test-strong-secret", + ) + self.activity = Activity.objects.create( + owner_profile=owner, created_by=owner, + title="Démonstration du catalogue documentaire", + status=ActivityStatus.PUBLISHED, + visibility=ActivityVisibility.PUBLIC, + ) + self.owner = owner + UserProfile.objects.get_or_create(user=owner, defaults={"public_profile": True, "searchable": True}) + + def _resource(self, key, *, kind=ResourceKind.TEXT, visibility=ResourceVisibility.PUBLIC, + content="Instructions", external_url="", occurrence=None): + return ActivityResource.objects.create( + activity=self.activity, created_by=self.owner, + occurrence=occurrence, title=key, key=key, kind=kind, + text_content=content if kind == ResourceKind.TEXT else "", + external_url=external_url if kind == ResourceKind.URL else "", + status=ResourceStatus.PUBLISHED, visibility=visibility, + published_at=timezone.now(), + ) + + def test_long_public_text_has_bounded_inline_sample_and_full_owner_reader(self): + long_text = "Préparation accessible. " * 180 + self._resource("instructions", content=long_text) + public = self._resource( + "note-privee", content="Uniquement pour les participants", + visibility=ResourceVisibility.PARTICIPANT, + ) + resources = _activity_resource_previews([self.activity.pk])[str(self.activity.pk)] + self.assertEqual(len(resources), 1) + row = resources[0] + self.assertEqual(row["kind"], ResourceKind.TEXT) + self.assertEqual(row["text_content"], long_text[:DISCOVERY_RESOURCE_INLINE_CHARS]) + self.assertTrue(row["text_truncated"]) + self.assertEqual( + row["read_url"], + reverse("preparation:resource-text", kwargs={"resource_id": resources[0]["id"]}), + ) + full = self.client.get(row["read_url"]) + self.assertEqual(full.status_code, 200) + self.assertEqual(full.content.decode("utf-8"), long_text) + self.assertEqual(full["Cache-Control"], "private, no-store") + self.assertIsNone(row["download_url"]) + self.assertIsNone(row["external_url"]) + self.assertNotIn(str(public.pk), repr(resources)) + self.assertNotIn("Uniquement pour les participants", repr(resources)) + + def test_external_url_only_allows_http_or_https(self): + approved = self._resource("lien-ok", kind=ResourceKind.URL, external_url="https://example.org/") + legacy = self._resource("lien-legacy", kind=ResourceKind.URL, external_url="https://example.org/") + ActivityResource.objects.filter(pk=legacy.pk).update(external_url="javascript:alert(1)") + resources = _activity_resource_previews([self.activity.pk])[str(self.activity.pk)] + by_id = {r["id"]: r for r in resources} + self.assertEqual(by_id[str(approved.pk)]["external_url"], "https://example.org/") + self.assertIsNone(by_id[str(legacy.pk)]["external_url"]) + + def test_resource_collection_has_no_silent_presentation_limit(self): + count = 13 + for number in range(count): + self._resource(f"sample-{number:02d}") + resources = _activity_resource_previews([self.activity.pk])[str(self.activity.pk)] + self.assertEqual(len(resources), count) + self.assertEqual( + {resource["title"] for resource in resources}, + {f"sample-{number:02d}" for number in range(count)}, + ) + + def test_total_inline_budget_preserves_all_document_refs(self): + for index in range(9): + self._resource(f"long-{index:02d}", content="a" * 2000) + resources = _activity_resource_previews([self.activity.pk])[str(self.activity.pk)] + self.assertEqual(len(resources), 9) + self.assertEqual( + sum(len(item["text_content"]) for item in resources), + DISCOVERY_RESOURCE_ACTIVITY_INLINE_BUDGET, + ) + self.assertTrue(all(item["read_url"] for item in resources)) + self.assertTrue(all(item["text_truncated"] for item in resources)) + self.assertEqual(resources[-1]["text_content"], "") + + def test_owner_privacy_denies_even_metadata_and_read_endpoint(self): + resource = self._resource("not-for-guests", content="No leak") + row = _activity_resource_previews([self.activity.pk])[str(self.activity.pk)][0] + self.assertIn("not-for-guests", row["title"]) + privacy = self.owner.profile + privacy.public_profile = False + privacy.save(update_fields=["public_profile"]) + self.assertEqual(_activity_resource_previews([self.activity.pk]), {}) + response = self.client.get( + reverse("preparation:resource-text", kwargs={"resource_id": resource.pk}) + ) + self.assertEqual(response.status_code, 404) + + def test_untrusted_and_credentialed_external_urls_are_not_exposed(self): + self.assertEqual(_safe_public_http_url("https://example.test/help"), "https://example.test/help") + self.assertIsNone(_safe_public_http_url("javascript:alert(1)")) + self.assertIsNone(_safe_public_http_url("https://user:pass@example.test/help")) + self.assertIsNone(_safe_public_http_url("https://example.test\nmalformed")) + self.assertIsNone(_safe_public_http_url("//example.test/unsafe")) + + def test_occurrence_specific_resource_stays_with_admitted_variant(self): + first = Occurrence.objects.create( + activity=self.activity, + start_at=timezone.now() + timedelta(days=1), + end_at=timezone.now() + timedelta(days=1, hours=1), + ) + other = Occurrence.objects.create( + activity=self.activity, + start_at=timezone.now() + timedelta(days=2), + end_at=timezone.now() + timedelta(days=2, hours=1), + ) + self._resource("common") + self._resource("first-only", occurrence=first) + self._resource("other-only", occurrence=other) + scoped = _activity_resource_previews( + [self.activity.pk], + occurrence_scope={str(self.activity.pk): {str(first.pk)}}, + )[str(self.activity.pk)] + self.assertEqual( + {resource["title"] for resource in scoped}, + {"common", "first-only"}, + ) + self.assertNotIn("other-only", str(scoped)) diff --git a/discovery/api/test_stage07_sharing.py b/discovery/api/test_stage07_sharing.py new file mode 100644 index 0000000000..1625a8e1c7 --- /dev/null +++ b/discovery/api/test_stage07_sharing.py @@ -0,0 +1,132 @@ +"""Stage-07 Sharing: owner-confirmed link, revocation, recipient isolation.""" +import json +from urllib.parse import urlsplit + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from opportunities.models import ( + Opportunity, OpportunityKind, OpportunityPublicationStatus, + OpportunityRevision, +) +from sharing.models import ShareEnvelope, ShareStatus + +User = get_user_model() + + +class DiscoveryStage07SharingTests(TestCase): + def setUp(self): + self.sender = User.objects.create_user( + username="share-07-sender", email="sender07@example.test", password="test", + ) + self.recipient = User.objects.create_user( + username="share-07-recipient", email="recipient07@example.test", password="test", + ) + self.opportunity = Opportunity.objects.create( + kind=OpportunityKind.SCHOLARSHIP, created_by=self.sender, + ) + revision = OpportunityRevision.objects.create( + opportunity=self.opportunity, + version=1, + title="Bourse partagée 07", + summary="Offre publiée", + issuer_name="Fondation", + created_by=self.sender, + ) + now = timezone.now() + OpportunityRevision.objects.filter(pk=revision.pk).update(published_at=now) + Opportunity.objects.filter(pk=self.opportunity.pk).update( + current_revision=revision, + publication_status=OpportunityPublicationStatus.PUBLISHED, + published_at=now, + ) + self.path = reverse("sharing_api:possibility", kwargs={ + "family": "opportunity", "item_id": self.opportunity.pk, + }) + + def post_share(self, body=None): + return self.client.post( + self.path, + data=json.dumps(body or {"intent": "view"}), + content_type="application/json", + ) + + def test_unauthenticated_share_does_not_create_envelope(self): + response = self.post_share() + self.assertIn(response.status_code, (401, 403)) + self.assertFalse(ShareEnvelope.objects.exists()) + + def test_owner_confirmed_public_link_resolves_only_while_authorized(self): + self.client.force_login(self.sender) + response = self.post_share() + self.assertEqual(response.status_code, 201) + payload = response.json() + self.assertEqual(payload["state"], "link") + self.assertIn(urlsplit(payload["url"]).scheme, ("http", "https")) + token = urlsplit(payload["url"]).path.rstrip("/").rsplit("/", 1)[-1] + self.client.logout() + resolve = reverse("sharing_api:resolve", kwargs={"token": token}) + resolved = self.client.get(resolve) + self.assertEqual(resolved.status_code, 200) + self.assertEqual(resolved.json()["family"], "opportunity") + self.assertEqual(resolved.json()["id"], str(self.opportunity.pk)) + self.assertEqual(resolved["Cache-Control"], "no-store") + + envelope = ShareEnvelope.objects.first() + envelope.status = ShareStatus.REVOKED + envelope.revoked_at = timezone.now() + envelope.save() + self.assertEqual(self.client.get(resolve).status_code, 404) + + def test_withdrawn_owner_subject_is_not_shareable_or_resolvable(self): + self.client.force_login(self.sender) + created = self.post_share() + self.assertEqual(created.status_code, 201) + token = urlsplit(created.json()["url"]).path.rstrip("/").rsplit("/", 1)[-1] + Opportunity.objects.filter(pk=self.opportunity.pk).update( + publication_status=OpportunityPublicationStatus.WITHDRAWN, + ) + self.assertEqual(self.post_share().status_code, 404) + self.client.logout() + self.assertEqual( + self.client.get(reverse("sharing_api:resolve", kwargs={"token": token})).status_code, + 404, + ) + + def test_explicit_empty_internal_recipient_does_not_fall_back_to_link(self): + self.client.force_login(self.sender) + response = self.post_share({"intent": "view", "recipient_id": ""}) + self.assertEqual(response.status_code, 400) + self.assertFalse(ShareEnvelope.objects.exists()) + + def test_invalid_internal_recipient_is_rejected_without_a_delivery(self): + self.client.force_login(self.sender) + response = self.post_share({"intent": "view", "recipient_id": "not-a-uuid"}) + self.assertEqual(response.status_code, 400) + from sharing.models import ShareDelivery + self.assertFalse(ShareDelivery.objects.exists()) + + def test_private_delivery_cannot_be_resolved_by_another_profile(self): + # A direct delivery is only valid if the recipient is public/searchable; + # the canonical owner API produces the delivery identity. + from accounts.models import UserProfile + sender_profile, _ = UserProfile.objects.get_or_create(user=self.sender) + recipient_profile, _ = UserProfile.objects.get_or_create(user=self.recipient) + recipient_profile.searchable = True + recipient_profile.save() + self.client.force_login(self.sender) + response = self.post_share({ + "intent": "view", "recipient_id": str(recipient_profile.pk), + }) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["state"], "delivered") + delivery_id = response.json()["delivery_id"] + detail = reverse("sharing_api:delivery", kwargs={"delivery_id": delivery_id}) + self.client.logout() + self.assertIn(self.client.get(detail).status_code, (401, 403)) + self.client.force_login(self.sender) + self.assertEqual(self.client.get(detail).status_code, 403) + self.client.force_login(self.recipient) + self.assertEqual(self.client.get(detail).status_code, 200) diff --git a/discovery/api/test_stage07_watch_save.py b/discovery/api/test_stage07_watch_save.py new file mode 100644 index 0000000000..abf64abc2e --- /dev/null +++ b/discovery/api/test_stage07_watch_save.py @@ -0,0 +1,175 @@ +"""Stage-07: Watch identity, idempotence, tenant isolation and Save separation.""" +import json + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from discovery.models import DiscoveryWatch +from opportunities.models import ( + Opportunity, OpportunityKind, OpportunityPublicationStatus, OpportunitySave, + OpportunityRevision, +) + +User = get_user_model() + + +class DiscoveryStage07WatchSaveTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user( + username="stage07-owner", email="stage07-owner@example.test", + password="test-password", + ) + self.other = User.objects.create_user( + username="stage07-other", email="stage07-other@example.test", + password="test-password", + ) + self.url = reverse("discovery_api:watches") + + def post_json(self, path, body): + return self.client.post( + path, data=json.dumps(body), content_type="application/json", + ) + + def patch_json(self, path, body): + return self.client.patch( + path, data=json.dumps(body), content_type="application/json", + ) + + def test_watch_requires_authenticated_owner(self): + response = self.post_json(self.url, { + "name": "Déplacements", "criteria": {"q": "bus"}, + }) + self.assertIn(response.status_code, (401, 403)) + self.assertEqual(DiscoveryWatch.objects.count(), 0) + + def test_watch_replay_reuses_active_owner_identity_and_does_not_save(self): + self.client.force_login(self.owner) + body = {"name": "Veille transport", "criteria": {"q": "bus"}} + first = self.post_json(self.url, body) + second = self.post_json(self.url, body) + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 200) + first_watch = first.json()["data"]["watch"] + second_watch = second.json()["data"]["watch"] + self.assertEqual(first_watch["id"], second_watch["id"]) + self.assertTrue(first.json()["data"]["created"]) + self.assertFalse(second.json()["data"]["created"]) + self.assertEqual(DiscoveryWatch.objects.filter(owner=self.owner).count(), 1) + + def test_pause_resume_delete_are_explicit_and_private(self): + self.client.force_login(self.owner) + response = self.post_json(self.url, { + "name": "Veille cours", "criteria": {"q": "formation"}, + }) + watch_id = response.json()["data"]["watch"]["id"] + detail = reverse("discovery_api:watch-detail", kwargs={"watch_id": watch_id}) + paused = self.patch_json(detail, {"status": "paused"}) + self.assertEqual(paused.status_code, 200) + self.assertEqual(paused.json()["data"]["watch"]["status"], "paused") + other = self.post_json(self.url, { + "name": "Veille cours", "criteria": {"q": "formation"}, + }) + self.assertEqual(other.status_code, 201) + self.assertNotEqual(other.json()["data"]["watch"]["id"], watch_id) + resumed = self.patch_json(detail, {"status": "active"}) + self.assertEqual(resumed.status_code, 200) + self.assertEqual(resumed.json()["data"]["watch"]["status"], "active") + self.client.force_login(self.other) + self.assertEqual(self.patch_json(detail, {"status": "paused"}).status_code, 404) + self.assertEqual(self.client.delete(detail).status_code, 404) + self.client.force_login(self.owner) + self.assertEqual(self.client.delete(detail).status_code, 204) + self.assertFalse(DiscoveryWatch.objects.filter(pk=watch_id).exists()) + + def test_edit_name_and_criteria_preserves_watch_identity(self): + self.client.force_login(self.owner) + created = self.post_json(self.url, { + "name": "Veille initiale", "criteria": {"q": "bourse"}, + }) + self.assertEqual(created.status_code, 201) + watch_id = created.json()["data"]["watch"]["id"] + detail = reverse("discovery_api:watch-detail", kwargs={"watch_id": watch_id}) + renamed = self.patch_json(detail, {"name": "Veille renommée"}) + self.assertEqual(renamed.status_code, 200) + self.assertEqual(renamed.json()["data"]["watch"]["name"], "Veille renommée") + changed = self.patch_json(detail, {"criteria": { + "q": "formation", "place": "Lubumbashi", + }}) + self.assertEqual(changed.status_code, 200) + self.assertEqual(changed.json()["data"]["watch"]["id"], watch_id) + self.assertEqual(changed.json()["data"]["watch"]["criteria"]["q"], "formation") + self.assertEqual(DiscoveryWatch.objects.filter(owner=self.owner).count(), 1) + + def test_web_watch_create_uses_same_idempotent_owner_transition(self): + self.client.force_login(self.owner) + form = reverse("discovery:watch-create") + body = {"name": "Veille Web", "criteria": json.dumps({"q": "transport"})} + first = self.client.post(form, data=body) + second = self.client.post(form, data=body) + self.assertEqual(first.status_code, 302) + self.assertEqual(second.status_code, 302) + self.assertEqual(DiscoveryWatch.objects.filter( + owner=self.owner, name="Veille Web", + ).count(), 1) + + def test_bad_watch_criteria_and_status_are_400_not_server_errors(self): + self.client.force_login(self.owner) + for criteria in ({}, {"lat": "-11.6"}, {"vertical": "not_supported"}): + res = self.post_json(self.url, {"name": "Veille", "criteria": criteria}) + self.assertEqual(res.status_code, 400) + self.assertEqual( + self.post_json(self.url, { + "name": "X" * 141, "criteria": {"q": "test"}, + }).status_code, 400, + ) + created = self.post_json(self.url, { + "name": "Veille", "criteria": {"q": "test"}, + }) + detail = reverse("discovery_api:watch-detail", kwargs={ + "watch_id": created.json()["data"]["watch"]["id"], + }) + self.assertEqual(self.patch_json(detail, {"status": "fake"}).status_code, 400) + self.assertEqual(self.patch_json(detail, {"criteria": {}}).status_code, 400) + + def test_opportunity_save_is_idempotent_and_independent_of_watch(self): + opportunity = Opportunity.objects.create( + kind=OpportunityKind.SCHOLARSHIP, created_by=self.owner, + ) + revision = OpportunityRevision.objects.create( + opportunity=opportunity, version=1, + title="Bourse Save 07", summary="Offre publiée", + issuer_name="Fondation", created_by=self.owner, + ) + now = timezone.now() + OpportunityRevision.objects.filter(pk=revision.pk).update(published_at=now) + Opportunity.objects.filter(pk=opportunity.pk).update( + current_revision=revision, + publication_status=OpportunityPublicationStatus.PUBLISHED, + published_at=now, + ) + url = reverse("discovery_api:item-saved", kwargs={ + "family": "opportunity", "item_id": opportunity.pk, + }) + self.client.force_login(self.other) + saved_first = self.client.put(url) + saved_again = self.client.put(url) + self.assertEqual(saved_first.status_code, 200) + self.assertEqual(saved_again.status_code, 200) + self.assertEqual( + saved_again.json()["data"]["item"]["saved"]["state"], "saved", + ) + self.assertEqual( + OpportunitySave.objects.filter(profile=self.other, opportunity=opportunity).count(), + 1, + ) + self.assertFalse(DiscoveryWatch.objects.filter(owner=self.other).exists()) + unsaved = self.client.delete(url) + self.assertEqual(unsaved.status_code, 200) + self.assertEqual( + unsaved.json()["data"]["item"]["saved"]["state"], "not_saved", + ) + self.assertFalse( + OpportunitySave.objects.filter(profile=self.other, opportunity=opportunity).exists(), + ) diff --git a/discovery/api/test_stage08_http_privacy.py b/discovery/api/test_stage08_http_privacy.py new file mode 100644 index 0000000000..17bca459d8 --- /dev/null +++ b/discovery/api/test_stage08_http_privacy.py @@ -0,0 +1,58 @@ +"""Stage 08: cache boundaries on viewer-dependent Discovery and Sharing API. + +These tests are intentionally added without executing the CI stage (09). +""" +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +User = get_user_model() + + +class DiscoveryStage08HttpPrivacyTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="stage08-private", email="stage08@example.test", + password="temporary-test-only", + ) + + def assert_not_cacheable(self, response): + cache = response.get("Cache-Control", "") + self.assertIn("no-store", cache) + vary = {part.strip().lower() for part in + response.get("Vary", "").split(",")} + self.assertIn("authorization", vary) + self.assertIn("cookie", vary) + + def test_public_discovery_has_no_shared_http_cache(self): + self.assert_not_cacheable( + self.client.get(reverse("discovery_api:items")) + ) + + def test_personal_discovery_and_watches_cannot_be_shared_in_cache(self): + self.client.force_login(self.user) + self.assert_not_cacheable( + self.client.get(reverse("discovery_api:items")) + ) + self.assert_not_cacheable( + self.client.get(reverse("discovery_api:watches")) + ) + + def test_owner_denial_is_not_cacheable_as_a_permanent_empty_state(self): + self.client.force_login(self.user) + self.assert_not_cacheable(self.client.get( + reverse("discovery_api:item-detail", kwargs={ + "family": "activity", + "item_id": "11111111-2222-3333-4444-555555555555", + }), + )) + + def test_public_token_and_private_delivery_errors_do_not_cache(self): + self.assert_not_cacheable(self.client.get( + reverse("sharing_api:resolve", kwargs={"token": "not-a-real-token"}), + )) + self.assert_not_cacheable(self.client.get( + reverse("sharing_api:delivery", kwargs={ + "delivery_id": "11111111-2222-3333-4444-555555555555", + }), + )) diff --git a/discovery/api/throttles.py b/discovery/api/throttles.py new file mode 100644 index 0000000000..1636389941 --- /dev/null +++ b/discovery/api/throttles.py @@ -0,0 +1,23 @@ +"""Bound repeated, user-invoked paid provider actions by authenticated Profile. + +These are defense-in-depth limits shared by Django Web and DRF entry points. +They rely on the configured Django cache (use a shared cache in deployment). +""" +from rest_framework.throttling import UserRateThrottle + + +class _FixedUserThrottle(UserRateThrottle): + rate = None + + def get_rate(self): + return self.rate + + +class DiscoveryClarifyThrottle(_FixedUserThrottle): + scope = "discovery_clarify" + rate = "12/hour" + + +class DiscoveryResearchThrottle(_FixedUserThrottle): + scope = "discovery_research" + rate = "12/hour" diff --git a/discovery/api/urls.py b/discovery/api/urls.py index 4652be46c0..17ca4f8ee7 100644 --- a/discovery/api/urls.py +++ b/discovery/api/urls.py @@ -4,6 +4,8 @@ BookmarkDetailAPIView, BookmarkListCreateAPIView, DiscoveryForYouAPIView, + DiscoveryClarifyAPIView, + DiscoveryResearchAPIView, DiscoveryItemDetailAPIView, DiscoveryItemSavedAPIView, DiscoveryItemsAPIView, @@ -17,6 +19,8 @@ app_name = "discovery_api" urlpatterns = [ + path("clarify/", DiscoveryClarifyAPIView.as_view(), name="clarify"), + path("research/", DiscoveryResearchAPIView.as_view(), name="research"), path("items/", DiscoveryItemsAPIView.as_view(), name="items"), path( "items///", diff --git a/discovery/api/views.py b/discovery/api/views.py index 2670a72ac6..689ba090b9 100644 --- a/discovery/api/views.py +++ b/discovery/api/views.py @@ -1,7 +1,10 @@ -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError as DjangoValidationError from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.utils.cache import patch_vary_headers -from rest_framework.exceptions import NotFound +from rest_framework.exceptions import NotFound, ValidationError +from django.core.exceptions import ValidationError as DomainValidationError from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -18,7 +21,7 @@ public_discovery_events, serialize_event, ) -from discovery.watches import execute_watch, normalize_watch_criteria +from discovery.watches import execute_watch, normalize_watch_criteria, ensure_discovery_watch from objectives.models import Dossier from .composition import ( @@ -26,11 +29,28 @@ paginated_projection, parse_page_params, project_rows, + spatial_index_from_rows, + spatial_discovery_projection, + _public_occurrence_facts_for_rows, set_saved_state, detail_projection, ) +class PrivateDiscoveryResponseBoundary: + """An API response may depend on viewer/owner authority at every request. + + Do not let a browser, proxy or shared HTTP cache reuse a personal field, + Watch, Bookmark, N2 detail, error response or signed continuation. + """ + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + patch_vary_headers(response, ("Authorization", "Cookie")) + return response + + MAX_MAP_POINTS = 100 @@ -45,7 +65,63 @@ def _reject_profile_override(request): ) -class DiscoveryItemsAPIView(APIView): +from .throttles import DiscoveryClarifyThrottle, DiscoveryResearchThrottle + + +class DiscoveryResearchAPIView(PrivateDiscoveryResponseBoundary, APIView): + """Optional external leads; never a canonical Discovery item or GET side effect.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [DiscoveryResearchThrottle] + + def post(self, request): + from discovery.assistance import research_discovery_sources + + if not isinstance(request.data, dict) or set(request.data) != {"q"}: + raise ValidationError({"q": ["Recherche invalide."]}) + try: + result = research_discovery_sources( + actor=request.user, text=request.data["q"], + ) + except ValueError as exc: + raise ValidationError({"q": [str(exc)]}) from exc + response = Response(projection_envelope( + projection="discovery.source-leads", + scope="personal", data=result, + )) + response["Cache-Control"] = "private, no-store" + return response + + +class DiscoveryClarifyAPIView(PrivateDiscoveryResponseBoundary, APIView): + """Explicit, authenticated Discovery clarification. Never a billable GET.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [DiscoveryClarifyThrottle] + + def post(self, request): + from discovery.assistance import clarify_discovery_search + + if not isinstance(request.data, dict) or set(request.data) != {"criteria"}: + raise ValidationError({"criteria": ["Contrat de recherche invalide."]}) + try: + result = clarify_discovery_search( + actor=request.user, criteria=request.data["criteria"], + ) + except ValueError as exc: + raise ValidationError({"criteria": [str(exc)]}) from exc + response = Response( + projection_envelope( + projection="discovery.clarification", + scope="personal", + data=result, + ) + ) + response["Cache-Control"] = "private, no-store" + return response + + +class DiscoveryItemsAPIView(PrivateDiscoveryResponseBoundary, APIView): """Canonical mature Discovery collection. The legacy Event-only mobile feed remains under /api/v1/events/discover/. @@ -57,10 +133,13 @@ class DiscoveryItemsAPIView(APIView): def get(self, request): _reject_profile_override(request) - data = paginated_projection( - request.query_params, - profile=request.user, - ) + try: + data = paginated_projection( + request.query_params, + profile=request.user, + ) + except DjangoValidationError as exc: + raise ValidationError({"discovery": exc.messages}) from exc return Response( projection_envelope( projection="discovery.items", @@ -70,7 +149,7 @@ def get(self, request): ) -class DiscoveryItemDetailAPIView(APIView): +class DiscoveryItemDetailAPIView(PrivateDiscoveryResponseBoundary, APIView): permission_classes = [AllowAny] def get(self, request, family, item_id): @@ -91,7 +170,7 @@ def get(self, request, family, item_id): ) -class DiscoveryItemSavedAPIView(APIView): +class DiscoveryItemSavedAPIView(PrivateDiscoveryResponseBoundary, APIView): """Explicit conservation, distinct from Interest, Watch and engagement.""" permission_classes = [IsAuthenticated] @@ -129,17 +208,30 @@ def delete(self, request, family, item_id): ) -class DiscoveryMapAPIView(APIView): +class DiscoveryMapAPIView(PrivateDiscoveryResponseBoundary, APIView): """Public/private-viewer map projection from the same Discovery field.""" permission_classes = [AllowAny] def get(self, request): _reject_profile_override(request) - composition = compose_discovery( - request.query_params, - profile=request.user, - ) + try: + composition = compose_discovery( + request.query_params, + profile=request.user, + ) + except DjangoValidationError as exc: + raise ValidationError({"discovery": exc.messages}) from exc + # Spatial presentation of the *same admitted candidates* as Discovery. + # Opt-in keeps the legacy 100-point API stable for existing consumers. + if request.query_params.get("spatial") == "1": + if request.query_params.get("details") == "1": + return Response(spatial_discovery_projection( + composition.rows, profile=request.user + )) + # Compatibility path for existing Map consumers. + return Response(spatial_index_from_rows(composition.rows)) + points = [] for family, _, candidate in composition.rows: if family != "activity": @@ -160,7 +252,7 @@ def get(self, request): ) -class DiscoveryForYouAPIView(APIView): +class DiscoveryForYouAPIView(PrivateDiscoveryResponseBoundary, APIView): """Historical Event-compatibility surface, deliberately not Z3 ranking.""" permission_classes = [AllowAny] @@ -187,7 +279,7 @@ def get(self, request): ) -class BookmarkListCreateAPIView(APIView): +class BookmarkListCreateAPIView(PrivateDiscoveryResponseBoundary, APIView): """Legacy Event-shaped bookmark API backed by canonical ActivityBookmark.""" permission_classes = [IsAuthenticated] @@ -230,7 +322,7 @@ def post(self, request): ) -class BookmarkDetailAPIView(APIView): +class BookmarkDetailAPIView(PrivateDiscoveryResponseBoundary, APIView): permission_classes = [IsAuthenticated] def delete(self, request, event_id): @@ -269,14 +361,24 @@ def _watch_payload(watch): } +def _normalize_watch_or_error(raw): + try: + return normalize_watch_criteria(raw) + except DomainValidationError as exc: + raise ValidationError({"criteria": exc.messages}) from exc + + def _resolve_personal_dossier(request, raw_id): if raw_id in (None, ""): return None - dossier = Dossier.objects.filter( - pk=raw_id, - owner_profile=request.user, - owning_space__isnull=True, - ).first() + try: + dossier = Dossier.objects.filter( + pk=raw_id, + owner_profile=request.user, + owning_space__isnull=True, + ).first() + except (ValueError, TypeError, DomainValidationError) as exc: + raise ValidationError({"dossier_id": ["Identifiant de Dossier invalide."]}) from exc if dossier is None: raise ValidationError( {"dossier_id": ["Dossier personnel introuvable."]} @@ -284,7 +386,7 @@ def _resolve_personal_dossier(request, raw_id): return dossier -class DiscoveryWatchListCreateAPIView(APIView): +class DiscoveryWatchListCreateAPIView(PrivateDiscoveryResponseBoundary, APIView): permission_classes = [IsAuthenticated] def get(self, request): @@ -302,29 +404,25 @@ def get(self, request): def post(self, request): name = str(request.data.get("name") or "").strip() - if not name: - raise ValidationError({"name": ["Le nom de la Veille est obligatoire."]}) - criteria = normalize_watch_criteria(request.data.get("criteria")) + if not name or len(name) > 140: + raise ValidationError({"name": ["Le nom de la Veille doit contenir entre 1 et 140 caractères."]}) + criteria = _normalize_watch_or_error(request.data.get("criteria")) dossier = _resolve_personal_dossier( - request, - request.data.get("dossier_id"), + request, request.data.get("dossier_id"), ) - watch = DiscoveryWatch.objects.create( - owner=request.user, - name=name, - criteria=criteria, - dossier=dossier, + watch, created = ensure_discovery_watch( + owner=request.user, name=name, criteria=criteria, dossier=dossier, ) return Response( projection_envelope( projection="discovery.watch", - data={"watch": _watch_payload(watch)}, + data={"watch": _watch_payload(watch), "created": created}, ), - status=201, + status=201 if created else 200, ) -class DiscoveryWatchDetailAPIView(APIView): +class DiscoveryWatchDetailAPIView(PrivateDiscoveryResponseBoundary, APIView): permission_classes = [IsAuthenticated] def get(self, request, watch_id): @@ -340,13 +438,13 @@ def patch(self, request, watch_id): watch = _owned_watch(request, watch_id) if "name" in request.data: name = str(request.data.get("name") or "").strip() - if not name: + if not name or len(name) > 140: raise ValidationError( - {"name": ["Le nom de la Veille est obligatoire."]} + {"name": ["Le nom de la Veille doit contenir entre 1 et 140 caractères."]} ) watch.name = name if "criteria" in request.data: - watch.criteria = normalize_watch_criteria( + watch.criteria = _normalize_watch_or_error( request.data.get("criteria") ) if "status" in request.data: @@ -361,7 +459,10 @@ def patch(self, request, watch_id): request, request.data.get("dossier_id"), ) - watch.save() + try: + watch.save() + except DomainValidationError as exc: + raise ValidationError({"watch": exc.messages}) from exc return Response( projection_envelope( projection="discovery.watch", @@ -375,7 +476,7 @@ def delete(self, request, watch_id): return Response(status=204) -class DiscoveryWatchResultsAPIView(APIView): +class DiscoveryWatchResultsAPIView(PrivateDiscoveryResponseBoundary, APIView): permission_classes = [IsAuthenticated] def get(self, request, watch_id): diff --git a/discovery/assistance.py b/discovery/assistance.py new file mode 100644 index 0000000000..fb7952815c --- /dev/null +++ b/discovery/assistance.py @@ -0,0 +1,248 @@ +"""User-invoked, owner-scoped clarification of a Discovery search. + +No free-form chat, factual invention or new Action registry. The result only +contains bounded Discovery criteria; Discovery remains the search owner. +""" +from __future__ import annotations + +import logging + +from accounts.models import UserProfile +from intelligence.capabilities import IntelligenceCapability +from intelligence.gateway import IntelligenceGateway +from intelligence.runtime import build_runtime_registry + +from .intent import resolve_discovery_intent +from .intelligence import interpret_with_intelligence, _should_augment + + +_logger = logging.getLogger("makolo.discovery") + + +def _record_external_action(*, action: str, status: str, reason: str = "", + candidates: int = 0) -> None: + """Safe operational event. Never include search text, user, key or URL.""" + public_reason = reason if reason in { + "invalid_credentials", "rate_limited", "network_unavailable", + "connection_unavailable", "provider_unavailable", + } else "" + _logger.info( + "discovery.external_action", + extra={ + "makolo_event": "discovery.external_action", + "action": action, + "status": status, + "reason": public_reason, + "candidate_count": max(0, min(int(candidates), 3)), + }, + ) + + +_ALLOWED_CRITERIA = frozenset({ + "q", "place", "when", "period", "vertical", "price", + "date", "date_from", "date_to", "timezone", + "lat", "lon", "radius_km", +}) + + +def _public_failure(reason: str) -> str: + """Closed vocabulary for UI and logs; never forward provider messages.""" + if reason in { + "invalid_credentials", "rate_limited", "network_unavailable", + "connection_unavailable", + }: + return reason + if reason == "capability_not_configured": + return "connection_unavailable" + return "provider_unavailable" + + +class _ObservedGateway: + """Observe one explicit action, without storing its provider output.""" + def __init__(self, gateway): + self.gateway = gateway + self.failure = "" + + def execute(self, request): + result = self.gateway.execute(request) + if not result.available: + self.failure = _public_failure(result.reason) + return result + + +def clarify_discovery_search(*, actor, criteria: dict) -> dict: + """Explicit action, with no client-selected provider/owner or persistent effect.""" + if not getattr(actor, "is_authenticated", False): + raise ValueError("Authentification requise.") + if not isinstance(criteria, dict) or set(criteria) - _ALLOWED_CRITERIA: + raise ValueError("Critères non autorisés.") + if not all(isinstance(value, str) for value in criteria.values()): + raise ValueError("Critères invalides.") + if any(len(value) > (120 if key == "q" else 160) for key, value in criteria.items()): + raise ValueError("Critères trop longs.") + params = {key: value.strip() for key, value in criteria.items()} + intent = resolve_discovery_intent(params) + status = "already_structured" + reason = "" + if _should_augment(intent) and not any( + value for key, value in params.items() if key not in {"q", "radius_km"} + ): + profile = UserProfile.objects.filter(user=actor).first() + registry = build_runtime_registry( + capability=IntelligenceCapability.STRUCTURED_GENERATE, + profile=profile, + ) + if not registry.providers_for(IntelligenceCapability.STRUCTURED_GENERATE): + status = "unavailable" + reason = "connection_unavailable" + else: + observer = _ObservedGateway(IntelligenceGateway(registry=registry)) + try: + enriched = interpret_with_intelligence( + intent, gateway=observer, + ) + except Exception: + # No provider exception or credential details reach the client. + enriched = intent + observer.failure = "provider_unavailable" + status = ("unavailable" if observer.failure else + "applied" if enriched != intent else "unchanged") + reason = observer.failure + intent = enriched + elif not intent.raw_text: + status = "empty" + _record_external_action(action="clarify", status=status, reason=reason) + return { + "status": status, + "reason": reason, + "query": intent.to_search_params(), + "constraints": [ + { + "key": constraint.key, + "value": constraint.value, + "label": constraint.label, + "source": constraint.source.value, + } + for constraint in intent.constraints + ], + } + +def research_discovery_sources(*, actor, text: str) -> dict: + """Find bounded external leads tied to a Discovery query, not Makolo facts.""" + import ipaddress + from urllib.parse import urlsplit + + from django.utils import timezone + + from research_missions import ( + ResearchFamily, ResearchMission, ResearchOrigin, ResearchOriginKind, + ) + from web_research import WebResearchMode, WebResearchRequest + from web_research.intelligence_engine import IntelligenceWebResearchEngine + + if not getattr(actor, "is_authenticated", False): + raise ValueError("Authentification requise.") + if not isinstance(text, str) or not text.strip() or len(text.strip()) > 120: + raise ValueError("Recherche invalide.") + text = text.strip() + profile = UserProfile.objects.filter(user=actor).first() + registry = build_runtime_registry( + capability=IntelligenceCapability.WEB_RESEARCH, + profile=profile, + ) + if not registry.providers_for(IntelligenceCapability.WEB_RESEARCH): + _record_external_action( + action="research", status="unavailable", + reason="connection_unavailable", + ) + return {"status": "unavailable", "reason": "connection_unavailable", + "sources": [], "candidates": []} + + mission = ResearchMission( + primary_family=ResearchFamily.POSSIBILITY, + subject=text, + questions=(f"Quelles sources publiques correspondent à : {text} ?",), + origins=(ResearchOrigin( + kind=ResearchOriginKind.INITIAL, + source_ref="discovery:explicit-source-research", + ),), + reasons=("Explorer des pistes externes liées à une recherche Makolo.",), + limits={"max_candidates": 3, "max_queries": 1}, + ) + try: + result = IntelligenceWebResearchEngine( + IntelligenceGateway(registry=registry), + ).execute(WebResearchRequest( + mission=mission, mode=WebResearchMode.DISCOVER, + requested_at=timezone.now(), + )) + except Exception: + _record_external_action( + action="research", status="unavailable", + reason="provider_unavailable", + ) + return {"status": "unavailable", "reason": "provider_unavailable", + "sources": [], "candidates": []} + + if result.outcome.value == "failed": + reason = _public_failure(result.failure_code or "") + _record_external_action( + action="research", status="unavailable", reason=reason, + ) + return {"status": "unavailable", + "reason": reason, "sources": [], "candidates": []} + + sources = {} + for source in result.sources: + parts = urlsplit(source.locator) + if parts.scheme != "https" or not parts.hostname or parts.username or parts.password: + continue + hostname = parts.hostname.lower().rstrip(".") + if hostname == "localhost" or hostname.endswith(".localhost"): + continue + try: + ip = ipaddress.ip_address(hostname) + except ValueError: + pass + else: + if not ip.is_global: + continue + sources[source.source_ref] = { + "title": (source.title or parts.hostname)[:160], + "url": source.locator, + "host": parts.hostname, + } + + candidates = [] + for candidate in result.candidates: + citations = [ + sources[ref] for ref in candidate.source_refs if ref in sources + ][:2] + if not citations: + continue + candidates.append({ + "label": candidate.label[:160], + "summary": (candidate.summary or "")[:400], + "sources": citations, + }) + if len(candidates) >= 3: + break + + cited_urls = { + source["url"] + for candidate in candidates for source in candidate["sources"] + } + _record_external_action( + action="research", + status="completed" if candidates else "no_results", + candidates=len(candidates), + ) + return { + "status": "completed" if candidates else "no_results", + "reason": "", + "sources": [ + source for source in sources.values() + if source["url"] in cited_urls + ][:6], + "candidates": candidates, + } diff --git a/discovery/n2_presentation.py b/discovery/n2_presentation.py new file mode 100644 index 0000000000..70e61952a4 --- /dev/null +++ b/discovery/n2_presentation.py @@ -0,0 +1,113 @@ +"""Read-only N2 presentation from an admitted Discovery owner projection. + +No local alternative inference, no generic ranking and no owner mutations. +""" +from collections import defaultdict + +_STATES = ("favorable", "limiting", "unknown") + + +def _known(value): + if not isinstance(value, str): + return None + value = value.strip() + return value if value and value.lower() not in {"null", "none", "unknown", "n/a"} else None + + +def _time(timing, exact, date_key, clock_key): + if not isinstance(timing, dict): + return None + instant = _known(timing.get(exact)) + if instant: + return instant.replace("T", " ") + date = _known(timing.get(date_key)) + if not date: + return None + return " · ".join(value for value in ( + date, _known(timing.get(clock_key)), _known(timing.get("timezone")), + ) if value) + + +def build_n2_presentation(projection): + """Expose only owner-published values; dimension alignment must be real.""" + assessment = {state: [] for state in _STATES} + for fact in projection.get("assessment") or []: + if not isinstance(fact, dict): + continue + state = fact.get("state") + label = _known(fact.get("label")) + if state in assessment and label and label not in assessment[state]: + assessment[state].append(label) + + locations = defaultdict(list) + for fact in projection.get("spatial_facts") or []: + if not isinstance(fact, dict): + continue + occurrence = fact.get("occurrence") or {} + place = fact.get("place") or {} + if not isinstance(occurrence, dict) or not isinstance(place, dict): + continue + if occurrence.get("kind") != "occurrence": + continue + identity = _known(occurrence.get("id")) + name, locality = _known(place.get("name")), _known(place.get("locality")) + if not identity: + continue + label = ( + f"{name}, {locality}" if name and locality and name.casefold() != locality.casefold() + else (name or locality) + ) + if label and label not in locations[identity]: + locations[identity].append(label) + + variants = [] + seen = set() + identity = projection.get("identity") or {} + if not isinstance(identity, dict): + identity = {} + allow_occurrence_variants = identity.get("family") == "activity" + for variant in ((projection.get("variants") or []) if allow_occurrence_variants else ()): + if not isinstance(variant, dict): + continue + occurrence = variant.get("occurrence") or {} + if not isinstance(occurrence, dict) or occurrence.get("kind") != "occurrence": + continue + identity = _known(occurrence.get("id")) + if identity is None or identity in seen: + continue + seen.add(identity) + timing = variant.get("timing") or {} + variants.append({ + "id": identity, + "start": _time(timing, "start_at", "start_date", "start_time"), + "end": _time(timing, "end_at", "end_date", "end_time"), + "places": tuple(locations[identity]), + }) + + comparison = [] + if len(variants) > 1: + for label, key in (("Début", "start"), ("Fin", "end"), ("Lieu", "places")): + values = [ + " · ".join(variant[key]) if key == "places" else variant[key] + for variant in variants + ] + if all(values) and len(set(values)) > 1: + comparison.append({"label": label, "values": tuple(values)}) + relations = [] + for relation in projection.get("relations") or []: + if not isinstance(relation, dict): + continue + label = _known(relation.get("label")) + if label and label not in relations: + relations.append(label) + + freshness = projection.get("freshness") or {} + if not isinstance(freshness, dict): + freshness = {} + return { + "assessment": assessment, + "variants": tuple(variants), + "comparison": tuple(comparison), + "relations": tuple(relations), + "freshness": _known(freshness.get("label")), + } diff --git a/discovery/search.py b/discovery/search.py index b6bd236908..2e45a141a4 100644 --- a/discovery/search.py +++ b/discovery/search.py @@ -165,6 +165,8 @@ def _base_queryset(*, now): "activity__event_vertical", "activity__event_vertical__category", "activity__service_details", + "activity__funding_details", + "activity__obtention_details", "activity__transport_service", "activity__transport_service__route", "transport_departure", diff --git a/discovery/test_m8b_mature_discover.py b/discovery/test_m8b_mature_discover.py index b8d5ef5fb5..d4668080b5 100644 --- a/discovery/test_m8b_mature_discover.py +++ b/discovery/test_m8b_mature_discover.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from django.contrib.auth import get_user_model from django.test import TestCase @@ -16,6 +16,7 @@ from .card_contract import present_opportunity_card from .candidate_identity import opportunity_candidate_key from .unified import public_opportunity_discovery_items +from .views import _added_date_label, _discovery_first_person_action_label, _web_discovery_card User = get_user_model() @@ -126,6 +127,54 @@ def test_discover_renders_opportunity_through_universal_card_contract(self): self.assertEqual(response.context["map_items"], []) self.assertEqual(response.context["mappable_result_count"], 0) + def test_discover_n1_opens_owner_admitted_n2_for_public_opportunity(self): + opportunity, _ = self.published_opportunity( + "Bourse profondeur N2", + opens_at=self.now - timedelta(days=1), + deadline_at=self.now + timedelta(days=10), + ) + n2_url = reverse("discovery:possibility-detail", kwargs={ + "family": "opportunity", + "item_id": str(opportunity.pk), + }) + homepage = self.client.get( + reverse("discovery:home"), {"q": "Bourse profondeur N2"}, + ) + self.assertEqual(homepage.status_code, 200) + self.assertContains(homepage, "Approfondir cette possibilité") + self.assertContains(homepage, n2_url) + + detail = self.client.get(n2_url, { + "from": reverse("discovery:home") + "?q=Bourse", + }) + self.assertEqual(detail.status_code, 200) + self.assertEqual(detail.context["n2_item"]["identity"]["family"], "opportunity") + self.assertContains(detail, "Bourse profondeur N2") + self.assertContains(detail, "Informations pratiques") + self.assertNotContains(detail, "Comparer les variantes connues") + self.assertEqual( + detail.context["return_url"], reverse("discovery:home") + "?q=Bourse", + ) + + def test_n2_rejects_unsupported_family_and_external_return_url(self): + invalid = self.client.get( + reverse("discovery:possibility-detail", kwargs={ + "family": "invented", "item_id": "not-real", + }), + ) + self.assertEqual(invalid.status_code, 404) + opportunity, _ = self.published_opportunity( + "Bourse anti redirection", + opens_at=self.now - timedelta(days=1), + deadline_at=self.now + timedelta(days=10), + ) + n2_url = reverse("discovery:possibility-detail", kwargs={ + "family": "opportunity", "item_id": str(opportunity.pk), + }) + response = self.client.get(n2_url, {"from": "//evil.example/path"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["return_url"], reverse("discovery:home")) + def test_authenticated_opportunity_save_state_uses_canonical_opportunity_save(self): opportunity, _ = self.published_opportunity( "Bourse enregistrée M8-B", @@ -143,6 +192,68 @@ def test_authenticated_opportunity_save_state_uses_canonical_opportunity_save(se self.assertContains(response, 'aria-pressed="true"') self.assertContains(response, "Enregistré") + def test_n1_action_row_uses_three_positions_and_existing_share_options(self): + opportunity, _ = self.published_opportunity( + "Bourse N1 complète", + opens_at=self.now - timedelta(days=1), + deadline_at=self.now + timedelta(days=10), + ) + self.client.force_login(self.viewer) + + response = self.client.get(reverse("discovery:home"), {"q": "Bourse N1 complète"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["discovery_cards"]), 1) + self.assertContains(response, 'aria-label="Interactions avec cette possibilité"') + self.assertContains(response, "J’enregistre") + self.assertContains(response, reverse("opportunities:save-toggle", args=[opportunity.pk])) + self.assertContains(response, "data-share-internal") + self.assertContains(response, "data-share-person-search") + self.assertContains(response, "data-share-native") + self.assertContains(response, "data-share-copy") + self.assertContains(response, "data-share-qr-button") + self.assertNotContains(response, "mk-discovery-preview-action") + + def test_n1_web_actions_preserve_distinct_web_and_api_authority(self): + projection = { + "identity": { + "family": "service_activity", + "candidate_key": "service_activity:example", + "resource": {"kind": "activity", "id": "example"}, + "occurrence": None, + }, + "representation": { + "title": "Atelier de formation", + "summary": "Atelier", + "kind": "service", + }, + "owner": {"display_name": "Organisateur"}, + "saved": {"state": "not_saved"}, + "capabilities": ["save"], + "links": {"web": "/atelier/", "detail": "/api/detail/"}, + "engagement": { + "presentation": { + "label": "S'inscrire", + "capability": "start", + "enabled": False, + "web_enabled": True, + "web": "/inscription/", + }, + }, + } + + card = _web_discovery_card(projection) + self.assertIsNone(card.actions.save) + self.assertIsNotNone(card.actions.primary) + self.assertTrue(card.actions.primary.enabled) + self.assertEqual(card.actions.primary.label, "Je m'inscris") + self.assertEqual(card.actions.primary.url, "/inscription/") + self.assertEqual(_discovery_first_person_action_label("Payer"), "Je paye") + self.assertEqual(_discovery_first_person_action_label("Voir mon billet"), "Voir mon billet") + + projection["engagement"]["presentation"]["web_enabled"] = False + self.assertFalse(_web_discovery_card(projection).actions.primary.enabled) + def test_discovery_cards_follow_the_common_paginated_candidate_sequence(self): first, _ = self.published_opportunity( "Séquence M8-B alpha", @@ -177,3 +288,68 @@ def test_closed_opportunity_is_not_promoted_as_available_discover_card(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.context["discovery_cards"], []) self.assertContains(response, "Aucune possibilité ne correspond à cette recherche.") + + def test_n1_human_added_at_is_distinct_from_owner_event_timing(self): + local_zone = timezone.get_current_timezone() + now = timezone.make_aware(datetime(2026, 10, 31, 14, 0), local_zone) + self.assertEqual( + _added_date_label((now - timedelta(hours=2)).isoformat(), now=now), + "il y a 2 h", + ) + self.assertEqual( + _added_date_label((now - timedelta(days=1)).isoformat(), now=now), + "Hier", + ) + self.assertEqual( + _added_date_label((now - timedelta(days=14)).isoformat(), now=now), + "il y a 2 sem.", + ) + self.assertEqual(_added_date_label("not-a-date", now=now), "") + self.assertEqual( + _added_date_label((now + timedelta(days=1)).isoformat(), now=now), + "01/11/2026", + ) + + def test_n1_web_practical_roles_classification_and_known_unavailability(self): + projection = { + "identity": { + "family": "activity", + "candidate_key": "activity:transport", + "resource": {"kind": "activity", "id": "transport"}, + "occurrence": None, + }, + "representation": { + "title": "Transport Lubumbashi – Kolwezi", + "summary": "Un départ connu.", + "kind": "route", + "vertical_label": "Transport", + "eyebrow": "Transport", + }, + "presentation_facts": [ + {"code": "type", "label": "Type", "value": "Transport"}, + ], + "availability": {"state": "sold_out"}, + "spatial_facts": [ + {"role": "departure", "place": {"name": "Lubumbashi"}}, + {"role": "stopover", "place": {"name": "Likasi"}}, + {"role": "stopover", "place": {"name": "Fungurume"}}, + {"role": "arrival", "place": {"name": "Kolwezi"}}, + ], + "temporal_facts": [ + {"kind": "deadline_at", "value": "2026-11-01T12:00:00+02:00"}, + ], + "date": {"kind": "added_at", "value": "2026-10-30T12:00:00+02:00"}, + "links": {"web": "/transport/"}, + } + card = _web_discovery_card(projection) + self.assertEqual(card.type_labels, ("Transport",)) + self.assertFalse(any(fact.label == "Type" for fact in card.facts)) + by_label = {fact.label: fact.value for fact in card.facts} + self.assertEqual(by_label["Disponibilité"], "Complet") + self.assertEqual(by_label["Départ"], "Lubumbashi") + self.assertEqual(by_label["Escale 1"], "Likasi") + self.assertEqual(by_label["Escale 2"], "Fungurume") + self.assertEqual(by_label["Arrivée"], "Kolwezi") + self.assertIn("Date limite", by_label) + self.assertTrue(card.added_label) + self.assertNotEqual(card.added_label, by_label["Date limite"]) diff --git a/discovery/test_n2_presentation.py b/discovery/test_n2_presentation.py new file mode 100644 index 0000000000..e454b1155a --- /dev/null +++ b/discovery/test_n2_presentation.py @@ -0,0 +1,92 @@ +"""N2 is a read-only owner projection, never a universal alternative score.""" +from django.test import SimpleTestCase +from django.urls import reverse + +from .n2_presentation import build_n2_presentation + + +class DiscoveryN2PresentationTests(SimpleTestCase): + def _base(self, *, family="activity"): + return {"identity": { + "family": family, + "resource": {"kind": "activity", "id": "parent"}, + }} + + def test_assessment_unknown_is_not_false_and_duplicate_labels_are_bounded(self): + projection = self._base() + projection["assessment"] = [ + {"state": "favorable", "label": "Disponible"}, + {"state": "limiting", "label": "Dernières places"}, + {"state": "unknown", "label": "Votre éligibilité reste inconnue"}, + {"state": "unknown", "label": "Votre éligibilité reste inconnue"}, + {"state": "unsupported", "label": "UX inference"}, + ] + n2 = build_n2_presentation(projection) + self.assertEqual(n2["assessment"]["favorable"], ["Disponible"]) + self.assertEqual(n2["assessment"]["limiting"], ["Dernières places"]) + self.assertEqual(n2["assessment"]["unknown"], ["Votre éligibilité reste inconnue"]) + self.assertFalse(n2["comparison"]) + + def test_variants_can_compare_only_complete_shared_owner_dimensions(self): + projection = self._base() + projection.update({ + "price": {"state": "fixed", "amount": 90, "currency": "USD"}, + "variants": [ + {"occurrence": {"kind": "occurrence", "id": "morning"}, + "timing": {"start_date": "2026-11-12", "start_time": "09:00"}}, + {"occurrence": {"kind": "occurrence", "id": "evening"}, + "timing": {"start_date": "2026-11-12", "start_time": "17:00"}}, + {"occurrence": {"kind": "occurrence", "id": "morning"}, + "timing": {"start_date": "2026-11-13"}}, + ], + "spatial_facts": [ + {"occurrence": {"kind": "occurrence", "id": "morning"}, + "place": {"name": "Campus A", "locality": "Lubumbashi"}}, + {"occurrence": {"kind": "occurrence", "id": "evening"}, + "place": {"name": "Campus B", "locality": "Lubumbashi"}}, + ], + }) + n2 = build_n2_presentation(projection) + self.assertEqual(len(n2["variants"]), 2) + self.assertEqual(n2["variants"][0]["places"], ("Campus A, Lubumbashi",)) + self.assertEqual(n2["variants"][1]["places"], ("Campus B, Lubumbashi",)) + self.assertEqual([row["label"] for row in n2["comparison"]], ["Début", "Lieu"]) + self.assertEqual(n2["comparison"][0]["values"], + ("2026-11-12 · 09:00", "2026-11-12 · 17:00")) + + def test_absent_or_foreign_facts_do_not_create_comparison(self): + for family in ("funding_activity", "service_activity", "opportunity"): + projection = self._base(family=family) + projection["variants"] = [ + {"occurrence": {"kind": "occurrence", "id": "one"}, + "timing": {"start_date": "2026-11-01"}}, + {"occurrence": {"kind": "occurrence", "id": "two"}, + "timing": {"start_date": "2026-11-02"}}, + ] + n2 = build_n2_presentation(projection) + self.assertFalse(n2["comparison"]) + self.assertFalse(n2["variants"]) + projection = self._base() + projection["variants"] = [ + {"occurrence": {"kind": "occurrence", "id": "one"}, + "timing": {"start_date": "2026-11-01"}}, + {"occurrence": {"kind": "occurrence", "id": "two"}, + "timing": {}}, + ] + self.assertFalse(build_n2_presentation(projection)["comparison"]) + + def test_complement_is_not_reclassified_as_alternative(self): + projection = self._base() + projection["relations"] = [ + {"label": "Peut aider à financer la formation", "kind": "complement"}, + {"kind": "alternative", "target": {"kind": "activity", "id": "two"}}, + ] + n2 = build_n2_presentation(projection) + self.assertEqual(n2["relations"], ("Peut aider à financer la formation",)) + self.assertFalse(n2["comparison"]) + + def test_route_is_specific_to_an_admitted_owner_detail(self): + path = reverse("discovery:possibility-detail", kwargs={ + "family": "activity", "item_id": "abc", + }) + self.assertEqual(path, "/discover/possibilities/activity/abc/") diff --git a/discovery/test_presentation_contract.py b/discovery/test_presentation_contract.py index a1272fa823..7bd1b10945 100644 --- a/discovery/test_presentation_contract.py +++ b/discovery/test_presentation_contract.py @@ -244,7 +244,7 @@ def test_discovery_exposes_continuation_without_page_navigation(self): def test_unauthenticated_discovery_has_explicit_save_primary_share_fallback(self): response = self.client.get(reverse("discovery:home")) self.assertEqual(response.status_code, 200) - self.assertContains(response, "Enregistrer") + self.assertContains(response, "J'enregistre") self.assertContains(response, "Partager") self.assertContains(response, reverse("core:login")) self.assertNotContains(response, "personnes intéressées") diff --git a/discovery/test_stage6_prefetch.py b/discovery/test_stage6_prefetch.py new file mode 100644 index 0000000000..4241fb9da0 --- /dev/null +++ b/discovery/test_stage6_prefetch.py @@ -0,0 +1,21 @@ +"""Focused regression for stage 6 Discovery bulk vertical relationship loading.""" +from django.test import SimpleTestCase +from django.utils import timezone + +from discovery.search import _base_queryset + + +class DiscoveryVerticalPrefetchTests(SimpleTestCase): + def test_occurrence_search_preloads_all_vertical_relations(self): + # Building the queryset is read-only: no fixtures or database access. + related = _base_queryset(now=timezone.now()).query.select_related + activity = related["activity"] + for relation in ( + "event_vertical", + "service_details", + "transport_service", + "funding_details", + "obtention_details", + ): + with self.subTest(relation=relation): + self.assertIn(relation, activity) diff --git a/discovery/test_stage6_service_prefetch.py b/discovery/test_stage6_service_prefetch.py new file mode 100644 index 0000000000..11fab5ca34 --- /dev/null +++ b/discovery/test_stage6_service_prefetch.py @@ -0,0 +1,18 @@ +"""Stage 6: Service Discovery preloads vertical relations without changing admission.""" +from django.test import SimpleTestCase + +from discovery.unified import _service_discovery_queryset + + +class DiscoveryServiceVerticalPreloadTests(SimpleTestCase): + def test_service_candidates_preload_verticals(self): + relations = _service_discovery_queryset().query.select_related + activity = relations["activity"] + for name in ( + "event_vertical", + "transport_service", + "funding_details", + "obtention_details", + ): + with self.subTest(relation=name): + self.assertIn(name, activity) diff --git a/discovery/test_watches.py b/discovery/test_watches.py index cc1e2c4f6e..3ff66f9a96 100644 --- a/discovery/test_watches.py +++ b/discovery/test_watches.py @@ -59,6 +59,44 @@ def test_owner_isolation_and_private_routes(self): self.client.logout() self.assertEqual(self.client.get(reverse("discovery:watch-list")).status_code, 302) + def test_empty_watch_list_uses_product_language(self): + self.client.force_login(self.owner) + response = self.client.get(reverse("discovery:watch-list")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Aucune veille enregistrée.") + self.assertContains(response, "Découvrir") + self.assertNotContains(response, "rejouables") + self.assertNotContains(response, ">Discovery<") + + def test_create_watch_explains_saved_criteria_without_internal_terms(self): + self.client.force_login(self.owner) + response = self.client.get( + reverse("discovery:watch-create"), {"q": "bourse"} + ) + self.assertEqual(response.status_code, 200) + self.assertContains( + response, + "Retrouvez cette recherche avec les mêmes critères dans vos veilles.", + ) + self.assertNotContains(response, "Makolo rejouera") + self.assertNotContains(response, ">Discovery<") + self.assertContains(response, "Vous pouvez associer cette veille à l’un de vos dossiers.") + + def test_invalid_watch_action_is_explained_without_backend_vocabulary(self): + watch = DiscoveryWatch.objects.create( + owner=self.owner, name="Canada", criteria={"q": "Canada"} + ) + self.client.force_login(self.owner) + response = self.client.post( + reverse("discovery:watch-status", args=[watch.id]), + {"action": "unknown"}, + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Impossible de modifier cette veille.") + watch.refresh_from_db() + self.assertEqual(watch.status, DiscoveryWatchStatus.ACTIVE) + def test_pause_reactivate_and_modify(self): watch = DiscoveryWatch.objects.create(owner=self.owner, name="Canada", criteria={"q": "Canada"}) self.client.force_login(self.owner) diff --git a/discovery/tests.py b/discovery/tests.py index bf98840323..2130fa3949 100644 --- a/discovery/tests.py +++ b/discovery/tests.py @@ -23,6 +23,8 @@ from geography.models import Place, SpacePlace, SpacePlaceRole from journeys.models import Journey, JourneyStatus, WorkflowKind from organizations.models import Organization, OrganizationVerificationStatus, SpaceArchetype +from subscriptions.models import FeatureDefinition +from subscriptions.runtime_services import create_entitlement_grant from transport.services import ( configure_transport_fare, create_transport_departure, @@ -62,6 +64,16 @@ def setUp(self): verification_status=OrganizationVerificationStatus.VERIFIED, created_by=self.user, ) + self.now = datetime(2026, 8, 20, 10, 0, tzinfo=LUB) + # Transport creation uses the canonical subscription gate; give this + # discovery fixture an explicit Activity capability rather than bypassing it. + create_entitlement_grant( + feature=FeatureDefinition.objects.get(code="activities.create"), + value=True, + reason="Canonical Discovery transport integration fixture", + space=self.space, + valid_from=self.now, + ) self.category = EventCategory.objects.create(name="Culture Discovery") self.lubumbashi = Place.objects.create( name="Maison Makolo Lubumbashi", @@ -87,7 +99,6 @@ def setUp(self): role=SpacePlaceRole.SERVICE_POINT, is_public=True, ) - self.now = datetime(2026, 8, 20, 10, 0, tzinfo=LUB) now_patcher = patch("discovery.search.timezone.now", return_value=self.now) now_patcher.start() self.addCleanup(now_patcher.stop) diff --git a/discovery/unified.py b/discovery/unified.py index 1dc9cd75b4..2bf391b082 100644 --- a/discovery/unified.py +++ b/discovery/unified.py @@ -5,7 +5,6 @@ from django.db.models import Q from django.urls import reverse -from activities.models import ActivityStatus, ActivityVisibility from core.participant_activity_context import participant_state_context_for_activities from presentations.participant_state import resolve_participant_activity_state from presentations.product_language import vocabulary_for @@ -14,7 +13,8 @@ filter_queryset_by_activity_group_eligibility, ) from opportunities.selectors import open_opportunities, upcoming_opportunities -from services.models import OpportunityPolicy, ServiceDetails +from services.models import OpportunityPolicy +from services.views import public_services from .candidate_capabilities import family_can_satisfy_filters, requested_filter_keys from .candidate_identity import opportunity_candidate_key, service_activity_candidate_key @@ -54,6 +54,8 @@ def _opportunity_discovery_row(opportunity, *, temporal_state): "opportunity_id": str(opportunity.pk), "revision_id": str(revision.pk), "temporal_state": temporal_state, + "opens_at": revision.opens_at.isoformat() if revision.opens_at else None, + "deadline_at": revision.deadline_at.isoformat() if revision.deadline_at else None, "title": revision.title, "summary": revision.summary, "issuer_name": revision.issuer_name, @@ -172,17 +174,26 @@ def _service_discovery_row( } -def public_service_discovery_item(activity_id, *, profile=None): - service = ( - ServiceDetails.objects.filter( - activity_id=activity_id, - activity__status=ActivityStatus.PUBLISHED, - activity__visibility=ActivityVisibility.PUBLIC, - ) - .exclude(activity__space__verification_status="suspended") - .select_related("activity", "activity__space", "activity__owner_profile") - .first() + +def _service_discovery_queryset(): + """Preserve owner admission; preload relationships read by vertical_for. + + The Service candidate is already admitted by public_services(). This is + strictly a query optimization, never a replacement for owner/group gates. + """ + return public_services().select_related( + "activity__event_vertical", + "activity__transport_service", + "activity__funding_details", + "activity__obtention_details", ) + + +def public_service_discovery_item(activity_id, *, profile=None): + # The owner owns public admission. Never reproduce a looser Service + # status/visibility filter in Discovery (Space lifecycle and Profile + # publication choices are checked by public_services). + service = _service_discovery_queryset().filter(activity_id=activity_id).first() if service is None: return None if service.activity_id not in eligible_activity_ids_for_profile( @@ -230,15 +241,7 @@ def public_service_discovery_items( return [] text = (params.get("q") or "").strip() - services = ( - ServiceDetails.objects.filter( - activity__status=ActivityStatus.PUBLISHED, - activity__visibility=ActivityVisibility.PUBLIC, - ) - .exclude(activity__space__verification_status="suspended") - .select_related("activity", "activity__space", "activity__owner_profile") - .order_by("activity__title", "id") - ) + services = _service_discovery_queryset() opportunities = _matching_opportunities(text) opportunity = opportunities[0] if opportunities else None diff --git a/discovery/urls.py b/discovery/urls.py index 994f2ffdeb..c77ced50e5 100644 --- a/discovery/urls.py +++ b/discovery/urls.py @@ -5,6 +5,9 @@ BookmarkToggleView, DiscoveryActivityDetailView, DiscoveryHomeView, + DiscoveryClarifyWebView, + DiscoverySourceResearchWebView, + DiscoveryPossibilityDetailView, ForYouView, MyEventsView, ) @@ -20,7 +23,10 @@ app_name = "discovery" urlpatterns = [ + path("clarify/", DiscoveryClarifyWebView.as_view(), name="clarify"), + path("research/", DiscoverySourceResearchWebView.as_view(), name="research"), path("", DiscoveryHomeView.as_view(), name="home"), + path("possibilities///", DiscoveryPossibilityDetailView.as_view(), name="possibility-detail"), path("activities//", DiscoveryActivityDetailView.as_view(), name="activity-detail"), path("for-you/", ForYouView.as_view(), name="for-you"), path("watches/", WatchListView.as_view(), name="watch-list"), diff --git a/discovery/views.py b/discovery/views.py index f933f9f248..5f90c216cc 100644 --- a/discovery/views.py +++ b/discovery/views.py @@ -1,5 +1,8 @@ +from datetime import datetime, time from types import SimpleNamespace +from django.utils.dateparse import parse_date, parse_datetime + from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin @@ -7,8 +10,10 @@ from django.core.paginator import Paginator from django.db.models import Q from django.http import Http404 -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone +from django.utils.http import url_has_allowed_host_and_scheme from django.views import View from django.views.generic import ListView, TemplateView @@ -26,7 +31,8 @@ from .presentation import build_discovery_item, presenter_for from .search import get_public_occurrence, public_occurrences_for_activities, search_occurrences from .services import build_recommendations, public_discovery_events -from .api.composition import paginated_projection +from .api.composition import detail_projection, paginated_projection +from .n2_presentation import build_n2_presentation from .telemetry import record_search from .unified import public_opportunity_discovery_items, public_service_discovery_items @@ -168,6 +174,65 @@ def _discovery_fact(code, label, value, icon=None): return SimpleNamespace(code=code, label=label, value=value, icon=icon) +def _discovery_first_person_action_label(label): + """Humanize a confirmed owner Web action without changing its capability.""" + normalized = label.strip().casefold() + return { + "s'inscrire": "Je m'inscris", + "s’inscrire": "Je m’inscris", + "réserver": "Je réserve", + "payer": "Je paye", + "commander": "Je commande", + "candidater": "Je candidate", + "participer": "Je participe", + "acheter": "J’achète", + "louer": "Je loue", + "demander": "Je demande", + "contribuer": "Je contribue", + "postuler": "Je postule", + }.get(normalized, label) + + +def _added_date_label(value, *, now=None): + """Friendly N1 added-at label; not an Occurrence date or guessed timestamp.""" + if not value: + return "" + instant = parse_datetime(str(value)) + if instant is None: + day = parse_date(str(value)[:10]) + if day is None: + return "" + instant = timezone.make_aware( + datetime.combine(day, time.min), + timezone.get_current_timezone(), + ) + if timezone.is_naive(instant): + instant = timezone.make_aware(instant, timezone.get_current_timezone()) + current = now or timezone.now() + local = timezone.localtime(instant) + current = timezone.localtime(current) + delta = current - local + if delta.total_seconds() < 0: + return local.strftime("%d/%m/%Y") + days = (current.date() - local.date()).days + if days == 0: + minutes = int(delta.total_seconds() // 60) + if minutes < 1: + return "À l’instant" + if minutes < 60: + return f"il y a {minutes} min" + return f"il y a {int(delta.total_seconds() // 3600)} h" + if days == 1: + return "Hier" + if days == 2: + return "Avant-hier" + if days < 7: + return f"il y a {days} j" + if days < 30: + return f"il y a {days // 7} sem." + return local.strftime("%d/%m/%Y") + + def _web_discovery_card(item): identity = item["identity"] representation = item["representation"] @@ -176,62 +241,151 @@ def _web_discovery_card(item): engagement = item.get("engagement") or {} engagement_presentation = engagement.get("presentation") or {} - facts = [ - _discovery_fact( - fact.get("code") or "fact", - fact.get("label") or "", - fact.get("value") or "", - fact.get("icon"), - ) - for fact in item.get("presentation_facts", []) - ] + # Classification is rendered under the title, never as a practical fact. + type_labels = [] + def add_type(value): + text = str(value or "").strip() + if text and text.casefold() not in {label.casefold() for label in type_labels}: + type_labels.append(text) + + add_type(representation.get("vertical_label")) + add_type(representation.get("eyebrow")) + facts = [] + seen = set() + seen_labels = set() + + def canonical_code(code): + normalized = str(code or "").strip().lower() + return { + "capacity": "availability", + "start_at": "when", + "start_date": "when", + "place_primary": "place", + }.get(normalized, normalized) + + def add_fact(code, label, value, icon): + key = canonical_code(code) + normalized_label = str(label or "").strip().casefold() + if ( + value is None + or not str(value).strip() + or str(value).strip().casefold() in {"unknown", "inconnu", "inconnue", "n/a", "à confirmer"} + or key in seen + or normalized_label in seen_labels + ): + return + seen.add(key) + seen_labels.add(normalized_label) + facts.append(_discovery_fact(key, label, value, icon)) + + for fact in item.get("presentation_facts") or []: + code = str(fact.get("code") or "fact") + label = str(fact.get("label") or "") + if code.casefold() == "type" or label.casefold() == "type": + add_type(fact.get("value")) + else: + add_fact(code, label, fact.get("value"), fact.get("icon")) + timing = item.get("timing") or {} place = item.get("place") price = item.get("price") or {} availability = item.get("availability") or {} - if not facts: - if timing.get("start_at"): - facts.append(_discovery_fact("when", "Quand", timing["start_at"], "calendar-clock")) - elif timing.get("start_date"): - facts.append(_discovery_fact("when", "Quand", timing["start_date"], "calendar-clock")) - - if place: - place_value = place.get("name") or place.get("locality") - if place.get("name") and place.get("locality") and place["locality"] != place["name"]: - place_value = f"{place['name']}, {place['locality']}" - if place_value: - facts.append(_discovery_fact("place", "Lieu", place_value, "map-pin")) - - if price.get("state") == "free": - facts.append(_discovery_fact("price", "Prix", "Gratuit", "wallet-cards")) - elif price.get("minimum") is not None: - value = str(price["minimum"]) - if price.get("currency"): - value = f"{value} {price['currency']}" - facts.append(_discovery_fact("price", "Prix", value, "wallet-cards")) - - availability_state = availability.get("state") - if availability_state: - facts.append( - _discovery_fact( - "availability", - "Disponibilité", - _AVAILABILITY_LABELS.get(availability_state, availability_state), - "users", - ) + + if timing.get("start_at"): + add_fact("when", "Quand", timing["start_at"], "calendar-clock") + elif timing.get("start_date"): + add_fact("when", "Quand", timing["start_date"], "calendar-clock") + + if place: + place_value = place.get("name") or place.get("locality") + if place.get("name") and place.get("locality") and place["locality"] != place["name"]: + place_value = f"{place['name']}, {place['locality']}" + if place_value: + add_fact("place", "Lieu", place_value, "map-pin") + + if price.get("state") == "free": + add_fact("price", "Prix", "Gratuit", "wallet-cards") + elif price.get("minimum") is not None: + price_value = str(price["minimum"]) + if ( + price.get("state") == "range" + and price.get("maximum") is not None + and price["maximum"] != price["minimum"] + ): + price_value = f"{price_value} – {price['maximum']}" + elif price.get("state") in {"priced", "variant_dependent"}: + price_value = f"À partir de {price_value}" + if price.get("currency"): + price_value = f"{price_value} {price['currency']}" + add_fact("price", "Prix", price_value, "wallet-cards") + + availability_state = availability.get("state") + if availability_state and availability_state != "unknown": + add_fact( + "availability", + "Disponibilité", + _AVAILABILITY_LABELS.get(availability_state, availability_state), + "users", + ) + temporal_labels = { + "start_at": "Début", + "start_date": "Début", + "end_at": "Fin", + "end_date": "Fin", + "opens_at": "Ouverture", + "closes_at": "Clôture", + "deadline_at": "Date limite", + } + for fact in item.get("temporal_facts") or []: + kind = fact.get("kind") or "" + if kind in {"start_at", "start_date", "start_time"} and "when" in seen: + continue + if fact.get("value") is not None and kind in temporal_labels: + add_fact(kind, temporal_labels[kind], fact["value"], "calendar-clock") + + spatial_labels = { + "primary": "Lieu", + "venue": "Lieu", + "departure": "Départ", + "arrival": "Arrivée", + "stopover": "Escale", + "boarding": "Embarquement", + "dropoff": "Débarquement", + } + spatial_facts = item.get("spatial_facts") or [] + role_counts = {} + for fact in spatial_facts: + role = str(fact.get("role") or "").lower() + role_counts[role] = role_counts.get(role, 0) + 1 + role_positions = {} + for fact in spatial_facts: + role = str(fact.get("role") or "").lower() + place = fact.get("place") or {} + if not isinstance(place, dict): + continue + name = str(place.get("name") or "").strip() + locality = str(place.get("locality") or "").strip() + display = f"{name}, {locality}" if name and locality and name.casefold() != locality.casefold() else (name or locality) + if display: + role_positions[role] = role_positions.get(role, 0) + 1 + repeated = role_counts[role] > 1 + ordinal = role_positions[role] + code = ( + f"place_{role}_{ordinal}" if repeated + else "place" if role in {"primary", "venue"} else f"place_{role}" ) + label = spatial_labels.get(role, "Lieu") + if repeated: + label = f"{label} {ordinal}" + add_fact(code, label, display, "map-pin") save_action = None - if ( - links.get("web_saved") - or "save" in item.get("capabilities", []) - or "unsave" in item.get("capabilities", []) - ): + if links.get("web_saved"): is_saved = saved.get("state") == "saved" save_action = SimpleNamespace( code="unsave" if is_saved else "save", role="save", - label="Enregistré" if is_saved else "Enregistrer", + label="Enregistré" if is_saved else "J’enregistre", icon="orbit", state="saved" if is_saved else "available", url=links.get("web_saved"), @@ -244,12 +398,24 @@ def _web_discovery_card(item): primary_action = SimpleNamespace( code=engagement_presentation.get("capability") or "open", role="primary", - label=engagement_presentation.get("label") or "Continuer", - icon="arrow-right", + label=_discovery_first_person_action_label(engagement_presentation.get("label") or "Continuer"), + icon={ + "start": "play-circle", + "reserve": "ticket", + "book": "ticket", + "register": "user-plus", + "contribute": "hand-heart", + "buy": "shopping-bag", + "apply": "send", + }.get(engagement_presentation.get("capability"), "external-link"), state="available", url=engagement_presentation["web"], emphasis="primary", - enabled=bool(engagement_presentation.get("enabled", True)), + enabled=bool( + engagement_presentation.get( + "web_enabled", engagement_presentation.get("enabled", False) + ) + ), ) share_action = None @@ -273,6 +439,16 @@ def _web_discovery_card(item): "transport": "Opéré par", }.get(presentation_kind, "Proposé par") + added_date = None + date_info = item.get("date") or {} + if date_info.get("kind") == "added_at" and date_info.get("value"): + value = str(date_info["value"]) + parsed = parse_datetime(value) + if parsed is not None: + added_date = timezone.localtime(parsed).date() if timezone.is_aware(parsed) else parsed.date() + else: + added_date = parse_date(value[:10]) + return SimpleNamespace( candidate_key=identity["candidate_key"], activity_id=( @@ -283,12 +459,37 @@ def _web_discovery_card(item): occurrence_id=occurrence["id"] if occurrence else None, presentation_kind=presentation_kind, vertical_label=representation.get("vertical_label") or "Possibilité", + type_labels=tuple(type_labels), + date_display=added_date, + added_label=_added_date_label(date_info.get("value")) if added_date else "", title=representation["title"], summary=representation.get("summary") or "", + description=(item.get("detail") or {}).get("description") or "", operator_label=operator_label, operator_name=owner.get("display_name") or "", + owner=owner, + date=item.get("date"), representation=SimpleNamespace(**representation), facts=tuple(facts), + resources=tuple( + resource + for resource in (item.get("resources") or []) + if resource.get("title") + ), + gallery_media=tuple( + media + for media in (item.get("media_bindings") or []) + if (media.get("resource") or {}).get("mime_type", "").startswith("image/") + and (media.get("resource") or {}).get("url") + and (media.get("resource") or {}).get("url") != representation.get("image_url") + ), + context_notes=tuple( + fact.get("label") + for fact in (item.get("assessment") or []) + if fact.get("state") == "limiting" and fact.get("label") + )[:2], + variant_count=(len(item.get("variants") or []) if len(item.get("variants") or []) > 1 else 0), + relation_count=len(item.get("relations") or []), participant_state=( SimpleNamespace( participant_state=item["participant_presentation"].get("state"), @@ -305,9 +506,100 @@ def _web_discovery_card(item): secondary=(), ), url=links.get("web") or links.get("detail") or "#", + n2_url=reverse("discovery:possibility-detail", kwargs={ + "family": identity["family"], + "item_id": identity["resource"]["id"], + }), ) +class DiscoverySourceResearchWebView(LoginRequiredMixin, View): + """Source leads for an explicit Discovery query; not canonical possibilities.""" + + def post(self, request): + from discovery.assistance import research_discovery_sources + from discovery.api.throttles import DiscoveryResearchThrottle + + if not DiscoveryResearchThrottle().allow_request(request, self): + messages.warning(request, "Trop de recherches externes récentes. Réessayez plus tard.") + return redirect("discovery:home") + text = request.POST.get("q", "") + try: + result = research_discovery_sources(actor=request.user, text=text) + except ValueError: + messages.error(request, "Recherche de sources invalide.") + return redirect("discovery:home") + response = render(request, "discovery/source_leads.html", { + "query_text": text.strip(), + "external_leads": result, + }) + response["Cache-Control"] = "private, no-store" + return response + + +class DiscoveryClarifyWebView(LoginRequiredMixin, View): + """One explicit paid-provider attempt; fall back to ordinary Discovery.""" + + def post(self, request): + from urllib.parse import urlencode + from discovery.assistance import clarify_discovery_search + from discovery.api.throttles import DiscoveryClarifyThrottle + + if not DiscoveryClarifyThrottle().allow_request(request, self): + messages.warning(request, "Trop de clarifications récentes. Réessayez plus tard.") + return redirect("discovery:home") + criteria = { + key: request.POST.get(key, "") + for key in ("q", "place", "when", "period", "vertical", "price", + "date", "date_from", "date_to", "timezone", + "lat", "lon", "radius_km") + if key in request.POST + } + try: + result = clarify_discovery_search( + actor=request.user, criteria=criteria, + ) + except ValueError: + messages.error(request, "Recherche invalide. Corrigez vos critères.") + return redirect("discovery:home") + + status = result["status"] + if status == "applied": + messages.success( + request, + "Makolo a clarifié votre recherche. Vérifiez les critères proposés.", + ) + elif status == "unavailable": + failure_labels = { + "connection_unavailable": "Aucune connexion Intelligence utilisable. Vérifiez Moi → Connexions.", + "invalid_credentials": "La clé du fournisseur a été refusée. Vérifiez Moi → Connexions.", + "rate_limited": "Le fournisseur a atteint sa limite d'usage. Réessayez plus tard.", + "network_unavailable": "Le fournisseur est momentanément injoignable. Réessayez plus tard.", + } + messages.info( + request, + failure_labels.get( + result.get("reason"), + "Aucun service de clarification n’est disponible.", + ) + " La recherche habituelle reste utilisable.", + ) + elif status in {"unchanged", "already_structured"}: + messages.info( + request, + "Aucun critère supplémentaire confirmé. " + "Vos critères de recherche restent utilisables.", + ) + # Preserve navigation-only/geospatial filters; these are still + # validated by the existing Discovery GET selector. + query_params = dict(result["query"]) + for key in ("lat", "lon", "radius_km", "ordering"): + if key in request.POST and key not in query_params: + query_params[key] = request.POST[key] + query = urlencode(query_params) + destination = reverse("discovery:home") + return redirect(destination + ("?" + query if query else "")) + + class DiscoveryHomeView(TemplateView): template_name = "discovery/home.html" @@ -504,6 +796,47 @@ def get_context_data(self, **kwargs): return context +class DiscoveryPossibilityDetailView(TemplateView): + """The same authorized canonical detail as the mobile N2 owner projection.""" + + template_name = "discovery/possibility_detail.html" + allowed_families = frozenset({ + "activity", "service_activity", "funding_activity", "opportunity", + }) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + family, item_id = kwargs["family"], kwargs["item_id"] + if family not in self.allowed_families: + raise Http404 + try: + projection = detail_projection(family, item_id, profile=self.request.user) + except (ObjectDoesNotExist, ValidationError, ValueError, TypeError) as exc: + raise Http404 from exc + if projection is None: + raise Http404 + context["discovery_base_template"] = ( + "base/app.html" if self.request.user.is_authenticated + else "base/public.html" + ) + context["n2_item"] = projection + context["card"] = _web_discovery_card(projection) + context["n2"] = build_n2_presentation(projection) + requested_return = self.request.GET.get("from", "") + context["return_url"] = ( + requested_return + if requested_return.startswith("/discover/") + and not requested_return.startswith("//") + and url_has_allowed_host_and_scheme( + requested_return, + allowed_hosts={self.request.get_host()}, + require_https=self.request.is_secure(), + ) + else reverse("discovery:home") + ) + return context + + class DiscoveryActivityDetailView(TemplateView): template_name = "discovery/activity_detail.html" diff --git a/discovery/watch_forms.py b/discovery/watch_forms.py index da2d31e33a..1c130563d4 100644 --- a/discovery/watch_forms.py +++ b/discovery/watch_forms.py @@ -4,7 +4,7 @@ from objectives.models import Dossier from .models import DiscoveryWatch -from .watches import normalize_watch_criteria +from .watches import normalize_watch_criteria, ensure_discovery_watch WHEN_CHOICES = [("", "À venir"), ("today", "Aujourd’hui"), ("tomorrow", "Demain"), ("weekend", "Ce week-end"), ("week", "Cette semaine")] PERIOD_CHOICES = [("", "Toute la journée"), ("morning", "Matin"), ("afternoon", "Après-midi"), ("evening", "Soir")] @@ -33,7 +33,15 @@ def clean_criteria(self): return normalize_watch_criteria(self.cleaned_data["criteria"]) def save(self): - return DiscoveryWatch.objects.create(owner=self.user, name=self.cleaned_data["name"], dossier=self.cleaned_data.get("dossier"), criteria=self.cleaned_data["criteria"]) + # The Web form follows the same idempotent Discovery-owner transition + # as the mobile API, including concurrent double-submit protection. + watch, _created = ensure_discovery_watch( + owner=self.user, + name=self.cleaned_data["name"], + dossier=self.cleaned_data.get("dossier"), + criteria=self.cleaned_data["criteria"], + ) + return watch class DiscoveryWatchEditForm(OwnerDossierFormMixin, forms.Form): diff --git a/discovery/watch_views.py b/discovery/watch_views.py index b9388db314..a07f50c8d7 100644 --- a/discovery/watch_views.py +++ b/discovery/watch_views.py @@ -101,7 +101,7 @@ def post(self, request, *args, **kwargs): watch.save(update_fields=["status", "updated_at"]) messages.success(request, "Veille réactivée.") else: - messages.error(request, "Action de Veille invalide.") + messages.error(request, "Impossible de modifier cette veille.") return redirect("discovery:watch-detail", watch_id=watch.pk) diff --git a/docs/architecture/action-conversations.md b/docs/architecture/action-conversations.md index 6b56615d93..7695a26bb7 100644 --- a/docs/architecture/action-conversations.md +++ b/docs/architecture/action-conversations.md @@ -228,3 +228,103 @@ Ne jamais affaiblir un test pour obtenir du vert. Corriger la cause racine. J est fermé lorsque Makolo peut montrer à un Profile non pas combien de messages se sont accumulés, mais ce qui reste effectivement à savoir, répondre, confirmer, décider ou résoudre, tout en conservant l’historique et les frontières canoniques. > **Pas le plaisir de rester. Le plaisir d’avancer.** + + +## 15. C3 — Composition Web et Flutter (contrat de consommation) + +Le compositeur travaille sur une Conversation réelle et ne déclare un envoi réussi qu'après confirmation du propriétaire serveur. Il consomme la projection `composer` de `GET /api/v1/conversations/{id}/` : `can_create`, `allowed_kinds`, audiences disponibles, sélection requise, `text_mode`, échange cible et capacité explicite de représentation d'Espace. + +Les commandes autorisées et leurs propriétaires sont : + +- `POST /api/v1/conversations/{id}/compose/` : `mode=text` contribue au Point Exchange actif et autorisé (sélection explicite s'il y en a plusieurs) ou crée une Information seulement avec l'autorité de publication ; `mode=point` publie Information, Question, Demande, Confirmation, Sondage ou Point Exchange conformément à la policy. Un `client_reference` stable rend les reprises idempotentes. +- `POST /api/v1/conversations/points/{point_id}/entries/` : contribution textuelle au Point Exchange canonique, avec contrôle du destinataire, de l'acteur et de la période d'ouverture. +- `GET /api/v1/conversations/by-context/{kind}/{id}/` : retourne les Conversations existantes visibles et `can_ensure` calculé par le serveur. +- `POST /api/v1/conversations/by-context/{kind}/{id}/ensure/` : crée/réutilise la Conversation de contexte via le service métier si l'autorité MANAGE est vérifiée. Aucun DM direct n'est créé depuis le Profil public ; les Conversations directes autorisées déjà établies restent consommables. + +La réponse d'une commande n'est pas un transfert de `Permission`, `Mandate`, `Access` ni d'autorité sur les médias. La projection Web/Flutter filtre les extraits Exchange selon la visibilité des réponses, pas seulement celle du Point. Les erreurs préservent le brouillon et la référence d'idempotence ; aucune action hors ligne n'est affichée comme envoyée. Les actions Form Request, vocal et média relèvent de C4. + + +## 16. C4 — Formulaires, vocaux, médias privés et profondeur Exchange + +C4 raccorde des **propriétaires existants**, et non un second stockage documentaire ou un flux de messages. + +**Form Request → Questionnaires.** La capacité `composer.can_form` dépend de `ConversationPolicy.allow_form_requests`, de la permission de publier et de `activity.manage` sur l'Activity portant le contexte. `composer.form_options` expose seulement les versions publiées de cette Activity. `POST /api/v1/conversations/{conversation_id}/forms/` prend `title`, `client_reference`, `audience_id` et soit `form_version_id` (réutilisation), soit `questions` (préparation de 1 à 8 questions simples). Il appelle les services de `questionnaires`, publie un Point `form_request` et matérialise **un FormRequest canonique par Profil attendu et réellement autorisé**. Le Point indique au seul destinataire `form_request.id/status/links`. Le Web ouvre `questionnaires:request-detail` ; Flutter ouvre le formulaire propriétaire sur `/conversations/{conversation_id}/forms/{request_id}` sans inventer de Journey. + +**Media → stockage privé Conversation.** `composer.media_kinds` découle de la policy et de la capacité de publication ou de contribution Exchange. `POST /api/v1/conversations/{conversation_id}/media/` accepte multipart `file`, `kind`, `client_reference` et les paramètres contextuels permis. Les documents et images réutilisent `validate_artifact_upload` ; les signatures audio/vidéo sont vérifiées par le bridge Conversation. Le service crée `ConversationPointAttachment` lié au Point ou à l'entrée Exchange, avec idempotence de référence et de contenu. Les octets sont privés. `GET /api/v1/conversations/attachments/{attachment_id}/download/` revalide les droits à chaque requête, envoie `private, no-store`, et ne révèle pas un objet interdit. Flutter récupère via le transport authentifié dans un fichier temporaire, puis l'efface à la fermeture du lecteur. La capture vocale démarre au premier appui après permission OS ; pause, reprise, annulation, aperçu et envoi sont séparés. La perte réseau ne déclare jamais une réussite. + +**Point Exchange → contributions.** `GET/POST /api/v1/conversations/points/{point_id}/entries/` expose une profondeur N2 paginée et une contribution textuelle via `create_exchange_entry`. Les extraits, les entrées et chaque pièce sont filtrés par `point_visible_to`, `response_visibility` et l'autorité courante ; une permission de voir un Point ne rend pas publiques les réponses privées de ses contributeurs. + +**Frontières.** Un formulaire n'est pas un Point libre de questionnaire parallèle ; un média Conversation n'est ni Resource, ni Proof, ni JourneyArtifact. Aucune réponse de sondage n'altère implicitement l'Occurrence. Les accès et autorisations restent contrôlés par le serveur. Tests ciblés : version Form liée à la bonne Activity, destinataire, expiration/refus, upload valide/invalide, idempotence/concurrence, visibilité des contributions et téléchargement anti-IDOR. + +**Limite de vérification :** l'exécution sur appareil Android réel, en particulier permission micro et capture, doit être attestée séparément avant de déclarer la recette matérielle fermée. + +**Réponses média structurées.** Lorsqu'un Point canonique attend `response_mode=FILE` ou `VOICE`, `POST /api/v1/conversations/points/{point_id}/reply-media/` (ou sa route Web homologue) appelle `submit_point_response`, puis attache le fichier validé à **cette réponse** dans la même transaction. Cette action ne crée aucune Information supplémentaire. Une reprise avec la même référence et les mêmes octets retourne la réponse déjà confirmée, même après sa résolution ; un changement d'octets est refusé. La visibilité des pièces suit la réponse et `response_visibility`, recalculée au téléchargement. + + +## 17. C5 — Consultation mature, recherche et fermeture (4 novembre 2026) + +### Contrat serveur de la profondeur + +- `GET /api/v1/conversations/{conversation_id}/points/?section=history|active|all&q=…&limit=25&cursor=…` renvoie une page de Points **publiés et réellement visibles**. Le curseur est signé et lié au viewer, à la Conversation, au filtre, à la recherche, à la taille de page et au plafond temporel. Il ne peut pas être partagé entre Profils, Conversations ou filtres. +- La profondeur et les détails réévaluent `can_view_conversation`, `point_visible_to`, la policy `FROM_JOIN`, l'audience de résolution et la visibilité de chaque réponse ou pièce jointe. Aucun ancien Point ne donne accès au contenu d'un successeur privé. +- La recherche inspecte les titres, corps, résumés de résolution, pièces et contributions Exchange, **mais ne retient un résultat que si le viewer peut lire le texte ayant effectivement correspondu**. Pas de révélation par simple existence d'un match privé. Un Point remplacé reste consultable avec une liaison vers son successeur seulement s'il est visible. +- `response_status` exprime la réponse personnelle confirmée ; `response_count` n'est fourni qu'à une autorité ou lorsque le contrat `response_visibility` autorise l'agrégat. Jamais de liste d'identités dérivée des totaux. +- Une Conversation fermée reste consultable ; elle ne projette plus de capacité de réponse ni d'attention métier et refuse les mutations de Point/Exchange au serveur. Archiver, épingler, masquer, mettre en sourdine et revoir plus tard restent **des états personnels**, jamais une fermeture partagée. + +### Contrat d'expérience Web / Flutter + +- Liste et détail sont deux N1 distincts ; historique et Point précis sont des profondeurs N2. L'ouverture d'une profondeur conserve la pile de retour. +- La recherche depuis le détail inclut les Points actuels et passés ; les filtres permettent de distinguer historique, tous les Points et Points actifs. Les Conversations archivées restent retrouvables séparément des Conversations à traiter. +- Un nouveau Point confirmé déclenche le chargement ciblé de son identifiant pour le focus ; sa publication ne dépend pas du succès d'un GET de rafraîchissement. Une 403/404 retire les projections locales et les rows privées déjà présentées ; un timeout préserve uniquement la dernière projection autorisée et affiche une récupération. +- Les dimensions compactes à larges, le texte agrandi, le clavier, les états sombres et les boutons directs sont couverts par des scénarios Flutter dédiés ; l'application ne simule jamais un envoi hors ligne. + +### Suite ciblée et critères d'intégration + +Serveur : `python manage.py test conversations.test_c3_composition conversations.test_c4_rich conversations.test_c5_mature conversations.test_j7_contracts` (adapter aux modules présents sur le checkout actuel). + +Flutter, depuis `mobile/` : `flutter pub get` puis `dart format --output=none --set-exit-if-changed lib/features/continuity test/conversation_c5_mature_test.dart`, `flutter analyze`, `flutter test test/conversation_c5_mature_test.dart test/conversation_mature_repository_test.dart`, puis les régressions de navigation locale et Goldens pertinentes. Régénérer et versionner `pubspec.lock` avec **la toolchain Flutter officielle** si les dépendances C4 `record`, `just_audio` et `open_filex` restent ajoutées ; ne pas fabriquer un verrouillage de dépendances à la main. + +Recette visuelle : Conversation directe, Activity/Occurrence multipoints, Space multi-audiences et Conversation longue 150+ Points ; Compact 360/430, Medium 834, Wide 1440, clair/sombre, texte 1,6, réduction des animations, clavier et retour depuis N2. Android réel : ouvrir Conversation, répondre, publier, revenir au nouveau Point, capturer/annuler/envoyer un vocal, passer en mode avion et reprendre ; recueillir captures/Goldens réels. + +**État de vérification :** les changements C5 et tests ciblés sont présents dans la PR #522 ; aucune exécution locale Flutter/Django sur le HEAD C5, aucun rendu Android réel ni Golden de référence nouvellement généré n'a été attesté depuis cet environnement connecté au dépôt. Ces gates restent obligatoires pour déclarer « livré, intégré et vérifié » et pour toute fusion ; ne pas les inscrire comme verts sans preuve. + + +## 18. C5-UX-Liste — Lecture visuelle immédiate des Conversations (5 novembre 2026) + +### Problème observé + +La liste N1 affichait simultanément le contexte complet, un titre de préparation contenant une copie du contexte, puis de longs textes métier. Sur mobile, une seule Conversation pouvait occuper une grande partie de l'écran. Il manquait les repères visuels (type de contexte, forme de contenus, résultat et action attendue). L'objectif n'est **pas** de se différencier artificiellement d'une messagerie : il est de réduire l'effort pour reconnaître et ouvrir la Conversation utile. + +### Contrat de présentation dérivée + +La liste et la recherche gardent leurs champs API historiques (`id`, `title`, `purpose`, `context`, `attention_count`, `latest_result`, `updated_at`) et ajoutent une clé **`card`** calculée côté serveur : + +- `card.title` : identité courte, privilégiant le sujet contextuel en cas de titre composé comme « Préparation pratique — [sujet] » ; le titre métier original demeure intact dans la Conversation. +- `card.context_label` : contexte secondaire, sans répéter le sujet. `card.context_kind` : famille canonique du contexte pour choisir une icône explicite. +- `card.preview` et `card.preview_kind` : résultat autorisé, à défaut Point visible, à défaut finalité existante. Le client tronque **visuellement** le texte sans réécrire ni posséder les vérités métier. +- `card.media_kinds` : maximum quatre signaux issus de Points visibles (`poll`, `form`, `confirmation`, `exchange`) ou de pièces jointes dont le téléchargement est réellement autorisé (`voice`, `audio`, `image`, `video`, `file`). Aucun nom de fichier, miniature privée, identité de répondant ou faux compte « non lu » dans la liste. +- `card.context_image_url` : seulement la couverture d'un Event propriétaire déjà publié sur une Activity publique et publiée, et uniquement si l'URL de média est relative au même site. Direct, Space, Group et autres contextes utilisent leurs icônes sans exposer d'avatar ou de média dont la politique de visibilité n'est pas démontrée. +- `card.attention_count` : nombre de Points réellement à traiter, pas nombre de messages reçus. Les archives, masquages et recherches restent scindés par viewer. + +### Contrat mobile et Web + +Chaque ligne a un élément visuel de 56 px environ, titre sur deux lignes maximum, contexte sur une ligne, résultat/aperçu sur une ligne, indices médias à faible poids visuel et badge d'action réel si nécessaire. La ligne entière est accessible et ouvre le même N1 (ou le Point issu d'une recherche). Couleurs, Typographies et espacements réutilisent les tokens Makolo. Flutter conserve la compatibilité avec les anciennes projections local-first dépourvues de `card`. L'aperçu ne devient jamais une timeline. + +### Validation + +Serveur : `python manage.py test conversations.test_c5_mature conversations.test_m9c_performance` ; inclut contexte très long, signal d'image/vocal filtré par audiences distinctes, couverture Event publiée/non publiée et lecture Web. Flutter : `flutter test test/conversation_list_tile_test.dart test/conversation_mature_repository_test.dart` et `flutter analyze`. Recette visuelle sur Android : 320/360/430 px, texte 1,6, clair/sombre ; puis tablette/large, photo de couverture publique, média privé non révélé, recherche et retour. Le budget de requêtes M9-C et les tests de permissions restent des critères de sortie. + +Cette correction C5 est implémentée dans la PR #522 sans nouveau modèle, migration ni API propriétaire parallèle. L'attestation d'une compilation/flutter test et la vérification visuelle sur un Android réel exigent toujours leur exécution effective. + +## 18. Détail natif d’une Conversation — J4 / #522 (5 novembre 2026) + +La profondeur **Détails de la conversation** utilise le même propriétaire Conversations que la profondeur de lecture. Elle n'est pas un groupe social parallèle. + +- **Objet** : `subject` est dérivé du `ConversationContext` réel. Une Activity/Occurrence peut présenter la couverture publiée, publique et déjà autorisée par Discovery. Un interlocuteur direct peut utiliser le portrait du Profil si le Passport est public **et** que sa visibilité l'autorise. Un Espace/Groupe sans média canonique conserve son pictogramme ; aucun avatar n'est inventé. +- **Personnes** : `ConversationParticipation`, `GroupMembership`, audiences et autorités sont différents. La liste dérivée des personnes ayant accès à une Conversation n'est pas un annuaire. Le couple direct est connu dans son consentement. Seul un responsable réellement autorisé voit l'aperçu des membres actifs d'un Groupe et leur total réel ; les autres contextes ne dévoilent pas implicitement un effectif ou des identités. +- **Médias** : la vue liste au maximum 24 pièces déjà autorisées et filtrées par `point_visible_to` + `can_download_attachment`. Aucun média privé n'est préchargé en clair ni servi par `Image.network` ; le lecteur éphémère actuel revalide l'autorité au téléchargement. +- **Ressources** : seules des métadonnées `ActivityResource` publiées et autorisées par `can_view_resource` peuvent être présentées pour Activity/Occurrence/Journey. Leur présence ne transforme pas les Attachments en Resources et ne transfère aucun accès. +- **Actions personnelles** : sourdine, épingle, revoir plus tard, archives, masquage consomment l'API personnelle existante et attendent la confirmation owner. Les règles communes sont présentées en lecture seule tant qu'aucune commande de gestion autorisée n'est implémentée. +- **Partage** : le partage natif propose uniquement la route Web authentifiée existante `/conversations//`. Le lien est une adresse, pas un jeton d'Access, pas une invitation et pas un transfert de Permission. Tout destinataire reste soumis à `can_view_conversation`. +- **Local-first** : les nouveaux champs sont dans la projection `conversation.detail` du Profil courant ; le dernier détail encore valide reste lisible hors ligne, mais un refus serveur explicite prime sur la copie locale. +- **Vérification** : tests ciblés de confidentialité et de navigation ajoutés à #522. Exécution Flutter/Android et CI non attestées par ce checkpoint. diff --git a/docs/architecture/backend-ux-projection-api.md b/docs/architecture/backend-ux-projection-api.md index 07a12f006d..ba33e084ba 100644 --- a/docs/architecture/backend-ux-projection-api.md +++ b/docs/architecture/backend-ux-projection-api.md @@ -1031,3 +1031,197 @@ Contrats principaux : - Payment, Access, Capacity, Form, Resource et Proof restent des liens/états de leurs domaines lorsqu’ils sont composés, jamais des copies Obtention. Le contrat métier complet est documenté dans [`obtention.md`](obtention.md). + + +## Découvrir N1 — projection d'objets et continuation (19 octobre 2026) + +Cette section décrit le contrat d'implémentation de la branche de PR #522, sans +anticiper sa fusion. Les invariants métier et le code effectif restent +prioritaires ; elle ne crée aucun nouveau modèle `Possibility`. + +- L'identité de la possibilité demeure la `candidate_key` et ses références + `identity.resource/occurrence/revision`. La liste et le détail utilisent le + même `project_rows` ; aucun propriétaire n'est redéduit sur le client. +- `owner.display_name` est conservé pour les consommateurs historiques. + Lorsqu'il existe un porteur public autorisé, `owner.identity` identifie un + `profile` ou un `space`, `owner.representation` décrit le nom et + l'avatar publiables, et `owner.links` expose des destinations explicites. + `links.native` ne vaut que pour les routes natives effectivement déclarées. + Une Activity sans `space` ni `owner_profile` public reçoit `owner=null` ; + `created_by` n'est jamais substitué. Les Opportunity externes conservent + leur émetteur, sans création de Profil ou Space. +- `date.kind=added_at` sur Activity est dérivé de `Activity.created_at` ; + `date.kind=published_at` n'est utilisé pour Opportunity que lorsque sa + publication est connue. Ne pas assimiler ces dates à une Occurrence ni au + `generated_at` de la réponse. +- `price` peut représenter `free`, `exact`, `range` ou `unknown` + lorsqu'il existe des Offers Commerce applicables à la fenêtre et au + contexte Activity/Occurrence. Le sélecteur propriétaire groupé + `offer_price_facts_many` minimise les requêtes N1 ; il ne calcule ni devis, + ni état de Capacity, ni paiement. Un montant de Funding n'est jamais un prix. +- Les `presentation_facts` n'écrasent pas `price`, `timing`, `place`, + `availability` ni les faits temporels/spatiaux propres à la verticale. + Les ressources restent limitées à la visibilité publique du propriétaire. +- `engagement` continue de distinguer présentation Web, capacités serveur + et liens réellement exécutables. Pour Service, un utilisateur authentifié + peut recevoir un handoff typé vers `ServiceDetails.pk`, jamais vers un + `Activity.pk` utilisé comme `service_id`. Le GET Services prévalide et + fournit, selon la politique, les Opportunities publiées recherchables ; + le POST Services vérifie les droits, l'idempotence et retourne une Journey + réelle. Les clients ne créent pas cette Journey. +- Pour Event/Tickets, l'inspection des billets et leur commande utilisent + les APIs propriétaires Events et Tickets. Le client ne confirme ni Access ni + Payment à la création d'une commande. Les handoffs publics restent bornés + aux types de billets publiables. Le retour vers la commande appartient à + Tickets. +- Une API native de Profil public expose uniquement les attributs déjà + publiables dans le Passeport canonique. Les Profiles non publics, non + cherchables ou inactifs restent inaccessibles. La route native `/people/:id` + consomme une identité typée, jamais un slug deviné. +- En compact, N1 est bord à bord sans Card extérieure, avec marges internes + de lecture, titre dominant et trois colonnes d'actions à icônes seules + et libellés accessibles. Le grand écran conserve une grille et les libellés + appropriés. Les contenus longs et faits établis ne sont plus coupés à + deux ou trois lignes pour suivre une maquette historique. +- Ces règles N1 remplacent les contraintes de hauteur et d'habillage des + exemples G03/G04/G05 antérieurs ; les invariants de navigation, recherche, + pagination, carte, local-first et permissions restent applicables. + +**Frontières :** la projection est un read model, pas un propriétaire +d'Activity, Opportunity, Ticket, Service, Resource ou Permission. Les routes +natives doivent revalider les capacités et les mutations côté serveur. + +## Découvrir N1 / N2 / action métier — contrat de navigation figé (25 octobre 2026) + +**Décision UX et d'architecture pour la PR #522.** Ce contrat précise la +continuation de N1 ; les possibilités, ressources, permissions, droits et +parcours restent la propriété des domaines existants. La navigation n'introduit +ni `Start` universel, ni modèle `Possibility`, ni création implicite de Journey. + +### Intentions et destinations + +1. **Toucher la surface non interactive d'une possibilité N1** (notamment + titre / ouverture de l'unité) signifie **consulter N2**, pour comprendre + la même possibilité, avec la même `candidate_key`, les mêmes faits et + le même contexte d'exploration. Cela **ne démarre jamais** un Service, + une réservation, une demande, une inscription, une commande ou un paiement. +2. **La colonne centrale N1** représente **une seule continuation principale** + choisie par le serveur et le domaine propriétaire : par exemple + « Commencer », « Voir les billets », « Réserver », « S'inscrire », + « Demander », « Acheter », « Contribuer », « Postuler » ou « Payer », + **uniquement si l'action et sa destination sont réellement disponibles, + autorisées et compatibles avec le client**. +3. Cette colonne **ouvre la profondeur métier nécessaire**, et non + nécessairement une mutation immédiate : sélection d'Occurrence, + d'offre, de place, de variante, préparation de dossier, vérification de + conditions, confirmation d'achat, etc. Le domaine propriétaire effectue + ensuite les contrôles et mutations. +4. **N2 réexpose la continuation principale pertinente**, en fonction du + contexte réévalué, sans rendre N2 obligatoire avant une action directe. + Les deux chemins sont `N1 → N2 → action` et `N1 → parcours métier`. +5. **Sans continuation exploitable**, la colonne centrale mène à + **« Détails » / N2**. Ne jamais fabriquer un bouton de réservation, + de paiement ou de Start pour combler l'emplacement. +6. La possibilité déjà engagée peut proposer **la continuation de l'existant** + (« Voir mon inscription », « Reprendre », « Voir mon accès ») au lieu + d'inciter à recommencer. La simple observation ou sauvegarde n'engage + personne. + +**Gestes autonomes :** le porteur ouvre uniquement son Profil/Espace public +autorisé ; la description se déplie localement ; média/galerie et annexes +ouvrent leur lecteur/destination ; Enregistrer agit sur Bookmark ; Partager +ouvre Sharing. Aucun de ces gestes ne déclenche l'action centrale, et +inversement. Les cibles tactiles et leurs libellés accessibles sont distincts. + +### Propriété des responsabilités + +| Couche | Possède / décide | Ne doit pas faire | +| --- | --- | --- | +| **Domaines propriétaires** (Services, Events, Tickets, Journey, Capacity, Commerce, Payment, Funding, Obtention, Access, etc.) | Éligibilité, Permission/Mandate, capacités, créneaux, offres, prix, disponibilité, prérequis, validations, état et mutations ; routes/API et réponses métier, concurrence et idempotence lorsque pertinentes | Transférer une autorité par simple composition ou considérer l'ouverture d'un écran comme une transaction | +| **Serveur Discovery / orchestration** | Admission et identité de la possibilité ; composition des faits publics dans le contexte acteur ; `engagement` et **`engagement.primary_action`** à partir de handoffs **vérifiés** ; code, label métier, état/capabilities et destination typée ; repli `detail` si nécessaire | Déduire une action d'un titre, d'un prix, d'une verticale, d'un texte ou d'une URL ; exécuter les mutations des propriétaires | +| **API / projection** | Transporter `identity`, `links.detail`, faits et `engagement.primary_action` ; cohérence liste/détail, divulgation minimale, auth, contrats stables et destinations explicitement disponibles | Exposer des permissions internes, URL privées, secrets ou actions fictives | +| **Web** | N1/N2 et liens vers les parcours Web réellement reconnus ; label et icône métier, état disabled/fallback ; interactions indépendantes | Inventer un endpoint ou faire passer un simple lien de détail pour une exécution autorisée | +| **Flutter** | Même sémantique N1/N2 ; interpréter les destinations typées **seulement si une route native connue est supportée** ; conserver contexte, feedback et ergonomie compact/wide ; consultation N2 sûre en repli | Forger un chemin à partir d'ID/slug, simuler le succès d'une action, requérir un écran Start universel, reconstruire Permission/Readiness/Payment | +| **N2 / profondeur** | Montrer détails, variantes, conditions et continuité ; proposer la même action métier actualisée si applicable | Devenir une étape imposée pour toute action ou un deuxième propriétaire métier | + +### Contrat de continuation et sûreté + +- `engagement.primary_action` est la **décision sémantique serveur** : + `code`, `label`, `icon`, `destination: {kind, path}`. Les champs + `engagement.owner`, `state`, `capabilities`, `resource`, + `links` et `presentation` étayent cette décision ; une présence + de route seule ne confère pas l'autorité. +- `destination.kind=detail` signifie ouvrir N2 sans mutation. + `destination.kind=native` exige une route prise en charge dans Flutter + **et** la preuve de capability attendue ; sinon repli vers N2. + Le Web utilise seulement la destination Web explicitement exposée + et revalidée par l'owner, ou le détail. +- Une action peut être **absente, non actionnable, expirée ou conditionnelle**. + L'expérience ne promet pas « Réserver », « Commencer » ou « Payer » + si l'owner ne permet pas réellement la continuation. +- **Payer** suppose une obligation ou transaction propriétaire admissible ; + un prix visible dans N1 ne crée ni dette ni paiement. De même + « Réserver » suppose sélection/vérification de Capacity selon le + contexte. `Access` ne se confond pas avec `AccessCredential`. +- La route propriétaire **revérifie au moment de l'exécution** la + visibilité, les droits, l'état, les prérequis, prix et capacité. + Une action côté client ne vaut jamais confirmation. +- La navigation vers N2 conserve la sélection, le résultat et le retour + aux filtres/recherche du champ ; une ressource annexée n'est pas une + preuve de satisfaction d'un Requirement. +- **Backend générique, métier contextuel :** `start`, `inspect_offers`, + `reserve`, `apply`, `contribute`, `buy`, etc. ne sont + admissibles que lorsqu'un vrai handoff propriétaire les supporte. + Le tableau des intentions est une cible de convergence, **pas + l'affirmation que toutes les verticales sont livrées**. + +### Référence runtime et critères de fermeture de #522 + +**État d'implémentation de la branche de #522 (25 octobre 2026) :** +`discovery/api/composition.py::_primary_action_for_projection` expose +`start` pour Service, `inspect_offers` pour Event, +`contribute` pour Funding et `start_journey` pour Obtention, seulement +lorsqu'un handoff propriétaire vérifié existe. Sinon, `detail`. +Flutter possède une route distincte pour chacune de ces continuations : + +- Service : `/services/:id/start` et API Services ; +- Event : `/events/:slug/tickets`, API Tickets, commande, paiement sandbox + et entrée en liste d'attente lorsque le ticket est réellement épuisé ; +- Funding : `/funding/:id/contribute`, détail public autorisé, + contribution idempotente, puis PaymentObligation ; +- Payment : `/payments/obligations/:id`, lecture des droits/états, création + et confirmation sandbox uniquement si le serveur l'active ; +- Obtention : `/obtention/:id/choose`, modes publiés par l'owner, + création d'une Journey réelle puis profondeur `/journeys/:id` existante. + +**Aucune action centrale N1 Flutter n'ouvre une page Web.** +La présentation Web continue d'utiliser ses propres routes et le même +métier côté serveur. Les destinations mobiles non supportées se replient +vers N2, sans action fictive ni transaction implicite. Ce tableau est un +inventaire des raccordements écrits dans la PR, **pas une attestation de +tests exécutés ou de paiement validé sur appareil**. Les intégrations +métiers sans API ou destination native exploitable restent ouvertes et +ne doivent jamais être présentées comme des actions exécutables. + +Critères ciblés à valider avant de considérer la colonne centrale close : + +- toucher N1 hors contrôle ouvre N2, jamais Start ; le texte, porteur, + les médias, annexes, Bookmark, action centrale et partage restent + indépendants ; +- action serveur `detail`, absence d'action ou destination native + non reconnue → N2, sans mutation ; +- Service `start` autorisé → parcours Service existant ; Event + `inspect_offers` → billets existants ; échec/permission retirée → + état métier réel, sans succès simulé ; +- Web et Flutter affichent la même intention propriétaire, mais + choisissent leur destination **réellement prise en charge** ; +- utilisateur anonyme/connecté, action personnelle / Space autorisée, + indisponibilité, capacité épuisée, données obsolètes, retour de N2 + et accessibilité sont testés sur les chemins réellement concernés ; +- aucun ajout de modèle, de migration, de route fictive ou d'action + cliente exécutant une mutation à la place du domaine. + +**Séquençage :** fermer d'abord la colonne centrale N1 et ses handoffs +réels ; traiter ensuite l'expérience approfondie N2 en conservant ce +contrat. Ne pas forcer toutes les verticales vers Start. + diff --git a/docs/architecture/c2-interoperability-actor-journeys-authority-2026-10-31.md b/docs/architecture/c2-interoperability-actor-journeys-authority-2026-10-31.md new file mode 100644 index 0000000000..10d21d62d0 --- /dev/null +++ b/docs/architecture/c2-interoperability-actor-journeys-authority-2026-10-31.md @@ -0,0 +1,142 @@ +# C2 — Interopérabilité : parcours et frontières d'autorité (Profile / Space / Platform) + +**Statut : contrat de conception et fermeture des parcours actuellement consommables ; branche PR #522.** +**Sources :** code actuel de `interoperability/`, `intelligence/interoperability.py`, `interoperability/api_views.py`, `core/interoperability_views.py`, `organizations/space_web_views.py`, `mobile/lib/features/interoperability/`, `mobile/lib/sync/sync_engine.dart`, blueprint, M7 et Z16. +**Invariants :** backend générique, métier contextuel ; Assignment = responsabilité, Mandate/Permission = autorité ; une Connection donne un canal, jamais le droit implicite de lire/agir ; aucune vérité propriétaire déplacée. + +## 1. Ce que signifie « connexion » + +Trois réalités non interchangeables : + +1. **Configuration technique d'un fournisseur** : p. ex. `intelligence.ProviderConnection` et ses routes/credentials. L'owner Intelligence conserve création, secrets, activation et exécution. Le client peut lire une projection Z16 autorisée, pas la configuration. +2. **Relation consentie d'un acteur avec un service externe** : lorsque l'owner concerné offre réellement établissement, vérification et révocation. Le seul kernel M7 ne prouve pas que ce parcours existe. +3. **Capacité métier utilisant une intégration** : Payments, Notifications, Geography, Spatiotemporal, Accounts, etc. Elle vit dans son parcours propriétaire, et ne devient pas automatiquement une Connection à afficher dans « Connexions ». + +`available` ≠ `connected` ≠ `usable` ≠ `manageable`. Le statut sanitaire du provider n'est ni une preuve de droit ni une réussite métier. + +## 2. Scénarios Profile + +### P1 — Consulter ses connexions personnelles (LIVRÉ) + +**Entrée :** Avatar → Connexions, Web `/me/connections/`, Flutter `/connections`. +**Lecture :** projection serveur `GET /api/v1/me/interoperability/`, `z16.v1`, `context=profile`. +**Autorité :** authentification + sélection `ProviderConnection.profile__user=actor`. Aucun `profile_id` client. +**Sortie :** connexions effectivement sélectionnées, état et capacités non sensibles ; sinon état vide vrai. +**Interdits :** aucune Connection Space/Platform, aucun credential, endpoint, modèle technique ni secret. +**Web :** partage `build_profile_interoperability_payload` entre API et template, sans requête HTTP vers soi-même. +**Flutter :** `personal.interoperability` isolé par Profil, SyncEngine, snapshot acquis ; lors d'échec réseau, observation préservée et marquée ancienne, jamais déclarée preuve d'autorité actuelle. + +### P2 — Actualiser et reprendre (LIVRÉ) + +**Déclencheurs :** ouverture sans snapshot, refresh manuel, événements de synchronisation existants. +**Réponse valide :** nouvelle projection autorisée remplace l'ancienne atomiquement. +**Panne transport :** conserver la dernière observation sans fabriquer un succès. +**403/404 explicitement owner-scoped :** appliquer la purge prévue par le noyau sync ; jamais réafficher une projection révoquée. +**401 :** utiliser la récupération de session existante. +**Offline sans snapshot :** expliquer l'indisponibilité, préserver le fonctionnement des autres surfaces. +**Reprise :** ne pas déclencher un appel de mutation au seul retour en ligne. + +### P3 — Comprendre l'état et les capacités (LIVRÉ en lecture) + +L'UI expose une description humaine de `enabled/status/health/usable`, les capabilities allowlistées et la source propriétaire. Une Connection reliée sans capacité active n'est pas vendue comme actionnable. Les Actions M7 projetées sont une *capacité potentielle* : sans lien d'exécution propriétaire, aucun CTA « Exécuter ». Les Extensions ne sont présentées que si autorisées, disponibles et activées. + +### P4 — Connecter un nouveau service / consentir (NON LIVRÉ) + +Un bouton `Connecter` n'est admissible que si l'owner expose une offre réelle et un parcours fiable avec : fournisseur concret, finalité, données/scopes, identification de l'acteur, retour sûr, preuve d'autorisation, stockage secret côté serveur, traitement de refus/expiration, état final relu du propriétaire. **Ne pas créer d'OAuth générique à partir des callbacks Accounts ni de `ProviderConnection` Intelligence.** Un fournisseur présent dans Accounts pour le login n'est pas une permission de lire ses données. + +### P5 — Désactiver, reconnecter et révoquer (NON LIVRÉ génériquement) + +`manageable=true` est une décision d'autorité, **pas une URL disponible**. Un contrôle mutateur requiert un owner, une route et une méthode réelles, CSRF/session Web ou bearer mobile, validation serveur de portée, protection idempotence/concurrence, confirmation de révocation et invalidation des caches. La révocation d'un token externe et la désactivation d'une connexion sont deux opérations distinctes : ne jamais les confondre. Aucun client ne modifie sa projection locale en guise de confirmation. + +### P6 — Utiliser une capacité dans une démarche (CONDITIONNEL) + +La démarche (Journey, Resource, Payment, Notification, etc.) choisit l'action et son contrat. L'owner vérifie Permission/Mandate, Connection autorisée, paramètres métier, idempotence et résultat. Connexions sert à comprendre/gérer la relation, **pas** à exécuter toutes les actions disponibles. + +## 3. Scénarios Space + +### S1 — Consulter les connexions du Space (LIVRÉ Web/API) + +**Entrée :** console Nous → Connexions ou `GET /api/v1/organizations/workspaces//interoperability/`. +**Autorité :** Mandate direct `space.manage` sur ce Space ; Membership, Team, Group, Assignment ou Platform ne donnent jamais ce droit automatiquement. +**Refus :** 404 pour ne pas divulguer l'existence d'un autre Space. +**Projection :** seules Connections `scope=space` de l'organisation courante. +**Exécution :** uniquement via un owner explicite, jamais sur un simple état `manageable`. + +### S2 — Consultation Flutter Space (OUVERT) + +Le contrat API existe mais aucun écran Flutter Space de connexion n'est attesté par ce checkpoint. La future consommation doit se brancher sur le contexte Space déjà existant et isoler la lecture par `actor + space`, sans copier le snapshot personnel. Un changement/retrait de Mandate invalide cette lecture. Ne pas ouvrir la navigation sans autorité. + +### S3 — Création/gestion d'une connexion Space (NON LIVRÉ génériquement) + +Même contrat owner-led que P4/P5, avec identité du Space et contrôle direct de `space.manage` sur **chaque** opération serveur. Aucune autorisation par simple appartenance ou par autorité Platform implicite. + +## 4. Scénarios Platform + +### T1 — Inspecter les connexions Platform (LIVRÉ Web/API) + +**Entrée :** Platform, `GET /api/v1/platform/interoperability/`. +**Autorité :** `platform.manage`. +**Isolation :** seules Connections Platform ; `space_modules_included=false`, `personal_modules_included=false`. +**Confidentialité :** pas de credentials ni d'internes techniques dans le payload client. + +### T2 — Pilotage/configuration fournisseur (INTERNE / propriétaire) + +Intelligence Admin a une frontière technique spécifique. Ne pas transformer Platform en navigateur ORM ni exporter secrets/configuration. Ajouter un parcours Platform seulement après contrat owner contrôlé et audit explicite. + +## 5. Matrice des réponses d'autorité + +| Événement | Projection/UX | Opération possible ? | +|---|---|---| +| Profile connecté et propriétaire | Z16 personnel | Lecture ; mutation seulement si owner l'expose | +| Autre Profile | Données absentes | Non | +| Space avec `space.manage` direct | Z16 Space | Lecture ; mutation propriétaire distincte | +| Space avec Membership seulement | 404 | Non | +| Platform avec `platform.manage` | Z16 Platform uniquement | Lecture ; mutation propriétaire distincte | +| Connection disabled | visible si autorisé, `usable=false` | Pas d'usage | +| Health unavailable/degraded/unknown | état honnête, distinct du droit | Revalidation owner avant action | +| Perte réseau Flutter | snapshot daté, lecture seule | Aucun nouveau droit | +| 403/404 owner-scoped | retrait du snapshot concerné | Non | +| 401 | récupération authentification | Non avant réauthentification | +| Permission/Mandate révoqué | serveur refuse, client invalide | Non | +| Action sans lien propriétaire | métadonnée informative seulement | Non | + +## 6. Frontières transverses + +- **Accounts/social login** : connexion de compte Makolo ≠ délégation d'accès à un service. +- **Payments** : initiation et confirmations gardées chez Payments, selon ses propres permissions et idempotence. +- **Notifications push** : endpoint d'installation et révocation gardés chez Notifications. +- **Geography/Spatiotemporal** : provider d'information géographique/météo n'est pas une Connection personnelle par défaut. +- **Events/Webhooks** : contrat transport + signature ; pas d'abonnement utilisateur direct sans owner autorisé. +- **Extension** : seulement les Actions, événements, projections et UI slots allowlistés ; aucune exécution arbitraire ou marketplace. +- **Local-first** : cache d'observation seulement, pas de consentement, droit ou succès stocké comme vérité indépendante. + +## 7. Critères de sortie et tests ciblés + +- Même vérité Z16 pour le Web et Flutter Profile ; états de connexion cohérents. +- Profile A ne voit jamais B ; une fixture Space ne rejoint jamais `personal.interoperability`. +- Space sans Mandate direct reste 404, même si Membership ou privilège Platform existe. +- Platform ne révèle jamais Profile/Space. +- Aucun token, clé, endpoint provider ni credential dans une projection Web/API/cache. +- Disabled ≠ usable ; degraded et unknown ≠ autorisation permanente. +- Sans snapshot Flutter, panne transport ≠ chargement infini ; avec snapshot, panne ≠ suppression. +- 403/404 provoquent les invalidations du noyau sync ; 401 conserve la récupération de session. +- Actions et Extensions sans endpoint owner restent informatives ; absence de CTA fictif. +- Pas de migration ni d'API mutable générique requise pour fermer ces parcours de lecture. + +## 8. Transfert vers C3 / lots d'implémentation futurs + +1. Faire converger les libellés et états entre Web et Flutter à partir des vérités Z16. +2. Concevoir la profondeur Connexion N2 en lecture (finalité, disponibilité, capacité, propriété) seulement si une connexion réelle existe, sans montrer la configuration technique. +3. Brancher un éventuel écran Flutter Space sur la couche de contexte Space existante **après** audit des projections et gestion révocation. +4. Pour chaque service connectable futur, ouvrir un contrat owner spécifique (établir/consentir/révoquer) et des tests IDOR/révocation/idempotence avant de publier un CTA. +5. Ne pas prétendre que C2 rend les services externes OAuth libre-service disponibles. + +**Verdict :** les parcours de consultation et de continuité sont prêts dans leur frontière canonique ; les parcours de connexion, de révocation et d'exécution utilisateur nécessitent des contrats propriétaires absents, et ne sont pas « implémentables » honnêtement par la seule couche Web/Flutter. + +## 9. Fermeture C2 et passage à C3 — 31 octobre 2026 + +**C2 fermé pour les parcours réellement servis par Z16** : consultation Profile/Space/Platform, autorité par portée, continuité personnelle, lecture contextualisée des capacités et statuts. Les preuves d'autorité restent exclusivement serveur ; aucun contrôle de gestion ne découle du seul booléen `manageable`. Le checkpoint C3 associé est `c3-interoperability-web-flutter-presentation-contract-2026-10-31.md`. + +**Hors fermeture, intentionnellement** : établissement/consentement/révocation d'une relation externe libre-service, exécution générique d'Actions et gestion mobile Space. Ce sont des parcours à livrer ultérieurement depuis des contrats propriétaires concrets. Les qualifier de « terminés » contredirait le runtime M7/Z16 courant. + +**Implémentation C2/C3 dans #522 :** alignement des libellés Web/Flutter et explications sur les états de Connexions ; tests de présentation ciblés. Pas de nouvelle API ni migration. Ne pas utiliser la documentation comme attestation de tests exécutés ou de CI verte. diff --git a/docs/architecture/c3-interoperability-web-flutter-presentation-contract-2026-10-31.md b/docs/architecture/c3-interoperability-web-flutter-presentation-contract-2026-10-31.md new file mode 100644 index 0000000000..8787481b06 --- /dev/null +++ b/docs/architecture/c3-interoperability-web-flutter-presentation-contract-2026-10-31.md @@ -0,0 +1,85 @@ +# C3 — Contrat commun de présentation Interoperability Web et Flutter + +**Statut : C3 implémenté pour les parcours de lecture Z16 ; mutations non couvertes faute de contrats owner.** +**Branche de travail :** PR #522 ; document vivant, le code courant gagne. + +## 1. Problème résolu + +Une projection Z16 peut exposer `connected=true` sans prouver qu'une action soit exécutable. Connexions doit distinguer une relation existante, l'autorité effective, l'état du service et une capacité active, sans interpréter localement une permission. Le Web et Flutter partagent le contrat serveur, mais pas leur runtime de navigation et synchronisation. + +## 2. Inputs autorisés + +| Surface | Source | Filtre de portée | Propriétaire | +|---|---|---|---| +| Web Moi → Connexions | `build_profile_interoperability_payload(actor)` | Profile de l'acteur | Interoperability projection, Intelligence pour les faits | +| Flutter Moi → Connexions | `GET /api/v1/me/interoperability/` → `personal.interoperability` | `context=profile`, `scope=profile`, store isolé | Z16 + SyncEngine | +| Web Nous → Connexions | `build_interoperability_payload(context=space)` | `space.manage` direct sur Espace | Space/Intelligence | +| API Platform | `GET /api/v1/platform/interoperability/` | `platform.manage` | Platform/Intelligence | + +Z16 `schema_version=z16.v1` constitue la seule source des propriétés `available`, `connected`, `usable`, `manageable`, `enabled`, `health` et `capabilities`. Le client ne recalcule pas `usable` à partir d'autres attributs. + +## 3. Vocabulaire commun et règles de présentation + +- Disabled : **Désactivé** — « Cette connexion est désactivée. » +- Health unavailable : **Momentanément indisponible** — « Le service ne répond pas actuellement. » +- Health degraded : **Connecté · disponibilité réduite** (si connecté) — « Le service peut être temporairement limité. » +- Health unknown : **Connecté · état à vérifier** (si connecté) — « La disponibilité doit être vérifiée. » +- Connecté avec `capabilities=[]` : **Relié · aucune capacité active** — « Aucune capacité active n'est exposée. » +- Usable : **Connecté et disponible** — « Cette connexion possède une capacité active. » +- Autres cas : **Disponible/État à vérifier** — « Aucune action n'est disponible depuis cette connexion. » + +Ces messages décrivent une observation, jamais une promesse d'exécution ou la validité d'un consentement externe. Web utilise `interoperability.presentation` ; Flutter applique la même grille à la projection locale. + +## 4. Niveaux de profondeur + +- **N1 :** Moi (ou Nous pour l'Espace). Aucun nouvel onglet principal. +- **N2 :** Connexions, lecture globale et empty state véridique. +- **N3 :** détail de connexion uniquement lorsque sa finalité/permissions publiques sont réellement exposées par l'owner ; ne pas inventer N3 pour un `ProviderConnection` technique. +- **Contextuel :** action propriétaire dans Journey, Resource, Payment, Notification ou autre parcours, et non une interface d'exécution générique. + +## 5. États d'expérience + +| Condition | Web | Flutter | +|---|---|---| +| Projection valide non vide | rendu serveur des seules données autorisées | rendu du dernier snapshot validé | +| Liste vide valide | message sobre, sans service factice | même vérité et possibilité d'actualiser | +| Chargement initial | cycle HTTP de la page | attente acquisition puis état explicite | +| Réseau absent avec snapshot | requête Web échoue | snapshot lisible, état ancien | +| Réseau absent sans snapshot | erreur Web | indisponibilité locale, pas de spinner infini | +| Erreur d'acquisition | gestion Web standard | message et retry, snapshot préservé | +| Refus owner 403/404 | aucun accès | purge du snapshot concerné via SyncEngine | +| 401 | session Web/Login | récupération session canonique | +| Découverte d'une Action sans lien | libellé informatif | libellé informatif | +| Refresh | nouvelle lecture | SyncEngine, revalidation du propriétaire | +| Changement Profil | nouvelle session/projection | isolation des stores, aucun partage | +| Changement Space/Mandate | lecture serveur filtrée | pas de réutilisation de snapshot Profile | + +## 6. Interdictions + +Aucun CTA `Connecter`, `Exécuter`, `Déconnecter` ou `Révoquer` sans lien/contrat propriétaire explicite. Aucun endpoint ni callback OAuth construit à partir d'un code provider. Aucun credential, secret, token, URL interne, modèle technique ou diagnostic de latence dans la projection. Aucun changement local optimiste de droit. Aucun nouveau `Connection` persistant parallèle à Intelligence/owner. Aucun transfert automatique de Permission/Mandate entre Profile, Space et Platform. + +## 7. Adaptateurs et implémentation + +- `interoperability/presentation.py` : messages humains déterministes et sans effet métier. +- `templates/core/personal_connections.html` : statut + explication pour chaque connexion réellement sélectionnée. +- `mobile/lib/features/interoperability/connections_screen.dart` : même explication à partir de la projection stockée ; refresh via SyncEngine ; dernier état connu préservé. +- `mobile/lib/repositories/interoperability_repository.dart` : rejet `schema_version/context` inattendu et filtrage `scope=profile`. +- Aucun nouveau modèle, migration, API de mutation, client HTTP ou moteur de synchronisation. + +## 8. Tests ciblés et critères de sortie + +1. Mes connexions Web et Flutter affichent des formulations cohérentes pour désactivation, indisponibilité, dégradation, unknown, sans capacité et utilisabilité. +2. Les résultats ne divulguent jamais données d'un autre Profile/Space/Platform, secrets, configuration ou protocole interne. +3. Les Actions ne promettent jamais une exécution sans contrat owner. +4. Empty, erreur, panne réseau et refus explicite demeurent des états différents. +5. L'interface reste utilisable sans catalogue M7 installé ; une liste vide n'est pas une panne. +6. Aucun statut affiché n'est utilisé par le client comme permission de mutation. + +## 9. Limitations résiduelles (ne pas déclarer livrées) + +- Connexion OAuth/consentement utilisateur et révocation de service : pas de contrat owner générique livré. +- Connexions Space Flutter : à intégrer avec contexte Space local-first et révocation Mandate, sans contamination des projections Profile. +- UI N3 et exécution d'Action : attendre les liens d'owner effectivement publiés. +- Santé de provider ne signifie pas capacité métier vérifiée : conserver la nuance jusqu'à l'action réelle. + +**Verdict :** C3 est fermé pour la présentation et la continuité des projections Z16 personnelles existantes. L'interopérabilité de bout en bout pour des services connectables est conditionnelle à des contrats propriétaire supplémentaires ; ne pas assimiler cette dépendance à un simple retard Flutter. diff --git a/docs/architecture/c4-interoperability-owner-lifecycle-web-flutter-2026-11-01.md b/docs/architecture/c4-interoperability-owner-lifecycle-web-flutter-2026-11-01.md new file mode 100644 index 0000000000..cf8049e343 --- /dev/null +++ b/docs/architecture/c4-interoperability-owner-lifecycle-web-flutter-2026-11-01.md @@ -0,0 +1,123 @@ +# C4 — Interoperability consommable : première intégration complète par owner + +**Date :** 2026-11-01 +**Portée :** PR #522, sans fusion, aucun modèle/migration ajouté. +**Statut :** livré en code pour le cycle de vie **Intelligence BYO API key** et sa consommation Web/Flutter ; validation d'exécution des tests et services externes non attestée. **Pas de catalogue OAuth générique livré.** + +## Pourquoi + +C1 constate M7 comme kernel et Z16 comme projection GET, tandis que des domaines comme Intelligence, Payments, Notifications et Accounts possèdent déjà leurs adaptations. C2/C3 fixent les parcours et la présentation. C4 ne doit pas créer une fausse marketplace, ni utiliser les intégrations d'authentification sociale pour accéder à des calendriers/fichiers. La première intégration exploitable choisie s'appuie sur l'existant `intelligence.ProviderConnection`, `IntelligenceRoute`, `ProviderCredential`, `build_runtime_registry` et les adapters déjà présents. + +## Propriétaire et catalogue de fournisseurs + +**Owner :** Intelligence. **Pas d'API M7 de mutation ni de nouvelle table**. + +Fournisseurs explicitement exposés, avec endpoint HTTPS fixe côté serveur, sans URL saisie par l'acteur : + +| Offer code | Protocole existant | Capacités | Paramètre requis | +|---|---|---|---| +| `tavily` | `tavily_search` | `web_research` | Clé Tavily | +| `exa` | `exa_search` | `web_research` | Clé Exa | +| `openai` | `openai_compatible` | `text_generate`, `structured_generate`, `embed` | Clé + identifiant modèle | +| `openai_web` | `openai_responses_web` | `web_research` | Clé + identifiant modèle | + +Les endpoints de ces offres sont vérifiés contre les spécifications publiques et fixés dans `intelligence/connection_management.py`. Le client n'envoie jamais `base_url`, `priority`, `routes`, `timeout_seconds`, `scope`, `profile_id`, `space_id` ou credential chiffré. + +**Références fournisseur (techniques, non secrets) :** +- https://help.tavily.com/articles/4840311948-tavily-search-api +- https://github.com/exa-labs/openapi-spec/blob/master/exa-openapi-spec.yaml +- https://developers.openai.com/api/reference/overview + +La présence d'un offer signifie **adapter disponible dans Makolo et clé à apporter**, pas « compte fournisseur déjà connecté ». Les frais et les quotas restent ceux du compte fournisseur de l'utilisateur. + +## Contrats HTTP propriétaire (distincts de Z16) + +| Scope | Catalogue + création | Détail + mise à jour + retrait | +|---|---|---| +| Profile | `GET/POST /api/v1/me/intelligence/connections/` | `GET/PATCH/DELETE /api/v1/me/intelligence/connections//` | +| Space | `GET/POST /api/v1/organizations/workspaces//intelligence/connections/` | `GET/PATCH/DELETE /api/v1/organizations/workspaces//intelligence/connections//` | +| Platform | `GET/POST /api/v1/platform/intelligence/connections/` | `GET/PATCH/DELETE /api/v1/platform/intelligence/connections//` | + +- `GET` collection = `{"offers":[{"code","label","description","capabilities","requires_model","can_connect"}]}`, sans credential ni endpoint. +- `POST` = `{"provider","name","api_key","model","consent":true}` ; `consent=true` est obligatoire. Création atomique d'une Connection Intelligence, de sa credential chiffrée et de ses routes de capacités allowlistées. Rejet des paramètres inconnus, des doublons de même nom/protocole/endpoint par portée, des valeurs invalides et des protocoles non allowlistés. +- `PATCH` = `{"enabled":true|false}` ou `{"api_key":"..."}` ; réactivation impossible sans clé. Une rotation remet la santé à `unknown`. +- `DELETE` = retire Connection et credential locale, y compris les routes associées. **Ne révoque pas la clé chez le fournisseur externe** ; l'interface l'explique. +- Réponses = uniquement `project_provider_connection` sans secret ; `Cache-Control: private, no-store`. +- `INTELLIGENCE_CREDENTIAL_MASTER_KEY` reste un prérequis opérationnel existant ; si absent, catalogue `can_connect=false` et `POST` refuse avec 503, sans enregistrer la clé. +- Pas de vérification réseau facturable déclenchée simplement par l'ouverture d'un écran ou la création d'une connexion. + +## Autorité et confidentialité + +- **Profile :** `profile.user == request.user` sur lecture et mutations. Autre Profile => 404. +- **Space :** `space.manage` direct sur le Space courant, réévalué dans la vue et dans les services. `TeamMembership` ou autorité Platform isolée ne suffisent pas. Refus hors portée => 404. +- **Platform :** `platform.manage` obligatoire en API et sur la vue Web. Connexions du seul scope Platform. +- Aucun champ fourni par le client ne choisit la portée ou le propriétaire ; routes HTTP et acteur serveur le fixent. +- Une clé n'est jamais sérialisée, loggée par ces nouveaux chemins ni copiée dans ProfileStore. +- Chaque création, modification/rotation et retrait enregistre une trace OperationsAuditLog minimale : actor, portée, ID et type d'opération ; ni secret, ni endpoint, ni nom de modèle. La connexion est déclarée liée à Makolo et non au compte social. +- L'état `manageable` dans Z16 ne confère jamais un endpoint générique M7 ; les mutations sont documentées exclusivement chez Intelligence. +- Les `Actions` M7, `Extensions` et Webhooks ne deviennent pas une marketplace par cette livraison. + +## Parcours Web + +- Moi → Connexions : options autorisées, formulaire de connexion avec consentement, statuts et gestion de clés avec CSRF, confirmation du retrait. +- Nous → Connexions : même composant formulaire, contrôlé par Mandate direct `space.manage`. +- Platform → Interopérabilité : affichage et gestion techniques sous `platform.manage`. +- Les formulaires serveur ne reflètent jamais les credentials en réponse ni en message d'erreur, et la lecture partage Z16 avec l'API. + +## Parcours Flutter + +- **Moi → Connexions :** projection locale `personal.interoperability` maintenue par `SyncEngine` ; le composant propriétaire charge seulement les offers depuis Intelligence, puis exécute `POST/PATCH/DELETE` avec revalidation Z16. Les clés en formulaire ne vont jamais dans Drift/ProfileStore. Panne : dernière observation explicite, aucune réussite locale optimiste. +- **Nous → Connexions :** handoff serveur limité à `space.manage` direct, route `/space//connections`, GET Z16 autorisé et revalidation du contexte acteur avant toute gestion. **Online-only** pour cette profondeur C4 ; aucune projection Space stockée dans le ProfileStore personnel. Une perte d'autorité ou changement d'acteur efface immédiatement l'état du composant. Une future cache propriétaire Space doit réutiliser SpaceSync et non un second système. +- Platform demeure Web/API ; l'application personnelle Flutter n'ouvre pas de shell Platform. + +## Tests C4 ajoutés (écrits, non exécutés ici) + +- API : création atomique, lecture Z16, chiffrement/déchiffrement côté serveur, consentement explicite, rotation, disable/enable, retrait, absence de secrets dans les réponses et les audits, no-store, refus cross-Profile, Membership ≠ Mandate, Platform ≠ Space, validation stricte, indisponibilité de la clé maître. +- Web : formulaires réels Profile/Space, contrôle de propriété, absence de secrets dans l'HTML et confirmation du retrait. +- Flutter : catalogue vérifié côté serveur, consentement avant soumission, clé présente uniquement dans requête POST du propriétaire, non rendue dans l'écran. +- Test plus vaste E2E avec véritables credentials fournisseur et vérification physique d'une action de recherche/génération : **non exécuté**, dépend d'un environnement explicitement configuré et d'une clé consentie. + +## Limitations explicites et suite + +- Pas de « connecter Google Drive », « lire Google Calendar » ou autre OAuth : Accounts social login n'accorde aucune de ces permissions. +- Le retrait local de la clé n'invalide pas celle du fournisseur ; l'acteur doit également la révoquer dans sa console fournisseur si nécessaire. +- La capacité de recherche/génération est obtenue par les domaines Makolo qui utilisent déjà `build_runtime_registry` et leur propre Action/context ; C4 n'ajoute **aucune action universelle** `execute` ni exécution facturable depuis une carte Connexions. +- Le health check explicite reste administrable techniquement par Intelligence ; la disponibilité d'une clé nouvellement ajoutée n'est pas garantie sans utilisation/contrôle réel. +- La validation CI et les tests locaux n'ont pas été exécutés à la demande ; **aucune fusion** n'est autorisée à être considérée sûre sur cette seule base. + +**Critère de fermeture du lot livré :** un acteur peut, dans une portée autorisée, découvrir un provider compatible, accepter l'usage de sa clé, le relier, voir sa Connection projetée, désactiver/réactiver/rotater/retirer, et retrouver l'état cohérent sur Web et Flutter. L'état réel du fournisseur reste une dépendance externe. + + +## C4.5 — Correctifs de fermeture du 2 novembre 2026 + +### Bugs et incohérences corrigés dans #522 + +1. **Santé non vérifiée ≠ disponibilité confirmée :** l'écran Flutter Space examine d'abord le health Z16, avant `usable`. Une connexion dont le health est `unknown` affiche « disponibilité à vérifier », jamais « connectée et disponible ». La propriété `usable` conserve sa signification Z16 (« autorisée et techniquement routable »), sans prétendre garantir un résultat fournisseur. Test de présentation : `mobile/test/space_connections_status_test.dart`. +2. **Santé obsolète après réactivation ou rotation :** la mise à jour owner remet `health_status=unknown`, `last_checked_at=None` et `last_latency_ms=None` lorsque la connexion est réactivée après désactivation ou qu'une clé est remplacée. Test API de non-réutilisation d'une ancienne preuve de santé. +3. **Continuité Flutter :** une projection personnelle en état `offline`, `failed`, `stale` ou `conflict` ne propose pas de mutation active ; les formulaires ne mettent jamais un secret en outbox. Un échec de mutation n'est pas traité comme un succès local. +4. **Confirmation serveur distincte de la lecture :** après `POST/PATCH/DELETE`, un rafraîchissement de la projection qui échoue n'annule pas une mutation déjà confirmée. Message dédié à chaque issue. +5. **Registry réellement consommable :** un test de contrat vérifie qu'une nouvelle Connection Profile Intelligence apparaît dans le `build_runtime_registry` du Profil propriétaire, pas celui d'un autre Profil, disparaît à la désactivation, réapparaît à la réactivation, puis disparaît au retrait. +6. **Contrôles supplémentaires :** tests Web Platform sous `platform.manage` et vérification CSRF sur les formulaires de credential ; absence de secrets dans les audits. + +### Commandes de validation ciblée + +À exécuter dans un checkout de la PR #522, avec les dépendances du projet et le runtime de tests configuré : + +```bash +python manage.py test intelligence.test_connection_management_api intelligence.test_connection_management_web interoperability.test_z16_api core.test_w8_personal_interoperability core.test_platform_web organizations.test_ws4_space_web +python manage.py makemigrations --check --dry-run +cd mobile +flutter test test/intelligence_connection_controls_test.dart test/space_connections_status_test.dart test/interoperability_screen_test.dart +flutter analyze lib/features/interoperability test/intelligence_connection_controls_test.dart test/space_connections_status_test.dart +``` + +La vérification fonctionnelle externe exige un fournisseur de test et une clé réellement autorisée (non consignée dans les tests/commits). Pour chaque scope : créer, inspecter Z16, vérifier une route réelle, désactiver/réactiver, tourner le credential, retirer, puis tester la perte de Mandate/le changement de compte et les erreurs réseau. Les appels réseau facturables exigent un consentement explicite ; aucun test E2E payant automatique n'est ajouté. + +### Statut de preuve et limites + +- **Livré dans le code :** chemins Profile, Space, Platform, Web, Flutter, correctifs et tests ciblés. +- **Vérifié par inspection des sources GitHub :** existence et intégration des fichiers dans la PR #522. +- **Non attesté dans cet environnement :** exécution de Django/Flutter, `makemigrations --check`, compilation, vérification de migrations sur PostgreSQL, test E2E avec un vrai fournisseur. Le conteneur de la session ne dispose pas du checkout GitHub, de Django ni de Flutter ; aucune CI n'est déclenchée. +- **Reste à valider pour prononcer C4 fermé :** sortie verte des commandes ciblées, corrections d'éventuels échecs et démonstration d'un parcours d'intégration fournisseur autorisé. Si une de ces étapes échoue, ne pas marquer C4 `DONE`. + +L'objectif reste une fermeture **dans le périmètre Intelligence BYO API key** ; l'OAuth générique, les Actions universelles et les connexions de domaine non implémentées ne sont pas des conditions artificielles de ce C4. diff --git a/docs/architecture/c5-intelligence-consumption-discovery-actions-web-flutter-2026-11-02.md b/docs/architecture/c5-intelligence-consumption-discovery-actions-web-flutter-2026-11-02.md new file mode 100644 index 0000000000..2cb0a2aeac --- /dev/null +++ b/docs/architecture/c5-intelligence-consumption-discovery-actions-web-flutter-2026-11-02.md @@ -0,0 +1,63 @@ +# C5 — Connexions Intelligence consommées dans une action Makolo + +**Chantier :** PR #522, après C4. **Statut :** implémenté en code ; tests ciblés écrits, non exécutés dans cette session. Aucune migration, aucun modèle, aucune nouvelle Action M7. + +## 1. Besoin et périmètre + +C4 permet de relier et de gérer des services Intelligence à clé API. C5 ferme le premier usage utilisateur **de bout en bout** dans un domaine propriétaire existant : + +1. **Découvrir / Clarifier ma recherche** utilise `structured_generate` (OpenAI compatible) pour proposer des filtres canoniques déjà définis par Discovery. +2. **Découvrir / Chercher des sources externes** utilise `web_research` (Tavily, Exa ou OpenAI Responses Web) pour afficher des pistes externes liées à la recherche. Une piste externe n'est pas une Activity, Occurrence, Opportunity, Proof ni donnée Mayele admise. + +Les usages existants de `IntelligenceGateway` (Discovery, Interpreter, WebResearch) sont réutilisés. L'Interpreter en batch conserve son contexte plateforme ; C5 ne lui transfère pas la clé d'un Profil sans mandat et ne crée pas d'API `M7.execute`. + +**Parcours :** le Profil choisit un geste → serveur récupère le Profil courant sans accepter de propriétaire client → registre Intelligence résout les routes actives du propriétaire et les routes plateforme existantes → Gateway exécute l'adapter → Discovery vérifie son contrat → Web/Flutter présentent critères ou sources → utilisateur poursuit son exploration avec le selector canonique. + +## 2. Contrats HTTP explicites + +| Route | Autorité | Effet | +|---|---|---| +| `POST /api/v1/discovery/clarify/` | Profil authentifié | `{"criteria":{"q":"..."}}` → enveloppe `discovery.clarification` avec `status`, `reason`, `query`, `constraints` | +| `POST /api/v1/discovery/research/` | Profil authentifié | `{"q":"..."}` → enveloppe `discovery.source-leads` avec `status`, `reason`, `sources`, `candidates` | +| `POST /discover/clarify/` | Session Web + CSRF | Redirige vers la recherche déterministe avec les critères vérifiés et le contexte spatial conservé | +| `POST /discover/research/` | Session Web + CSRF | Page éphémère de sources externes avec retour au champ Découvrir | +| `GET /api/v1/discovery/items/`, `GET /discover/` | selon contrats Discovery existants | Restent déterministes : aucun appel fournisseur facturable implicite lors d'une simple exploration | + +Aucun champ `profile_id`, `space_id`, `provider_id`, `base_url` ou `api_key` n'est accepté par ces actions. Le propriétaire réel de la Connection est Intelligence ; Discovery possède la décision d'appliquer les critères et de présenter des pistes dans une exploration. + +## 3. Preuves métier et limitations + +- **Clarification :** n'applique que les valeurs allowlistées `vertical`, `place`, `when`, `period`, `price`, `text` après validation et rapprochement avec les connaissances canoniques de lieux existants. Un filtre explicite n'est pas écrasé par un résultat de fournisseur. Une réponse invalide laisse la recherche déterministe intacte. +- **Web Research :** construit une `ResearchMission` temporaire, bornée à trois candidats et une requête, en réutilisant `IntelligenceWebResearchEngine`. Les références source et candidat sont produites par les contrats existants. Les réponses exposées sont limitées aux sources HTTPS et aux candidats effectivement sourcés, sans transformation en objet métier persistant. +- **Disponibilité :** status `unavailable` si aucune route utilisable, clé refusée, quota atteint, transport indisponible ou erreur. Les motifs publics sont bornés à `invalid_credentials`, `rate_limited`, `network_unavailable` et `provider_unavailable` ; aucune exception brute n'est exposée. +- **Confidentialité :** pas de secret dans la réponse, les liens, la projection, le cache, l'outbox ou l'analytics. Le texte de recherche part au fournisseur **uniquement sur POST explicite**, avec le consentement C4 préalable du titulaire de la clé. Les frais et quotas éventuels appartiennent au fournisseur. +- **Continuité :** en cas d'absence de service, d'erreur ou de résultat inutilisable, l'exploration déterministe est préservée. La carte, les filtres, la pagination et les viewers restent sous Discovery ; aucun résultat externe n'est enregistré comme possibilité. + +## 4. Clients + +- **Web :** deux formulaires CSRF distincts à côté du champ Découvrir après la recherche initiale. Pour les pistes, une profondeur transitoire affiche des liens externes avec indication explicite de non-vérification et retour à la recherche originale. +- **Flutter :** le `DiscoveryRepository` transporte les deux actions sans écrire dans Drift/ProfileStore. `DiscoverySearchScreen` permet de clarifier les critères, relance le selector existant, et ouvre les sources dans une feuille temporaire. Les URLs externes doivent être HTTPS et sans identifiants incorporés. Aucun usage en arrière-plan ni queue hors ligne. + +## 4.1 Bornes de sécurité supplémentaires + +- Les deux POST sont limités à **12 invocations par heure et par utilisateur authentifié**, via les throttles DRF existants (réutilisés dans les vues Django Web). Déployer avec un cache partagé si plusieurs workers doivent appliquer un budget global ; le cache non partagé reste une protection locale seulement. +- Les paramètres explicites de date, lieu, verticalité, moment et géographie ne sont jamais écrasés par une interprétation externe. Le rayon seul, sans position, ne déclenche pas une fausse autorité géographique. +- Les URLs de sources doivent être HTTPS, sans identifiants incorporés, et ne doivent pas cibler localhost, les IP privées ou les IP réservées. Seules les sources réellement citées par des pistes affichées figurent dans la réponse. +- Les erreurs de clé, de quota et de disponibilité sont réduites à des catégories publiques stables. Le contrat historique de l'IntelligenceGateway pour les erreurs réseau reste compatible ; aucune réponse brute du fournisseur ne ressort de l'API. +- Les utilisateurs peuvent rejoindre **Moi → Connexions** depuis Découvrir lorsqu'une capacité Intelligence n'est pas disponible ; aucune Permission ou Mandate n'est déduite de la présence d'un bouton. + +## 5. Tests ciblés et contrat de sortie + +Tests ajoutés : `discovery/api/test_c5_owner_actions.py` et `mobile/test/discovery_c5_connected_actions_test.dart`. Ils couvrent également le budget d'appels, la protection CSRF, les refus cross-Profil, le GET sans fournisseur, les filtres spatiaux, les URLs privées et la présentation Web des sources. + +```bash +python manage.py test discovery.api.test_c5_owner_actions discovery.test_intent discovery.api.test_mature_discovery intelligence.test_connection_management_api intelligence.tests web_research.tests.test_provider_pack +python manage.py makemigrations --check --dry-run +cd mobile +flutter test test/discovery_c5_connected_actions_test.dart test/discovery_mature_screen_test.dart test/intelligence_connection_controls_test.dart +flutter analyze lib/features/discovery lib/features/interoperability test/discovery_c5_connected_actions_test.dart +``` + +**Non attesté dans la session :** tests Django et Flutter réellement exécutés, compatibilité PostgreSQL et essais réseau avec clés réelles. La PR #522 reste ouverte, sans fusion. La fusion reste conditionnée à la vérification des gates requises. Les appels externes réels ne doivent être testés qu'avec un compte et des frais explicitement autorisés. + +**Critère de fermeture fonctionnelle de C5 :** un utilisateur possédant une Connection personnelle configurée peut lancer explicitement la clarification ou la recherche de sources, voir un résultat borné et utile, puis poursuivre Découvrir sans fuite, faux faits, privilèges implicites ni perte de contexte. diff --git a/docs/architecture/c6-owner-domain-integrations-push-payments-2026-11-02.md b/docs/architecture/c6-owner-domain-integrations-push-payments-2026-11-02.md new file mode 100644 index 0000000000..a3fb22a11e --- /dev/null +++ b/docs/architecture/c6-owner-domain-integrations-push-payments-2026-11-02.md @@ -0,0 +1,88 @@ +# C6 — Extensions des intégrations métier réelles + +**Programme :** continuité C1–C5 ; PR #522. +**Statut :** implémenté dans la branche de PR ; tests ciblés écrits, non exécutés dans cette session. **Aucune migration ni nouveau modèle.** + +## Problème utilisateur + +C4 relie un fournisseur Intelligence et C5 permet à Découvrir de l'utiliser. Les autres capacités spécialisées de Makolo ne doivent pas être présentées comme des comptes externes connectables : certaines sont déjà utilisables à travers leur domaine propriétaire ; d'autres n'ont encore aucun fournisseur installé. + +C6 ferme les parcours de **gestion des appareils push** et de **visibilité contextuelle des modes de paiement effectivement disponibles**. Il n'ajoute ni marketplace, ni OAuth universel, ni Action exécutable sans owner, ni faux providers. + +## Audit des propriétaires + +| Domaine | Faits runtime constatés | C6 | +|---|---|---| +| Intelligence | ProviderConnection / Credential / Route, Web+Flutter et Découvrir C4–C5 | Réutilisé, aucun double | +| Notifications | FCM : PushEndpoint chiffré, livraison owner, inscription et révocation | Inventaire privé d'appareils, retrait et réactivation explicite | +| Payments | `PaymentProvider.SANDBOX` sous flag et `PaymentProvider.MANUAL` pour autorité finance | Disponibilité par owner, modes distincts des connexions, aucune initiation générique | +| Accounts | Social login Google, Microsoft, Facebook, LinkedIn sous allauth | **Non** assimilé à une permission d'accès aux fichiers, calendriers ou messages | +| Geography | Contrat `Geocoder`, aucune implémentation réseau canonique | Aucune fausse connexion créée | +| Spatiotemporal | Interfaces routing/traffic/weather + défaut NoOp | Aucune disponibilité fournisseur affirmée | +| Domain Events/Webhooks | Contrats de signature, delivery injecté, pas de key resolver universel attesté | Pas de webhook « activable » inventé | +| M7 Extensions | Registre allowlisté, pas de catalogue produit générique installé | Aucun plugin arbitraire, aucune exécution directe | + +## Contrats livrés + +### 1. Notifications : appareils privés et révocation durable + +- `GET /api/v1/notifications/push/endpoints/` renvoie uniquement les appareils **actifs de l'acteur** (maximum 30), avec `id`, provider, platform, version, statut et dernière observation. Réponse `private, no-store`. +- `DELETE /api/v1/notifications/push/endpoints//` : seul propriétaire, 404 anti-IDOR, révocation idempotente (204), délégation à `notifications.push.revoke_push_endpoint_by_id`. +- L'API d'enregistrement existante `POST /api/v1/notifications/push/endpoints/` reste propriétaire et compatible ; son serializer public ne renvoie désormais plus l'`installation_id` ni `token_hint`. Les tokens restent chiffrés côté serveur. +- Le nouvel argument `reactivate` est `false` par défaut : une inscription automatique ou un changement de token **ne peut plus annuler la révocation décidée par ce Profil sur cette installation**. La réactivation demande `reactivate=true` de l'acteur authentifié via le token local de son appareil. +- Web Moi → Connexions : appareils visibles sans secrets, retrait protégé CSRF sur le service owner Notifications. +- Flutter Moi → Connexions : lecture online-only du propriétaire, retrait sous contrôle d'une projection fraîche, activation explicite « Activer sur cet appareil » lorsque Firebase est effectivement disponible dans le runtime mobile. La source FCM n'est jamais stockée dans Drift/ProfileStore. +- Limite : révoquer l'inscription Makolo ne supprime pas nécessairement le token chez Firebase ; cela supprime son usage de livraison par Makolo. + +### 2. Payments : capacités réelles, sans faux comptes externes + +- Nouveau selector propriétaire `payments.integration_availability.payment_configuration_for(actor)`, utilisé **à la fois** par l'API existante `GET /api/v1/payments/configuration/` et par les projections d'interopérabilité. +- La configuration ne montre `sandbox` que sous `PAYMENTS_SANDBOX_ENABLED` et `manual` que pour une autorité Finance pertinente. +- **Aucun des deux n'est une Connection externe** et `connected=false` est explicite. Une possibilité de paiement ne déclenche jamais `initiate_payment` hors de l'obligation et de l'autorité métier. +- Dans Nous, le mode manuel n'est projeté que pour `finance.manage` **direct** sur l'Espace considéré ; simple Membership, rôle Platform ou `space.manage` ne crée pas cette autorité financière. +- Dans Platform, seuls les modes owner disponibles sont exposés aux opérateurs `platform.manage`, sans détails Profil/Space. + +### 3. Lecture Z16 additive, sans second système + +`interoperability.projections.build_interoperability_payload` ajoute un champ optionnel `domain_integrations`. **Ce ne sont ni des `connections`, ni des M7 `actions`, ni des `extensions`.** Le schéma `z16.v1` conserve les champs existants : les anciens snapshots Flutter sans ce champ restent acceptés. + +Chaque ligne retenue possède un code, un owner, une catégorie, une disponibilité technique, un statut et un descriptif. Les intégrations push incluent un compte d'appareils (pas les credentials). Les modes Payments ne déclarent jamais de compte financier relié. Les liens techniques pointent uniquement vers les routes owner déjà vérifiées. + +L'état `available` n'accorde ni Permission, ni Mandate, ni droit d'exécution. Chaque mutation repart du contrôleur propriétaire et recontrôle la session et le propriétaire. + +### 4. Interfaces Web et Flutter + +- Moi : affichage distinct « Services utilisés dans Makolo » ; liste/retirer les appareils push ; présentation honnête de `sandbox_only` et `manual_available`. +- Nous : modes Payments seulement si `finance.manage` direct ; handoff Web et Flutter sous la lecture Z16 Space déjà autorisée. +- Platform : disponibilité de traitement owner affichée dans la page technique et l'API, pas de shell Platform personnel Flutter. +- Offline : une ancienne projection est une observation, **jamais une autorisation de modifier un appareil**. Le contrôle Flutter n'envoie pas de mutation lorsque l'état Sync est dégradé. + +## Contrôle qualité + +Tests ajoutés ou étendus : +- `notifications/test_push.py` : inventaire secret-free, révocation cross-Profile refusée, répétition idempotente, interdiction de réactivation silencieuse après token refresh, réactivation explicite. +- `interoperability/test_c6_owner_integrations.py` : exposition Profile/Space/Platform, finance direct, aucun PSP imaginaire, CSRF, absence d'identifiants sensibles, suppression Web owner. +- `mobile/test/c6_domain_integrations_projection_test.dart` : décodage additif, séparation des Connections, ancien Z16. +- `mobile/test/c6_push_device_controls_test.dart` : contrôle de révocation, pas de token affiché, mode lecture seule quand la fraîcheur ne permet pas les mutations. + +Commandes de validation ciblée dans le checkout de #522 : + +```bash +python manage.py test notifications.test_push interoperability.test_c6_owner_integrations interoperability.test_z16_api core.test_w8_personal_interoperability +python manage.py test payments +python manage.py makemigrations --check --dry-run +cd mobile +flutter test test/c6_domain_integrations_projection_test.dart test/c6_push_device_controls_test.dart test/intelligence_connection_controls_test.dart +flutter analyze lib/features/interoperability lib/repositories/interoperability_repository.dart lib/platform/notifications/push_endpoint_registrar.dart +``` + +**Non attesté ici :** exécution Django/Flutter, PostgreSQL, test de livraison FCM réel, tests de configuration Payments sur environnement de bêta. Les CI n'ont pas été déclenchées à la demande. Ne pas assimiler code poussé à validation d'exécution. + +## Critères de sortie + +1. Les seules intégrations montrées proviennent de faits propriétaires réels. +2. Un utilisateur peut inspecter et retirer ses appareils, sans lire ceux des autres, ni exposer de tokens/installation IDs. +3. Un appareil révoqué ne se réactive pas sans intention explicite sur l'appareil. +4. Payments garde la vérité des obligations et de l'argent, aucun PSP externe/connexion bancaire n'est fabriqué. +5. Space/Platform respectent les frontières d'autorité, y compris finance séparé de space.manage. +6. Web et Flutter consomment le même Z16 additif, sans état métier dupliqué, succès optimiste ni migration. diff --git a/docs/architecture/c7-interoperability-web-flutter-continuity-closure-2026-11-02.md b/docs/architecture/c7-interoperability-web-flutter-continuity-closure-2026-11-02.md new file mode 100644 index 0000000000..00589e59a2 --- /dev/null +++ b/docs/architecture/c7-interoperability-web-flutter-continuity-closure-2026-11-02.md @@ -0,0 +1,95 @@ +# C7 — Fermeture de l’interopérabilité dans l’expérience Web et Flutter + +**PR de travail :** #522 — `fix/discover-a-continuation-context-20261014` +**Statut :** correctifs et tests ciblés écrits et poussés ; validation exécutable non attestée dans cette session. +**Portée :** fermer la cohérence des parcours réellement livrés C1–C6, sans inventer C8, OAuth universel, marketplace, nouveaux providers, second moteur de Sync ou nouvelles tables. + +## 1. Constat runtime à la reprise + +- M7 contient les contrats génériques Capability / Connection / Action / Extension / Webhook ; il ne fournit **aucun droit** par une simple installation. +- Z16 projette les vraies `ProviderConnection` Intelligence aux scopes Profile / Space / Platform. +- C4 fournit le lifecycle de clés chiffrées via Intelligence, en Web et Flutter personnels/Space et Web Platform. +- C5 permet deux actions explicites de Découvrir : clarification structurée et Web Research, sans fournisseur facturable sur un GET et sans sauvegarder les pistes externes comme faits métier. +- C6 réutilise Notifications/FCM et les modes Payments existants, en séparant `domain_integrations` de `connections`. Aucune liaison bancaire, géocodage réseau ou calendrier externe n'est déduite de leur existence. + +## 2. Contrats de fermeture implémentés + +### Autorité, session, propriétaire + +1. **Backend :** l'autorité est contrôlée lors de chaque GET et mutation propriétaire. Profile : `profile.user` ; Space : Mandate direct `space.manage` (pas TeamMembership ni rôle Platform) ; Platform : `platform.manage`. Une révocation effective retire la lecture et refuse immédiatement PATCH/DELETE. Les tests de C7 révoquent réellement des Mandates après une première lecture valide. +2. **Flutter Profile :** un snapshot Z16 stocké est une observation, jamais un droit actuel. À l'entrée, l'écran sollicite `SyncEngine.pullInteroperability()` même si un snapshot existe ; le dernier état connu reste visible en cas d'échec mais les mutations restent fermées. `_refreshAndConfirm` distingue une modification serveur confirmée d'une projection ensuite inaccessible. +3. **Flutter Space :** lecture online-only, sans copie dans le `ProfileStore`. L'acteur et l'ID Space sont revalidés après chaque réponse. Le changement de Space, runtime ou perspective invalide immédiatement les données ; le contexte est réinspecté avant d'autoriser des contrôles. Les états Sync dégradés ou syncing interdisent les mutations. +4. **Dialogues et catalogues :** `IntelligenceConnectionControls` surveille `runtime + owner path`, invalide les requêtes de catalogue hors génération, et ne soumet pas une clé saisie dans un dialogue ouvert pour un ancien propriétaire. Une opération en vol n'affecte pas les flags de la nouvelle portée. + +### Statut, erreur, reprise + +5. **Vocabulaire commun :** `interoperability.presentation` (Web) et `interoperability_presentation.dart` (Flutter Profile et Space) ont la même hiérarchie : disabled → unavailable → degraded → unknown → usable/no-capability → connected/available. `health=unknown` ne signifie jamais que le fournisseur a été vérifié. +6. **Confirmer avant d'annoncer :** les mutations owner ne se doublent pas d'une vérité locale optimiste. La confirmation de l'écriture serveur reste distincte d'un éventuel échec du refresh. Les messages cachent les erreurs et clés brutes. +7. **Empty/offline/error :** les états non peuplés, la projection sans snapshot, la panne réseau et le snapshot ancien restent distinguables et reprenables. Une page Connexions ne rend pas inopérants Découvrir, Moi, Nous ou le reste de Makolo. + +### Push, continuité de révocation + +8. Le retrait d'un PushEndpoint appartient à Notifications, et les notifications déjà en file sont ignorées si l'appareil est inactif. +9. Une installation révoquée ne peut plus être réactivée silencieusement ni par token refresh, ni automatiquement après changement de Profil. Le propriétaire peut demander une réactivation explicite depuis l'appareil, avec la source FCM réelle. Aucun `installation_id`, `token_hash`, `token_hint` ni token chiffré n'est exposé dans les projections. +10. Flutter garde les requêtes d'appareils online-only et ne présente pas le résultat d'un ancien Profil comme succès après changement de runtime. +11. Une inscription automatique du même appareil sous un autre Profil ne peut pas réactiver un endpoint déjà révoqué : une intention explicite est nécessaire. Les projections en conflit ou obsolètes restent informatives et la mutation est désactivée. + +## 3. Surfaces consommées, sans nouveau système + +| Contexte | Entrée Web | Entrée Flutter | Autorité et owner | +|---|---|---|---| +| Profile | Moi → Connexions | `/connections` | Profil authentifié ; Intelligence + Notifications ; Payments pour modes exposables | +| Space | Nous → Connexions | `/space//connections` | `space.manage` direct ; `finance.manage` direct distinct pour modes financiers | +| Platform | Interopérabilité | Pas de shell Platform mobile personnel | `platform.manage` ; Intelligence + faits Payments stricts | +| Découvrir | `POST /discover/clarify/`, `POST /discover/research/` | recherches explicites depuis la recherche native | Profil ; Discovery conserve vérité des critères et des items ; Gateway seulement si volontaire | + +Aucun écran ne promet qu'une Action M7 sans endpoint propriétaire puisse être exécutée. Les Extensions vides restent vides. Le health et un badge `usable` ne remplacent jamais une permission. + +## 4. Matrice d'épreuves ciblées + +| Scénario | Attendu | +|---|---| +| Profil B lit/modifie la clé du Profil A | 404 / refus, aucun secret | +| Space Manager valide, puis Mandate révoqué | GET 404, mutation 404, état propriétaire inchangé | +| Platform admin sans Mandate Space direct | Space 404 ; Platform isolée | +| Mandate Platform révoqué | GET/gestion Platform 403 | +| Cache personnel + offline | lisible en dernier état connu, mutation désactivée | +| Espace A → Espace B pendant GET | réponse tardive A ignorée ; jamais de copie dans ProfileStore | +| Dialogue clé ouvert puis changement runtime/path | aucune clé POST vers le nouveau propriétaire | +| Rotation/désactivation/intention de retrait | serveur confirme ; relecture séparée ; aucune modification optimiste | +| Santé unknown/degraded/disabled | libellés identiques Web/Profile/Space | +| Push révoqué, livraison déjà en file | aucune livraison à un endpoint retiré | +| Push révoqué et autre utilisateur démarre le même appareil | pas de réactivation silencieuse | +| Découvrir sans clé ou fournisseur indisponible | parcours déterministe préservé ; aucune vérité externe inventée | + +## 5. Tests et vérifications à exécuter dans un checkout + +Tests ajoutés ou étendus C7 : +- `interoperability/test_c7_live_authority.py` : révocations réelles et absence de transferts d'autorité ; +- `mobile/test/c7_intelligence_owner_continuity_test.dart` : réponse de catalogue obsolète et ancien dialogue de clé ; +- `mobile/test/c7_space_owner_continuity_test.dart` : réponse Space A après changement vers B, lecture seule sur Sync dégradée ; +- `mobile/test/space_connections_status_test.dart` : état et langage communs ; +- `notifications/test_push.py` : non-réactivation cross-Profil et non-livraison après révocation. + +Régressions propriétaires C4–C6 à conserver : + +```bash +python manage.py test interoperability.test_z16_api interoperability.test_c7_live_authority intelligence.test_connection_management_api intelligence.test_connection_management_web notifications.test_push interoperability.test_c6_owner_integrations discovery.api.test_c5_owner_actions core.test_w8_personal_interoperability +python manage.py makemigrations --check --dry-run +cd mobile +flutter test test/interoperability_screen_test.dart test/space_connections_status_test.dart test/c7_intelligence_owner_continuity_test.dart test/c7_space_owner_continuity_test.dart test/intelligence_connection_controls_test.dart test/c6_domain_integrations_projection_test.dart test/c6_push_device_controls_test.dart +flutter analyze lib/features/interoperability lib/repositories/interoperability_repository.dart lib/platform/notifications/push_endpoint_registrar.dart +``` + +Aucune CI déclenchée dans cette session. **Les tests sont écrits, pas attestés verts.** Le contrat de fermeture définitive suppose l'exécution des suites ciblées, l'inspection des éventuels échecs et un passage réel sur appareils + comptes autorisés (Profile, Space, Platform) ; il ne suppose pas de faux fournisseurs ni de données de démonstration en environnement réel. + +## 6. Décisions et hors périmètre + +- Pas de migrations ni de nouvelle entité. Les vérités métier restent dans Intelligence, Payments, Notifications, Discovery et Authorization. +- Pas d'OAuth générique ou permission Google Calendar/Drive/Gmail par social login. +- Pas de provider PSP réel, de météo, geocoding ou routing déclaré si absent. +- Pas de webhook tenant sans identité et key resolver prouvés. +- Pas d'usage externe déclenché automatiquement hors contrat utilisateur. +- C8 n'est pas fixé par ce document : vérifier le plan canonique avant d'en faire un nouveau chantier. + +**Verdict C7 : code de fermeture poussé ; QA d'exécution non attestée, ne pas dire “fermé et validé” avant les preuves.** diff --git a/docs/architecture/c8-interoperability-operational-closure-2026-11-02.md b/docs/architecture/c8-interoperability-operational-closure-2026-11-02.md new file mode 100644 index 0000000000..fe25efe867 --- /dev/null +++ b/docs/architecture/c8-interoperability-operational-closure-2026-11-02.md @@ -0,0 +1,105 @@ +# C8 — Fermeture opérationnelle de l'interopérabilité C1–C7 + +**Périmètre retenu le 2 novembre 2026** : dernier chantier transversal de consolidation des parcours existants, proposé après C7. Ce numéro ne correspond pas à un C8 historique retrouvé. +**Branche de travail** : PR #522, `fix/discover-a-continuation-context-20261014`. +**Verdict** : correctifs et régressions **poussés en code** ; tests, PostgreSQL, Flutter, fournisseur réel et FCM appareil **non exécutés/attestés dans cette session**. Les gates doivent rester explicites avant fusion. + +## 1. Problème et règle de composition + +C1–C3 ont établi le catalogue, les autorités et le contrat de présentation. C4 fournit les connexions Intelligence à clé propre, C5 leur consommation explicite dans Découvrir, C6 les intégrations réellement propriétaires (Notifications/FCM et modes Payments), C7 l'isolation des contextes et la continuité Web/Flutter. + +C8 ne doit ni créer des fournisseurs inexistants, ni un « M7.execute » universel, ni déplacer les secrets, les Mandates, Payments, l'observation FCM ou les faits Discovery dans un domaine Interoperability. M7/Z16 restent les contrats de composition, Intelligence / Discovery / Notifications / Payments / Authorization les sources de vérité. + +## 2. Correctifs C8 implémentés + +### C8.1 — Intégrité serveur, sécurité, révocation + +- **Intelligence / health** : un probe potentiellement facturable lancé avant une rotation, une désactivation ou un retrait ne peut plus réécrire `health_status=healthy` après la mutation. La mise à jour utilise une comparaison atomique avec le `updated_at` et l'activation observés au démarrage ; une réponse devenue obsolète retourne `unknown`, sans réintroduire de preuve périmée. +- **Intelligence / health** : la réponse `IntelligenceResult.available=False` est désormais interprétée comme un échec de santé, même sans exception. `invalid_credentials` a un état propriétaire distinct. Un résultat mal formé ne certifie pas la disponibilité. +- **Intelligence / admin** : une rotation de credential via la console technique invalide désormais la santé et les mesures antérieures, comme dans le service API C4 ; pas de réutilisation d'une preuve attachée à l'ancienne clé. +- **Intelligence / concurrence Platform** : la création de connexions sous une portée Platform unique est désormais sérialisée entre opérateurs distincts avec un verrou transactionnel PostgreSQL à identifiant stable. Le verrou individuel d'opérateur ne suffisait pas. Aucun nouvel état persistant ; le fallback SQLite de développement n'offre pas cette garantie concurrente et le test dédié est réservé à PostgreSQL. +- **Interoperability et Intelligence APIs** : `Cache-Control: private, no-store` est imposé à tous les résultats, y compris les erreurs d'autorité, d'authentification et de validation, via le mixin de confidentialité existant. +- Les Mandates Profile/Space/Platform continuent à être contrôlés dans les propriétaires lors de chaque mutation. Aucun booléen Z16 `manageable` n'est un droit générique. + +### C8.2 — Usage métier complet et vérité + +- Test HTTP propriétaire **create → Z16 → Découvrir Web Research → disable → unavailable/no network → enable → rotate → utiliser nouvelle clé → DELETE → credential supprimé/Z16 vide → unavailable/no network**. La requête au fournisseur est simulée et l'en-tête Bearer courant est contrôlé ; aucun appel réel payant ni clé utilisateur. +- Les sources externes restent des pistes sourcées, non admises comme Activities/Occurrences/Opportunities. Aucun accès aux données privées d'un autre Profil n'est accordé au fournisseur. +- Notifications garde son cycle d'inscription/révocation/réactivation FCM et Payments ses seuls modes réellement présents (sandbox ou traitement manuel autorisé). Les tests C6–C7 couvrent ce cycle sans inventer de PSP connecté. + +### C8.3 — Continuité Web/Flutter + +- Flutter Moi : après une mutation confirmée par le serveur, une simple `Future` SyncEngine terminée ne prouve plus que l'observation a été appliquée. `ProfileConnectionsScreen._refreshAndConfirm` compare l'horodatage du snapshot Z16 précédent à celui de la nouvelle observation. En cas de réponse abandonnée, erreur ou autorité modifiée, le client annonce l'indisponibilité de la relecture, **pas** une confirmation fictive. +- C7 conserve la purge/suspension lors du changement de Profil/Space, la lecture Space online-only et l'absence de clés dans Drift/ProfileStore. Les boutons mutations dégradées restent inactifs ; la dernière observation est étiquetée comme telle. + +### C8.4 — Robustesse et exploitation + +- Discovery journalise un événement `discovery.external_action` avec seulement l'action (`clarify`/`research`), le statut public, la catégorie d'échec publique et un nombre de pistes borné à trois. Il n'écrit **ni requête, ni utilisateur, ni URL, ni clé, ni prompt, ni réponse de fournisseur** dans ces nouveaux événements. +- Le `IntelligenceGateway` conserve sa télémétrie provider-neutral existante (capability, issue, latence, code et feature, sans payload) ; les categories d'erreur historiques non normalisées ne sont pas modifiées artificiellement pour passer un test. +- Discovery reste une action **POST explicite**, limitée par utilisateur, avec repli déterministe et `private, no-store`. Rien ne facture un fournisseur par un simple GET Découvrir. + +### C8.5 — Réconciliation, preuve et handoff + +Les contrats C1–C7 et leurs frontières restent inchangés. Aucun modèle ni migration nouvelle C8, aucun backfill, aucune installation OAuth/Calendar/Drive/PSP/Geography/Spatiotemporal non prouvée. + +## 3. Fichiers concernés + +**Correctifs** : `intelligence/health.py`, `intelligence/admin.py`, `intelligence/connection_api.py`, `interoperability/api_views.py`, `discovery/assistance.py`, `mobile/lib/features/interoperability/connections_screen.dart`. + +**Tests C8** : +- `intelligence/test_c8_health_consistency.py` — succès sain, `available=False`, diagnostic retardé après rotation, disable et DELETE, rotation admin. +- `intelligence/test_c8_platform_creation_concurrency.py` — deux administrateurs Platform créant simultanément le même fournisseur sous PostgreSQL, un seul succès attendu. +- `interoperability/test_c8_owner_response_privacy.py` — 404 Space, 403 Platform, 400 payload, profil et `no-store`. +- `discovery/api/test_c8_interop_journey.py` — parcours de connexion et usage propriétaire jusqu'au retrait, sans transport externe réel. +- `discovery/api/test_c8_operational_events.py` — logs utilisables et minimaux, sans requête privée ou clé. +- `mobile/test/c8_interop_mutation_refresh_test.dart` — écriture Intelligence acceptée, rafraîchissement Z16 interrompu, conservation du snapshot et absence de secrets dans le store. +- Les tests C4–C7 existants restent obligatoires (permissions, CSRF, frontières d'autorité, FCM, Flutter Sync/Space et sorties Discovery). + +## 4. Matrice d'acceptation et commandes reproductibles + +| Gate | Preuve requise | Statut | +|---|---|---| +| Autorité | Cas Profile/Space/Platform, IDOR, Mandate retiré, no-store sur erreur | Tests écrits ; non exécutés | +| Concurrent health | Rotation/désactivation/DELETE pendant un probe ; résultat indisponible jamais sain | Tests écrits ; non exécutés | +| Gateway réel | Connexion personnelle avec clé consentie, opération avec quota connu, état provider observé | Externe, non attesté | +| Découvrir | Résultats structurés, provenance et limite, erreur/réparation, coût explicite | Mock tests écrits ; externe non attesté | +| FCM | Inscription Android/iOS, notification, révocation, réactivation après redémarrage/changement de Profil | Tests code ; appareil non attesté | +| Payments | Sandbox flag et finance direct, aucune fausse connexion PSP | Tests C6 ; non exécutés | +| Flutter | Cold-start, absence réseau, cache conservé, Z16 réellement appliqué, changement de Profil et Space, retrait | Tests code ; appareil non attesté | +| Données | Aucune migration C8 ; schémas déjà livrés et migrations des autres chantiers vérifiés | Contrôle de migrations non exécuté | +| Intégration PR | Branches concurrentes examinées, intégration sans écraser leurs travaux ; CI obligatoire avant merge | PR ouverte ; CI non déclenchée | + +```bash +python manage.py test intelligence.test_c8_health_consistency intelligence.test_c8_platform_creation_concurrency interoperability.test_c8_owner_response_privacy discovery.api.test_c8_interop_journey discovery.api.test_c8_operational_events +python manage.py test intelligence.test_connection_management_api intelligence.test_connection_management_web intelligence.test_web_research_routing interoperability.test_z16_api interoperability.test_c6_owner_integrations interoperability.test_c7_live_authority discovery.api.test_c5_owner_actions notifications.test_push +python manage.py makemigrations --check --dry-run +cd mobile +flutter test test/c8_interop_mutation_refresh_test.dart test/interoperability_screen_test.dart test/c7_intelligence_owner_continuity_test.dart test/c7_space_owner_continuity_test.dart test/c6_domain_integrations_projection_test.dart test/c6_push_device_controls_test.dart test/discovery_c5_connected_actions_test.dart +flutter analyze lib/features/interoperability lib/repositories/interoperability_repository.dart +``` + +Le test réel d'un fournisseur, la configuration opérationnelle et les coûts restent soumis à autorisation explicite. `INTELLIGENCE_CREDENTIAL_MASTER_KEY` est requis pour les credentials BYO, `MAKOLO_FIREBASE_PUSH_ENABLED` gouverne FCM et `PAYMENTS_SANDBOX_ENABLED` le mode de démonstration : ne pas déduire leur valeur effective d'un document. Une configuration de cache partagée est nécessaire pour que les throttles interworkers aient une portée globale. + +## 5. Critères de sortie et dette résiduelle déclarée + +**C8 terminé en implémentation** signifie : aucun scénario nouveau inventé, corrections et tests de bord présents dans #522, vérité propriétaire et interfaces réconciliées. + +**C8 validé en exploitation** exige **réellement** : tests verts, migrations contrôlées, postures PostgreSQL, essais mobiles avec authentification et changements d'acteurs, au moins un fournisseur autorisé testé sous budget connu, aucune régression critique de sécurité/continuité. Ces preuves ne sont pas disponibles dans cette session et ne doivent pas être remplacées par une mention « DONE ». + +À surveiller dans la fermeture et les gates M9/M10, sans les traiter comme fonctionnalités C8 fictives : concurrence inter-opérateurs de création de connexions Platform corrigée pour le chemin propriétaire PostgreSQL (sans contrainte d'unicité DB générale, contrôler les voies d'administration directe), santé réelle indépendante de la capacité métier, échec de transport après commit pouvant nécessiter une relecture, coût/quotas réels externes, environnement de push iOS/Android et capacités non installées. + +**Décision :** arrêter l'expansion du chantier C1–C8 une fois les défauts réels corrigés ; transférer les gates d'exécution au processus de qualité existant, sans masquer les limites ni créer de nouvelle architecture locale. + +## 6. Solde des reliquats de code C1–C8 (2 novembre 2026) + +Ces corrections sont intervenues **après** l'implémentation initiale de C8, dans la même PR #522. Elles ne constituent pas un nouveau train C9. + +1. **Notifications — changement de Profil** : `register_push_endpoint` refuse désormais tout transfert implicite d'une installation FCM appartenant à un autre Profil, même si un token se renouvelle. Une réactivation explicite sur l'appareil exige **simultanément** le token FCM courant et le même identifiant d'installation que l'endpoint existant. Un seul identifiant ne permet pas de reprendre un endpoint tiers. Les correspondances en conflit entre propriétaires ne sont pas supprimées en cascade. `PushEndpointRegistrar.start` gère les refus de synchronisation automatique en best effort et maintient l'écoute des futurs tokens ; seule `reactivateCurrent()` expose une erreur à la demande de l'utilisateur. Ce transfert ne représente ni mandat, ni permission, ni preuve métier générale. Si le fournisseur a renouvelé le token depuis le dernier enregistrement de l'autre Profil, une migration inter-Profil peut nécessiter un retrait par l'ancien propriétaire plutôt qu'un transfert automatique non vérifiable ; pas de preuve cryptographique artificielle introduite. +2. **Flutter — vérité post-réactivation** : trois états distincts, conservés à l'écran : écriture acceptée par Notifications, liste des appareils rafraîchie, projection Z16 des connexions effectivement confirmée. Une deuxième relecture échouée n'annonce plus un succès global ; le service peut être enregistré sans que l'interface ait la dernière projection. +3. **Payments — contexte financier** : `payments.manual` n'est plus projeté globalement dans Moi à partir d'un rôle Finance valable quelque part. Son état reste propriétaire dans l'Espace qui possède `finance.manage` direct ou dans Platform sous permission pertinente. Une démonstration sandbox reste clairement séparée d'un prestataire bancaire réellement connecté. +4. **Continuité réelle des capacités** : une connexion Intelligence personnelle ayant `web_research` ou `structured_generate` reçoit un accès vers la page **Découvrir** existante sur Web et Flutter. Ce lien n'exécute pas automatiquement un appel payant et ne crée pas une nouvelle Action. Les cartes informatives M7 Action/Extension sans destination propriétaire implémentée sont clairement décrites comme **contrats déclarés**, non comme boutons exécutables. +5. **Intelligence / Découvrir — reprise après échec** : distinction publique et stable entre `connection_unavailable` (aucune route utilisable), `invalid_credentials` (clé refusée), `rate_limited` (attendre le quota) et `network_unavailable` (fournisseur temporairement inaccessible). Les liens vers Moi → Connexions ne sont proposés que si configurer ou réparer une clé constitue une suite pertinente ; les erreurs réseau et quotas gardent la recherche déterministe. Aucun détail technique, credential ou texte utilisateur n'est ajouté au journal. + +Tests ciblés ajoutés ou renforcés : `notifications/test_push.py`, `mobile/test/c6_push_device_controls_test.dart`, `interoperability/test_c6_owner_integrations.py`, `mobile/test/interoperability_screen_test.dart`, `discovery/api/test_c8_recovery_remainders.py`, `discovery/api/test_c5_owner_actions.py`, `discovery/api/test_c8_operational_events.py` et `intelligence/tests.py`. + +**Statut des preuves** : changements poussés à la branche de PR, tests ciblés rédigés mais non exécutés dans cette session, CI non sollicitée selon l'instruction. Ne pas déclarer la fermeture opérationnelle validée à partir du seul push GitHub. Aucune migration, aucune extension de catalogue ni modification directe de `main`. diff --git a/docs/architecture/discovery-n1-layout-contract.md b/docs/architecture/discovery-n1-layout-contract.md new file mode 100644 index 0000000000..6d26d31f1a --- /dev/null +++ b/docs/architecture/discovery-n1-layout-contract.md @@ -0,0 +1,65 @@ +# Découvrir N1 — composition de la possibilité + +Statut : contrat d’assemblage Web / Flutter, implémenté sur la branche de PR #522. +Sources propriétaires : le backend et les domaines canoniques. Ce document décrit un **rendu** et n’introduit aucune vérité persistante. + +## Ordre des zones, sans emplacement vide + +1. **Porteur + ajout** : avatar et nom du Profil/Espace navigables selon les droits, date **d’ajout** à droite et distincte de toute date d’Occurrence. +2. **Titre** : désigne la possibilité. +3. **Verticale / type** : libellé métier principal et sous-type pertinent, dédupliqués. L’état de participation personnel n’est pas un type. +4. **Description** : aperçu court à N1 ; toucher le texte ou « Voir plus » développe, un second toucher ou « Voir moins » replie. Seul le texte est affecté. +5. **Informations pratiques** : prix canonique, lieu, quand, disponibilité, dates/repères particuliers, variantes et contexte autorisé. Les libellés et valeurs restent visibles et stables pendant le dépliage du texte ; aucune extraction de prix depuis une description libre. +6. **Média principal / galerie** : uniquement les usages de présentation publics, disponibles et autorisés. Aucun visuel artificiel lorsque les médias n’existent pas ; l’image et les ressources documentaires restent distinctes. +7. **Documents et ressources** : les deux premières ressources publiques dans l’ordre canonique. Pour plus de deux, un contrôle distinct révèle la collection entière, puis permet le repli. PDF, liens, textes et fichiers sont consultables conformément à leurs capacités. +8. **Interactions** : trois colonnes égales sur mobile, Enregistrer / Action métier autorisée ou consultation / Partager, sans changement implicite des capacités. + +## Invariants de la projection + +- Aucune limite arbitraire de six ressources annexes : `discovery/api/composition.py` transmet toutes les ressources **publiées et publiques**, sans transférer les ressources privées. +- Les deux premiers éléments sont une **limite d'affichage replié**, et non une limitation du nombre de ressources serveur. +- Un état de disponibilité inconnu ne justifie pas un calcul client ; un prix mentionné seulement dans le texte reste éditorial. +- Les faits temporels et spatiaux conservent leur sens métier (départ, arrivée, échéance, etc.). +- Les zones conditionnelles sont omises en leur absence, sans placeholder ni hauteur réservée. +- Web et Flutter consomment la même identité et les mêmes faits, avec présentation adaptative. +- Aucun nouveau modèle `Possibility`, `Media` ou `Resource` n'est introduit pour cette composition. + +## Régressions à protéger + +- Seven-or-more resource presentation, with only two initially visible. +- Ressource privée jamais exposée par Discovery. +- Classification sans duplication de « Type ». +- Les faits restent visibles lors de « Voir plus / Voir moins ». +- Ordre des zones, navigation du porteur, actions et contraste clair/sombre. +- Galerie, ressources et lecteurs propriétaires non perturbés par le changement de composition. + +Ce contrat complète, sans remplacer, `docs/architecture/makolo-domain-blueprint.md` et les contrats UX Découvrir. Les domaines propriétaires et leurs permissions restent prioritaires. + +## Implémentation du chantier 04 — 31 octobre 2026 + +Ce complément est un relevé du code sur #522 et **ne remplace pas** +les huit zones ci-dessus. + +- Le temps « ajouté » reste un champ `date.kind=added_at` et se présente + à droite sous forme relative (à l'instant, il y a quelques minutes/heures, + hier, jours/semaines), avec date exacte accessible ; aucune date + d'Occurrence n'est déduite de ce libellé. +- Les libellés de lieux et de temps restent sémantiques : + `Départ`, `Escale`, `Arrivée`, `Embarquement`, `Débarquement`, (les escales répétées sont toutes présentées dans leur ordre, avec un numéro de libellé), + `Ouverture`, `Clôture`, `Date limite`. Une disponibilité + **connue et indisponible** n'est pas une inconnue : elle reste affichable. +- Les notes contextuelles sont celles projetées comme + `assessment.state=limiting` par l'owner ; les inconnues sans valeur + pratique ne remplissent pas la zone. Les variantes et relations ne + sont comptées que lorsqu'elles sont présentes. +- Les images `PresentationMediaUse` gardent leur `mime_type` JPEG/PNG + et `alt_text` dans la galerie Flutter et le Reader. +- L'existence d'un texte très long n'autorise pas à déplacer les faits + hors de leur zone. Les deux ressources initialement visibles + restent une règle d'affichage, pas de suppression des ressources. + +**Preuves de code :** [chantier 04](../implementation/discover-stage-04-presentation-n1-2026-10-31.md), +`discovery/views.py`, `templates/discovery/_universal_card.html`, +`mobile/lib/presentation/possibilities/discovery_possibility_unit.dart`. +Les scénarios de test ont été ajoutés sans exécution de CI ni test +sur appareil ; cette certification relève du chantier 09. diff --git a/docs/architecture/discovery-sharing-mobile-handoff.md b/docs/architecture/discovery-sharing-mobile-handoff.md new file mode 100644 index 0000000000..08a8cf2077 --- /dev/null +++ b/docs/architecture/discovery-sharing-mobile-handoff.md @@ -0,0 +1,93 @@ +# Découvrir — Sharing mobile et liens de Possibilité + +Cette note décrit le raccordement **existant** entre `sharing.services`, l'API +`/api/v1/sharing/`, le menu N1 Flutter et la destination N2. Elle ne crée ni +domaine de production, ni fournisseur de partage tiers, ni secret. + +## Contrat fonctionnel + +- **Dans Makolo** : recherche limitée aux Profils actifs et recherchables, + authentification requise, envoi via `ShareDelivery`, notification existante, + contrôle du destinataire à l'ouverture. Une livraison n'est pas un Access, + une Permission ou une opération métier. +- **À l'extérieur** : lien créé par `ShareLink` et jeton opaque, transmis par + la feuille système `share_plus` (applications installées : Mail, WhatsApp, + Facebook, etc.), copie ou QR. Aucun SDK Facebook n'est nécessaire pour ce + scénario de partage de lien. +- **Ouverture** : `/s//` garde son landing Web lorsqu'il n'y a pas + d'application ; si Android vérifie le domaine et ouvre l'application, le + client appelle `GET /api/v1/sharing/resolve//` et navigue vers + `/discover/items//`. Les liens expirés/révoqués/non publics ne + donnent pas de destination. +- **Livraison interne** : `/shares//` exige une session du + destinataire ; Flutter utilise `GET /api/v1/sharing/deliveries//`, + puis ouvre N2. Un autre compte reçoit 403. +- Le serveur ne délègue pas ses décisions d'autorité au client. +- Les actions propriétaires (inscription, paiement, réservation) ne sont + jamais exécutées par Sharing. + +## Configuration Android à fournir avec les identités réelles + +Le projet contient déjà les variables de release suivantes : + +- Backend Django : `MAKOLO_PUBLIC_BASE_URL` (origine publique HTTPS). +- Flutter : `MAKOLO_APP_LINKS_ENABLED`, + `MAKOLO_APP_LINKS_SCHEME`, `MAKOLO_APP_LINKS_HOST`. +- Build Gradle : `MAKOLO_ANDROID_APP_LINK_SCHEME`, + `MAKOLO_ANDROID_APP_LINK_HOST`. +- Signature : `mobile/android/key.properties` local ou + `MAKOLO_ANDROID_KEYSTORE_*` existantes (jamais commitées). +- Identités Android effectives : + `com.makolo.dev`, `com.makolo.beta`, `com.makolo` selon la flavor. + +Le host du build, celui du runtime Flutter, l'origine de +`MAKOLO_PUBLIC_BASE_URL` et le domaine réellement contrôlé doivent +correspondre. Le serveur du domaine publiera une réponse HTTPS **non redirigée** +à `/.well-known/assetlinks.json` avec le package réel et l'empreinte SHA-256 +de la signature installée, puis vérifiera l'association avec les outils +Android. Aucun package/fingerprint n'est ajouté arbitrairement à ce dépôt. + +Pour connaître l'empreinte de la clé du build concerné, utiliser `keytool` +sur le keystore réel dans un environnement sécurisé ; ne jamais publier le +keystore, les mots de passe, les propriétés de signature ou une clé privée. + +Le manifest Android contient le filtre HTTPS pour `/s/` et `/shares/`. +Les builds DEV sans host confirmé utilisent intentionnellement un domaine +`.invalid` non vérifiable. Cela permet de compiler localement sans prétendre +que l'association de production est effective. + +**iOS** : le host iOS et ses entitlements ne sont pas fournis dans le dépôt +mobile courant. Les Universal Links exigent un App ID et un Team ID réels, +`Associated Domains` et le fichier +`/.well-known/apple-app-site-association`. Ne pas les remplacer par des +valeurs simulées. À intégrer seulement après disponibilité du host et de +l'identité iOS, avec validation sur appareil. + +Un navigateur embarqué dans une application tierce peut choisir d'afficher +la page Web même si Makolo est installé ; ce comportement relève de la +plateforme. L'expérience Web reste donc un fallback à part entière. + +## Vérifications + +```powershell +cd D:\Projects\makolo +git fetch origin --prune +git switch fix/discover-a-continuation-context-20261014 +git pull --ff-only +python manage.py check +python manage.py test sharing.test_discovery_share_api sharing.test_direct sharing.test_p5_hardening +cd mobile +flutter pub get +bash tool/dart_format.sh write +bash tool/dart_format.sh check +flutter analyze +flutter test test/incoming_intent_test.dart +flutter test test/discovery_mature_screen_test.dart +flutter test test/guest_router_test.dart +``` + +Sur deux comptes de test : rechercher le destinataire, envoyer, vérifier la +notification et l'ouverture avec le bon compte ; répéter avec un compte tiers +(403), un lien révoqué (404), un Guest et sans l'application installée. +Sur Android signé et domaine vérifié : ouvrir le lien HTTPS dans le navigateur +et via la feuille native depuis plusieurs applications installées. diff --git a/docs/architecture/mobile-a2-local-first-surfaces.md b/docs/architecture/mobile-a2-local-first-surfaces.md index aaaf34e31a..93ef1f8d00 100644 --- a/docs/architecture/mobile-a2-local-first-surfaces.md +++ b/docs/architecture/mobile-a2-local-first-surfaces.md @@ -366,3 +366,33 @@ logout/relogin autre Profile ~~~ Le résultat attendu est la continuité, pas un écran d'erreur global. + + +## 13. Découvrir — consolidation acquisition / présentation (#522, novembre 2026) + +**Runtime de la branche #522 ; les politiques métier restent celles des owners.** + +L'ouverture d'un niveau visuel n'est pas une nouvelle requête par principe : + +~~~text +GET discovery.items/?include_spatial_index=1 (premier lot, même contexte viewer) + → collection N1 paginée, ordonnée par le serveur + → spatial_index : candidats cartographiables du même champ, + composés depuis les lieux publiables et les faits Discovery détaillés + → ProfileStore local, même périmètre viewer + ├── N1 : liste et pagination signée + ├── Carte : ancres géographiques, sélection, caméra locale + ├── N2 : détail déjà acquis, matérialisé à la demande + └── N3 : aperçu read-only d'Activity/Occurrence si suffisant +~~~ + +- `include_spatial_index=1` est une option de lecture pour le premier lot mobile, pas un nouveau propriétaire ni un nouveau moteur de classement. Les clients qui ne la demandent pas gardent la pagination existante. Une recherche qui change ses critères renouvelle le champ ; une nouvelle page conserve le curseur signé serveur. +- Le serveur fournit le **maximum de faits utiles et autorisés pour cette exploration**. La projection géographique publique est composée avec `project_rows` pour enrichir le N2 ; elle n'infère ni Place, ni prix, ni capacité, ni Permission. Ce mode enrichi peut être volumineux : surveiller le temps de composition et la taille du payload avant toute généralisation à un corpus supérieur. +- Le client ne copie pas le catalogue en modèles métier. `DiscoveryRepository` retrouve l'item dans les snapshots déjà possédés et matérialise uniquement le N2 consulté, en conservant la date/fraîcheur du lot d'origine. Une donnée propriétaire plus récente (dont un Saved confirmé) ne peut être écrasée par un ancien lot. +- Une vue Carte ouverte après un N1 récent se nourrit du même champ. Les marqueurs et regroupements restent des représentations du client ; aucune décision de pertinence ou de disponibilité n'est recalculée. +- Les N3 Activity/Occurrence peuvent réutiliser des faits ou aperçus acquis. Si la vue profonde exige plus d'informations, une lecture propriétaire supplémentaire reste légitime. +- Les actions `Saved`, `Sharing`, Journey, Commerce, Payment, Access, Capacity et Jour J ne sont **jamais** confirmées par le cache : l'appel actionnel, l'autorisation serveur et la réponse d'owner restent nécessaires. Le Jour J demande une vérification fraîche de l'Occurrence. +- Source révoquée (403/404), identité viewer non concordante, snapshot expiré ou fil incompatible : ne jamais ressusciter le fait depuis une autre vue. Afficher les informations locales encore valides selon leur politique de divulgation, sans permettre une action sensible sur un état périmé. +- Le retour N3 → N2 → Carte conserve la sélection et la caméra locales, sans rafraîchir tout le champ. Les changements effectifs d'autorité sont réconciliés depuis la réponse du propriétaire. + +Critère de sortie : **zéro GET de détail redondant pour une possibilité déjà acquise et suffisamment fraîche**, une requête lorsque la donnée manque ou doit être revalidée, et aucune mutation/autorité créée par Flutter. diff --git a/docs/architecture/mobile-a2-personal-interoperability.md b/docs/architecture/mobile-a2-personal-interoperability.md index 52c5b7ea8b..c6456659cb 100644 --- a/docs/architecture/mobile-a2-personal-interoperability.md +++ b/docs/architecture/mobile-a2-personal-interoperability.md @@ -246,3 +246,35 @@ Après merge : - vérifier sur appareil réel : cold start, splash unique, signup/availability, réseau lent/absent, navigation authentifiée, Mark central, Avatar → Connexions. La fermeture a ensuite été réconciliée avec `main@f3879e04` après l’intégration de Web Search #360 ; ce changement n’ajoute aucune responsabilité mobile et sert uniquement à garantir les gates sur le `main` réellement courant. + + +## 13. Réconciliation #522 — 31 octobre 2026 + +**Constat vérifié dans le runtime de #522 :** la vue Flutter consomme `GET /api/v1/me/interoperability/` et retient une projection `personal.interoperability` par Profil. Le serveur sélectionne les `ProviderConnection` Intelligence personnelles via `profile__user`, sans divulguer les credentials, les routes internes ni les configurations techniques. `z16.v1` expose des faits en lecture seule, pas une opération générale de création ou d'autorisation d'un service externe. + +La distinction reste importante : + +- **M7** : primitives de Connections, Actions, Providers et Extensions ; +- **Z16** : projection de ce que le runtime a effectivement configuré pour ce Profil ; +- **Flutter** : présentation et synchronisation de ces faits, sans devenir propriétaire des autorisations ; +- **services réellement connectables** : nécessitent un parcours métier propriétaire, un provider concret configuré et une mutation autorisée explicitement côté serveur. + +Une liste vide de Z16 ne prouve pas une panne Flutter ; elle peut être la représentation exacte d'un Profil sans Connection personnelle et sans catalogue M7 runtime installé. + +### Fermeture mobile ciblée portée par #522 + +L'écran Connexions dispose d'une actualisation volontaire (bouton et geste de rafraîchissement), via le `SyncEngine` existant. En cas d'échec, le snapshot local reste visible avec un message de fraîcheur ; lorsque aucun snapshot n'existe et que l'acquisition échoue, un état d'erreur explicite remplace le chargement illimité. Le rendu des capacités distingue la disponibilité déclarée de toute possibilité d'exécution directe : il n'existe toujours pas de CTA fictif. + +### Ce qui reste nécessaire pour une connexion personnelle utilisable de bout en bout + +Avant d'ajouter dans Flutter « Connecter », « Autoriser », « Désactiver » ou « Exécuter », le domaine propriétaire du service doit livrer un contrat consommable réel, incluant : + +1. un service concret, son identité publique et sa finalité pour l'utilisateur ; +2. un flux d'autorisation ou de configuration valide pour le Profil (consentement, scopes, révocation) ; +3. une opération serveur autorisée, idempotente selon le besoin, et sa représentation de résultat ; +4. des liens/actions allowlistés dans un contrat public dont Flutter peut suivre la destination, sans déduire d'URL ou de permission ; +5. les tests d'isolation entre Profils, de révocation, d'échec/récupération, de confidentialité et de compatibilité local-first. + +Les credentials restent exclusivement dans leur coffre propriétaire ; aucun secret ne rejoint le `ProfileStore` ni la projection. Les Connections Space et Platform conservent leur autorité spécifique. **Ne pas qualifier la connexion libre-service de livrée tant que ce contrat serveur n'existe pas réellement.** + +La validation Android et les gates Flutter/CI de #522 restent obligatoires avant toute fusion. diff --git a/docs/architecture/mobile-geographic-search-foundation.md b/docs/architecture/mobile-geographic-search-foundation.md new file mode 100644 index 0000000000..f9f925c149 --- /dev/null +++ b/docs/architecture/mobile-geographic-search-foundation.md @@ -0,0 +1,101 @@ +# Makolo Mobile — recherche géographique (fondation) + +## Problème utilisateur + +Une personne doit pouvoir retrouver rapidement une ville, une adresse ou un lieu réel, +y compris lorsque Makolo n'a encore aucune Activity, Occurrence ou Space dans ce lieu. + +La suggestion UX issue d'un fournisseur n'est **pas** un `Place` ou une `Zone` +canonique. La sélection ne crée aucune ligne métier. La carte (MapLibre) +n'est pas un fournisseur de géocodage. + +## État livré : fondation seulement + +- `GeoSearchGateway` fournit `suggest(query, language, limit, cancelToken)`. +- `GeoSuggestion` conserve l'identifiant externe, le libellé, les coordonnées, + le type et la provenance. Aucune donnée de Profile ni position GPS implicite. +- `MapTilerGeoSearchGateway` est un adaptateur candidat, injectable, + activé uniquement si le runtime possède une configuration explicite. +- Réponses malformées / requêtes réseau refusées / annulations ont des contrats distincts. +- Une saisie non vide peut déclencher une suggestion ; le composant partagé + utilise un debounce de 350 ms, limite à cinq résultats, annule les appels + obsolètes et ignore les réponses tardives. + +Aucun compte, clé, quota, prix spécifique au compte ni droit commercial +de géocodage Makolo n'a été vérifié. L'existence d'un style MapTiler +DEV ne prouve pas que la clé autorise l'API Geocoding. +Ne pas extraire automatiquement de clé du style cartographique. +L'activation future exige une configuration client approuvée, +avec clés restreintes et plafond de consommation connu. + +## Choix du fournisseur à valider + +**Candidat : MapTiler Cloud Geocoding.** Il est compatible avec la +cartographie existante, expose les requêtes autocomplete, limites, types, +langues et recherche mondiale. MapTiler Cloud facture suivant le plan, +les requêtes/sessions et leurs quotas ; le plan Free est réservé aux +usages non commerciaux et au développement autorisé. +Vérifier directement le compte, le plan, les plafonds et les domaines ou +packages mobiles autorisés avant activation. Le modèle de facturation par +session décrit pour les SDK ne doit pas être supposé disponible pour +le simple adaptateur HTTP Flutter. + +**Écarté : Nominatim public d'OpenStreetMap** pour le typeahead : +sa politique interdit expressément l'autocomplétion client. +Un service OSM tiers autorisé serait un fournisseur distinct à évaluer. + +**Alternative : GeoNames**, avec attribution CC-BY, limites publiées, +qualité et couverture des adresses à éprouver ; pas retenu comme provider +principal sans tests qualitatifs sur les régions desservies. + +Références : +- https://docs.maptiler.com/cloud/api/geocoding/ +- https://www.maptiler.com/terms/cloud/ +- https://docs.maptiler.com/guides/account/sessions-vs-requests/ +- https://operations.osmfoundation.org/policies/nominatim/ +- https://www.geonames.org/export/ + +## Conditions d'activation et d'intégration + +1. Vérifier la licence et le quota effectifs avant une utilisation commerciale. +2. Utiliser les valeurs explicites `MAKOLO_GEOCODING_ENABLED` et + `MAKOLO_GEOCODING_API_KEY`, indépendantes de `MAKOLO_MAP_STYLE`. +3. Injecter `GeoSearchGateway` dans le composant `GeoAutocompleteField`, + sans lier le provider aux vues métier. +4. Le widget gère 350 ms de debounce, suppression des résultats obsolètes, + annulation par `CancelToken`, résultats bornés, erreurs et saisie libre. +5. Ne solliciter le GPS que pour une action utilisateur distincte. +6. Limiter les caches aux données temporaires autorisées par la licence. +7. Tester avant l'activation la recherche au Congo/RDC et ailleurs, + les lieux homonymes, les accents, la connectivité lente et les quotas. + +Le lot 1 ne modifie ni backend, ni migrations, ni écran Discover, +ni tarifs, ni contrat métier Geography. + +## Configuration locale (les clés ne sont pas dans le code) + +Pour le développement, copier `mobile/config/dev.json` vers +`mobile/config/dev.local.json` (ignoré par Git) puis y ajouter : + +```json +"MAKOLO_GEOCODING_ENABLED": "true", +"MAKOLO_GEOCODING_API_KEY": "VOTRE_CLE_CLIENTE_GEOCODING" +``` + +Conserver les autres paramètres du JSON. Démarrer depuis `mobile/` : + +```bash +flutter run --flavor dev --dart-define-from-file=config/dev.local.json +``` + +Ces clés restent remplaçables par environnement. Le plan MapTiler Free +ou payant ne change pas le code Flutter. Une clé incluse dans l'APK est +visible par le client : la restreindre et maîtriser les quotas ; ne jamais +utiliser une clé privée serveur. Les clés diffusées dans un chat ou des logs +sont à régénérer avant une diffusion au public. + +La suggestion externe ne modifie jamais `Geography.Place`. Dans le filtre +Discovery actuel, la ville sélectionnée fournit une valeur textuelle compatible +avec le paramètre `place` côté serveur ; les coordonnées externes restent +attachées à la suggestion UX, sans forcer une recherche nearby. Le filtre +`date` exact existant est alimenté par le sélecteur calendrier Flutter. diff --git a/docs/architecture/owner-facts-native-actions.md b/docs/architecture/owner-facts-native-actions.md new file mode 100644 index 0000000000..e510140955 --- /dev/null +++ b/docs/architecture/owner-facts-native-actions.md @@ -0,0 +1,189 @@ +# Contrats propriétaires — prix, relations, actions et connaissance + +> Chantier serveur transverse, **sans nouveau domaine métier**. Base initiale : +> `main@d0f0ec421c94c255fa4c0acd3509938a70c6322f`. +> Les tests, migrations, selectors et API en cours priment sur ce document. +> Ce texte décrivait initialement le sous-chantier des owners ; sa formule de +> périmètre « ne modifie pas Discovery ni Flutter » est **historique** et ne +> décrit pas à elle seule toute la PR #522 après consolidation. +> Relevé actuel : [chantier 01 / propriétaires](../implementation/discover-stage-01-owner-facts-2026-10-31.md). +> Les tests ciblés de ce relevé n'ont pas encore été exécutés. + +## Problème et frontière + +La collection Découvrir n'a pas à fabriquer un prix, déduire des relations, +transformer un lien Web en mutation native, ou déduire l'actualité d'un fait +de la date de génération d'une réponse. + +```text +owner métier → selector / read model propriétaire → composition autorisée → client +``` + +Chaque consommateur décide **quand demander** un calcul plus coûteux ou une +mutation. Les propriétaires gardent l'autorité sur la vérité, les permissions, +les contraintes, la temporalité et le cycle de vie. + +## Commerce — faits de prix + +`commerce.offer_price_facts.offer_price_facts(activity=…)` ne regarde que +les Offers génériques réellement applicables à l'Activity. +`offer_price_facts(occurrence=…)` inclut les Offers applicables à cette +Occurrence et les Offers Activity réellement partagées. + +Le contrat `OfferPriceFacts.as_dict()` distingue : + +- `unknown` : aucune Offer active dans la fenêtre ou plusieurs devises non + convertibles sans taux ; +- `free` : Offer(s) explicitement à zéro ; +- `exact` : un même montant unitaire connu ; +- `range` : plusieurs montants unitaires connus dans une devise ; +- `minimum`, `maximum`, `currency`, `offer_count` et `reason` qualifient + exactement l'échantillon propriétaire utilisé. + +`occurrence_variant_price_facts(occurrences=[…])` calcule **à la demande**, +pour 2 à 12 Occurrences distinctes de la même Activity, les termes par +Occurrence et `variant_dependent` seulement lorsque leurs prix constatés +diffèrent. Le consommateur doit avoir préalablement résolu la visibilité +des Occurrences et l'éligibilité du viewer ; ce selector n'est pas exposé +directement par ID dans une API publique. + +Ces montants sont des **prix unitaires d'Offers**, et ne constituent jamais +un total client payé : taxes, frais, discounts, choix de paiement, Capacity +et Quote finale restent les responsabilités des services Commerce/Payment. + +`owner_updated_at` signifie modification de ligne propriétaire, pas +vérification du prix réel. `earliest_offer_end_at` est une borne connue de +la fenêtre d'une Offer, pas la validité garantie de toute la collection. +`fact_freshness` reste `not_asserted`. + +## Opportunities — connaissance et fraîcheur + +`opportunities.knowledge.public_source_knowledge(opportunity_id)` ne répond +que pour les Opportunities publiées avec une révision publiée. + +Le résultat borne les observations des sources **actives**, expose les +`last_checked_at`/`verified_at` réellement connus et la révision +observée, mais aucune note privée, empreinte de suivi ou identité de +curateur. Les autres statuts, révisions et secrets ne sont pas rendus publics. +Un contrôle réussi de la source **ne certifie pas** les délais, montants, +conditions ou capacités de l'Opportunity : `fact_freshness=not_asserted`. +Le drapeau `source_observations_truncated` évite de feindre +l'exhaustivité lorsque plus de huit sources existent. + +Les contrôles d'autres propriétaires (Commerce, Capacity, Occurrence, +Funding, etc.) devront garder la même distinction quand ils disposent +d'observations propres. + +## Services — relation canonique et divulgation + +`services.selectors.service_opportunity_relation_for_viewer(journey_id, viewer)` +lit le lien déjà persisté dans `ServiceJourneyContext` : la Journey et +l'Opportunity sélectionnée avec la révision épinglée. Il passe +obligatoirement par `service_journeys_visible_to(viewer)`. + +Cette relation est **privée au parcours autorisé** ; elle ne devient +jamais automatiquement une complémentarité publique entre deux +Possibilities Discovery, ni une permission sur l'Opportunity externe. +En absence de lien, ou pour un viewer non autorisé, le résultat est `None`. + +## Services — vrai point de démarrage natif + +`GET /api/v1/services//start/` fournit le contrat +de préparation : références du Service, politique Opportunity, +capability `start`, contraintes et lien de mutation. + +`POST` vers le même endpoint, authentifié et avec en-tête +`Idempotency-Key` (16 à 128 caractères autorisés), accepte seulement +`objective` (maximum 1000 caractères) et `opportunity` lorsqu'elle est +admise par la politique Services. + +Le serveur : + +1. réutilise les règles de publication des Services et la Group Eligibility + du Profile connecté ; ne lit aucun `profile_id` ou bénéficiaire arbitraire ; +2. utilise `ServiceStartForm` pour les contraintes de la verticale ; +3. appelle `create_service_journey`, qui demeure l'unique propriétaire de + la mutation, de sa Journey, du template publié et de la révision liée ; +4. stocke un SHA-256 de la combinaison actor, Service et clé d'opération dans + le `ServiceJourneyContext` existant ; +5. rend `201` pour la création, `200` pour un rejeu strictement identique, + ou `409 idempotency_conflict` pour une même clé avec un autre payload ; +6. retourne une référence API vers la Journey créée, jamais une page HTML. + +Un rejeu strictement identique renvoie l'ancienne Journey même si +l'Opportunity épinglée a changé de publication depuis la création. Une +perte d'autorisation de voir le Service, elle, continue d'interdire l'accès. + +**Migration justifiée :** +`services.0005_service_start_operation_fingerprint` ajoute un champ unique +nullable au modèle existant. Les Journeys Web et historiques gardent +`NULL` et ne sont pas backfillées artificiellement. Aucun nouveau modèle +ni nouvelle table de projections n'est introduit. + +## Intégration future (pas dans cette branche) + +Discovery pourra composer les résultats **lorsqu'ils sont admissibles** : + +- prix exact/range par owner, sans transformer l'absence en gratuité ; +- relation de contexte seulement sous autorisation, jamais déduite du texte ; +- Service handoff API uniquement si l'utilisateur et la politique permettent + réellement l'action ; +- observations de source et état de connaissance sans garantie d'actualité. + +La comparaison libre de deux objets n'a **pas** de route générique : +le domaine doit définir ce qui est comparé (prix unitaires, total final, +readiness, échéances, distance, etc.) et les permissions de chacun. +La lecture des variantes Commerce est disponible au niveau owner avant +qu'un endpoint autorisé soit justifié. + +## Tests et gates + +Tests ciblés à exécuter : + +```bash +DJANGO_ENV=test python manage.py check +DJANGO_ENV=test python manage.py makemigrations --check --dry-run +DJANGO_ENV=test python manage.py test \ + commerce.test_offer_price_facts \ + opportunities.test_public_source_knowledge \ + services.test_owner_relations \ + services.test_native_start_api +``` + +Puis exécuter les tests pertinents de Commerce, Opportunities et Services, +ainsi que PostgreSQL pour l'unicité et les rejeux concurrents. Ne pas +fusionner si la CI est rouge. Les scénarios Guest, autre Profile, Group gate, +conflit d'idempotence et compatibilité Web sont des critères de sortie. + + +## Mise à jour : publication du porteur et interdiction des nouveaux engagements (31 octobre 2026) + +La publication de l'Activity ne suffit plus à exposer un Funding ou un +Service porté par un Espace qui a retiré son profil public/recherchable, +qui est archivé ou suspendu. Le selector propriétaire doit contrôler +`Organization.public_profile`, `searchable`, `lifecycle=active` +et `verification_status != suspended`. Une Activity personnelle +avec un propriétaire `User.is_active=False` ou un choix explicite de +`UserProfile.public_profile=False` / `searchable=False` n'est pas une offre +publique. En l'absence de ligne UserProfile héritée, le statut public explicite +de l'Activity reste compatible et ne crée pas une permission nouvelle. + +- **Funding** : `public_fundings` applique ces conditions à la découverte et + à la lecture publique. `funding_accepts_contributions` interdit également + la **création d'une nouvelle contribution et de son PaymentObligation** + lorsqu'un porteur rétracte sa publication ou est suspendu. L'API de récupération + d'une contribution déjà créée demeure distincte de cette décision ; + aucune annulation rétroactive de paiement n'est inférée. +- **Services** : `services.views.public_services` est le selector commun + du catalogue Web et du démarrage natif. `services.api_views` réutilise + ce selector avant le contrôle d'éligibilité de Groupe, sans déléguer de + Permission à Discovery. Les Journeys existantes ne sont pas effacées. +- **Consommateur Discovery** : `discovery/unified.py` contient encore un + filtre local Service historiquement plus large ; sa réconciliation est une + obligation explicite de l'étape 02, non une raison de démultiplier le selector. + +Six tests de non-régression ont été ajoutés dans +`funding/test_z15_api.py` et `services/test_native_start_api.py` : +retrait du profil public, fin de recherchabilité, archive, suspension, +inactivation de propriétaire personnel, confidentialité explicite +UserProfile, absence de nouvelle contribution ou Journey. **Écriture vérifiée sur la branche, exécution non attestée.** diff --git a/docs/architecture/p2-p5-presentation-media-n1-handoff.md b/docs/architecture/p2-p5-presentation-media-n1-handoff.md new file mode 100644 index 0000000000..1d253e3a5c --- /dev/null +++ b/docs/architecture/p2-p5-presentation-media-n1-handoff.md @@ -0,0 +1,83 @@ +# Makolo — P2–P5 : médias de représentation N1 (branche #522) + +## Principe + +Les données persistées et leurs propriétaires font autorité. Un seed ne fabrique pas une image dans l'API. Une possibilité sans média est valide. Le fichier (`PresentationAsset`), l'association (`PresentationMediaUse`), la publication, la ressource de préparation (`ActivityResource`) et le rendu MPS (`ActivityPresentation`) restent des responsabilités distinctes. + +## P2 : usage propriétaire + +- `PresentationMediaUse` est une relation Activity/Occurrence vers PresentationAsset avec purpose, ordre, état et texte alternatif. +- Services `associate_presentation_media`, `publish_presentation_media`, `retire_presentation_media`, `replace_presentation_media`, `reorder_presentation_media`. +- `ACTIVITY_MANAGE` ne vaut pas permission implicite sur un asset tiers. +- Le Studio de l'Activity dispose d'un formulaire séparé pour importer et publier une image JPEG/PNG de reconnaissance, sans Template, et la retirer. +- L'Event `cover_image` existant est conservé comme source métier spécialisée. + +## P3 : projection et livraison + +- `public_presentation_media_for_activities` retourne des usages publiés sur les Activities publiées et publiques, sans URL directe brute. +- `project_rows` compose une collection bornée pour la page admise, les restrictions de Groupe et la portée Occurrence, puis projette `media_bindings[]` (purpose RECOGNIZE) et `representation.image_url` depuis la même relation. +- La couverture de spécialité est conservée comme repli ; les Resources ne sont jamais promues automatiquement en couvertures. +- `presentations:published-media-file` retourne le contenu par `FileResponse` après vérification au moment de l'accès : état publié, propriétaire du sujet public/actif, Groupe admissible, ownership et disponibilité physique. Réponse non mise en cache, 404 sinon. +- L'API et le détail empruntent le même `project_rows`. + +## P4 : N1 mobile + +- `DiscoveryPossibilityUnit` consomme déjà `representation.image_url` et `media_bindings` ; `baseUri` permet de résoudre les chemins relatifs sous l'origine API. +- La profondeur N2 résout désormais elle aussi l'URL relative sans créer un deuxième inventaire de médias. +- Les médias manquants n'ont pas d'image de remplissage synthétique. +- Pas de nouvelle capacité de galerie/vidéo/audio dans ce lot ; N1 est le premier périmètre. + +## P5 : validation et déploiement de test + +Tests ciblés : `python manage.py test presentations.test_media_use`, `python manage.py check`, `python manage.py makemigrations --check --dry-run`, puis les tests Discovery pertinents. Sur Flutter : `flutter test test/discovery_mature_screen_test.dart test/guest_router_test.dart`. + +Cas manuels sur données réellement stockées : +1. aucune image : possibilité N1 lisible ; +2. Event avec couverture : lien média chargeable depuis l'API de test ; +3. Activity personnelle et Activity Espace : image importée au Studio avec autorité ; +4. publication explicite : binding RECOGNIZE et image_url cohérents sur collection/détail ; +5. retrait : URL de consultation du media-use retourne 404 et la projection ne le renvoie plus ; +6. propriété étrangère, Groupe privé, Activity privée, propriétaire non public : aucune divulgation ; +7. fichier absent dans le stockage : 404, pas d'erreur 500 ; +8. réponse API et N1 mobile sur la même base/test. + +Un APK DEV généré par GitHub Actions référence l'API HTTPS prévue par `mobile/config/dev.json`, actuellement une URL PythonAnywhere de test. **L'APK ne déploie pas le backend Django** : la branche #522 et la migration doivent être appliquées séparément à cet environnement de test avant de valider les nouveaux médias. Pas de déploiement de production ni de merge implicite. + +## Limite de confidentialité héritée à traiter + +`PresentationAsset.file` demeure actuellement un FileField utilisant le stockage MEDIA par défaut. L'ancien Studio expose déjà `asset.file.url` dans `hero_image`. Un URL brut `/media/presentations/assets/...` servi publiquement par l'hébergeur peut contourner la révocation de l'URL `published-media-file`. **Ce chantier n'efface pas cette dette historique** : sécuriser ou migrer le stockage des assets sans casser les données existantes doit faire l'objet d'une validation de migration physique et du routage serveur, avant exposition de médias sensibles. Ne jamais placer d'actif confidentiel dans ce stockage public. + +## État et gates + +Implémentation sur #522 non fusionnée. Les contrôles CI et les tests pertinents restent obligatoires avant merge ; la demande utilisateur de reporter leur fermeture à après P5 ne transforme pas une CI rouge en validation. Aucun nouveau seed ni backfill artificiel. + + +## Ajout P2 : stockage protégé des nouveaux médias N1 + +La migration additive `presentations.0006_private_presentation_assets` conserve `PresentationAsset.file` pour les anciens rendus MPS et ajoute `PresentationAsset.private_file`, stocké hors du `MEDIA_ROOT` public via `PrivatePresentationStorage`. Le formulaire d'import N1 écrit seulement dans ce champ protégé. La publication et le téléchargement autorisé privilégient `private_file` et conservent une lecture de compatibilité des anciens assets `file`. Les anciens liens `editorial_data.hero_image` ne sont pas automatiquement migrés ; leur confidentialité reste à vérifier selon le routage serveur existant. + + +## Addendum — clôture du chantier 03 (31 octobre 2026) + +Le relevé précédent décrivait des états historiques de #522. Depuis le +[chantier 03](../implementation/discover-stage-03-media-resources-2026-10-31.md) : + +- `public_presentation_media_for_activities` et `published_media_file` + appliquent la visibilité logique du Space/Profile, l'éligibilité Groupe + du viewer et l'état de l'Occurrence à l'instant de l'accès ; +- les nouveaux imports N1 et les nouveaux uploads `hero_image` du Studio + MPS sont protégés (`private_file`) : les seconds passent par + `presentations:editorial-media-file`, sous contrat de publication de + `ActivityPresentation` ou autorité de lecture/preview ; +- les liens bruts déjà enregistrés dans les anciens `editorial_data` + ne sont **pas** convertis automatiquement. La commande + `harden_presentation_media_assets` traite les assets liés à des + media-uses, avec audit/copie privée et purge optionnelle seulement si + les références éditoriales connues le permettent ; +- la rétention automatique Flutter exige désormais l'origine API de + confiance, et les refus explicites 403/404 purgent les images retenues + après les écritures en cours. + +La migration des octets historiques, l'inspection des règles nginx/CDN +et les tests en exécution demeurent à réaliser sur le déploiement. Les +versions de production ne sont pas modifiées par ces commits. diff --git a/docs/architecture/p2-presentation-media-use-contract.md b/docs/architecture/p2-presentation-media-use-contract.md new file mode 100644 index 0000000000..1d21010983 --- /dev/null +++ b/docs/architecture/p2-presentation-media-use-contract.md @@ -0,0 +1,55 @@ +# P2 — Association explicite des médias de présentation + +Base initiale : `main` `d0f0ec421c94c255fa4c0acd3509938a70c6322f`. + +## Problème + +Un `PresentationAsset` possède les octets et la provenance ; `ActivityPresentation` configure un rendu MPS et non la simple représentation d'une possibilité. `Event.cover_image` reste une source spécialisée. `ActivityResource` reste une ressource de préparation. Un `editorial_data.hero_image` historique est une URL, pas une relation propriétaire. Aucun de ces modèles ne représente à lui seul le sujet, l'usage, l'ordre et la publication d'un fichier graphique. + +## Décision + +`PresentationMediaUse` est une relation additive du domaine `presentations` vers `PresentationAsset`, `Activity` et éventuellement `Occurrence`. + +- Un media-use est créé en brouillon ; il ne devient public que via le service `publish_presentation_media`. +- `purpose` : `recognize`, `understand`, `establish`, `prepare`, `act` (sémantique différente des purposes MPS `public_page`, `invitation`…). +- Le propriétaire de l'asset doit être le propriétaire logique de l'Activity : Profil personnel ou Espace. Aucun accès implicite croisé. +- Un usage Occurrence reste limité à cette Occurrence. Une association Activity est commune à l'Activity. +- `position` assure un ordre déterministe. `alt_text` est obligatoire. +- L'usage retiré reste historique et permet un nouvel usage explicite du même asset. Aucun backfill. +- JPEG/PNG uniquement, conformément au service d'upload Presentation existant. Pas de média universel vidéo/audio. +- `Event.cover_image`, `ActivityResource`, `ActivityPresentation` et `hero_image` préexistants ne sont pas modifiés. + +## Opérations et frontières + +`associate_presentation_media`, `publish_presentation_media`, `retire_presentation_media`, `reorder_presentation_media`, `replace_presentation_media` vérifient `ACTIVITY_MANAGE` côté serveur. L’association et la publication revalident aussi le droit d’utiliser l’asset : auteur de l’upload, Profil propriétaire ou `SPACE_MANAGE` du bon Espace. Un mandataire Activity seul ne peut pas divulguer un asset Espace importé par un tiers. Le modèle revalide l'ownership et le lien Occurrence/Activity. La publication vérifie les octets au stockage à cet instant, mais ne crée aucun statut `available` en BDD. + +`public_presentation_media_for_activities(activities, occurrences=(), purpose=...)` retourne les références de média publiées sous forme de DTOs, **sans URL de fichier**. Elle ne réexécute pas le moteur d'admission Discovery ni les règles Group/Privacy d'une possibilité ; l'appelant doit fournir des Activities déjà admissibles. Elle applique néanmoins un garde minimum : Activity `public` et `published`, usage `published`, occurrence explicitement fournie. Une seule requête groupée, `select_related("asset")`. + +## Handoff P3 + +P3 devra : +1. invoquer ce selector seulement après l'admission canonique des possibilités ; +2. donner à chaque binding un `target` et une finalité, sans émettre une URL privée/brute issue du stockage ; +3. définir et sécuriser le mécanisme HTTP de consultation des assets publics (contrôle d'autorité, retrait, cache, traitement des erreurs et disponibilité du fichier) ; +4. faire dériver `representation.image_url` du même ensemble publié lorsque ce transport est sécurisé ; +5. préserver les sources existantes `Event.cover_image` et les Resources, sans promouvoir automatiquement les Resources en couvertures ; +6. préserver l'absence légitime de média. + +## Limites connues + +Le stockage `PresentationAsset.file` historique et `editorial_data.hero_image` sont déjà publics via leurs URL lorsque le déploiement dessert ce stockage : P2 n'introduit aucun nouveau endpoint et ne prétend pas garantir la confidentialité de ces anciennes URL. Leur remplacement exige une analyse de compatibilité et un contrat de transport séparé avant P3. + +## Vérifications attendues + +`python manage.py test presentations.test_media_use presentations.tests presentations.test_library_hardening` + +`python manage.py check` + +`python manage.py makemigrations --check --dry-run` + +Migration additive `presentations.0005_presentationmediause` à valider sur PostgreSQL en CI. Aucune modification de données de démonstration, API Discovery ou Flutter. + + +## Ajout P2 : stockage protégé des nouveaux médias N1 + +La migration additive `presentations.0006_private_presentation_assets` conserve `PresentationAsset.file` pour les anciens rendus MPS et ajoute `PresentationAsset.private_file`, stocké hors du `MEDIA_ROOT` public via `PrivatePresentationStorage`. Le formulaire d'import N1 écrit seulement dans ce champ protégé. La publication et le téléchargement autorisé privilégient `private_file` et conservent une lecture de compatibilité des anciens assets `file`. Les anciens liens `editorial_data.hero_image` ne sont pas automatiquement migrés ; leur confidentialité reste à vérifier selon le routage serveur existant. diff --git a/docs/architecture/profile-passport-stage3-content-closure.md b/docs/architecture/profile-passport-stage3-content-closure.md new file mode 100644 index 0000000000..c8b0ff7d98 --- /dev/null +++ b/docs/architecture/profile-passport-stage3-content-closure.md @@ -0,0 +1,147 @@ +# Passeport Makolo personnel — fermeture de contenu (étape 3) + +**Portée :** Projection du Profil personnel, Web / API / Flutter / PDF. +**Statut :** contrat de réalisation proposé sur une branche empilée sur la PR #522 ; **non fusionné dans main**. +**Référence :** `docs/ux/Makolo_UX_Moi_Identite_Capital_Personnel_Contrat_Consolide_2026-10-08.md`, `docs/architecture/makolo-domain-blueprint.md`, `docs/architecture/profile-relevance-action-network.md`, `docs/architecture/profile-passport-visibility.md`, `docs/experience/passport-pdf-editorial-contract.md`. + +## 1. Fonction et frontière + +Le Passeport représente les faits existants qui facilitent une action réelle. Ce n'est **ni** une identité civile certifiée, **ni** un CV universel, **ni** un réseau social, **ni** un lieu où saisir ou stocker une deuxième vérité métier. + +```text +Faits des propriétaires canoniques + + admission métier + + visibilité effectivement autorisée + + sélection éventuelle + = projection Passeport du lecteur +``` + +Aucune sélection ne transforme un droit de consultation en Mandate, Permission, Access ou Payment. Une information absente ne doit jamais être inventée pour remplir la mise en page. + +## 2. Contenu du Passeport personnel + +| Rubrique UX | Propriétaire | Critère d'admission | +|---|---|---| +| Identité | Accounts / PublicIdentifier | identifiant stable ; nom et portrait seulement si divulgués | +| Présentation | Accounts / UserProfile | bio, profession, entreprise et organisation *renseignées* ; pas d'affiliation attestée par défaut | +| Localisation générale | UserProfile | ville / pays séparément autorisés ; pas d'adresse ni de coordonnées fines | +| Liens | Accounts | liens renseignés et divulgués | +| Intérêts | Topics / ProfileInterest | déclaration explicite, Topic actif, visibilité de l'élément | +| Ouvert à | Topics / ProfileOpenTo | déclaration explicite **active**, Topic lié s'il existe ; indépendant des Interests | +| Activités portées | Activity | propriétaire Profile ; états `PUBLISHED`, `COMPLETED`, `ARCHIVED` ; jamais brouillon | +| Faits établis | Trust / Proof | preuve `ACTIVE`, appartenance au Profil, protection de l'Activity source | +| Attestations reçues | Trust / Credential | détenteur Profil, statut réel, contexte source autorisé ; une révocation n'est jamais présentée comme valide | +| Équipes, Groupes | Membership et propriétaires correspondants | relation actuelle et Groupe actif (`status=ACTIVE`), seulement vue `complete` du titulaire ; Groupe archivé = historique chez son propriétaire | +| Espaces accessibles | Organizations / permissions existantes | accès effectif ; ne signifie pas Membership | +| Mandats actifs | Authorization / Mandate | autorité actuelle dans une portée explicite ; ne signifie pas TeamMembership | +| Coordonnées personnelles | Accounts | seulement `complete` propriétaire, jamais autres variantes par héritage | + +Le Passeport Complet est exhaustif **dans ce périmètre**, pas un export de tout le compte. Veilles, favoris, Journeys en cours, documents privés, secrets, AccessCredentials, détails de paiement et autres données opérationnelles ne sont pas importés implicitement. + +## 3. Quatre variantes, une seule projection source + +- **Complet :** titulaire authentifié, identité et capital représentable, plus relations/coordonnées owner-only. Les informations privées restent régies par leur domaine. +- **Public :** uniquement les faits divulguables selon les règles d'admission et de publication ; `public_profile` n'implique pas `searchable` et inversement. +- **Thématique :** sélection explicite de Topics pertinents parmi les Interests, Open to actifs, Activities, Proofs actives et Credentials réellement représentables. La projection thématique reste privée tant qu'un partage autorisé distinct n'a pas été établi. Elle ne fabrique aucune nouvelle compétence. +- **Personnalisé :** sélection bornée de sections et d'éléments appartenant au titulaire ; refus des IDs étrangers, aucun fait personnel privé présélectionné. L'identifiant minimal subsiste. + +Le choix d'un Topic ne signifie pas « je possède une compétence ». Un `Open to` n'est pas un `Interest`, et doit néanmoins proposer son thème dans le sélecteur, même lorsqu'il est le seul fait disponible sur ce thème. **Web et Flutter affichent le libellé de l'ouverture ET son Topic facultatif**, pour distinguer « Mentorat · Géométrie » de « Mentorat · Recherche ». + +## 4. Langage et provenance + +- Faits déclarés : libellés naturels « À propos », « Profession », « Intérêts », « Ouvert à » ; pas de badge « Déclaré » répété. +- Activity publiée : « activité publiée », pas « participation réussie ». +- Proof active : « fait établi », avec contexte et date lorsqu'ils existent. +- Credential délivré : « attestation », avec émetteur et statut. +- Team / Group : appartenance, jamais autorité implicite. Mandate : autorité située, jamais qualification globale. +- QR documentaire : atteste l'émission d'une version, pas la vérité de toutes les déclarations. + +## 5. Comportement Web / Flutter / PDF + +L'API `personal.me.passport`, les vues Web et le PDF utilisent les mêmes faits canoniques. Flutter ne doit jamais inventer des champs absents du serveur. Les variantes `thematic` et `custom` doivent préserver la sélection lors du retour et ne pas afficher un contenu d'une autre variante depuis un cache. Les changements d'audience demandent une réévaluation côté serveur. Une édition de Profil modifie son propriétaire réel, pas une copie stockée dans Sharing. + +Une section sans valeur est omise ou remplacée par une aide d'édition contextualisée réservée au titulaire. Ne pas remplir artificiellement les espaces ni transformer Moi en tableau de bord. + +## 6. Critères de validation + +- [x] Pas de nouveau modèle, migration ni source de vérité : composition par `sharing/passport.py`. +- [x] Sélection des thèmes `Open to` actifs même sans Interest ou Activity associés. +- [x] Une Proof révoquée seule ne rend pas artificiellement son Topic disponible. +- [x] Groupes archivés omis de la projection des affiliations actuelles, sans toucher à leur historique canonique. +- [x] Quatre tests ciblés ajoutés dans `sharing/test_passport_stage3_content.py`. +- [x] Le client Flutter conserve le contexte Topic d'Open To et dispose d'un test widget dédié dans `mobile/test/me_passport_stage3_content_test.dart`. +- [ ] Exécuter `python manage.py test sharing.test_passport_stage3_content sharing.test_g6_passport`. +- [ ] Exécuter `python manage.py check` et `python manage.py makemigrations --check --dry-run`. +- [ ] Exécuter `flutter test test/me_passport_stage3_content_test.dart` et `flutter analyze` depuis `mobile/`. +- [ ] Vérifier les autres tests de projection propriétaire, le rendu Web/Flutter/PDF et les CI sur le HEAD à intégrer. +- [ ] Réconcilier avec la PR #522 puis vérifier `main` après fusion ; **aucune fusion avec CI rouge**. + +## 7. Hors de l'étape 3 : transmettre à l'étape 4 + +Les audiences fines, le défaut de consentement des nouveaux Interests / Open to, la sémantique exacte de « Mes collectifs », les vues « Voir comme » et la révocation des partages/historiques restent régis par leurs propriétaires et par le chantier de confidentialité. **Sur #522, les nouveaux Interests/Open to passent de privé par défaut (`main`) à public par défaut : ne pas traiter ce changement comme implicitement validé.** Toute correction doit être évaluée avec les tests et migrations concernés. + +**Promesse :** « Makolo marche pour vous. » Le Passeport présente ce qui est déjà utile pour avancer. + + +## Addendum de mise en œuvre — 3 novembre 2026 / Flutter, PR #522 + +Cet addendum remplace **pour le client personnel Flutter** les anciennes décisions +d'aperçu personnalisé minimal et de recalcul serveur de chaque variante. + +- La lecture privée du titulaire `/api/v1/me/passport/?variant=complete` + renvoie une projection source avec une enveloppe `composition.schema_version=1`, + une identité publique admise et les identifiants publics admis pour chaque + famille de faits. Le cache reste personnel, remplaçable et purgé sur refus. +- Flutter compose Complet, Public, Thématique et Personnalisé depuis ce seul + chargement. Les thèmes des déclarations et les classifications Activity sont + transmis dans la projection personnelle ; aucun client ne réinterprète les + droits des domaines. +- Public = faits admis publiquement ; Thématique = même socle général autorisé, + filtrage des faits thématisables ; Personnalisé = socle public dont la + personne retire explicitement des éléments. Le protocole natif envoie + `exclude`, `exclude_activity`, `exclude_proof`, `exclude_credential` + à l'émission. L'API conserve la compatibilité des sélections `include` + historiques sur Web. +- L'émission, l'intégrité du snapshot, l'empreinte et les droits sont de la + responsabilité du serveur. Un lecteur tiers ne reçoit jamais la projection + complète du titulaire. +- Les implications confirmées `ActivityInvolvement` sont représentées comme + expériences avec titre, fonctions et date d'Occurrence si connue. + Ni un Journey ni une réservation n'infère une participation. +- Les données personnelles manquantes peuvent être indiquées sobrement à + leur titulaire dans Flutter ; aucune section vide n'est imprimée. +- Les relations internes Membership/Mandate/Access restent owner-only sans + autorisation explicite de divulgation propre à leur domaine. Une projection + ne fait pas naître un droit collectif. +- L'édition du Profil relevait d'un chantier distinct lors de cet addendum. + Elle a ensuite été intégrée à #522 via le même propriétaire Accounts, + sans modèle `PassportEditor` parallèle. + +**Qualité non attestée :** les modifications ont été poussées sur #522, mais +la suite Django, le formatage et les tests Flutter/Android sont à exécuter avant +une éventuelle fusion. La PR reste non fusionnée. + + +## Rectification de composition et de l'éditeur — 3 novembre 2026 + +- La propriété privée `private_details` est réservée au propriétaire de + Complet. Les autres variantes transportent `presentation_details`, + liste bornée par la visibilité de chaque fait ; Flutter expose cette + présentation comme `personal_details`, sans réutiliser le conteneur privé. +- L'audience de `name` contrôle le prénom et le nom, sans cacher + l'identifiant public. `city` et `country` sont indépendants. + Les coordonnées sensibles (téléphone, adresse, naissance) ne sont pas + rendues publiques implicitement. +- Une émission conserve dans le snapshot la même sélection autorisée ; + la consultation publique historique réévalue les retraits d'audience. +- Les variantes restent des suppressions sur un seul socle local. + Aucun chargement serveur supplémentaire n'est introduit par le changement + d'onglet en lui-même. +- L'édition du Profil est réorganisée en groupes naturels, affiche + l'e-mail sans le modifier, et utilise `auth/identifier/` pour un + éventuel changement d'identifiant, soumis aux règles Accounts. +- Le menu de partage courant contient quatre options. L'ancien document + relève exclusivement de la page Versions. + +Les tests ajoutés décrivent ces contrats. Leur exécution et la vérification +sur appareil restent nécessaires avant une éventuelle fusion. diff --git a/docs/architecture/profile-passport-visibility.md b/docs/architecture/profile-passport-visibility.md new file mode 100644 index 0000000000..9d0cdb8b93 --- /dev/null +++ b/docs/architecture/profile-passport-visibility.md @@ -0,0 +1,104 @@ +# Passeport Makolo - visibilite personnelle (PR #522) + +## Objectif +Un seul Profil, plusieurs projections selon le lecteur. La variante Complete est reservee au proprietaire ; la vue partagee est filtree selon la politique de visibilite et les permissions des domaines d'origine. + +## Domaines proprietaires +- Accounts porte l'identite et les regles de divulgation (ProfileVisibilityPolicy, ProfileVisibilityAudience). +- Topics porte Interests et Open To, jamais copies dans le Passeport. +- Groups, Organizations et Authorization portent les relations collectives actuelles. +- Trust, Activity, Journey gardent leurs protections propres. +- Le Passeport demeure une projection, pas une source de verite supplementaire. + +## Audiences cumulables +- Public : tout visiteur. +- Mes collectifs : membres actuels partageant une appartenance effective a un collectif actif avec le proprietaire, relation recalculee a chaque consultation. Un Mandate ou une Permission d'Espace sans Membership n'etablit jamais cette appartenance. +- Espace, Equipe ou Groupe choisi : relation effective des deux profils au collectif selectionne. +- Personne choisie : profil Makolo designe. +- Une politique sans audience est privee. +- Public est exclusif des audiences restreintes pour eviter toute + divulgation involontaire. Plusieurs audiences restreintes peuvent se cumuler. +- L'identifiant public du porteur reste accessible. + +Aucun collectif aujourd'hui signifie aucun membre autorise. Une adhesion future ouvre automatiquement la visibilite a ses membres. Un depart la ferme. L'appartenance n'accorde aucune autorite metier. + +## Compatibilite et protection +- Nouveaux Interests / Open To publics par defaut ; les flags historiques explicitement prives restent prives sans migration de donnees. +- Le consentement a la recherche reste independant de la visibilite du profil. +- Secrets, telephone, email, naissance, adresse precise, coordonnees fines et AccessCredential sont hors des cibles publiables. +- Les documents PDF/HTML publics produits pour un lecteur tiers ne contiennent pas de faits reserves aux collectifs. +- Les anciennes versions exportees sont des documents dates et ne changent pas retroactivement. +- Une verification publique ancienne conserve la preuve d'emission, mais son contenu + est masque si la divulgation personnelle publique s'est restreinte depuis. + Un partage explicite d'instantane releve de Sharing (duree et revocation). + +## Surfaces +- API privee proprietaire : GET/PUT /api/v1/me/passport/visibility/ +- Web : Passeport puis Confidentialite +- Flutter : Passeport puis action Confidentialite +- Consultation Web / API publique : filtre serveur, jamais seulement client + +## Validation avant fusion +- python manage.py makemigrations --check --dry-run +- python manage.py migrate --plan +- python manage.py test accounts.test_profile_visibility accounts.test_passport_visibility_integration accounts.test_public_profile_api topics.tests sharing.test_g6_passport sharing.test_passport_public_identity +- dart format, flutter analyze et tests cibles Flutter +- PostgreSQL, tests d'appartenance active/retiree et absence de fuite sur exports +- Ne jamais fusionner avec CI rouge. + +## Fermeture P1-P4 sur #522 +- Les regles par champ incluent les faits declares entreprise et organisation, + visibles dans la presentation Web, Flutter et PDF selon leur audience. +- Le Passeport complet proprietaire compose les equipes, groupes et Espaces + accessibles deja presents dans Moi, sans les exposer par defaut en public. +- Web et API consomment la meme projection de reglage owner-scoped. +- Les IDs malformes de cibles de confidentialite produisent une reponse 400. +- Les variantes restent Public, Complet, Thematique et Personnalisees. + La lecture avec audience est un mode de consultation, pas une variante. +- Les scenarios de publication, retrait, nouvelles appartenances, depart de + collectif, IDOR et anciennes versions publiques sont couverts par des tests + cibles a executer localement dans P5. + +## Fermeture de la frontiere appartenance / autorite (etape 4) +- Les TeamMembership actives creent des relations Team et Space, sans accorder d'autorite. +- Les GroupMembership actives et les Groups personnels actifs possedes creent des relations Group, sans autorite implicite. +- Les Mandates d'Espace restent dans Authorization : ils ne sont pas des Membership et n'ouvrent pas « Mes collectifs ». +- Les audiences restreintes sont resolues sur le viewer authentifie et les appartenances courantes a chaque consultation, jamais par simple role global ou cache public. +- L'aperçu du lecteur peut exposer seulement les champs explicitement ouverts a ce lecteur ; la projection publique exportable conserve ses limites de publication. +- Regression cible : `accounts.test_passport_visibility_integration` couvre Mandate seul, adhesion active et depart. + +## Fermeture fonctionnelle de l'étape 4 — consentement et lecture (PR #522) + +### Consentement initial +- Le programme G conserve la proposition **Public par défaut** pour une nouvelle déclaration explicite Interest / Open To ; aucune déclaration n'est inférée d'une recherche, d'un Favori ou d'une veille. +- Sur Web, choisir une nouvelle déclaration propose automatiquement sa publication, mais décocher **Afficher publiquement** avant le premier enregistrement doit être respecté par le serveur. Sans JavaScript, le formulaire se replie sur le choix effectivement coché (non coché = privé). Les options visibles sont servies par `static/js/topic-declaration-visibility.js`, sans contrôle de permission côté client. +- L'API `/api/v1/me/considerations/settings/` accepte `new_visibility: "public" | "private"` pour les déclarations nouvellement créées. En cas d'omission, le comportement public de compatibilité est conservé ; un changement de sélection ne modifie jamais la visibilité des lignes retenues. +- Flutter propose **Ajouter publiquement / Garder privé** *avant* toute mutation. Le choix `private` ne déclenche ni `searchable` ni conversion d'Interest en Open To. +- L'ancien formulaire Topics et le réglage détaillé du Passeport ont une seule vérité de divulgation : les cases Public affichent la visibilité publique effectivement calculée. Une action explicite retire le cas échéant une ancienne règle PUBLIC persistée, sans effacer un partage ciblé toujours restreint. + +### Frontières du lecteur et des documents +- `public_profile` ouvre la consultation d'une projection publique ; `searchable` est une décision indépendante. Ni Membership, ni Mandate, ni Permission ne deviennent automatiquement une audience ; une relation collective active est nécessaire des deux côtés. +- Le mode de consultation par une personne autorisée (`audience=mine`) est **une vue Web dynamique**, pas un droit d'export ni un cinquième type de Passeport. Les téléchargements, PDF et documents publics restent filtrés pour un visiteur public. +- Une ancienne version publique conserve son identité documentaire et son empreinte. Sa lecture publique est refusée si le Profil, un champ déclaré, un Interest/Open To, une Activity, une Proof ou un Credential qu'elle contient n'est plus publiquement admissible selon le domaine propriétaire. Les identifiants stables (Activity.id, Proof.public_id, Credential.public_id) sont comparés aux sources actuellement représentables ; une simple modification de titre ne révoque pas le contenu daté. +- Un lien de vérification constate l'émission, il ne crée jamais un partage privé. Un partage explicite d'un snapshot privé non-complet relève de Sharing, avec expiration et révocation propres. Changer la visibilité du Passeport vivant ne rappelle pas les PDF déjà téléchargés ni ne révoque à lui seul un partage explicite : le titulaire doit pouvoir révoquer ce lien séparément. +- Aucun nouveau domaine de données, variable de production ou audience Activity/Proof/Credential parallèle n'est introduit. Les trois familles gardent les droits de leurs propriétaires canoniques. + +### Vérifications ciblées ajoutées, sans bloquer le chantier sur CI +- `topics.test_passport_initial_disclosure` : première sélection privée ou publique, audience antérieure PUBLIC retirée, audience ciblée préservée, mutation native privée et `searchable` indépendant. +- `sharing.test_passport_stage4_withdrawal` : un ancien document public est masqué après retrait de l'Activity, de la Proof ou du Credential ; l'émission datée n'est pas effacée. +- `accounts.test_passport_visibility_integration` : « Mes collectifs » et Space choisi ne s'ouvrent pas avec un Mandate seul. +- Les migrations historiques ne sont pas destructivement réécrites ; les comportements de publication déjà consentis restent valides. + +**État :** règles de l'étape 4 implémentées dans #522. Validation automatique et intégration finale sont des opérations séparées. + +## Lecture native d’une version émise — Web/API/Flutter + +Une vérification d'émission et un partage explicite de version datée restent deux contrats différents. + +- Le reader Web et l'API `sharing.passport.document` partagent la décision de publication actuelle et `_snapshot_content_allowed`. Un lien signé de vérification n'ouvre pas de document privé à un tiers, même lorsque le document est intègre. +- Le reader d'un lien Sharing passe toujours par `resolve_passport_share` (expiration, révocation, intégrité, variante non complète). Un lien partagé peut lire exclusivement le `PassportSnapshot.payload` original ; il ne reconstruit pas le Profil vivant. +- L'API fournit `payload=null` lorsque le viewer n'a pas de droit de lecture. Les réponses sensibles sont `private, no-store` et varient sur les éléments d'authentification. +- Flutter ouvre les liens `/passport/verify//` et `/passport/shared//` dans un lecteur natif sans stockage persistant automatique. Changer de token ou recevoir un refus retire la projection précédemment vue. +- Ce lecteur ne transfère aucun droit supplémentaire de Profile, Permission, Mandate, Access ou Requirement. La vérification d'émission ne certifie pas toutes les déclarations du titulaire. + +Contrat de mise en œuvre et tests ciblés : `docs/implementation/profile-passport-reader-closure-2026-11-02.md`. La séquence 01–06 du Passeport demeure inchangée ; il ne s'agit pas d'une étape canonique 07. diff --git a/docs/architecture/profile-relevance-action-network.md b/docs/architecture/profile-relevance-action-network.md index 80e036cda2..dee8d70fd3 100644 --- a/docs/architecture/profile-relevance-action-network.md +++ b/docs/architecture/profile-relevance-action-network.md @@ -125,7 +125,7 @@ Une donnée optionnelle ne doit pas devenir obligatoire uniquement pour faire pr Principes : -- nouveaux Interests privés par défaut ; +- nouveaux Interests publiables publics par défaut ; les sélections privées existantes restent privées ; - la personnalisation privée et l’exposition publique sont séparées ; - un comportement observé peut devenir un signal de recommandation mais ne réécrit pas silencieusement « Mes centres d’intérêt » ; - Follow, Favorite, recherche et historique ne sont jamais automatiquement convertis en Interests ; @@ -322,7 +322,7 @@ Cible forte : ### G2 — Topics & Interests ✅ intégré - `Topic` canonique ; -- `ProfileInterest` explicite et privé par défaut ; +- `ProfileInterest` explicite ; nouveau choix public par défaut, anciennes décisions privées préservées ; - `ActivityTopic` ; - première raison Discovery explicable ; - aucun Interest inféré depuis Follow/Favorite/historique. diff --git a/docs/architecture/subscription-funded-coverage-contract.md b/docs/architecture/subscription-funded-coverage-contract.md new file mode 100644 index 0000000000..cba61213de --- /dev/null +++ b/docs/architecture/subscription-funded-coverage-contract.md @@ -0,0 +1,72 @@ +# Couvertures financées — contrat d'implémentation + +**Statut : implémentation dans la PR #522, non fusionnée.** +**Domaine propriétaire : `subscriptions`.** +**Document de conception d'origine : `Makolo_Contrat_Couvertures_Financees_2026-11-03.md` (transfert projet).** + +## Promesse + +Un Profil ou un Espace finance une offre versionnée pour des bénéficiaires distincts (Profils, Espaces ou périmètre de Groupe), sans obtenir leurs permissions, mandats, activités ou données privées. Le financement est distinct de l'abonnement ordinaire du bénéficiaire. + +Le catalogue reste générique : les noms de plans, leurs prix et les quantités sont des instances de `SubscriptionPlan`, `PlanVersion` et `PlanVersionBillingTerms`. Aucun nom commercial n'est codé en dur. + +## Sources de vérité + +- `Subscription` et `SubscriptionItem` restent exclusivement Profile XOR Space. +- `SubscriptionCoverage` représente un achat financé, son payeur Profile XOR Space, son `PlanVersion` publiée, ses termes tarifaires figés, son prix/quantité et sa fenêtre contractuelle. +- `SubscriptionCoverageAllocation` représente les attributions historiques, cibles Profile XOR Space XOR Group. Une attribution à Group qualifie une population : elle ne produit jamais directement d'Entitlement. +- `GroupMembership` et `GroupSnapshotMember` restent propriétaires de l'éligibilité des membres. Une attribution personnelle consentie peut se rattacher à une attribution Group par `group_parent`. +- `SubscriptionBillingObligation` applique deux chemins XOR : Subscription ordinaire ou `SubscriptionCoverage`; `PaymentObligation` demeure la vérité financière. +- `PlanEntitlement` est résolu par la provenance : BASE/ADDON du bénéficiaire, couverture financée active et acceptée, grants administratifs. Aucune copie aveugle en `EntitlementGrant`. +- Les `PlanRequirement` et `EntitlementRequirement` sont évalués pour la version financée ; évaluation invalide = droit non accordé. + +## Tarification et durée + +`PlanVersionBillingTerms` a des attributs de couverture optionnels (`coverage_enabled`, `coverage_pricing_mode`, `coverage_max_quantity`). Seuls les brouillons sont configurables via le catalogue Platform. + +- **flat** : achat de la capacité forfaitaire exacte, un montant par cycle. +- **per_seat** : prix unitaire multiplié par quantité achetée, dans la limite publiée. +- La fenêtre `starts_at → ends_at` couvre **exactement un cycle tarifaire** défini par les termes figés. Un achat ne peut pas payer un mois pour déclarer un an. +- Chaque nouvelle période requiert une nouvelle couverture ou un renouvellement explicite. La date d'expiration coupe les contributions même si l'état administratif n'a pas encore été matérialisé en `ended`. +- Gratuit : aucune obligation positive fictive. Payant : `PaymentObligation` créée pour le payeur, plateforme bénéficiaire ; activation seulement après règlement. +- Suspension de la couverture coupe uniquement ses propres droits. La BASE personnelle du bénéficiaire reste persistée. + +## Flux serveur + +1. Le payeur autorisé crée une couverture versionnée (création idempotente, verrouillage transactionnel). +2. Le payeur invite individuellement des Profils/Espaces ou déclare un Groupe qualifiant. +3. Un Profil ne reçoit des droits effectifs qu'après invitation nominative, éligibilité et acceptation explicite. La même règle s'applique à un Espace via Permission. +4. Une attribution personnelle issue d'un Groupe doit toujours satisfaire sa politique : membres courants ou snapshot. +5. La capacité se calcule par nombre d'attributions effectives simultanées, hors racines de Groupe. +6. Une BASE financée n'entre dans la résolution que si elle a été explicitement choisie ; l'ancienne BASE personnelle reste disponible en repli. +7. Refus, révocation, échéance, sortie d'un Groupe dynamique, suspension ou impayé retirent uniquement la contribution correspondante. +8. Les changements émettent des Domain Events typés, avec identifiants de sources seulement, sans données personnelles de consommation. + +## API native + +Sur les routes existantes `api/v1/subscriptions/` : + +- `me/coverages/` : couvertures personnelles financées/reçues et achat. +- `me/coverages/allocations//` : acceptation, refus, choix de BASE ou retour au BASE personnel. +- `spaces//coverages/` : couvertures financées/reçues par un Espace, selon les permissions de Subscription. +- `spaces//coverages/allocations//` : décision d'un gestionnaire autorisé au nom de l'Espace. +- `coverages//` : consultation d'une couverture par son financeur. +- `coverages//allocations/` : invitations contrôlées (Profil/Espace/Groupe). +- `coverages//status/` : suspension, reprise, terminaison justifiées. + +Les projections de financement montrent prix/obligations **uniquement si** `payments.selectors.obligations_visible_to(actor)` accorde cette visibilité. Aucun endpoint ne donne au financeur la consommation privée des bénéficiaires. Toutes les réponses de ces vues sont `private, no-store`. + +Web : vue Moi abonnement et console Nous montrent les allocations reçues, le choix/retour de BASE et le suivi minimal des couvertures financées. + +## Points de contrôle + +- Deux modèles persistants ajoutés dans `subscriptions/migrations/0009_financed_coverages.py`. Aucun changement destructeur ou backfill des souscriptions historiques. +- Décompte des places sous verrou de couverture ; `Subscription` du destinataire verrouillée lors de choix BASE pour sérialiser les conflits. +- Protection serveur contre IDOR et déduction d'appartenance : acteur bénéficiaire ou Permission `SPACE_SUBSCRIPTION_MANAGE` ; financeur Profil propriétaire ou financeur Espace autorisé ; gestion du Groupe requiert `GROUP_MANAGE`. +- Tests : `subscriptions.test_funded_coverages`, `subscriptions.test_funded_coverage_native_api`, `subscriptions.test_funded_coverage_concurrency` (PostgreSQL), et régressions Subscription historiques. + +## Exclusions conservées + +Pas de `GroupSubscription`, `GiftSubscription`, `Seat` ni de `GroupEntitlement` supplémentaire ; pas de droit/autorité transmis par paiement, Membership ou Group ; pas de prix codé en dur ; pas de réecriture des `SubscriptionItem` historiques. + +**Limite opérationnelle :** ce document décrit le code livré dans #522 et ses invariants prévus ; il ne remplace pas les résultats effectifs de migrations, de tests PostgreSQL ni de revue de sécurité avant déploiement. diff --git a/docs/architecture/subscriptions-funded-coverage-implementation.md b/docs/architecture/subscriptions-funded-coverage-implementation.md new file mode 100644 index 0000000000..eb663c098b --- /dev/null +++ b/docs/architecture/subscriptions-funded-coverage-implementation.md @@ -0,0 +1,65 @@ +# Makolo — Couvertures financées et attribution des bénéficiaires + +**Statut : implémentation sur la branche de la PR #522.** Ce document décrit le comportement attendu du code courant, et non des offres commerciales nommées. Il n'atteste pas à lui seul l'exécution des tests ou le déploiement. + +## 1. Responsabilités + +- `SubscriptionPlan`, `PlanVersion`, `PlanEntitlement` et `FeatureDefinition` possèdent le catalogue. Plus, Max, Business, etc. ne sont que des données éventuelles configurées par Platform. +- `PlanVersionBillingTerms` possède le montant, la monnaie, la période, le mode tarifaire de couverture (`flat` ou `per_seat`) et le plafond de bénéficiaires. Ces paramètres sont figés après publication. +- `SubscriptionCoverage` possède une couverture achetée pour un cycle tarifaire exactement défini ; le financeur est Profil XOR Espace. Elle n'est ni la `Subscription` du bénéficiaire, ni une autorité sur ce dernier. +- `SubscriptionCoverageAllocation` possède l'attribution historisée. Cible XOR Profil/Espace/Groupe. Une cible Group est un périmètre de sélection, jamais un sujet final d'Entitlement. Une attribution nominative peut référencer `group_parent`. +- `PaymentObligation` est la vérité du paiement. `SubscriptionBillingObligation` utilise exactement une source : Subscription ordinaire ou Coverage financée. +- Le résolveur `resolve_effective_entitlements` incorpore les allocations effectives après consentement, état de la couverture, fenêtres temporelles, appartenance et Requirements. +- L'unique BASE effective est sélectionnée explicitement : la BASE personnelle reste stockée et réapparaît lorsque la couverture financée n'est plus effective. + +## 2. Cycle d'achat et d'attribution + +1. Un gestionnaire autorisé finance une `PlanVersion` publiée, avec `coverage_enabled` et quantité contractuelle. +2. Le backend calcule la période d'un cycle (jour/semaine/mois/an calendaire) suivant les Billing Terms. Une période arbitraire plus longue est rejetée. +3. Pour une couverture payante, elle reste `pending_payment` ; la réception d'un paiement satisfait via Payments fait progresser l'état. Un achat gratuit devient actif sans obligation positive. +4. Le financeur invite explicitement un bénéficiaire. L'invitation seule ne consomme pas de place et n'accorde aucun droit. +5. Une invitation nominative est acceptée par le bénéficiaire autorisé. Pour une BASE financée, le choix explicite `select_base=True` est requis. +6. Le service sérialise l'acceptation sous verrou PostgreSQL sur la couverture et la Subscription du bénéficiaire ; la capacité active ne dépasse pas `purchased_quantity`. +7. Le bénéficiaire peut refuser ou revenir à sa BASE personnelle, sans effacement d'historique et sans transférer ses données au financeur. +8. Suspension, remboursement (via le statut PaymentObligation), expiration, révocation ou départ du Groupe dynamique retirent la contribution financée, sans neutraliser des droits indépendants. + +## 3. Groupes + +- `Group`, `GroupMembership`, `GroupSnapshot` et `GroupSnapshotMember` restent propriétaires de la population. +- Une allocation de Groupe n'émet aucun droit personnel. +- En mode `current_members`, seuls les membres actifs, nommément invités et consentants, sont effectifs. Un départ coupe la contribution issue du Groupe. +- En mode `snapshot`, la population figée référence le snapshot existant. +- L'autorité de gestion d'un Groupe est contrôlée séparément de celle du financeur. Membership n'accorde aucune Permission/Mandate. + +## 4. API native privée + +Préfixe : `/api/v1/subscriptions/` + +| Chemin | Action | +|---|---| +| `GET/POST me/coverages/` | Mes couvertures financées et reçues ; achat personnel | +| `POST me/coverages//allocations/` | Invitation nominative ou périmètre Groupe | +| `POST me/coverages//state/` | Suspension, reprise ou clôture | +| `POST me/allocations///` | `accept`, `decline`, `select-base` | +| `POST me/base/personal/` | Retour à la BASE personnelle | +| `GET/POST spaces//coverages/` | Même parcours dans le contexte Espace | +| `POST spaces//coverages//allocations/` | Invitation sous autorité de l'Espace financeur | +| `POST spaces//allocations///` | Consentement via autorité de l'Espace bénéficiaire | +| `POST spaces//base/personal/` | Retour à la BASE propre de l'Espace | + +Toute mutation exige une authentification. Le financement d'Espace requiert `SPACE_SUBSCRIPTION_MANAGE`. Les réponses ont `Cache-Control: private, no-store`. La lecture d'une couverture reçue n'expose ni le financeur ni les données privées des autres bénéficiaires. + +Les opérations sensibles exigent `idempotency_key` (8–128 caractères côté API). Les dates sont optionnelles : le serveur peut calculer un cycle exact. Pour un renouvellement explicite, `renewal_of` référence une couverture appartenant au même financeur. + +## 5. Web / Platform + +- Le catalogue Platform peut configurer les modalités des couvertures uniquement sur un draft, puis publier une version immuable. +- Moi et Nous présentent les couvertures reçues/financées et les actions d'acceptation, de refus et de sélection de BASE. +- Les capacités affichées suivent le résolveur effectif ; la BASE personnelle demeure accessible en repli. +- Il n'y a pas de création automatique d'une couverture à la simple adhésion à un Groupe. + +## 6. Contrôles + +Tests à faire fonctionner sur la branche : `subscriptions.test_funded_coverages`, `subscriptions.test_coverage_native_api`, `subscriptions.test_coverage_concurrency` (PostgreSQL), et les suites Subscription/Billing existantes. Vérifier `manage.py check`, `makemigrations --check --dry-run`, `migrate` et la rétrocompatibilité des obligations historiques. + +Les interfaces graphiques web ne remplacent pas les règles serveur. Aucun ajout de `GroupSubscription`, `Seat` ou de nouveau système Payment. diff --git a/docs/architecture/z3-discovery-projection-audit.md b/docs/architecture/z3-discovery-projection-audit.md index 54f744a00c..40ef6a860e 100644 --- a/docs/architecture/z3-discovery-projection-audit.md +++ b/docs/architecture/z3-discovery-projection-audit.md @@ -489,3 +489,48 @@ Z3 est considéré implémenté lorsque : - aucune migration n'est ajoutée ; - tests ciblés, contrôle migrations, suite pertinente et CI sont verts ; - la PR est réconciliée puis mergée sur `main`, et `main` est revalidé. + +## B1 — Complétude additive du wire Discovery (octobre 2026) + +Cette section décrit les extensions **du wire observé dans la branche B1**, et +non une nouvelle vérité métier. Les selectors, les politiques d'accès et les +domaines propriétaires restent les seules autorités sur les faits. + +- La collection et le détail partagent l'identité canonique et la description + déjà autorisée. `results` et `items` restent compatibles. +- `variants[]` référence, pour une Activity comportant plusieurs Occurrences + sélectionnées par Discovery, jusqu'à douze Occurrences par leur identifiant + canonique et les horaires effectivement connus. Ce ne sont pas des + Alternatives indépendantes. Aucune variante n'est fabriquée pour les autres + familles. +- `temporal_facts[]` reprend les repères Occurrence déjà qualifiés, + `FundingDetails.opens_at/closes_at` et les dates de la révision publiée + d'Opportunity. Les instants qualifiés priment sur la répétition de leurs + composantes date/heure. +- `spatial_facts[]` transporte les rôles des lieux d'Occurrence admis dans le + résultat courant (maximum douze), ainsi que les arrêts publics des routes + Transport canoniques (départ, escale, arrivée) en conservant leur position + d'origine. Le contrôle public existant des lieux d'Espace est appliqué. + `place` reste le lieu principal de compatibilité. Les coordonnées manquantes + ne sont jamais estimées. +- `media_bindings[]` reprend uniquement une image déjà autorisée par la + représentation canonique, avec `RECOGNIZE`. Les documents et ressources ne + deviennent jamais implicitement des couvertures visuelles. +- `relations[]` est vide tant qu'aucune relation canonique, établie et + divulgable n'a été apportée par les propriétaires. Une collection vide ne + prétend pas que toute relation possible a été recherchée exhaustivement. +- Le contrat `price` existant est maintenu. Les modalités tarifaires + `exact/range/variant-dependent` exigent une vérité propriétaire adaptée : + elles ne sont pas inférées d'un libellé ou d'un prix minimum. +- `presentation_facts`, `assessment`, `saved`, `watch`, + `personal_relation`, `engagement`, `capabilities` et les handoffs + conservent leurs frontières et leurs états d'inconnu. +- La fraîcheur de génération n'atteste toujours pas la fraîcheur de chaque + fait métier. Aucun timestamp d'observation propriétaire n'est inventé. +- Les URLs Web conservées sont des champs de compatibilité, jamais une preuve + d'une mutation API. `links.detail` référence l'API Discovery existante. + +Les groupes/placements absents, la pertinence, Mayele, Molongo, le tri, la +qualité des données et les calculs métier coûteux sont hors de cette extension. +Le serveur ne dépend pas des profondeurs UX N1/N2/N3 du client. Le client +choisit librement sa présentation et ne ré-inférera pas les permissions. diff --git a/docs/demo-data.md b/docs/demo-data.md index 6a8c89230e..0935ff915b 100644 --- a/docs/demo-data.md +++ b/docs/demo-data.md @@ -4,6 +4,10 @@ Le seed Makolo est un environnement de démonstration et de test manuel reproduc Aucune donnée réelle ne doit être utilisée. Les comptes, contacts, lieux métier et historiques du profil bêta sont fictifs ; les adresses e-mail utilisent le domaine réservé `.test`. +Les titres des scénarios sont désormais **naturels** : aucun préfixe « Exemple — » ou « [DEMO] » n'est ajouté à l'intitulé visible. L'identité synthétique est préservée par les clés de seed, les slugs, les métadonnées et l'isolation de l'environnement de démonstration ; ces identifiants ne sont pas du contenu à afficher. Cette règle **ne permet jamais** de présenter une démonstration comme une offre réellement accessible : ne pas publier ces jeux de données sur un catalogue réel. Dans le jeu mobile mature, les sources externes sont des références indicatives, sans certification fictive de l'opportunité liée. Les indications de candidature et leurs résultats restent explicitement fictifs. + +La provenance est vérifiée par les générateurs et les validations du seed, pas par une réécriture générale du contenu publié par les vrais porteurs. **Une modification du générateur ne corrige pas à elle seule les enregistrements déjà présents dans une base bêta** : effectuer un reseed contrôlé des seules données de démonstration, puis exécuter la validation. Ne pas toucher aux offres réelles pour réparer une ancienne fixture. + ## Profil recommandé Le profil de référence pour la bêta est `beta` : diff --git a/docs/experience/passport-pdf-editorial-contract.md b/docs/experience/passport-pdf-editorial-contract.md new file mode 100644 index 0000000000..333d748b6f --- /dev/null +++ b/docs/experience/passport-pdf-editorial-contract.md @@ -0,0 +1,137 @@ +# Passeport Makolo — document PDF de représentation + +**Portée :** Profil personnel et Espace. Contrat d'impression du Passeport déjà composé et autorisé par Sharing. Cette note accompagne le chantier de la PR #522 ; elle ne crée aucun nouvel état métier. + +## Fonction + +Le PDF du Passeport Makolo peut accompagner un CV, un dossier, une candidature ou des attestations. Il représente des informations déclarées, des activités pertinentes et, lorsqu'ils existent, des faits établis ou des titres réellement délivrés. Il est émis par Makolo, **sans être présenté comme une pièce d'identité légale ni comme une certification générale de ses déclarations**. + +Le document est la traduction **éditoriale sur papier** du Passeport, non une capture d'écran ni une seconde source de vérité. + +## Chaîne propriétaire + +1. sharing/passport.py construit une PassportProjection selon le sujet, le contexte d'autorité et la variante (complete, public, thematic, custom). +2. sharing/passport_documents.py sérialise les faits admissibles et émet **un** PassportSnapshot daté, avec une empreinte documentaire. +3. sharing/passport_pdf.py associe à **ce même snapshot** l'identifiant documentaire, la date UTC et le lien de vérification signé ; il prépare uniquement la photo propriétaire admise dans la projection. +4. sharing/passport_pdf_layout.py compose le fichier A4 à partir du contenu émis et de médias déjà autorisés. Ce module ne lit aucune table métier et n'ajoute aucune permission. +5. Le Web et Flutter reçoivent le même PDF serveur ; PDF enregistre, Partager remet le PDF ou le lien public quand autorisé. + +Un QR vérifie **l'émission d'un document identifié**, non la véracité de toute affirmation, ni un accès implicite aux coordonnées privées. Une variante personnalisée ne rend pas son contenu public. + +## Principes de mise en page + +- A4 portrait, surface utile ~180 mm, pagination selon le volume réel. +- En-tête institutionnel avec le Makolo Mark officiel et titre « Passeport Makolo ». +- Bloc d'identité violet en **trois territoires** : portrait carré à gauche, identité naturelle au centre (nom complet sans étiquette Prénom/Nom, profession, lieu et identifiant), **QR de vérification à droite sur fond blanc** et avec zone calme pour la lecture optique. Orientation EXIF corrigée ; monogramme uniquement si aucun portrait autorisé. +- La langue et le genre ne figurent dans cette zone que lorsqu'ils sont renseignés et présents dans la variante complète autorisée. Aucun « Non renseigné », aucune identité reconstruite artificiellement. +- Localisations lisibles (pays en français, y compris les valeurs historiques RDC et le code CD). +- Les informations complémentaires réellement présentes (contact, organisation, date de naissance si autorisée) restent dans une section discrète ; ni tableau redondant Prénom/Nom, ni colonne vide pour un élément unique. +- Bio et faits personnels séparés des déclarations, preuves et attestations. Pas de sections factices. +- Centres d'intérêt et ouvertures en pastilles imprimables ; liens cliquables. +- Faits établis, activités et attestations avec métadonnées réellement disponibles, sans sceaux inventés. +- Bloc de référence **en bas de la dernière page**, distinct du QR de la première page : « Vérifier ce document », identifiant du snapshot, date UTC d'émission, variante et domaine réel d'émission lié au token signé. Le QR n'est **pas répété** au pied du document. +- Le QR renvoie à la vérification de **ce snapshot émis**, pas à un passeport public recomposé. Sur une installation locale, le domaine peut être 127.0.0.1 et ne pas être accessible depuis un autre appareil ; ne jamais inventer un domaine de production. +- Couleurs Makolo #5232DB, #2B176E, #FAF7F5, encre #0F172A. Typographie Unicode embarquée lorsque disponible dans l'environnement, Helvetica de repli. + +## Composition du parcours (étape 3) + +Le Passeport combine l'identité et un parcours d'action fondé sur les faits existants : activités portées (publication ≠ réalisation), Proofs établis par Makolo, Credential Trust reçus d'un émetteur, vérifications admises, et attestations émises par l'Espace sous forme de résumé sans bénéficiaires. + +Le Passeport **complet** d'un Profil ajoute, depuis les domaines propriétaires, ses appartenances actives aux Équipes et ses Mandates Espace actifs, sous des rubriques distinctes : appartenance ≠ autorité. Les variantes public, thématique et personnalisée ne reçoivent pas ces relations internes automatiquement. + +Le Passeport complet d'un Espace peut présenter les noms de ses Équipes actives **seulement** si la personne consultante possède la permission de gestion des équipes de cet Espace. Un simple accès à l'Espace ne suffit pas. Les sorties Web, API personnelle/Flutter et PDF rendent uniquement les faits admis par la projection propriétaire ; le PDF n'infère aucune relation. + +Aucune nouvelle source de vérité, table, inférence de compétence, score ou CV RH générique n'est introduite. + +## Variantes, sélection et divulgation (étape 4) + +- **Public** : représentation en lecture directe de l'identifiant public, admise uniquement si la publication de l'entité est autorisée ; le réglage `searchable` ne contrôle pas l'accès par lien. La personne propriétaire peut toujours consulter l'aperçu de sa propre version publique, même si elle ne l'a pas publiée. +- **Complet** : version propriétaire du Profil, ou version réservée à une autorité disposant du droit de consulter le workspace de l'Espace. Les noms des Équipes internes restent soumis à une autorité de gestion d'équipe indépendante du droit de consultation. +- **Thématique** : composition des faits du titulaire liés à un ou plusieurs Topics explicitement demandés ; cette variante reste **privée** tant que le titulaire n'a pas volontairement exporté son document. Elle ne transforme pas une preuve privée en déclaration publiquement accessible. +- **Personnalisé** : l'identité minimale (nom et identifiant) subsiste, mais la photo, la biographie, la profession, la localisation, les liens, les centres d'intérêt, les ouvertures et les faits sélectionnables ne sont transmis que s'ils sont explicitement inclus. Une sélection vide produit un Passeport d'identité minimal. Une sélection de fait étranger est rejetée par le serveur. +- **Adresse canonique `//`** : un visiteur ne peut obtenir que le public autorisé. Un utilisateur reconnu comme titulaire peut consulter les variantes privées par cette même adresse ; il ne transmet jamais cette autorisation aux autres visiteurs. +- **Web personnalisé** : aperçu limité aux informations choisies ; `Modifier la sélection` ouvre séparément la liste privée des choix admissibles avec cases conservées. Les documents exportés n'intègrent jamais ce catalogue. +- **Flutter** : aucun élément privé n'est présélectionné lors de la création d'une variante personnalisée ; partage et PDF exigent une projection en ligne à nouveau autorisée. Le propriétaire doit être averti lorsqu'il transmet un document susceptible de contenir des informations privées. +- **Document vérifiable** : la page du lien signé confirme l'émission et expose seulement les références de vérification ; elle ne diffuse pas le contenu privé de la projection. La vérification historique et les versions datées sont traitées à l'étape 5. + +Les variantes ne dupliquent aucune vérité métier, n'ajoutent aucune permission et ne rendent jamais un contenu public par simple composition. + +## Émission, publication et partage (étape 5 — décision) + +- **Consultation ordinaire** : le Passeport actuel ne crée aucune version. Impossible de fabriquer un état historique à partir d'une date arbitraire : seules les versions **réellement émises** existent. +- **Émission** : un export HTML, impression ou PDF crée un `sharing.PassportSnapshot` immuable de la composition admise (identité, faits présentés, date, variante et empreinte). L'émission ne confère aucun droit de partage. +- **Publication d'une émission à des visiteurs** : requiert ensemble `variant=public`, `public_at_issue=True` (publication réellement autorisée **à l'émission**), `public_profile=True` aujourd'hui, document non révoqué et empreinte intègre. Un Passeport privé en mai puis public en août **ne rend pas l'émission de mai publique**. Une ancienne émission publique devient inaccessible aux visiteurs si son titulaire retire sa publication. `searchable` ne vaut jamais Permission. +- **Propriétaire** : peut consulter toutes ses émissions intègres, même révoquées, en voyant clairement leur état. Les personnes autorisées pour l'Espace ont la même possibilité selon leurs permissions actuelles ; les équipes internes continuent de nécessiter `SPACE_TEAM_MANAGE`. +- **Partage volontaire** : crée **après l'émission** un `ShareEnvelope/ShareLink` avec un `PassportShareSubject` pointant vers cette émission, sans nouveau snapshot ni changement de publication générale. Le partage peut porter sur une sélection publique, thématique ou personnalisée, même si le Passeport reste privé. Il ne concerne pas le complet (qui peut contenir des coordonnées personnelles). Un lien expire après 1, 7 ou 30 jours et peut être révoqué. La création par un Espace exige `SPACE_MANAGE`, pas une simple Membership. +- **Lien signé de vérification** : référence l'émission et son empreinte mais **n'est pas un lien de partage**. La page peut indiquer l'authenticité sans rendre visibles les données privées. Le lien de partage est un droit distinct, révocable et expirant, pour une seule version. +- **Revocation** : elle empêche le visiteur et les destinataires d'un partage d'accéder au contenu, mais n'efface pas l'archive du propriétaire. L'empreinte invalide masque le contenu pour tous. +- **Données existantes** : aucune ancienne confidentialité n'est déduite arbitrairement : `public_at_issue=False` est conservateur sur les snapshots antérieurs à cette migration. Aucun backfill de versions non émises. Le snapshot préserve le contenu textuel émis, pas les anciens octets du portrait. Le PDF déjà téléchargé ne peut pas être effacé à distance. +- **Surfaces** : historique Web Profil/Espace, API personnelle et Flutter montrent les émissions datées ; ils n'infèrent aucune réalisation historique. Le lien de partage utilise les droits de Sharing et ne transfère jamais Permission, Mandate ni Access. + + +## Confidentialité + +Le Passeport complet peut contenir des coordonnées privées, exclusivement lorsqu'il est généré pour son propriétaire ou une autorité explicitement autorisée. Les autres variantes n'héritent jamais de ces coordonnées ; aucune information n'est reprise implicitement d'un document, d'une preuve ou d'un Espace associé. Les projections de partage public et les autorisations de média restent propriétaires des décisions de divulgation. + +## Vérifications ciblées + + python manage.py check + python manage.py test sharing.test_passport_pdf_layout sharing.test_g6_passport core.test_z4_me_capital_api + +Inspecter visuellement tous les feuillets des cas : identité sans photo, portrait EXIF 6, profil succinct, biographie longue, nombreux intérêts, preuves/attestations, Passeport public et Passeport d'Espace. Vérifier l'extraction du texte français, la lisibilité N&B et le QR sur le fichier final. Ne pas fusionner avec CI rouge ; aucune fusion automatique de #522. + + +## Addendum de fermeture Flutter — 3 novembre 2026 + +Pour le Passeport personnel Flutter, la présentation de la variante est +dorénavant dérivée localement d'une projection privée du titulaire. Les anciens +paragraphes de l'étape 4 prescrivant un Personnalisé minimal sans sélection +décrivent le comportement historique Web et ne sont plus la cible Flutter. +L'émission reste une revalidation serveur de la composition, puis un seul +`PassportSnapshot` immuable par action réussie. + +La barre supérieure possède quatre commandes directes : Confidentialité, +Versions, PDF et Partager. PDF émet et enregistre dans les Téléchargements +Android, sans prévisualisation ni choix de destination. Partager propose : +PDF dans l'OS ; destinataire Profil dans Makolo (Sharing direct) ; +lien public vivant avec copie au presse-papiers ; lien de version datée avec +un droit Sharing approprié. Les anciennes versions ne figurent pas dans ce +menu : elles restent accessibles uniquement par l'icône Versions, avec +Télécharger, Partager et Révoquer, sans émission supplémentaire. + +Une implication confirmée est imprimée comme titre, fonction et date connue ; +une appartenance ou une propriété ne vaut pas participation. Les faits absents +et rubriques vides sont omis. La langue française commande les libellés du +document ; elle n'est pas une donnée de la personne. Le PDF n'infère aucune +preuve, réalisation ou relation depuis une simple association. + +La version datée garde ses faits textuels. La question de la conservation +**visuelle exacte** de portraits historiques reste distincte de l'intégrité +du payload : aucun nouveau stockage documentaire de médias n'a été introduit. +Les versions Complet restent non partageables par lien privé dans le +contrat Sharing existant ; un partage interne de Complet doit utiliser une +sélection admissible. + +**Statut : implémenté sur la branche de PR #522, non validé sur appareil, +non fusionné.** Le formatter, les tests et la vérification Android restent +à faire avant intégration. + + +### Rectification du 3 novembre 2026 — Informations personnelles + +Le Passeport complet contient les faits personnels admissibles au titulaire, +dont certains sont publics. Les variantes filtrées ne doivent pas vider toute +la section « Informations personnelles ». Le serveur expose uniquement +`presentation_details` (prénom et nom si publiables, identifiant public, +ville et pays si publiables) dans les variantes Public, Thématique et +Personnalisé, séparément de `owner_details` (téléphone, adresse, naissance +et autres faits sensibles). Les champs absents sont omis dans Flutter et le +PDF, sans label « Non renseigné ». La lecture historique réévalue les droits +par champ. Aucun fait sensible n'est publié sur la seule base d'une case +cochée. + +L'éditeur Flutter conserve l'API Accounts existante. Il regroupe les champs +en Identité, Localisation, Contact, Activité professionnelle, Liens et réseaux +sociaux, Visibilité. L'e-mail est en lecture seule (absence de changement +vérifié dans l'API existante), l'identifiant utilise sa propre route sécurisée +et la langue est une préférence documentaire, pas une information personnelle. diff --git a/docs/implementation/connections-ux-closure-2026-11-03.md b/docs/implementation/connections-ux-closure-2026-11-03.md new file mode 100644 index 0000000000..ea94820d5f --- /dev/null +++ b/docs/implementation/connections-ux-closure-2026-11-03.md @@ -0,0 +1,61 @@ +# Connexions — expérience N2/N3 Web et Flutter (3 novembre 2026) + +## Périmètre + +Branche de travail : PR #522, sans nouveau domaine persistant ni modification directe de `main`. + +Objectif : remplacer les cartes techniques et le dialogue de connexion surchargé par une expérience propriétaire, concise et navigable : **Découvrir → fiche N3 → connecter**, ou **Mes connexions → fiche N3 → gérer**. + +Les captures Android transmises montrent des libellés très longs, des CTA compressés, un dialogue vertical trop dense et l'exposition de modes internes (Push/Payments) parmi les services externes. Cette passe traite la composition, sans attribuer de capacités fictives aux fournisseurs. + +## Contrats propriétaires conservés + +- `GET /api/v1/me/interoperability/` reste la projection Z16 personnelle et sa lecture local-first. +- Catalogue des fournisseurs connectables : `GET /api/v1/me/intelligence/connections/` et variante Space, géré par Intelligence. +- Écriture : `POST/PATCH/DELETE` Intelligence, sous portée Profile ou Space, avec consentement et revalidation serveur. +- La liste des connexions établies provient des `ProviderConnection` filtrées par propriétaire et de leur projection Z16. +- Les providers connectables ne sont **pas** des comptes externes déjà reliés. Le catalogue actuel reflète les adaptateurs Intelligence effectivement prévus côté serveur ; il ne constitue pas une marketplace ou une table générique de connecteurs en base. +- Les capacités `domain_integrations` restent du ressort de Notifications ou Payments et ne s'affichent pas comme des connexions externes. +- Aucune clé, URL d'endpoint interne ou identité de token n'est exposée dans les lectures ou dans les états persistés Flutter. + +## UX livrée en source + +### N2 — Découvrir et Mes connexions + +- Listes de lignes compactes, marges stables, informations secondaires limitées à deux lignes, ligne entière navigable. +- Recherche et filtre par capacités dérivées du catalogue owner (pas de noms de fournisseurs codés en dur dans les vues clientes). +- Navigation adaptée aux petits écrans et au grossissement du texte, sans `SegmentedButton` rigide. +- Liste des connexions authentiquement observées, statut humain et accès au détail. +- Dernier état local lisible sans réseau ; mutation suspendue tant que le serveur/owner n'est pas confirmé. +- Rafraîchissement explicite du catalogue et du Z16 sans les confondre. + +### N3 — Comprendre, connecter, gérer + +- Écran Flutter défilable, espace pour les champs, action principale ancrée ; pas de dialogue de saisie complexe. +- Fiche alimentée par les champs réels du catalogue (`label`, `description`, `capabilities`, `requires_model`, `can_connect`). +- Consentement explicite et secret uniquement dans les contrôleurs temporaires du formulaire et la requête propriétaire. +- Connexion établie : statut Z16, capacités, désactivation/réactivation, rotation de clé, retrait après confirmation. +- La navigation vers Découvrir est exposée uniquement pour une connexion Intelligence utilisable avec une capacité pertinente ; aucun appel fournisseur automatique. +- Les résultats d'une écriture confirmée et d'une relecture Z16 ratée restent distincts. + +### Web et Space + +- Présentation Web responsive factorisée entre Moi et Nous dans `templates/interoperability/connection_experience.html`. +- Sélection de fiche N3 et filtres traités côté serveur par `interoperability/catalog_presentation.py`. +- Les connections Space restent filtrées et gouvernées par `space.manage` direct ; l'identité du Space et, lorsqu'il existe, le contexte de responsabilité restent dans la navigation. +- Les formulaires Web conservent CSRF, opérations owner et aucun reflet de secret. Retour vers Mes connexions après mutation. +- Pas de nouvelles routes de mutation universelles et aucun transfert implicite d'autorité. + +## Vérifications + +Tests sources ajustés/ajoutés : `mobile/test/intelligence_connection_controls_test.dart`, `mobile/test/c8_interop_mutation_refresh_test.dart`, `mobile/test/c7_intelligence_owner_continuity_test.dart`, `mobile/test/c7_space_owner_continuity_test.dart`, `mobile/test/interoperability_screen_test.dart`, `mobile/test/connections_n2_n3_layout_test.dart`, `core/test_w8_personal_interoperability.py`, `intelligence/test_connection_management_web.py` et `organizations/test_ws4_space_web.py`. + +**Non attesté à cette étape :** exécution Flutter/Django, compilation Android, inspection réelle des mises en page sur téléphones et CI. L'utilisateur a demandé de ne pas s'occuper de la CI pour ce chantier. Les vérifications d'exécution et la réconciliation finale de la PR restent à effectuer avant tout merge ; aucune validation verte n'est prétendue. + +## Invariants inchangés + +- Profile personne globale ; Space exige son Mandate/Permission direct. +- Connection donne un canal ; Permission/Mandate donne l'autorité ; Action exécute un contrat ; owner garde la vérité. +- Une capacité disponible ne signifie pas connexion établie. +- Snapshot local = observation, jamais consentement, droit ou succès confirmé. +- Aucune migration, nouveau modèle générique Plugin, fausse intégration OAuth ni galerie de médias orphelins. diff --git a/docs/implementation/discover-carte-n1-n2-finition-2026-11-05.md b/docs/implementation/discover-carte-n1-n2-finition-2026-11-05.md new file mode 100644 index 0000000000..eefc8838ec --- /dev/null +++ b/docs/implementation/discover-carte-n1-n2-finition-2026-11-05.md @@ -0,0 +1,47 @@ +# Découvrir — Carte N1/N2 : finition fonctionnelle et visuelle +**Date :** 2026-11-05 +**Branche :** `fix/discover-a-continuation-context-20261014` — [PR #522](https://github.com/TraditionLearningCommunity/makolo/pull/522) +**État :** code poussé ; vérification Flutter/Android ciblée encore nécessaire. Pas de fusion. + +## Problème utilisateur + +Carte n'est pas un réseau d'opportunités séparé : c'est la représentation géographique du même champ Découvrir. Le parcours `carte → lieu → opportunité → N2 → retour` doit conserver son contexte, et ses trois interactions de bas de fiche doivent rester celles de Découvrir N1. La qualité éditoriale des titres et médias de démonstration n'est pas un critère de fermeture ; le comportement, l'autorité, la lisibilité et la composition le sont. + +## Changements + +1. **Actions communes :** Carte affiche les trois emplacements N1 (icône `DiscoveryOrbitIcon` Enregistrer, ouverture N2, `DiscoveryShareControl`). Save appelle `_saveDiscoveryPossibility`, le même flux propriétaire que le N1 de Découvrir, plutôt que de conserver sa propre procédure courte. Les boutons Save/Sharing sont présents mais **désactivés** tant que le détail owner et la source n'ont pas été vérifiés. Une position seule ne crée jamais l'autorité. +2. **Sharing :** Carte passe `showAsSheet=true` au composant Sharing commun ; les mêmes actions internes, externes, lien et QR sont affichées dans une feuille native sans recouvrir la petite fiche. Le partage interne bénéficie maintenant du garde d'erreurs `_action` utilisé par les autres options. +3. **Reconnaissance légère :** l'index compact transmet le nom public de l'opérateur et la représentation déjà publiée (type, eyebrow, image de reconnaissance) ainsi que le temps explicite de l'Occurrence. Les faits N2, permissions, commerce et Resources ne sont pas ajoutés. Flutter les utilise dans les lignes au même lieu et dans la fiche brève sans télécharger tous les détails. Si le média manque, une icône issue de la verticale publiée remplace un pictogramme identique universel. +4. **Liste au même lieu :** deux cartes de choix initialement visibles, expansion vers la liste complète par défilement ; image/icone, titre jusqu'à trois lignes, type/porteur et heure seulement s'ils sont connus. Sélectionne l'identité canonique et restaure la même carte. +5. **Carte et localisation :** bouton « Me localiser » flottant au coin inférieur droit, ancré juste au-dessus de la fiche lorsqu'elle existe, avec marge système. Le point de localisation de l'appareil reste une donnée locale et non une opportunité Discovery. +6. **MapLibre :** une source GeoJSON issue des **seuls lieux publics admis** applique le clustering natif par proximité au faible zoom (compteur de lieux et expansion au toucher). À zoom élevé, chaque coordonnée publiée retrouve son point ; une coordonnée commune à plusieurs opportunités continue d'ouvrir le sélecteur du lieu, sans les fusionner. L'ancienne annotation `CircleOptions` reste un repli si les couches natives échouent. Les mises à jour asynchrones de source sont sérialisées pour éviter qu'une réponse périmée remplace le nouveau champ. + +## Frontières et invariants + +- Un même `spatial_index` compact vient de la projection Discovery : pas de recherche/map cache indépendant ni de classement Flutter. +- Pas de nouvelle table, migration, dossier ou état métier. +- Aucune autorité déduite du zoom, d'un lieu ou d'un média. Pas de fuite de données privées ou de permissions à travers l'index compact. +- Le regroupement des points proches est **cartographique** ; il ne prouve pas que les activités appartiennent au même lieu canonique. Le groupe d'une coordonnée ne transfère aucune permission. +- Les compteurs des clusters natifs portent sur les **emplacements uniques**, tandis que la liste ouverte à une coordonnée donne le nombre d'**opportunités distinctes**. Il ne faut pas confondre ces unités. +- N3, propriétaires métier, Journey, Access, Payment et flux de Découvrir hors Carte restent inchangés. + +## Scénarios à valider sans grand audit + +- 23 ou 27 opportunités à une coordonnée : deux aperçus, expansion, sélection du dixième, retour N2 sans perte de caméra. +- Une position seule : trois commandes visuellement stables, Save et Sharing inactifs jusqu'à confirmation owner. Après confirmation, Orbit Save et Sharing sont identiques au flux N1. +- Partager depuis Carte : feuille des quatre actions, copie via `POST /api/v1/sharing/possibilities///` pour un Profil autorisé, erreur gérée sans exception non attrapée. +- Marqueurs proches à faible zoom : clusters, compteur des lieux et zoom au toucher ; lieux distincts au fort zoom ; plusieurs opportunités au même point restent ouvrables. +- « Me localiser » : en bas à droite, au-dessus d'une fiche ouverte, sans déplacer le contexte de recherche ni créer un nouvel objet Discovery. +- Aucune image démonstrative inventée : miniature publique si connue, avatar déjà divulgué si disponible, pictogramme métier sinon. + +**Tests ciblés de code ajoutés ou adaptés :** + +```powershell +cd mobile +dart format lib/features/discovery/discovery_mature_view.dart lib/features/discovery/discovery_screens.dart lib/features/discovery/discovery_share_sheet.dart lib/platform/maps/discovery_map_controller.dart lib/platform/maps/makolo_map_view.dart test/discovery_mature_screen_test.dart test/discovery_compact_spatial_test.dart test/discovery_map_clusters_test.dart +flutter test test/discovery_mature_screen_test.dart test/discovery_compact_spatial_test.dart test/discovery_map_clusters_test.dart test/discovery_stage07_actions_test.dart +cd .. +python manage.py test discovery.api.test_mature_discovery.MatureDiscoveryAPITests.test_compact_spatial_keeps_every_location_and_requests_details_on_demand --keepdb +``` + +Exécution Android réelle du clustering MapLibre et du partage interne non attestée par une exécution dans cet échange. Vérifier et corriger les éventuels écarts ciblés, sans détour par un audit CI global. diff --git a/docs/implementation/discover-closure-register.md b/docs/implementation/discover-closure-register.md new file mode 100644 index 0000000000..d435d38831 --- /dev/null +++ b/docs/implementation/discover-closure-register.md @@ -0,0 +1,187 @@ +# Makolo — registre de fermeture de Découvrir +**Document vivant — responsable : branche [#522](https://github.com/TraditionLearningCommunity/makolo/pull/522)** +**Initialisé le :** 2026-10-31 +**Chantier courant :** **09 — Fermeture d'implémentation uniquement sur #522**. [Contrat détaillé 09](discover-stage-09-certification-2026-11-02.md). Pas de checkpoint ni de réconciliation ; validation exécutée non attestée. +**Chantier 00 :** **FERMÉ — réconciliation documentaire**. +**Relevé de référence :** [chantier 00 — réconciliation](discover-stage-00-reconciliation-2026-10-31.md). + +> **RÈGLE DE PASSAGE : un chantier à la fois.** Les chantiers sont **séquentiels**, non parallèles. Ne jamais ouvrir l'implémentation N+1 tant que la livraison de code de N n'est pas achevée et documentée. À la demande explicite de l'utilisateur, la validation exécutée (tests/CI/appareil) est regroupée à l'étape 09 ; cela ne signifie ni tests verts ni certification anticipée. Les statuts `présent/partiel` décrivent l'implémentation rencontrée dans le code, **pas** l'état d'exécution du chantier. Les étapes 01–09 restent `VERROUILLÉ` ou `À OUVRIR` tant qu'on n'a pas travaillé et validé leur sortie. L'exécution CI est une exigence de la fermeture globale 09, explicitement **hors des chantiers 00–08** pour cette séquence de livraisons ; aucun résultat n'est revendiqué avant exécution. + +## 0. Doctrine de fermeture et niveaux de preuve + +**Faire seulement ce qui manque.** Le code existant est la base de réalisation, pas un chantier à recommencer. L'objectif est de fermer progressivement la chaîne : + +`owner métier → selector/read model → admission/composition Discovery → projection → Web/Flutter N1/N2 → handoff N3 owner → reprojection / local-first`. + +### Vocabulaire obligatoire + +- `EXISTANT_SOURCE` : chemin/mécanisme identifié dans le HEAD, sans certification automatique. +- `CONFORME_PROUVÉ` : code + scénario/test réellement vérifié, avec preuve enregistrée. +- `ÉCART_CODE` : divergence localisée dans un fichier et explicable. +- `PARTIEL` : mécanisme existant, obligation ou scénario non couvert complètement. +- `À_VÉRIFIER` : absence non démontrée ; ne pas lancer une implémentation spéculative. +- `OWNER_EXTERNE` : vérité ou mutation sous l'autorité d'un domaine hors Discovery ; corriger là si nécessaire. +- `OUVERT_CONCEPTUEL` : capacité optionnelle ou non définie par un owner, non bloquante. +- `FERMÉ` : critères de sortie du chantier satisfaits et consignés. + +**Critère de livraison de code pour 01–08 :** besoin et contrat, source de vérité, fichiers modifiés, tests de régression ajoutés/ajustés, risques et handoff consignés. Les tests non exécutés restent **non vérifiés** jusqu'à 09 ; aucune étape n'est dite certifiée avant les preuves d'exécution. + +### Anti-features protégées + +Pas de modèle persistant générique `Possibility`, de scoring client, de deuxième source cartographique indépendante, de faux owner, de capacité créée d'après un texte, de déduction d'autorité par Membership/Space, de conversion de `unknown` en gratuit/indisponible, de Save → Journey, de Watch → engagement, de feed infini artificiel, de migration pour confort visuel, ni de refonte de Now/En cours/Moi. + +## 1. Tableau de passage strict + +| Étape | Nom | État d'exécution | Sortie vérifiable | +| --- | --- | --- | --- | +| **00** | Réconciliation et gel des contrats | **FERMÉ** | [Inventaire daté](discover-stage-00-reconciliation-2026-10-31.md), sources et frontières, registre unique | +| **01** | Vérités et capacités propriétaires | **LIVRÉ EN CODE — validation différée à 09** | [Relevé owner 01](discover-stage-01-owner-facts-2026-10-31.md), correctifs Funding/Services, six tests ajoutés | +| **02** | Projection canonique Discovery | **LIVRÉ EN CODE — validation différée à 09** | [Relevé projection 02](discover-stage-02-canonical-projection-2026-10-31.md), admission Services, ressources, prix, pagination et tests | +| **03** | Médias, ressources et stockage | **CODE LIVRÉ — migration physique et validation différées** | [Relevé média 03](discover-stage-03-media-resources-2026-10-31.md) : gate owner commun, fichiers, Flutter, imports éditoriaux MPS privés, commande de migration conservatrice ; déploiement non réalisé | +| **04** | Presentation N1 | **LIVRÉ EN CODE — validation différée à 09** | [Relevé N1 04](discover-stage-04-presentation-n1-2026-10-31.md) : huit zones, ajout relatif, sens des faits et des contextes, média MIME/alt ; tests enregistrés | +| **05** | Recherche, carte et navigation | **LIVRÉ EN CODE — validation différée à 09** | [Relevé exploration 05](discover-stage-05-search-map-navigation-2026-10-31.md) : routes filtrées, carte compacte et repères, pagination signée, scroll/focus et public invité | +| **06** | N2, variantes et comparaison conditionnelle | **LIVRÉ EN CODE — validation différée à 09** | [Relevé 06](discover-stage-06-n2-variants-comparison-2026-10-31.md) : profondeur Web/Flutter, trois assessment states, variantes occurrence, comparaison strictement conditionnelle, aucun classement fictif, tests écrits | +| **07** | Save, Watch, Sharing, engagements N3 | **LIVRÉ EN CODE — validation différée à 09** | [Relevé 07](discover-stage-07-save-watch-sharing-engagement-2026-11-02.md) : Save identité confirmée, Watch CRUD Web/Flutter idempotent, Sharing interne/externe et reprise, routes owner N3 attestées par tests écrits | +| **08** | Local-first et confidentialité | **LIVRÉ EN CODE — validation différée à 09** | [Relevé 08](discover-stage-08-local-first-confidentiality-2026-11-02.md) : cache borné, tombstones, isolations Profil/Reader, retour offline, cold start, cache HTTP et tests écrits | +| **09** | Fermeture d'implémentation et contrat Découvrir | **EN COURS DANS #522 ; tests non attestés** | [Contrat détaillé 09](discover-stage-09-certification-2026-11-02.md) : N1/N2/N3, permissions, carte, pagination, Save/Watch/Sharing, local-first/confidentialité ; aucune autre PR de livraison | + +**Règle de retour :** si un chantier clos révèle une régression lors du suivant, créer une anomalie avec preuve et la corriger dans l'étape active comme dépendance bloquante ; ne pas relancer tout le périmètre ancien. Mise à jour du présent registre obligatoire. + +## 2. Frontière des propriétaires (préparation de l'étape 01) + +| Owner | Sources repérées | Obligation à prouver au chantier 01 | Interdit dans Discovery | +| --- | --- | --- | --- | +| **Activity / Occurrence** | Modèles `activities`, selectors `discovery/search.py`, `discovery/api/composition.py` | Publication, Occurrences réellement admises, horaires et variantes identifiées | Deviner des sessions à partir du texte | +| **Commerce** | `commerce/offer_price_facts.py`, `commerce/native_activity_checkout_api.py` | `unknown/free/exact/range`, prix unitaires, fenêtres, variantes par Occurrence | Calculer Quote, paiement, remise ou devise | +| **Geography / Transport** | `geography`, `transport`, `discovery/api/composition.py` | Lieux publics, rôle départ/arrivée/escale, localisation explicite | Inventer coordonnées, trajet ou lieux | +| **Opportunities** | `opportunities/knowledge.py`, `discovery/unified.py` | Révision/source admissible, provenance et réserve sur fraîcheur | Inventer une certitude ou relation | +| **Funding** | `funding/api_views.py`, `funding/api_serializers.py` | Fenêtres, conditions déclarées et inconnues | Déclarer un utilisateur éligible sans owner | +| **Services / Journey** | `services/native_start.py`, `services/selectors.py` | Démarrage autorisé, idempotence, lien privé à Opportunity | Créer une Journey depuis Save ou Watch | +| **Preparation / Resource** | `preparation/services.py`, `preparation/views.py` | Version, visibilité, contenu et accès effectifs | Promouvoir arbitrairement un document en média N1 | +| **Presentation / Storage** | `presentations/media_models.py`, `media_delivery.py`, `media_services.py` | Publication, propriété, retrait et livraison de fichiers | Déduire qu'une URL quelconque est publique | + +La présence de ces fichiers est attestée par l'inventaire de #522. Leur conformité n'est **pas encore validée** dans l'étape 01. + +## 3. Registre d'exigences — couverture actuelle et destination + +Les statuts ci-dessous sont des **observations d'audit**, pas des tâches commencées. Dans l'étape propriétaire, remplacer chaque `PARTIEL` ou `À_VÉRIFIER` par une preuve, une correction ciblée ou une limite owner légitime. + +| ID | Exigence contractuelle | Observation au 31/10 | Source / preuve d'entrée | Responsable de fermeture | +| --- | --- | --- | --- | --- | +| R01 | Possibilité distincte du modèle brut, identité owner stable | `EXISTANT_SOURCE` conservé ; aucun nouveau modèle ni ID métier artificiel, implémentation 02 livrée | `discovery/api/composition.py`, `discovery/api/projections.py` | 02 | +| R02 | Admission, visibilité, Groupe et non-divulgation | `ÉCART_CODE` corrigé pour Services via selector owner ; ressources contrôlées par Preparation et tests ajoutés, non exécutés | `discovery/unified.py`, `discovery/api/composition.py`, `discovery/api/test_mature_discovery.py` | 02 | +| R03 | Champ multi-famille sans classement local | `EXISTANT_SOURCE` préservé ; aucune nouvelle pertinence locale, aucun score | `discovery/unified.py`, `discovery/api/composition.py` | 02 | +| R04 | Faits et prix établis par Commerce | `OWNER_EXTERNE`, mécanisme identifié | `commerce/offer_price_facts.py` | 01 | +| R05 | Horaires/Occurrences/variantes sans inférence | `PARTIEL` | `discovery/api/composition.py`, modèles Activity | 01 puis 02 | +| R06 | Lieux multiples, public/privé et rôles transports | `PARTIEL` | `discovery/api/composition.py`, tests API | 01 puis 02/05 | +| R07 | Conditions et fraîcheur des Opportunities | `OWNER_EXTERNE`, mécanisme identifié | `opportunities/knowledge.py` | 01 | +| R08 | Conditions de Funding sans éligibilité fictive | `ÉCART_CODE` corrigé côté owner : Space suspendu/archivé/retiré refuse désormais publication/contribution ; tests ajoutés, non exécutés | `funding/selectors.py`, `funding/services.py`, `funding/test_z15_api.py` | 01 | +| R09 | Handoff Service owner et Journey idempotente | Selector Web/natif owner harmonisé, tests de retrait ajoutés non exécutés ; Discovery doit réutiliser ce gate en étape 02 | `services/views.py`, `services/api_views.py`, `services/native_start.py`, `services/test_native_start_api.py` | 01 puis 07 | +| R10 | Ressources publiques, sans données restreintes | `ÉCART_CODE` corrigé : droits Preparation, scope Occurrence, textes inline bornés sans supprimer les références ; tests non exécutés | `discovery/api/composition.py`, `discovery/api/test_resource_previews.py` | 02 ; livraison/stockage 03 | +| R11 | Cohérence collection/détail/médias | Collection/détail conservent la même composition et admission owner ; droits et fichiers des médias encore réservés à 03 | `discovery/api/composition.py`, `discovery/api/media_bindings.py`, `discovery/api/test_mature_discovery.py` | 02 ; médias 03 | +| R12 | Media-use owner, statut publié, retrait | `ÉCART_CODE` corrigé : media selector/livraison même admission owner, Group et Occurrence ; audit/migration legacy ajoutés, exécution non réalisée | `presentations/media_selectors.py`, `media_delivery.py`, `management/commands/harden_presentation_media_assets.py` | 03 ; migration déploiement | +| R13 | Rétention automatique de médias réellement first-party | `ÉCART_CODE` corrigé en 03/08 : origine de confiance, quota automatique public, retrait sérialisé, Reader isolé par Profil, aucune réutilisation de copies legacy partagées ; tests écrits non exécutés | `discovery_offline_store.dart`, `discovery_reader.dart`, `discovery_stage08_media_privacy_test.dart` | 03 ; 08 | +| R14 | N1 contextualisé et adaptatif | `ÉCART_CODE` corrigé : rôles géographiques, notes limitantes et variantes owner, section pratique adaptative, MIME/alt galerie ; tests N1 écrits, non exécutés | `discovery_possibility_unit.dart`, `discovery/views.py`, `templates/discovery/_universal_card.html` | 04 | +| R15 | N1 dates à droite, faits stables, ressources dépliables | Date ajout humanisée à droite et accessible exactement, description dépliable, deux ressources puis toutes et trois interactions réutilisées ; tests écrits non exécutés | `discovery_possibility_unit.dart`, `templates/discovery/_universal_card.html`, tests Flutter/Web | 04 | +| R16 | Recherche/filtres/continuation sur mêmes critères | `ÉCART_CODE` corrigé : routes conservent mêmes filtres, pages signées et historique local des curseurs réels, sans inventer de token ; tests ajoutés non exécutés | `discovery_repository.dart`, `discovery_screens.dart`, `discovery_routes.dart` | 05 | +| R17 | Carte liée au même champ et aux mêmes critères | `ÉCART_CODE` corrigé : query passée par URL, même watchItems, MapLibre avec repères owner, véritable carte compacte et sélection ; tests non exécutés | `discovery_routes.dart`, `discovery_screens.dart`, `discovery_mature_view.dart` | 05 | +| R18 | Retour, sélection, scroll et contexte après redémarrage | `ÉCART_CODE` corrigé : position de présentation par Profil, continuation opaque, root revalidé sur cold start, ancienne fenêtre éliminée à nouvelle première page, actions N3 suspendues sans nouvelle réponse owner ; essais Android restent à 09 | `discovery_screens.dart`, `discovery_repository.dart`, `discovery_stage08_local_first_test.dart` | 05 puis 08 | +| R19 | États NO_MATCH/END_OF_FIELD/OFFLINE distincts | Les états distincts du selector sont conservés ; réseau hors service ne purge pas le snapshot, refus 403/404 supprime et tombstone, sans faux résultat ni action. Tests écrits à 08, exécution à 09 | `sync_engine.dart`, `discovery_selector.dart`, `discovery_stage08_local_first_test.dart` | 05 puis 08 | +| R20 | N2 sens, profondeur, variantes et inconnues | `ÉCART_CODE` corrigé : N2 Web et Flutter owner-backed, favorable/limiting/unknown, ressources complètes, variantes et relations admises ; scénarios écrits non exécutés | `discovery_n2_model.dart`, `discovery_n2_depth.dart`, `discovery/n2_presentation.py`, `templates/discovery/possibility_detail.html` | 06 | +| R21 | Comparaison conditionnelle sur faits comparables | `ÉCART_CODE` corrigé : table facultative seulement pour ≥2 Occurrences d'une même Activity dont une dimension Début/Fin/Lieu est connue pour chacune ; pas d'alternative inter-verticale fictive | `discovery_n2_model.dart`, `discovery/n2_presentation.py`, tests dédiés | 06 | +| R22 | Save distinct de Watch distinct de Engage | `ÉCART_CODE` corrigé : confirmation Save identité+état, Watch créée et gérée indépendamment en API/Web/Flutter, retries idempotents, N2 Watch canonique ; tests écrits non exécutés | `discovery_repository.dart`, `discovery_watch_screens.dart`, `discovery/watch_forms.py`, `discovery/api/views.py` | 07 | +| R23 | Sharing interne/externe, reprise et refus | `ÉCART_CODE` corrigé : lien owner-confirmé, delivery ID obligatoire, tokens purgés au changement d'item, destinataire explicite non vide, reprise Auth pour partage privé, refus de route altérée ; tests écrits non exécutés | `sharing/api_views.py`, `discovery_share_sheet.dart`, `discovery_shared_link_screen.dart`, `test_stage07_sharing.py` | 07 | +| R24 | N3 owner-backed sans action fictive | Parcours natifs owner vérifiés et préexistants conservés ; tests N3 supplémentaires Commerce/Funding/Events/Obtention + Service existant, aucune action pour chemin forgé. Exécution à 09 | `discovery_possibility_unit.dart`, `discovery_stage07_actions_test.dart`, `discovery/api/test_personal_primary_actions.py` | 07 | +| R25 | Snapshot distingué de fraîcheur réseau | `ÉCART_CODE` corrigé : timestamps et fraîcheur distincts, lecture du snapshot périmée réconciliée avec source et refus, aucune mutation owner sur simple cache, revalidation volatile N1/N2/Map. Tests écrits, non exécutés | `discovery_repository.dart`, `discovery_screens.dart`, `discovery_possibility_unit.dart` | 08 | +| R26 | Pages bornées, reprise, profils, révocation | `ÉCART_CODE` corrigé : rotation bornée par Profile, pages actives protégées, suppression cascade owner, génération/époque de révocation, Reader Profile privé, non-cache HTTP, tests ajoutés non exécutés | `profile_store.dart`, `sync_engine.dart`, `discovery_offline_store.dart`, `test_stage08_http_privacy.py` | 08 | +| R27 | Cold start Android, liens et UI vraie | `À_VÉRIFIER` ; absence d'attestation | Runbook Android et tests Flutter | 09 | +| R28 | Migrations, compatibilité, CI, déploiement | `À_VÉRIFIER` — non exécuté à l'étape 00 | Six migrations listées dans le relevé | 09 | + +### Hors fermeture obligatoirement implémentée + +| Référence | Statut | Raison | +| --- | --- | --- | +| Scoring/ranking universel Molongo/Mayele | `OUVERT_CONCEPTUEL` | Aucun algorithme nouveau requis par le contrat ; le seam existant peut rester neutre | +| Comparaison universelle de toutes les possibilités | `OUVERT_CONCEPTUEL` | Propriétaire et dimensions comparables nécessaires | +| Relations n-aires inférées | `OUVERT_CONCEPTUEL` | Un texte similaire ne prouve pas un lien canonique | +| Veille sans owner contractuel | `OUVERT_CONCEPTUEL` | Ne pas inventer la persistance ou la notification | +| Média obligatoire sur chaque possibilité | `OUVERT_CONCEPTUEL` | Une possibilité sans image reste valide | + +## 4. Entrée et sortie complètes de chaque chantier + +### 00 — Réconciliation **FERMÉ** +**Entrées :** les trois fichiers joints, le code et les documents de #522, inventaire Git. +**Sorties vérifiées :** relevé daté, 381 chemins / six migrations, héritage de PR, propriétaires et tests, registre R01–R28, frontières de responsabilité et séquence obligatoire. +**Non réalisé volontairement :** tests, CI, correction de code des étapes suivantes, déploiement et merge. + +### 01 — Faits et capacités propriétaires **LIVRÉ EN CODE / VALIDATION À 09** +**Entrée :** R04–R09, frontières R10/R12, `docs/architecture/owner-facts-native-actions.md`. **Résultat technique :** [relevé de réalisation 01](discover-stage-01-owner-facts-2026-10-31.md). +**Travail :** inspecter pour chaque owner les modèles, selectors/services, API et tests ; partir de fixtures concrètes (Activity/Occurrence, Commerce, Geography/Transport, Opportunity, Funding, Service, Preparation). Corriger uniquement les faits réellement absents ou faux chez leur propriétaire, en réutilisant les fonctions existantes ; prouver la publication/visibilité, temporalité, variations et inconnues. +**Sortie exigée :** une matrice par owner avec statut `CONFORME_PROUVÉ` ou un écart corrigé, chemin réel, résultat de tests ciblés, contrat de consommation pour 02 ; aucune nouvelle vérité Discovery. +**Bloquant pour 02 :** un fait propriétaire encore indéfini, une autorisation floue, ou un champ ambigu dont Discovery devrait deviner le sens. + +### 02 — Projection Discovery **LIVRÉ EN CODE / VALIDATION À 09** +**Entrée :** sorties 01 ; R01–R03, R10–R11. +**Travail :** admission, identité, déduplication, familles, faits/provenance, collection/detail, pagination, sécurité et payload borné. +**Sortie livrée :** code de l'admission et de la projection, scénarios API ajoutés, [dossier 02](discover-stage-02-canonical-projection-2026-10-31.md) ; aucun objet de projection persistant ajouté. Tests non exécutés, résultat non certifié. + +### 03 — Médias et ressources **CODE LIVRÉ / DÉPLOIEMENT À VÉRIFIER** +**Entrée :** sorties 02 ; R12–R13 et fichiers Preparation/Presentation. +**Travail :** ownership/publication, médias first-party, ressources TEXT/FILE/URL, streaming/Reader, ancienne exposition publique et révocation, stockage sûr et budget. +**Sortie livrée :** [relevé 03](discover-stage-03-media-resources-2026-10-31.md) : gate owner partagé, liens contrôlés, client Flutter first-party, migration historique opt-in et tests écrits. Les tests, fichiers de production et CDN ne sont pas vérifiés. Les anciens liens éditoriaux `hero_image` réclament une action de migration avant suppression physique. + +### 04 — N1 **LIVRÉ EN CODE / VALIDATION À 09** +**Entrée :** sorties 03 ; R14–R15. +**Travail :** huit zones, description expandable, faits fixes, contexte, date ajout à droite, galerie, ressources et actions, responsive Web/Flutter. +**Sortie de code livrée :** [relevé 04](discover-stage-04-presentation-n1-2026-10-31.md), huit zones préservées, date ajout relative, faits contextualisés, médias owner et tests ciblés écrits. Compatibilité réelle Flutter/Web non attestée sans exécution. + +### 05 — Exploration / Carte / Navigation **LIVRÉ EN CODE / VALIDATION À 09** +**Entrée :** sorties 04 ; R16–R19. +**Travail :** query transférée dans la route Map, sélection, retour, état/pagination, géographie, Scroll/camera, recherche, filtres, curseur invalide. +**Sortie livrée :** [relevé 05](discover-stage-05-search-map-navigation-2026-10-31.md), query canonique Liste/Carte/Search, scroll/focus, pin et caméra owner, curseurs signés, public invité et tests écrits. Tests d'exécution non réalisés. + +### 06 — N2 / Variantes / Comparaison **LIVRÉ EN CODE / VALIDATION À 09** +**Entrée :** sorties 05 ; R20–R21. +**Travail :** profondeur réelle des propriétaires, descriptions et ressources, variantes, relations justifiées, inconnues, comparaison conditionnelle. +**Sortie livrée :** [rapport 06](discover-stage-06-n2-variants-comparison-2026-10-31.md) ; chaque famille admise possède N2 en lecture, assessment nuancé, variantes propriétaire liées à Occurrence, comparaison conditionnelle uniquement sur faits complets. Tests écrits non exécutés. + +### 07 — Actions et Engagement **LIVRÉ EN CODE / VALIDATION À 09** +**Entrée :** sorties 06 ; R22–R24. +**Travail :** Save/Watch/Share/owner engagement, handoff et retour, refus, auth, idempotence si nécessaire. +**Sortie livrée :** [rapport 07](discover-stage-07-save-watch-sharing-engagement-2026-11-02.md) ; Save vérifié, Watch CRUD idempotent, Sharing avec identités et reprises contrôlées, N3 strictement propriétaire, tests ciblés écrits. Les résultats métier ne sont jamais simulés ; tests non exécutés. + +### 08 — Local-first et Confidentialité **LIVRÉ EN CODE / VALIDATION À 09** +**Entrée :** sorties 07 ; R13/R18/R19/R25–R26. +**Travail :** snapshots, rotation, reprise, Profile isolation, media caches, refus/révocation, erreur réseau, démarrage à froid. +**Sortie livrée :** [rapport 08](discover-stage-08-local-first-confidentiality-2026-11-02.md). Garde-fous de lecture, synchro, cache, médias et reprise implémentés ; tests de scénario écrits ; exécution sur Android et certification non attestées avant 09. + +### 09 — Fermeture Découvrir **#522 UNIQUEMENT / EXÉCUTION NON ATTESTÉE** +**Entrée :** sorties 08 ; R27–R28. +**Travail :** tests Django/PostgreSQL, Flutter/Web/E2E, migration, formatage/analyse, compatibilité, vrai Android, CI, contrôle de la branche et décision d'intégration. +**Sortie à atteindre :** rapports de runs CI verts sur SHA final, goldens réellement approuvés, vérification vraie Android et migration/stockage, contrôles de collision et décision explicite d'intégration. Aujourd'hui gates rouges : voir [rapport 09](discover-stage-09-certification-2026-11-02.md). + +## 5. Journal des décisions et des preuves + +| Date | Étape | Décision / preuve | Effet | +| --- | --- | --- | --- | +| 2026-10-31 | 00 | Relevé `discover-stage-00-reconciliation-2026-10-31.md` ; main `d0f0ec4`, branche observée `7fc2418`, 381 fichiers, six migrations ; contrats joints examinés | Inventaire et frontières fixés | +| 2026-10-31 | 00 | Différences document historique ↔ runtime déclarées ; transferts #523/#525/#526/#527 reconnus ; **ascendance #524 vérifiée**, **divergence #528 vérifiée** | Pas de re-cherry-pick #524, pas de merge aveugle #528 | +| 2026-10-31 | 00 | **Chantier 00 fermé.** Aucun test, CI, changement fonctionnel ou merge. | **Prochain chantier autorisé : 01 exclusivement** | +| 2026-10-31 | 01 | Implémentation propriétaire Funding/Services sur #522 : visibilité du porteur, refus de nouvelle contribution et de démarrage après retrait ; six tests ajoutés, contrat des huit propriétaires consigné dans `discover-stage-01-owner-facts-2026-10-31.md`. | **Livrée en code ; certification différée à 09 sur demande utilisateur.** | +| 2026-10-31 | 02 | Projection multi-famille Discovery réconciliée avec Services, droits Preparation et scope Occurrence, texte inline borné, URLs publiques validées, prix Commerce prioritaires, curseur validé avant composition, erreurs HTTP 400 ; tests et document `discover-stage-02-canonical-projection-2026-10-31.md`. | **Livrée en code, tests non exécutés.** | +| 2026-10-31 | 03 | Gate Publication/Delivery owner+Group+Occurrence, trusted API origin Flutter, sauvegarde atomique/retrait sérialisé, nouveaux MPS hero en privé, commande migration legacy optionnelle et tests, dossier `discover-stage-03-media-resources-2026-10-31.md`. | **Code livré ; migration physique, CDN, anciens `hero_image` et tests non exécutés.** | +| 2026-10-31 | 04 | N1 Web/Flutter : date d'ajout relative à droite, signification des faits temporaux/spatiaux, absence d'inférence, notes limitantes et variantes réelles, galerie MIME/alt et sections conditionnelles ; tests dédiés dans `discovery_mature_screen_test.dart` et `test_m8b_mature_discover.py`, relevé `discover-stage-04-presentation-n1-2026-10-31.md`. | **Code livré, tests non exécutés.** | +| 2026-10-31 | 05 | Query Search/Carte, filtres et focus transités par URL et navigation globale/guest, vraie carte compacte et repères MapLibre, scroll root sauvegardé par profil, curseurs historiques, erreur continuation et sources ; tests et dossier `discover-stage-05-search-map-navigation-2026-10-31.md`. | **Code livré ; tests et carte réelle non certifiés.** | +| 2026-10-31 | 06 | N2 Flutter & Web sur `detail_projection` owner-admis, trois états d'assessment, variants Occurrence, relations explicites, ressources complètes, comparaison bornée aux dimensions réellement communes et tests `discovery_n2_depth_test.dart`/`test_n2_presentation.py`. | **Code livré ; tests/CI/appareil/Web non exécutés.** | +| 2026-11-02 | 07 | Save identité+état confirmés ; Watch CRUD owner et Web/Flutter idempotent, critères autonomes, pause/reprise, renommer, modifier et supprimer ; Sharing lien, livraison et reprise autorisés ; parcours N3 stricts + tests Python/Flutter ; dossier `discover-stage-07-save-watch-sharing-engagement-2026-11-02.md`. | **Code livré ; tests/CI non exécutés.** | +| 2026-11-02 | 08 | Lecture locale owner/Profil, cascade 403/404, courses réseau, rotation et continuation, Reader privé, médias publics, Cache-Control no-store, actions N3 à revalider, Watch confirmé, scénarios de régression Flutter/Django ; rapport `discover-stage-08-local-first-confidentiality-2026-11-02.md`. | **Code livré ; validation reprise au chantier 09.** | +| 2026-11-02 | 09 | Contrat de fermeture exhaustive livré dans #522 ; correction d'assainissement des anciennes copies Reader et test correspondant déjà présents dans #522 ; périmètre N1–N3 et local-first fixé. | **Implémentation en cours dans #522 ; tests/CI non demandés pour l'avancement, non certifié.** | + +Pour chaque livraison : ajouter une ligne `date | étape | commit de fin | fichiers | tests ajoutés | résultats exécutés (ou « non exécutés ») | anomalies restantes`. Le mot **certifié** reste réservé à l'exécution du chantier 09. + +## 6. Instruction impérative pour la suite + +1. Recharger le HEAD courant et le delta depuis l'état du chantier 00 ; vérifier les collisions sans refaire toute l'analyse. +2. La validation exécutée de 01–08 est différée à la certification 09, selon l'instruction explicite de l'utilisateur. Ne jamais prétendre que les tests ont réussi. +3. Conserver dans **#522** la traçabilité des livraisons 01–08 ; toute régression ultérieure se corrige à l'étape active avec un test ciblé. +4. **Rester strictement sur #522** pour les corrections d'implémentation et les tests écrits. Aucun checkpoint, aucune réconciliation, aucune fusion. Ne pas appeler le chantier certifié sans preuves d'exécution. diff --git a/docs/implementation/discover-stage-00-reconciliation-2026-10-31.md b/docs/implementation/discover-stage-00-reconciliation-2026-10-31.md new file mode 100644 index 0000000000..c527959a58 --- /dev/null +++ b/docs/implementation/discover-stage-00-reconciliation-2026-10-31.md @@ -0,0 +1,147 @@ +# Découvrir — chantier 00 : réconciliation de la PR #522 +**Date du constat :** 2026-10-31 +**Portée :** uniquement l'inventaire, les frontières, la provenance des contrats et les dépendances ; aucune correction fonctionnelle. +**Branche de travail :** `fix/discover-a-continuation-context-20261014` ([PR #522](https://github.com/TraditionLearningCommunity/makolo/pull/522)). +**Référence inspectée :** `7fc241864176f5f246a4a8d01aa655a4e8b28562`. +**Main inspecté :** `d0f0ec421c94c255fa4c0acd3509938a70c6322f`. +**Registre vivant :** [discover-closure-register.md](discover-closure-register.md). + +> Ce document est un relevé daté, non une promesse que le HEAD, les validations ou les PR empilées restent inchangés. Lors de l'ouverture du chantier suivant, relire uniquement les deltas postérieurs à ce relevé. Aucune CI ou suite de tests n'a été exécutée pendant ce chantier, à la demande de l'utilisateur. + +## 1. Décision d'intégration et règle de vérité + +- **Une seule branche d'intégration : #522.** Ne pas fusionner individuellement les PR historiques transférées ; ne pas fusionner automatiquement #522 dans `main`. +- **Un chantier à la fois.** Le chantier N+1 ne commence que lorsque le registre marque N comme `FERME`, avec les preuves explicites de sa sortie. Le fait qu'un module soit déjà partiellement développé n'ouvre pas son chantier. +- **Les contrats joints sont conceptuels**, non un schéma ORM, un JSON imposé ou une preuve de livraison ; ils orientent la conformité, tandis que le code actuel, les migrations, les contrôles d'accès et les tests font foi pour l'état réellement implémenté. +- **Pas de nouvelle vérité Discovery :** pas de modèle `Possibility`, calcul local de ranking, permission déduite, relation fondée sur la similarité des textes, Journey déclenchée par Save ou substitution d'un mock au propriétaire métier. +- **Web et Flutter consomment les mêmes vérités admises** ; leur Presentation peut différer. + +## 2. Références contractuelles vérifiées + +### Documents joints par l'utilisateur (hors dépôt ; ne pas les dupliquer) + +1. `Makolo_Contrat_Maitre_Now_Ongoing_Decouvrir_Me_2026-10-05(3).md` — gel conceptuel personnel ; la partie III régit Découvrir, les autres parties régissent les frontières avec Now/En cours. +2. `Makolo_DecouvrirMe_Contrats_Donnees_Serveur_Client_Presentation_Consolidation_2026-10-10(1).md` — chaîne owner → selector/read model → Discovery → projection → Presentation N1/N2/N3 → action propriétaire ; définit l'admission, l'identité, l'exploration et la connaissance. +3. `Makolo_UX_Decouvrir_Contrat_Consolide.md` — champ personnel des possibilités, liste/carte, comparaison conditionnelle, Save/Watch/Engage, local-first, anti-features et fins du champ. + +Ces fichiers sont des **entrées de travail externes à ce dépôt**. Leur disponibilité dans la conversation ne signifie pas qu'ils sont versionnés par Git. Les liens et les formulations ci-dessus n'assertent aucune implémentation. + +### Documents présents sur #522 + +| Document | Valeur | Précaution | +| --- | --- | --- | +| `docs/architecture/makolo-domain-blueprint.md` | Autorités et domaines canoniques | Source de conception architecture, à interpréter avec le code actuel | +| `docs/architecture/z3-discovery-projection-audit.md` | Inventaire de composition et des quatre familles | Snapshot historique ; ses SHAs et statuts ne sont pas ceux de la branche courante | +| `docs/architecture/backend-ux-projection-api.md` | Contrats projection / UX | Vérifier son état contre les projections réelles | +| `docs/architecture/discovery-n1-layout-contract.md` | Huit zones N1 et deux ressources initialement visibles | « Implémenté » ne prouve pas que tous les scénarios Web/Flutter ont été testés | +| `docs/architecture/owner-facts-native-actions.md` | Prix, connaissance, relation et démarrage Services | Sa phrase « ne modifie pas Discovery ni Flutter » décrivait un chantier propriétaire antérieur, pas #522 consolidée | +| `docs/architecture/p2-presentation-media-use-contract.md` | Association des médias et publication | Distinguer relation propriétaire et présentation | +| `docs/architecture/p2-p5-presentation-media-n1-handoff.md` | Média N1 et livraison contrôlée | Les notes sur l'ancien stockage public et `private_file` sont successives ; vérifier le routage legacy réel | +| `docs/architecture/discovery-sharing-mobile-handoff.md` | Liens partagés et App Links | Domaines, signatures et `assetlinks.json` de production non déductibles du dépôt | +| `docs/architecture/mobile-geographic-search-foundation.md` | Fondations recherche géographique | La correspondance liste ↔ carte est à éprouver | +| `docs/operations/android-discovery-beta-local-repro-2026-10-19.md` | Procédure de reproduction Android | Diagnostic, non preuve de résolution sur appareil | +| `docs/operations/discover-n1-media-demo.md` | Seed/démo et audit de fichiers physiques | Ne pas confondre fichiers de démo manquants et bug de livraison | +| `specs/004-mobile-discovery-mature/tasks.md` | T001–T013 cochées | Trace de réalisation historique, non clôture produit générale | + +**Décision documentaire :** ne pas réécrire ni uniformiser les contrats historiques. Le présent relevé fixe leur provenance ; [le registre vivant](discover-closure-register.md) porte désormais le statut courant d'acceptation. + +## 3. État Git inspecté + +- Comparaison `main...fix/discover-a-continuation-context-20261014` : branche en avance sur `main`, sans commit `main` manquant dans le résultat interrogé ; base commune `d0f0ec421c94c255fa4c0acd3509938a70c6322f`. +- **381 chemins modifiés** selon l'inventaire paginé de fichiers de la PR #522 ; **24 racines**. Le compare REST peut limiter son aperçu à 300 fichiers : ne jamais substituer ce plafond au listing paginé de la PR. +- Répartition exacte des 381 chemins à ce relevé : + +| Racine | Chemins | Racine | Chemins | Racine | Chemins | +| --- | ---: | --- | ---: | --- | ---: | +| `.github` | 2 | `accounts` | 9 | `commerce` | 8 | +| `config` | 1 | `core` | 26 | `discovery` | 12 | +| `docs` | 17 | `domain_events` | 1 | `e2e` | 2 | +| `funding` | 5 | `mobile` | 150 | `obtention` | 2 | +| `opportunities` | 2 | `organizations` | 17 | `payments` | 3 | +| `preparation` | 4 | `presentations` | 27 | `requirements.txt` | 1 | +| `scripts` | 20 | `services` | 9 | `sharing` | 18 | +| `static` | 11 | `templates` | 29 | `topics` | 5 | + +Les modifications dépassent donc Discovery et touchent les surfaces et propriétaires voisins. **Ce n'est pas une autorisation de refaire ces domaines pendant la fermeture de Découvrir.** + +### Migrations présentes dans le diff (six) + +1. `accounts/migrations/0009_profile_visibility_policies.py` +2. `presentations/migrations/0005_presentationmediause.py` +3. `presentations/migrations/0006_private_presentation_assets.py` +4. `services/migrations/0005_service_start_operation_fingerprint.py` +5. `sharing/migrations/0006_passport_issued_public_and_sharing.py` +6. `topics/migrations/0005_profile_publishable_defaults.py` + +**Statut : recensées, non exécutées ni certifiées dans le chantier 00.** Les vérifier dans le chantier propriétaire correspondant et dans la fermeture globale. + +## 4. Provenance des travaux voisins et risques de double fusion + +| PR | Relation observée | Consigne de réconciliation | +| --- | --- | --- | +| [#523](https://github.com/TraditionLearningCommunity/makolo/pull/523) | Décrit explicitement son transfert vers #522 : Commerce / Opportunities / Services | Ne pas merger séparément ; vérifier dans le chantier 01 les contracts et fichiers courants | +| [#525](https://github.com/TraditionLearningCommunity/makolo/pull/525) | Décrit son transfert vers #522 : `PresentationMediaUse`, tests et documentation | Ne pas merger séparément ; distinguer association et livraison des médias | +| [#526](https://github.com/TraditionLearningCommunity/makolo/pull/526) | Marquée superseded par #522 ; trois fichiers reportés, commits documentés | Ne pas rejouer les mêmes changements dans Flutter | +| [#527](https://github.com/TraditionLearningCommunity/makolo/pull/527) | #522 annonce le transfert des quatre commits et la fermeture de #527 | Ne pas dupliquer `SyncEngine` / `SyncLifecycle` ; vérifier la régression dans le chantier 08 | +| [#524](https://github.com/TraditionLearningCommunity/makolo/pull/524) | **Ascendance Git confirmée** : `fix/android-discovery-beta-visible-errors-20261019` (`d5b06767085cd6ccd13fcd107956aa395b6a4a63`) est ancêtre de #522, comparaison `behind=0` | Les commits de la branche sont déjà dans l'histoire de #522 ; ne pas re-cherry-pick ni merger séparément ; validation de comportement reste due | +| [#528](https://github.com/TraditionLearningCommunity/makolo/pull/528) | **Branches divergentes confirmées** : `feat/space-nous-mature-20261031` et #522 ont `merge-base=d0f0ec421c94c255fa4c0acd3509938a70c6322f` ; `#528...#522` montre un commit côté #528 absent de #522 au relevé | Ne pas merger à l'aveugle ; maintenir la frontière Nous et contrôler `accounts`, `organizations`, `mobile` commun | + +**Réconciliation terminée pour le chantier 00** signifie que les dépendances sont **identifiées, comparées et attribuées** : #524 est déjà dans l'ascendance de #522 ; #528 reste une branche divergente sans intégration présumée. Leurs comportements ne sont pas déclarés testés. + +## 5. Carte des propriétaires et modules existants + +| Responsabilité | Propriétaire / fichiers existants | Test ou vérification à réutiliser | Chantier destinataire | +| --- | --- | --- | --- | +| Prix, Offres, checkout | `commerce/offer_price_facts.py`, `commerce/native_activity_checkout_api.py` | `commerce/test_offer_price_facts.py`, `commerce/test_native_activity_checkout_api.py` | 01 | +| Activity / Occurrence / faits temporels | `discovery/search.py`, modèles `activities`, `discovery/api/composition.py` | `discovery/api/test_mature_discovery.py` | 01 puis 02 | +| Places / transports | modèles `geography`, `transport`, faits `discovery/api/composition.py` | `mobile/test/dg_discovery_geography_test.dart`, tests API | 01 puis 02/05 | +| Funding | `funding/api_serializers.py`, `funding/api_views.py` | `funding/test_discovery.py` | 01 | +| Opportunity / provenance | `opportunities/knowledge.py`, `discovery/unified.py` | `opportunities/test_public_source_knowledge.py` | 01 | +| Services / Journey | `services/native_start.py`, `services/selectors.py` | `services/test_native_start_api.py`, `services/test_owner_relations.py` | 01 puis 07 | +| Ressources métier | `preparation/services.py`, `preparation/views.py` | `preparation/tests.py` | 01 puis 03 | +| Admission, déduplication, wire | `discovery/api/composition.py`, `discovery/api/projections.py`, `discovery/api/views.py` | `discovery/api/test_mature_discovery.py`, `discovery/api/test_projections.py` | 02 | +| Médias de représentation | `presentations/media_models.py`, `presentations/media_services.py`, `presentations/media_delivery.py` | `presentations/test_media_use.py`, `discovery/api/test_media_bindings.py` | 03 | +| N1/Web | `templates/discovery/_universal_card.html`, `discovery/views.py` | `discovery/test_m8b_mature_discover.py` | 04 | +| N1/Flutter et N2 | `mobile/lib/presentation/possibilities/discovery_possibility_unit.dart`, `mobile/lib/features/discovery/discovery_mature_view.dart` | `mobile/test/discovery_mature_screen_test.dart`, `mobile/test/discovery_reader_test.dart` | 04 puis 06 | +| Recherche, map et navigation | `mobile/lib/features/discovery/discovery_routes.dart`, `discovery_screens.dart`, `discovery_repository.dart` | `mobile/test/dg_discovery_geography_test.dart`, `mobile/test/discovery_offline_position_test.dart` | 05 | +| Sharing / Save / Watch | `sharing/api_views.py`, `discovery/api/views.py`, `mobile/lib/features/discovery/discovery_share_sheet.dart` | `sharing/test_discovery_share_api.py`, `discovery/api/test_personal_primary_actions.py` | 07 | +| Local-first, sync, retenue | `mobile/lib/sync/sync_engine.dart`, `mobile/lib/data/local/profile_store.dart`, `mobile/lib/presentation/possibilities/discovery_offline_store.dart` | `mobile/test/sync_engine_test.dart`, `mobile/test/discovery_recognition_offline_test.dart` | 08 | + +Les chemins désignent des points d'entrée inspectés ou inventoriés, **pas** une attestation que leurs tests passent. + +## 6. Écarts démontrés, questions non prouvées et destinations + +| ID | Nature | Constats à ce relevé | Chantier | +| --- | --- | --- | --- | +| D-01 | **Écart de code constaté** | La route `/discover/map` construit `DiscoveryMapScreen` sans la requête de recherche ; l'écran utilise `static const _query = DiscoveryQuery()`. | 05 | +| D-02 | **Écart de portée constaté** | `discovery.presentation-position` retient page et continuation, pas un état complet query/filtres/sélection/scroll. | 05 puis 08 | +| D-03 | **Risque concret à corriger** | `isPublishedRecognitionUri` vérifie le chemin mais pas l'origine de confiance ; le widget accepte une URL HTTP(S) et la charge. | 03 puis 08 | +| D-04 | **Risque de coût** | La projection API expose les ressources publiques sans limite de nombre, avec `text_content` des ressources TEXT inline ; étudier la borne de poids sans supprimer les ressources légitimes. | 02 puis 03 | +| D-05 | **Validation non établie** | Search et root ont des mécanismes distincts de pagination ; continuité et cursors expirés à éprouver sur les deux. | 05 | +| D-06 | **Dette legacy / déploiement** | Anciennes URL `PresentationAsset.file` et nouveaux `private_file` ; leur exposition effective dépend du routage et du stockage de déploiement. | 03 | +| D-07 | **Couverture à mesurer** | Actions N3, relations/variantes, galerie, ressources, profondeur et comparaison conditionnelle sur chaque famille ; ne pas présumer absent sans tests. | 01, 04, 06, 07 | +| D-08 | **Validation non attestée** | Cold start Android, changement de Profile, offline, révocation, Web/E2E et intégration finale n'ont pas de preuve vérifiée dans cet audit. | 08 puis 09 | +| D-09 | **Coordination externe** | #524 est ancêtre de #522 : ne pas la rejouer. #528 est divergente : contrôler les collisions de composants partagés avant intégration. | 01 / 09 selon collision | + +**Terminologie :** `constaté` = localisé dans le code ; `risque` = chemin suspect sans incident reproduit ; `à mesurer` = couverture inconnue ; `non attesté` = aucune exécution vérifiée, et non « tests échoués ». + +## 7. Frontières explicites — travail interdit dans le chantier 00 + +- Aucun nouveau modèle, migration, API, widget, algorithme, extension opportuniste, test artificiel ou changement de droits. +- Aucun lancement ou modification de CI, aucun merge, aucun déploiement, aucun dédoublonnage manuel de commits sans preuve. +- Ne pas corriger prématurément D-01 à D-09 : les problèmes sont désormais classés et affectés ; leur traitement attend le chantier destinataire. +- Ne pas transformer les limites conceptuelles volontairement ouvertes (ranking, Molongo, comparaison universelle, nouvelles relations n-aires, watch non owner-backed) en obligations de développement. +- Ne pas fusionner les vérités Now, En cours, Moi et Découvrir. + +## 8. Critères de sortie du chantier 00 + +- [x] Branche, base et HEAD de référence identifiés. +- [x] Périmètre de PR et migrations inventoriés. +- [x] Contrats joints et documents du dépôt classés par provenance et autorité. +- [x] Transferts des PR voisines recensés, ascendance #524 et divergence #528 vérifiées. +- [x] Propriétaires, modules, tests existants et responsabilités cartographiés. +- [x] Écarts, risques et validations non prouvées assignés à des chantiers futurs. +- [x] Règle de séquentialité et interdiction des nouvelles vérités fixées. +- [x] Registre vivant associé au présent relevé. + +**Sortie du chantier 00 : DOCUMENTATION / RÉCONCILIATION FERMÉE** après présence de ce fichier et du registre vivant dans #522. Aucune sortie technique de chantier 01–09 n'est attestée ici. Une évolution ultérieure du HEAD appelle une revue de delta, pas une réouverture générale de 00. diff --git a/docs/implementation/discover-stage-01-owner-facts-2026-10-31.md b/docs/implementation/discover-stage-01-owner-facts-2026-10-31.md new file mode 100644 index 0000000000..01fe905dc6 --- /dev/null +++ b/docs/implementation/discover-stage-01-owner-facts-2026-10-31.md @@ -0,0 +1,97 @@ +# Découvrir — chantier 01 : vérités et capacités propriétaires + +**Date du relevé :** 2026-10-31 +**Intégration :** [PR #522](https://github.com/TraditionLearningCommunity/makolo/pull/522) ; branche `fix/discover-a-continuation-context-20261014`. +**Précondition :** [chantier 00](discover-stage-00-reconciliation-2026-10-31.md), fermé. +**Nature :** travail terminé côté source et tests ajoutés ; **exécution des tests non possible dans le contexte d'édition GitHub utilisé**. Aucun CI, déploiement, migration ni merge n'a été lancé. +**Registre :** [discover-closure-register.md](discover-closure-register.md). + +## 1. Décision : réutiliser avant de corriger + +Cette étape ne crée aucun modèle de Possibilité, ne modifie pas `discovery/api/composition.py`, ne forge pas de permission et ne calcule aucun fait de domaine côté Flutter. Le contrat est : + +`owner canonique → selector/read model owner → Discovery (étape 02) → Presentation`. + +Le statut `code existant` signifie qu'une implementation a été inspectée ; **il ne signifie jamais `tests verts`**. + +## 2. Matrice propriétaire vérifiée sur les sources + +| Domaine / obligation | Source canonique ou méthode effectivement inspectée | Conclusion et traitement étape 01 | Tests existants ou ajoutés | +| --- | --- | --- | --- | +| Activity : publication, visibilité, propriétaire logique | `activities/models.py` ; `discovery/search.py::_base_queryset`, `search_occurrences` | `ActivityStatus.PUBLISHED`, `ActivityVisibility.PUBLIC`, Occurrences `SCHEDULED` et filtre temporel existants. Les critères de composition Discovery restent au chantier 02. **Pas de modèle ni règle temporelle parallèle créée.** | `discovery/api/test_mature_discovery.py`, `discovery/test_m8b_mature_discover.py` | +| Occurrence : horaires et variantes | `activities/models.py` (`timing_kind`, `start_at`, `end_at`, dates/heures et timezone) ; `commerce/offer_price_facts.py::occurrence_variant_price_facts` | Les Occurrences gardent leurs identités et fenêtres. Le client ne doit pas reconstruire une variante depuis une description. **Réutilisation.** | `commerce/test_offer_price_facts.py`, `discovery/api/test_mature_discovery.py` | +| Commerce : prix réels | `commerce/selectors.py::applicable_offers`, `currently_available_offers` ; `commerce/offer_price_facts.py::offer_price_facts`, `offer_price_facts_many`, `occurrence_variant_price_facts` | `unknown/free/exact/range`, devise unique, offres actives dans leur fenêtre, prix unitaires, variantes bornées (2..12) et `fact_freshness=not_asserted` existent. Les remises, taxes, Quote/Payment ne sont **pas** des facts Discovery. **Aucun changement.** | `commerce/test_offer_price_facts.py` | +| Geography : places et divulgation | `geography/models.py::Place`, `SpacePlace` ; `discovery/presentation.py::_space_place_is_public` | Les coordonnées sont optionnelles, par paire et validées ; `SpacePlace` possède `is_public` et `is_active`. Les décisions de projection, rôle et sélection des lieux demeurent dans l'étape 02. **Aucun changement.** | `discovery/api/test_mature_discovery.py`, `mobile/test/dg_discovery_geography_test.dart` | +| Transport : itinéraires et départs | `transport/models.py::TransportRouteStop`, `TransportService`, `TransportDeparture` | Les positions, places, restrictions d'embarquement/débarquement, mode, Occurrence et contraintes de même Space sont propriétaires. Ne pas inventer de coordonnées, itinéraires ou places dans Discovery. **Aucun changement.** | `discovery/api/test_mature_discovery.py` (rôles spatiaux) | +| Opportunity : source et degré de connaissance | `opportunities/selectors.py::published_opportunities` ; `opportunities/knowledge.py::public_source_knowledge` | Révision publiée, sources actives bornées à huit avec indicateur de troncation, horodatage des observations ; `fact_freshness=not_asserted`. Contrôle de source ≠ certification des faits. **Aucun changement.** | `opportunities/test_public_source_knowledge.py` | +| Funding : ouverture, conditions, progression | `funding/models.py::FundingDetails`, `funding/selectors.py::funding_progress[_many]`, `funding_accepts_contributions`, `public_fundings` ; `funding/services.py::create_funding_contribution` | **Écart corrigé** : un Space non public/non recherchable, suspendu ou archivé ne publie plus de Funding dans la collection owner, et ne peut plus créer d'obligation de contribution nouvelle. Compte personnel inactif ou Profile explicitement privé/non recherchable exclu (absence de UserProfile conservée en compatibilité). La fenêtre `opens_at/closes_at` et les montants restent owner-backed. | Tests préexistants `funding/tests.py`, `funding/test_discovery.py`, `funding/test_z15_api.py` ; trois nouveaux tests dans `funding/test_z15_api.py` | +| Service : publication et démarrage Journey | `services/views.py::public_services` ; `services/api_views.py::ServiceStartAPIView` ; `services/native_start.py` ; `services/selectors.py::service_opportunity_relation_for_viewer` | **Écart corrigé** : catalogue Services Web et démarrage natif partagent désormais le filtrage du Space public/recherchable, actif et non suspendu, ainsi que l'owner Profile actif et ses préférences explicites de visibilité (compatibilité conservée si l'extension UserProfile est absente). L'identité d'opération idempotente et la relation privée Journey → Opportunity demeurent propriétaires. | `services/test_native_start_api.py`, `services/test_owner_relations.py` ; trois nouveaux tests dans `services/test_native_start_api.py` | +| Preparation / Resources : contenu, versions et autorité | `preparation/services.py::can_view_resource`, `create_resource`, `publish_resource`, `replace_resource`, `retire_resource` ; `preparation/views.py::ResourceDownloadView`, `ResourceTextReadView` | Public/participant/restreint ont des droits distincts ; fichiers et textes restent livrés par les routes propriétaires avec `no-store`. Les tests de capacité d'affichage et poids inline relèvent des étapes 02–03. **Aucun changement.** | `preparation/tests.py`, `discovery/api/test_resource_previews.py` | + +## 3. Implémentation ciblée livrée sur #522 + +### Funding : publication owner et contribution nouvelle + +1. `funding/selectors.py::public_fundings` refuse désormais les Spaces qui ne sont pas `public_profile`, `searchable`, `lifecycle=active`, ou dont `verification_status=suspended`. Il refuse aussi un propriétaire personnel désactivé. Ne modifie pas les données, l'historique ou les obligations déjà créées. +2. `funding/selectors.py::funding_accepts_contributions` applique ces mêmes restrictions owner à la **création** de contributions, tout en conservant le test de fenêtre et la règle `PRIVATE` existante. Il ne fait ni remboursement, ni retrait ou annulation de Payment déjà produit. +3. `funding/services.py::create_funding_contribution` utilise le verrou existant et précharge `activity__space` et `activity__owner_profile` avant de décider et de créer une obligation. +4. `funding/test_z15_api.py` vérifie Space suspendu, archivé, non public ou non recherchable, absence de nouvelle `FundingContribution`, restauration du comportement quand l'owner redevient public, propriétaire personnel inactif, et choix explicite de confidentialité du Profile. + +### Services : un seul selector d'admission owner pour Web et natif + +1. `services/views.py::public_services` sélectionne les Services publiés/publics dont le porteur est un Space public, recherchable, actif et non suspendu, ou un Profile propriétaire actif. +2. `services/api_views.py::_public_service_for_viewer` réutilise ce selector sans ajouter une deuxième exception locale ; l'éligibilité de Groupe reste contrôlée par le gate déjà présent. +3. `services/test_native_start_api.py` vérifie que GET/POST `/api/v1/services//start/` retournent 404 après retrait de la publication de l'owner, sans créer de Journey, et redeviennent accessibles après restauration ; propriétaire personnel désactivé et choix explicites de confidentialité de Profile également couverts. + +**Invariants préservés :** aucun nouveau schéma/migration, aucune nouvelle autorité, aucun `profile_id` client, aucune mutabilité retroactive d'un Payment ou d'une Journey existante. + +## 4. Contrat de consommation à transmettre à l'étape 02 (sans l'implémenter ici) + +- **Commerce** : choisir Activity ou Occurrence selon le contexte admis ; ne pas rabattre les prix de deux sessions sur un seul prix fictif. `OfferPriceFacts.as_dict()` est un fait indicatif unitaire, non une Quote. +- **Activity / Occurrence** : identité de possibilité et variantes à composer à partir des IDs admis ; ne pas transformer les mentions de dates dans une description en champs `timing`. +- **Geography / Transport** : ne divulguer que les Place et roles dont le propriétaire confirme la publication ; coordonnées nulles ne deviennent pas des pins inventés. +- **Opportunity** : fenêtre owner, `current_revision` et `public_source_knowledge` ; source vérifiée ≠ faits certifiés actuels. +- **Funding** : **réutiliser exclusivement `public_fundings`** comme source publique avec gate Groupe existant ; `funding_accepts_contributions` pour savoir si une contribution est effectivement possible maintenant ; ne pas déclarer l'éligibilité individuelle. +- **Services** : `public_services` est maintenant le selector canonique de publication Web et API native ; les branches Discovery utilisant directement `ServiceDetails.objects.filter(...)` doivent être **réconciliées au chantier 02**, pas modifiées ici. Le handoff Service reste owner-backed. +- **Preparation** : distinguer visibilité des Resources, occurrence liée, publication, autorité du lecteur et existence physique ; le document n'est pas automatiquement un média de reconnaissance. + +**Point d'intégration explicite :** `discovery/unified.py::public_service_discovery_item[s]` maintient un filtre `ServiceDetails` local historiquement distinct de `public_services`; le chantier 02 doit remplacer ou aligner ce gate avant de certifier la cohérence Web/natif. Ne pas appeler le chantier 01 une validation de tous les consommateurs Discovery. + +## 5. Contrôles et limites + +**Revues statiques effectuées :** inspection des sources ci-dessus et relecture sur GitHub des fichiers corrigés. Des tests de régression nouveaux sont présents, mais **ni leur exécution ni celle des tests existants ne sont attestées**. L'environnement d'édition GitHub de cette intervention ne fournit pas un checkout exécutable du dépôt ; le container ne peut joindre GitHub. L'absence de résultat ne vaut pas échec ni succès. + +**Commandes ciblées prêtes, SANS CI globale :** + +```bash +python manage.py test commerce.test_offer_price_facts opportunities.test_public_source_knowledge +python manage.py test funding.tests funding.test_discovery funding.test_z15_api +python manage.py test services.test_native_start_api services.test_owner_relations +python manage.py test preparation.tests discovery.api.test_mature_discovery +python manage.py check +python manage.py makemigrations --check --dry-run +``` + +Les tests Discovery dans cette liste sont **des gardes d'intégration des propriétaires**, pas l'ouverture anticipée du chantier 02. + +## 6. Décision de sortie + +- [x] Tous les domaines propriétaires attendus ont été inspectés et attribués à un selector/service existant. +- [x] Les vérités Commerce/Occurrence/Geography/Transport/Opportunity/Preparation ne sont pas dupliquées. +- [x] Les écarts Funding et Services constatés ont reçu des corrections propriétaires ciblées. +- [x] Six tests de régression owner ont été ajoutés (trois Funding, trois Service). +- [x] Le contrat de consommation et le risque de décalage Service/Discovery sont consignés pour le chantier 02. +- [ ] **Les tests ciblés n'ont pas été exécutés et leurs résultats ne sont pas attestés.** + +**Statut d'ingénierie : IMPLÉMENTÉ, EN ATTENTE DE VALIDATION CIBLÉE.** Conformément à la règle séquentielle du registre, **ne pas commencer le chantier 02** tant que les tests ciblés n'ont pas confirmé la sortie et que le statut 01 n'a pas été changé en `FERMÉ`. Ne pas présenter le chantier 01 comme certifié par une CI inexistante. + +## 7. Mise à jour de la séquence — 31 octobre 2026 + +Sur instruction expresse de l'utilisateur, la livraison des chantiers est centrée +sur **l'implémentation complète de leur périmètre** ; l'exécution des tests +et de la CI est reportée au chantier 09. La condition de blocage de la +section 6 concernant les tests ciblés est donc remplacée, pour le passage +de l'implémentation, par cette décision. **Aucun résultat de test ne doit +être inventé.** La livraison de code 01 a été suivie par l'implémentation +du [chantier 02](discover-stage-02-canonical-projection-2026-10-31.md), +sans que les corrections propriétaire aient été re-créées ou modifiées. diff --git a/docs/implementation/discover-stage-02-canonical-projection-2026-10-31.md b/docs/implementation/discover-stage-02-canonical-projection-2026-10-31.md new file mode 100644 index 0000000000..7722b7f8c7 --- /dev/null +++ b/docs/implementation/discover-stage-02-canonical-projection-2026-10-31.md @@ -0,0 +1,77 @@ +# Makolo — Découvrir : chantier 02, projection canonique Discovery + +**Date :** 2026-10-31 +**Branche :** `fix/discover-a-continuation-context-20261014` ([PR #522](https://github.com/TraditionLearningCommunity/makolo/pull/522)) +**Livraison :** implémentation et tests de régression ajoutés ; exécution de tests/CI reportée à la certification finale sur instruction explicite de l'utilisateur. +**Prérequis :** chantier 00 réconcilié ; chantier 01 implémenté dans les domaines propriétaires. +**Frontière :** aucune modification Flutter, aucun nouveau modèle `Possibility`, aucune migration, aucun algorithme Molongo, aucune ouverture des chantiers 03–09. + +## 1. Invariants conservés par le code préexistant + +- `discovery/api/composition.py::compose_discovery` demeure le point de composition des quatre familles (`activity`, `service_activity`, `funding_activity`, `opportunity`) sur les candidats des propriétaires. Aucun nouveau ranking ou score client. +- `_combine_candidates` préserve l'ordre amont et déduplique par identité canonique de candidat ; les IDs et révisions propriétaires subsistent. +- `project_rows` et `detail_projection` produisent la même grammaire de possibilité, les faits et métadonnées connus ; l'endpoint détail ne remplace pas Search. +- Group eligibility, `request.user`, sauvegarde, relations personnelles et permissions owner continuent à déterminer l'accès, jamais une identité fournie par le client. +- Le token de continuation signé est lié à la requête et au viewer. **Il ne constitue pas un snapshot immuable** : une modification du corpus entre deux pages peut encore modifier les résultats recalculés. Cela n'autorise pas une promesse plus forte. + +## 2. Écarts corrigés + +| Réf. | Ancien comportement | Implémentation effectuée | Preuve de code | +| --- | --- | --- | --- | +| P02-A — Admission Services | `discovery/unified.py` reconstruisait un filtre de statut/visibilité inférieur au `services.views.public_services` de l'étape 01. Le détail et la collection pouvaient donc présenter un Service dont l'owner a retiré sa publication. | `public_service_discovery_item` et `public_service_discovery_items` réutilisent `public_services()` ; le gate de Groupe est conservé. | `discovery/unified.py`, tests `test_service_and_funding_admission_reuse_owner_publication_rules` | +| P02-B — Handoff Services | Le relevé des handoffs recherchait des `ServiceDetails` sur les IDs admis, sans passer par le selector de publication owner. | `_service_handoffs` passe par `public_services()` sur les IDs déjà admis. Aucune nouvelle capacité n'est calculée. | `discovery/api/composition.py::_service_handoffs` | +| P02-C — Ressources documentaires | `_activity_resource_previews` s'appuyait sur `status=PUBLISHED` et `visibility=PUBLIC`, sans vérifier la même autorité de lecture que `preparation:resource-text` et `preparation:resource-download`. Elle pouvait inclure du texte ou des titres devenus non consultables. | Contrôle par `preparation.services.can_view_resource(profile, resource)` sur le viewer courant, cache de contrôle borné à ce calcul par requête et contexte Activity/Occurrence. Métadonnées et contenu exclus ensemble si le lecteur serait refusé. | `discovery/api/composition.py::_activity_resource_previews`, test de retrait de la visibilité owner | +| P02-D — Portée des ressources | Une ressource d'une Occurrence non sélectionnée pouvait être présentée avec l'Activity agrégée. | Les `matching_occurrence_ids` admis bornent la présence de ressources spécifiques à une Occurrence. Les ressources Activity génériques restent disponibles ; aucun autre événement n'est inventé. | `project_rows` et `test_occurrence_specific_resource_stays_with_admitted_variant` | +| P02-E — Poids des TEXT inline | La collection N1 pouvait inclure un `text_content` public de taille arbitraire pour chaque Resource. | Aperçu par TEXT limité à **1 400 caractères**, budget cumulé par Activity à **8 400 caractères**. `text_truncated` informe honnêtement de la coupure ; `read_url` permet de lire le contenu entier chez Preparation. **Tous les titres/IDs de ressources autorisées sont toujours exposés, sans limite silencieuse de six ou treize.** | `DISCOVERY_RESOURCE_INLINE_CHARS`, `DISCOVERY_RESOURCE_ACTIVITY_INLINE_BUDGET`, tests TEXT et budget | +| P02-F — Liens externes historiques | Une URL conservée dans une ancienne donnée pouvait être sérialisée sans validation du protocole au moment de projeter, notamment sur les `OpportunitySource`. | Les liens externes public RESOURCE et Opportunity utilisent `_safe_public_http_url` : http(s), host valide non vide, sans identifiants d'URL ni contrôles ASCII de nouvelle ligne/tabulation. Une URL refusée devient `null`, sans inventer une destination. | `_safe_public_http_url`, `_opportunity_source_previews`, tests URL héritée | +| P02-G — Prix `unknown` | `project_rows` ne réinjectait Commerce que lorsque son état était connu ; l'adaptateur Presentation pouvait donc laisser une valeur dérivée malgré `unknown` du owner. | Lorsque le selector `offer_price_facts_many` a fourni une valeur, elle est toujours la vérité de prix projetée, y compris `unknown`, `no_applicable_offer` et `mixed_currencies`. Ne convertit pas l'inconnu en gratuit. | `project_rows`, test `test_collection_price_uses_commerce_unknown_and_exact_owner_truth` | +| P02-H — Pagination/API | Les exceptions `django.core.exceptions.ValidationError` de pagination et d'exploration n'étaient pas normalisées en erreur DRF côté collection/carte. Une continuation invalide devait au contraire être un rejet HTTP propre. | `DiscoveryItemsAPIView` et `DiscoveryMapAPIView` convertissent en `rest_framework.exceptions.ValidationError` (400), sans cacher les échecs. `paginated_projection` valide le curseur **avant** la composition coûteuse. | `discovery/api/views.py`, `composition.py::paginated_projection`, tests d'expiration/contexte/tampering/paramètres | + +## 3. Tests ajoutés et ajustés + +### `discovery/api/test_mature_discovery.py` +- Règles owner identiques collection/détail pour Service et Funding : publication Space, recherchabilité, archive, suspension ; récupération après rétablissement. +- Absence de divulgation d'une Resource lorsque Preparation refuse la consultation. +- Rejet d'une URL historique `javascript:` dans la projection publique d'une Opportunity. +- Rejet HTTP 400 de curseurs modifiés, de changements de query, de page size, de viewer, de paramètres invalides et de filtre temporel incompatible sur Map. +- Prix Commerce `unknown → exact → unknown/mixed_currencies` représenté sans fausse valeur. + +### `discovery/api/test_resource_previews.py` +- Une ressource TEXT longue est tronquée dans la collection mais complète via son endpoint propriétaire `read_url`. +- Conservation de toutes les références avec budget total inline. +- Retrait propriétaire sans divulgation du titre ou du contenu ; endpoint refusé. +- URLs externes et liens avec identifiants ou sauts de ligne écartés. +- Les ressources spécifiques à une autre Occurrence ne se mélangent pas à la variante sélectionnée. + +**Note de méthode :** l'ajout et la vérification du contenu de ces tests ne signifie **pas** qu'ils ont été exécutés. La CI, l'Android et les E2E ne sont pas des étapes de ce chantier ; ils sont reportés au chantier 09 sur demande de l'utilisateur. + +## 4. Contrat projeté pour l'étape 03 + +1. Les médias de représentation continuent d'être admis par `presentations.media_selectors.public_presentation_media_for_activities` et `apply_recognition_media` ; ce chantier **n'a pas refait leurs droits, fichiers, révocations, origines ou caches**. +2. Les ressources `TEXT` et `FILE` ont des lecteurs propriétaires ; N1 expose un résumé/document, pas une nouvelle autorité de lecture. En cas de refus ultérieur, la route de Preparation revalide le droit. +3. Les images de reconnaissance, les ressources de Journey, les documents, les liens externes et les assets privés restent des objets distincts. +4. Le chantier 03 doit étudier les anciens chemins publics `PresentationAsset.file` et les `private_file`, le stockage, la révocation et les URL first-party, sans dupliquer le contrat Discovery. +5. L'optimisation d'acquisition (préchargement, disponibilité offline, Reader) reste du ressort de 03/08 ; elle ne doit jamais contourner un refus de lecture. + +## 5. Frontières des prochaines étapes + +- **04** : agencement N1, dates lisibles, contextualisation, gabarits, responsive Web/Flutter. +- **05** : l'écran Map autonome Flutter n'hérite pas encore systématiquement de la query/liste/sélection. Le serveur map n'est **pas** une deuxième source de pertinence, mais la continuité UX est un travail distinct. Les scénarios avec critères changés sont couverts ici uniquement au niveau de l'erreur de continuation. +- **06** : N2, variantes/comparaison conditionnelle et profondeur métier. +- **07** : Save/Watch/Share/engagement N3, décisions et mutation dans les propriétaires. +- **08** : snapshot, restauration du scroll et de l'état, rétention locale, quotas, révocation offline. +- **09** : tests exécutés, CI, device et décision de fusion. + +## 6. Critères de sortie de la livraison de code 02 + +- [x] Un seul selector Services pour Web/natif/Discovery, avec le filtre de Groupe inchangé. +- [x] Admission collection et détail des quatre familles préservée ; tests de retrait owner ajoutés. +- [x] Identité et déduplication des candidats non modifiées, aucun score nouveau. +- [x] Ressources admises par Preparation et par Occurrence, sans fuite de texte privé. +- [x] Payload inline TEXT borné, sans suppression de références légitimes. +- [x] Prix du selector Commerce prioritaire, même inconnu. +- [x] Validation cursor/requête et réponse HTTP 400 explicite. +- [x] Tests ciblés créés dans le dépôt ; documentation et handoff à 03 préparés. +- [ ] Exécution de Django/PostgreSQL, Flutter, CI, Android : **différée, non revendiquée**. + +**Décision : CHANTIER 02 — IMPLÉMENTATION LIVRÉE.** La certification des résultats d'exécution attend le chantier 09. Aucun chantier 03 n'a été exécuté dans cette livraison. diff --git a/docs/implementation/discover-stage-03-media-resources-2026-10-31.md b/docs/implementation/discover-stage-03-media-resources-2026-10-31.md new file mode 100644 index 0000000000..77dacfa33f --- /dev/null +++ b/docs/implementation/discover-stage-03-media-resources-2026-10-31.md @@ -0,0 +1,86 @@ +# Makolo — Découvrir : chantier 03, médias et ressources + +**Date :** 2026-10-31 +**Branche :** `fix/discover-a-continuation-context-20261014` — [PR #522](https://github.com/TraditionLearningCommunity/makolo/pull/522). +**État :** **implémentation livrée en code**, tests écrits, exécution des tests et CI volontairement différée à l'étape 09. +**Frontière :** pas de nouvelle identité métier, pas de modèle de Possibilité, pas de re-conception N1/N2, pas de migration automatique destructrice, aucun déploiement/merge réalisé. + +## 1. Autorités et principes + +- `PresentationAsset` : octets, provenance, ownership et stockage. +- `PresentationMediaUse` : association explicite à Activity/Occurrence, purpose, ordre, alt, publication et retrait. +- `ActivityResource` (Preparation) : document/texte/lien propriétaire, ses versions et son contrôle d'accès. Il n'est pas une image de couverture. +- `Discovery` : consomme les références publiques autorisées et les projette sans déduire la publication d'un simple fichier. +- `Flutter` : peut conserver des octets publics déjà acquis, mais ne fait aucune autorisation, ne déduit aucun droit privé et ne remplace pas l'API. + +## 2. Corrections livrées + +| Réf. | Dysfonctionnement / risque | Code réalisé | Contrôles ajoutés | +| --- | --- | --- | --- | +| M03-01 | Le sélecteur Presentation ne vérifiait pas la publication effective du propriétaire (retrait Space/Profile), mais la route de livraison la vérifiait : risque de divulguer ID, alt et URL d'un usage devenu non public. | `presentations/media_selectors.py::public_media_activities` introduit le gate canonique partagé : Activity publiée et publique, Space actif/recherchable/public et non suspendu, ou UserProfile personnel actif, public et recherchable. | `presentations/test_media_use.py` : suspension, archivage, non-publication, profil privé/non recherchable. | +| M03-02 | Les médias pouvaient être projetés sans le contrôle de Groupe du viewer et sans vérifier la validité courante de l'Occurrence. | `public_presentation_media_for_activities(..., profile=...)` recoupe l'éligibilité Group, les Occurrences `SCHEDULED` admises, le propriétaire de l'asset et le mime JPEG/PNG. `discovery/api/composition.py` transmet le viewer réel. | Groupe fermé vs membre autorisé, Occurrence annulée et propriété d'asset. | +| M03-03 | La route de fichier devait rester strictement alignée sur ce gate pour empêcher qu'un lien capturé antérieurement fournisse les octets après retrait. | `presentations/media_delivery.py` réutilise `public_media_activities`, le gate Group et l'état de l'Occurrence ; vérifie encore l'owner et le stockage à **chaque requête**. 404 en cas de refus ; `Cache-Control: private, no-store, max-age=0`, `nosniff`, `Content-Disposition: inline` et `Cross-Origin-Resource-Policy: same-site`. Aucun redirect vers `MEDIA_ROOT`. | Tests de retrait et de lecture refusée, tests existants des fichiers physiquement absents et médias retirés. | +| M03-04 | Le stockage automatique Flutter ne validait que la forme de `/presentation/media/uses//file/` ; un domaine externe imitant cette forme pouvait être conservé. | `DiscoveryOfflineStore` exige désormais une origine explicitement fournie par la configuration API (`trustedOrigin`) et la correspondance exacte scheme/host/port, en plus du chemin, sans query/fragment/userinfo. `RetainedPublicDiscoveryImage` et l'écran Découvrir lui transmettent cette origine. | Test de faux domaine et de refus sans origine de confiance. | +| M03-05 | Enregistrement mobile : suppression de la cible avant le rename et possibilité qu'une révocation 403/404 soit suivie par une écriture déjà en vol. | Remplacement via staging + backup/rollback pour images et documents explicitement conservés ; file d'opérations qui sérialise téléchargements, remplacement et suppression. Le retrait d'un média public passe **après** les écritures déjà en vol. | Tests de remplacement atomique en fonctionnement normal et de révocation gagnant sur une écriture concurrente. | +| M03-06 | Les anciens `PresentationAsset.file` peuvent rester physiquement sous `MEDIA_ROOT`, parfois exposé directement par nginx/CDN, même si l'URL `published-media-file` est révoquée. | Commande additive `harden_presentation_media_assets` : audit par défaut, `--apply` copie les assets associés à des usages vers `private_file`, vérifie SHA-256 source/copie ; `--purge-public` après copie et contrôle des références JSON `ActivityPresentation.editorial_data`, sans purge silencieuse. | `presentations/test_legacy_media_migration.py` : audit, copie, lecture via media-use après retrait du fichier brut, blocage sur lien éditorial historique. | +| M03-07 | Le Studio MPS générait encore de **nouveaux** liens `asset.file.url` vers la racine MEDIA publique pour `hero_image`. | `_editorial_from_post` importe maintenant avec `protected=True` et écrit une URL `presentations:editorial-media-file`. La nouvelle route exige un lien exact depuis une `ActivityPresentation` ; un brouillon n'est accessible qu'à son éditeur autorisé ; une présentation publiée doit satisfaire visibilité propriétaire/Groupe ou les droits personnels Invitation/Access. Le fichier reste privé et `no-store`. | `presentations/test_editorial_media_delivery.py` : aucun fichier public neuf, brouillon 404 pour tiers, prévisualisation owner, publication et retrait du Profile. | + +## 3. Ressources Preparation (réutilisées, non réécrites) + +Le chantier 02 a déjà assuré que l'aperçu documentaire de Discovery respecte `preparation.services.can_view_resource`, la portée Occurrence, les textes inline bornés et le retour vers les endpoints propriétaires `preparation:resource-text` et `preparation:resource-download`. Ce chantier ne recrée ni fichier, ni droits de lecteur, ni couche de cache pour les documents de Journey. Le Reader explicitement conservé reste distinct de la conservation automatique des images RECOGNIZE. + +Le nombre de ressources légitimement exposées **n'est pas plafonné à six**. Les limites de taille concernent les octets inline, non le nombre de documents. + +## 4. Stockage historique : procédure sûre (À EXÉCUTER sur l'environnement de déploiement) + +La migration schema `presentations.0006_private_presentation_assets` ajoute `private_file` sans effacer les noms de fichiers précédents. Les nouveaux uploads du gestionnaire N1 `ActivityMediaUploadView` écrivent déjà vers ce champ protégé. Les assets historiques `file` restent lisibles comme compatibilité par la route autorisée. + +**Commande non destructive d'inventaire :** + +```bash +python manage.py harden_presentation_media_assets +``` + +**Copie + vérification sans purge :** + +```bash +python manage.py harden_presentation_media_assets --apply +``` + +**Suppression contrôlée des anciens octets PUBLICS, uniquement après contrôle des références :** + +```bash +python manage.py harden_presentation_media_assets --apply --purge-public +``` + +La suppression refuse les assets dont une URL brute apparaît encore dans un `editorial_data` historique. Les fichiers manquants sont signalés, pas remplacés artificiellement. La commande ne modifie pas les pages MPS qui référencent directement une ancienne URL `hero_image` ; leur conversion vers des URLs contrôlées demeure une opération de déploiement/migration éditoriale, avant de pouvoir supprimer les originaux correspondants. + +**Limite explicite :** même après suppression, un cache de proxy/CDN ou une copie publique externe peut toujours servir des octets antérieurement exposés. En déploiement, bloquer la distribution publique de `/media/presentations/assets/`, auditer les références MPS, migrer ou corriger les pages historiques et purger le CDN selon la politique d'hébergement. **Ces contrôles externes n'ont pas été effectués ici.** Ne pas confondre « commande ajoutée au dépôt » et « fichiers réellement migrés et non publics en production ». + +**Protection des nouveaux imports MPS :** depuis ce chantier, `_editorial_from_post` utilise `protected=True` et la route gardée `editorial-media-file`. Les valeurs `hero_image` historiquement enregistrées comme URL brute restent inchangées ; la commande d'audit signale et protège leur suppression. Les anciens liens déployés doivent être inventoriés et convertis par une migration éditoriale contrôlée avant d'exiger une purge complète. + +## 5. Tests de régression + +Ajoutés ou étendus : +- `presentations/test_media_use.py` : retrait de la visibilité propriétaire, Groupe, Occurrence, livraison ; nombre de requêtes borné malgré le gate additionnel. +- `presentations/test_legacy_media_migration.py` : audit, copie vérifiée, purge conditionnelle et conservation du lien contrôlé `published-media-file`. +- `presentations/test_editorial_media_delivery.py` : nouveau `hero_image` privé, route avec droits de brouillon/publication, révocation. +- `mobile/test/discovery_recognition_offline_test.dart` : origine forgée, politique first-party, remplacement complet, file de retrait et Reader indépendant. +- Tests déjà présents : `discovery/api/test_media_bindings.py`, `presentations/test_media_use.py`, `discovery/api/test_resource_previews.py`. + +**Statut d'exécution : non exécutés.** L'utilisateur a demandé de poursuivre les implémentations sans se préoccuper des CI ; cela ne vaut ni attestation de sécurité de déploiement ni résultat de tests. + +## 6. Critères de sortie de code + +- [x] Sélecteur et transport media-use partagent l'admission propriétaire et le groupe. +- [x] Publication/retrait demeurent contrôlés par `media_services`, sans publication automatique depuis un fichier. +- [x] Aucun média d'Occurrence annulée ne passe le nouveau sélecteur/la livraison. +- [x] Aucune URL brute de stockage publiée dans la projection `media_bindings` ; contenu servi via lien media-use. +- [x] Retenue automatique conditionnée à la véritable origine API de confiance. +- [x] Remplacements locaux non destructeurs en cas d'échec de rename ; retrait sérialisé après écriture en cours. +- [x] Commande d'audit, copie privée et purge conditionnelle des fichiers hérités. +- [x] Nouveaux imports éditoriaux MPS sécurisés avec stockage privé et route de livraison autorisée. +- [x] Tests écrits et documentation d'exploitation ajoutés. +- [ ] Migration réelle, host/CDN, anciennes pages `hero_image`, tests exécutés : **ne pas prétendre réalisés**. + +**Décision : CHANTIER 03 — CODE LIVRÉ, MIGRATION/VALIDATION D'EXPLOITATION EN ATTENTE.** Les travaux de gabarit N1 sont réservés au chantier 04. Pas de merge ni déploiement implicite. diff --git a/docs/implementation/discover-stage-04-presentation-n1-2026-10-31.md b/docs/implementation/discover-stage-04-presentation-n1-2026-10-31.md new file mode 100644 index 0000000000..5575cd1c1e --- /dev/null +++ b/docs/implementation/discover-stage-04-presentation-n1-2026-10-31.md @@ -0,0 +1,89 @@ +# Makolo — Découvrir : chantier 04, Presentation N1 + +**Date :** 31 octobre 2026 +**Branche :** `fix/discover-a-continuation-context-20261014` ([PR #522](https://github.com/TraditionLearningCommunity/makolo/pull/522)). +**Périmètre :** composition **N1 Web et Flutter**, sans nouveau fait métier ni mutation et sans engager le chantier 05 (navigation, recherche, carte). +**Décision :** **implémentation N1 livrée en code** ; tests ajoutés, exécution des tests et CI différée au chantier 09 sur instruction de l'utilisateur. **Aucun test exécuté n'est affirmé vert.** + +## 1. Contrats et réutilisation + +Le contrat [`discovery-n1-layout-contract.md`](../architecture/discovery-n1-layout-contract.md) demeure normatif pour huit zones ordonnées. Les projections de la PR #522 restent la source des faits : `discovery/api/composition.py`, `discovery/api/projections.py`, `discovery/api/media_bindings.py`. Aucune migration, endpoint, table, `Possibility`, `Media`, `Resource` ou propriété de fait n'a été créée pour arranger le rendu. + +Le chantier réutilise : +- `DiscoveryPossibilityUnit` pour N1 Flutter (également employé à N2 avec `detailMode`) et `DiscoveryExplorationView` pour sa mise en page adaptative ; +- `_web_discovery_card`, `_universal_card.html` et `_resource_item.html` pour N1 Web ; +- `DiscoveryReader` et `DiscoveryShareControl` et les routes propriétaires pour lecture et interaction ; +- les associations `media_bindings`, champs `presentation_facts`, `spatial_facts`, `temporal_facts`, `resources`, `assessment` et `variants` déjà admis. + +## 2. Huit zones : état de sortie du chantier + +| Zone | Comportement utilisé / livré | Changement dans ce chantier | +| --- | --- | --- | +| **1. Porteur + ajout** | Ligne propriétaire/avatar/lien public lorsque autorisé, date d'ajout à droite distincte des dates métier | Flutter `discoveryAddedAtLabel` : « À l’instant », « il y a 2 min », « il y a 2 h », « Hier », « Avant-hier », jours/semaines puis date. Web `_added_date_label` + `card.added_label` ; `