Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f09836c470 | |||
| 9e90aed957 | |||
| d176416b15 | |||
| 5063bc2b69 | |||
| e95088ad25 | |||
| 313bdede03 | |||
| 8ceaf549e4 | |||
| 63d3ff63e7 | |||
| e1dbbbb274 | |||
| 139555dc8e | |||
| 1785e125c7 | |||
| 82d37288d6 | |||
| 9b0821926c | |||
| 0514fa063f | |||
| 71c7325370 | |||
| aeed9c7dc9 | |||
| 89c5fc9370 | |||
| d8c6bfcda0 | |||
| ec3ed054fd | |||
| 219cfcf1a6 | |||
| 708c07cf49 | |||
| e14900e9f0 | |||
| d1cbf972fe | |||
| fe2a0cf1d5 | |||
| c11faee824 | |||
| 9ccf68d983 | |||
| 132aa5a19b | |||
| 19b0c37a97 | |||
| ecdef605a7 | |||
| e2a918112b | |||
| 96b0718081 | |||
| d9e5d9dac6 | |||
| b0f9d31ee2 | |||
| 54bc9028ad | |||
| 37926e68ec | |||
| e2d3d29956 | |||
| 6921ab57f8 | |||
| 97dacb1189 |
@@ -3,6 +3,9 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- v*
|
- v*
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
name: Build and deploy the backend to production
|
name: Build and deploy the backend to production
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -10,15 +13,7 @@ jobs:
|
|||||||
name: Build and push image
|
name: Build and push image
|
||||||
uses: ./.gitea/workflows/workflow_build-image.yaml
|
uses: ./.gitea/workflows/workflow_build-image.yaml
|
||||||
with:
|
with:
|
||||||
tag: stable
|
# sets the tag to the git tag that triggered the workflow - the deployment (configured in a separate repository) will use this tag and be deployed to production by argocd
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
secrets:
|
secrets:
|
||||||
PACKAGE_REGISTRY_ACCESS: ${{ secrets.PACKAGE_REGISTRY_ACCESS }}
|
PACKAGE_REGISTRY_ACCESS: ${{ secrets.PACKAGE_REGISTRY_ACCESS }}
|
||||||
|
|
||||||
deploy-prod:
|
|
||||||
name: Deploy to production
|
|
||||||
uses: ./.gitea/workflows/workflow_deploy-container.yaml
|
|
||||||
with:
|
|
||||||
overlay: prod
|
|
||||||
secrets:
|
|
||||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
|
||||||
needs: build-and-push
|
|
||||||
|
|||||||
@@ -12,15 +12,32 @@ jobs:
|
|||||||
name: Build and push image
|
name: Build and push image
|
||||||
uses: ./.gitea/workflows/workflow_build-image.yaml
|
uses: ./.gitea/workflows/workflow_build-image.yaml
|
||||||
with:
|
with:
|
||||||
tag: unstable
|
# sets a unique tag for each commit in the PR - this gets deployed to a separate application instance using argocd
|
||||||
|
tag: sha${{ github.sha }}
|
||||||
secrets:
|
secrets:
|
||||||
PACKAGE_REGISTRY_ACCESS: ${{ secrets.PACKAGE_REGISTRY_ACCESS }}
|
PACKAGE_REGISTRY_ACCESS: ${{ secrets.PACKAGE_REGISTRY_ACCESS }}
|
||||||
|
|
||||||
deploy-prod:
|
notify:
|
||||||
name: Deploy to staging
|
runs-on: ubuntu-latest
|
||||||
uses: ./.gitea/workflows/workflow_deploy-container.yaml
|
name: Add a comment to the PR to notify about the deployment
|
||||||
with:
|
steps:
|
||||||
overlay: stg
|
- name: Download gitea client
|
||||||
secrets:
|
run: |
|
||||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
curl -sSL -o tea https://dl.gitea.com/tea/0.11.0/tea-0.11.0-linux-amd64
|
||||||
needs: build-and-push
|
chmod +x tea
|
||||||
|
|
||||||
|
- name: Login
|
||||||
|
run: |
|
||||||
|
./tea login add --url git.kluster.moll.re --name bot --token ${{ secrets.GITEA_TOKEN }}
|
||||||
|
./tea login default
|
||||||
|
- name: Post comment
|
||||||
|
run: |
|
||||||
|
./tea comment --repo anydev/anyway --login bot ${{ github.event.number }} """
|
||||||
|
The backend has been deployed to staging with url https://pr-${{ github.event.number }}.anyway-stg.anydev.info. Check the deployment status in ArgoCD:
|
||||||
|
|
||||||
|
[](https://argocd.kluster.moll.re/applications/anydev-anyway-backend-stg-pr-${{ github.event.number }})
|
||||||
|
"""
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||||
|
GITEA_REPO: ${{ secrets.GITEA_REPO }}
|
||||||
|
|||||||
@@ -15,18 +15,10 @@ jobs:
|
|||||||
|
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install pylint
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y python3 python3-pip
|
apt-get update && apt-get install -y python3 python3-pip pylint
|
||||||
pip install pipenv
|
|
||||||
|
|
||||||
- name: Install packages
|
|
||||||
run: |
|
|
||||||
ls -la
|
|
||||||
# only install dev-packages
|
|
||||||
pipenv install --categories=dev-packages
|
|
||||||
working-directory: backend
|
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: pipenv run pylint src --fail-under=9
|
run: pylint src --fail-under=9
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|||||||
@@ -15,20 +15,18 @@ jobs:
|
|||||||
|
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install uv (manually)
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y python3 python3-pip
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
pip install pipenv
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Install packages
|
- name: Install dependencies
|
||||||
run: |
|
|
||||||
ls -la
|
|
||||||
# install all packages, including dev-packages
|
|
||||||
pipenv install --dev
|
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
uv sync --frozen --no-cache --no-dev
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: pipenv run pytest src --html=report.html --self-contained-html --log-cli-level=DEBUG
|
run: uv run pytest src --html=report.html --self-contained-html --log-cli-level=DEBUG
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
- name: Upload HTML report
|
- name: Upload HTML report
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- frontend/**
|
- frontend/**
|
||||||
|
|
||||||
name: Build and release release apps to production track
|
name: Build and release apps to beta track
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
get-version:
|
get-version:
|
||||||
@@ -13,6 +13,7 @@ jobs:
|
|||||||
runs-on: macos
|
runs-on: macos
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Fetch tags from main branch
|
- name: Fetch tags from main branch
|
||||||
# since this workflow is triggered by a pull request, we want to match the latest tag of the main branch
|
# since this workflow is triggered by a pull request, we want to match the latest tag of the main branch
|
||||||
id: version
|
id: version
|
||||||
@@ -20,14 +21,21 @@ jobs:
|
|||||||
git fetch origin main --tags
|
git fetch origin main --tags
|
||||||
LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
|
LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||||
# remove the 'v' prefix from the tag name
|
# remove the 'v' prefix from the tag name
|
||||||
echo "BUILD_NAME=${LATEST_TAG//v}" >> $GITHUB_ENV
|
echo "BUILD_NAME=${LATEST_TAG//v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Output the version that is being used
|
||||||
|
run: |
|
||||||
|
echo "Building for version ${{ steps.version.outputs.BUILD_NAME }}"
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
build_name: ${{ steps.version.outputs.BUILD_NAME }}
|
||||||
|
|
||||||
build-android:
|
build-android:
|
||||||
name: Build and upload android app
|
name: Build and upload android app
|
||||||
uses: ./.gitea/workflows/workflow_build-app-android.yaml
|
uses: ./.gitea/workflows/workflow_build-app-android.yaml
|
||||||
with:
|
with:
|
||||||
build_type: beta
|
build_type: beta
|
||||||
build_name: ${{ env.BUILD_NAME }}
|
build_name: ${{ needs.get-version.outputs.build_name }}
|
||||||
secrets:
|
secrets:
|
||||||
ANDROID_SECRET_PROPERTIES_BASE64: ${{ secrets.ANDROID_SECRET_PROPERTIES_BASE64 }}
|
ANDROID_SECRET_PROPERTIES_BASE64: ${{ secrets.ANDROID_SECRET_PROPERTIES_BASE64 }}
|
||||||
ANDROID_GOOGLE_PLAY_JSON_BASE64: ${{ secrets.ANDROID_GOOGLE_PLAY_JSON_BASE64 }}
|
ANDROID_GOOGLE_PLAY_JSON_BASE64: ${{ secrets.ANDROID_GOOGLE_PLAY_JSON_BASE64 }}
|
||||||
@@ -40,7 +48,7 @@ jobs:
|
|||||||
uses: ./.gitea/workflows/workflow_build-app-ios.yaml
|
uses: ./.gitea/workflows/workflow_build-app-ios.yaml
|
||||||
with:
|
with:
|
||||||
build_type: beta
|
build_type: beta
|
||||||
build_name: ${{ env.BUILD_NAME }}
|
build_name: ${{ needs.get-version.outputs.build_name }}
|
||||||
secrets:
|
secrets:
|
||||||
IOS_ASC_KEY_ID: ${{ secrets.IOS_ASC_KEY_ID }}
|
IOS_ASC_KEY_ID: ${{ secrets.IOS_ASC_KEY_ID }}
|
||||||
IOS_ASC_ISSUER_ID: ${{ secrets.IOS_ASC_ISSUER_ID }}
|
IOS_ASC_ISSUER_ID: ${{ secrets.IOS_ASC_ISSUER_ID }}
|
||||||
@@ -48,4 +56,4 @@ jobs:
|
|||||||
IOS_MATCH_REPO_SSH_KEY_BASE64: ${{ secrets.IOS_MATCH_REPO_SSH_KEY_BASE64 }}
|
IOS_MATCH_REPO_SSH_KEY_BASE64: ${{ secrets.IOS_MATCH_REPO_SSH_KEY_BASE64 }}
|
||||||
IOS_MATCH_PASSWORD: ${{ secrets.IOS_MATCH_PASSWORD }}
|
IOS_MATCH_PASSWORD: ${{ secrets.IOS_MATCH_PASSWORD }}
|
||||||
IOS_GOOGLE_MAPS_API_KEY: ${{ secrets.IOS_GOOGLE_MAPS_API_KEY }}
|
IOS_GOOGLE_MAPS_API_KEY: ${{ secrets.IOS_GOOGLE_MAPS_API_KEY }}
|
||||||
needs: get-version
|
needs: build-android # technically not needed, but this prevents the builds from running in parallel
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- v*
|
- v*
|
||||||
|
|
||||||
name: Build and release release apps to production track
|
name: Build and release apps to production track
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
get-version:
|
get-version:
|
||||||
@@ -11,21 +11,28 @@ jobs:
|
|||||||
runs-on: macos
|
runs-on: macos
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Get version from git tag
|
- name: Get version from git tag
|
||||||
id: version
|
id: version
|
||||||
env:
|
env:
|
||||||
REF_NAME: ${{ gitea.ref_name }}
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
# remove the 'v' prefix from the tag name
|
# remove the 'v' prefix from the tag name
|
||||||
run: |
|
run: |
|
||||||
echo "BUILD_NAME=${REF_NAME//v}" >> $GITHUB_ENV
|
echo "BUILD_NAME=${REF_NAME//v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Output the version that is being used
|
||||||
|
run: |
|
||||||
|
echo "Building for version ${{ steps.version.outputs.BUILD_NAME }}"
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
build_name: ${{ steps.version.outputs.BUILD_NAME }}
|
||||||
|
|
||||||
build-android:
|
build-android:
|
||||||
name: Build and upload android app
|
name: Build and upload android app
|
||||||
uses: ./.gitea/workflows/workflow_build-app-android.yaml
|
uses: ./.gitea/workflows/workflow_build-app-android.yaml
|
||||||
with:
|
with:
|
||||||
build_type: release
|
build_type: release
|
||||||
build_name: ${{ env.BUILD_NAME }}
|
build_name: ${{ needs.get-version.outputs.build_name }}
|
||||||
secrets:
|
secrets:
|
||||||
ANDROID_SECRET_PROPERTIES_BASE64: ${{ secrets.ANDROID_SECRET_PROPERTIES_BASE64 }}
|
ANDROID_SECRET_PROPERTIES_BASE64: ${{ secrets.ANDROID_SECRET_PROPERTIES_BASE64 }}
|
||||||
ANDROID_GOOGLE_PLAY_JSON_BASE64: ${{ secrets.ANDROID_GOOGLE_PLAY_JSON_BASE64 }}
|
ANDROID_GOOGLE_PLAY_JSON_BASE64: ${{ secrets.ANDROID_GOOGLE_PLAY_JSON_BASE64 }}
|
||||||
@@ -38,12 +45,12 @@ jobs:
|
|||||||
uses: ./.gitea/workflows/workflow_build-app-ios.yaml
|
uses: ./.gitea/workflows/workflow_build-app-ios.yaml
|
||||||
with:
|
with:
|
||||||
build_type: release
|
build_type: release
|
||||||
build_name: ${{ env.BUILD_NAME }}
|
build_name: ${{ needs.get-version.outputs.build_name }}
|
||||||
secrets:
|
secrets:
|
||||||
IOS_ASC_KEY_ID: ${{ secrets.IOS_ASC_KEY_ID }}
|
IOS_ASC_KEY_ID: ${{ secrets.IOS_ASC_KEY_ID }}
|
||||||
IOS_ASC_ISSUER_ID: ${{ secrets.IOS_ASC_ISSUER_ID }}
|
IOS_ASC_ISSUER_ID: ${{ secrets.IOS_ASC_ISSUER_ID }}
|
||||||
IOS_ASC_KEY: ${{ secrets.IOS_ASC_KEY }}
|
IOS_ASC_KEY: ${{ secrets.IOS_ASC_KEY }}
|
||||||
IOS_MATCH_PASSWORD: ${{ secrets.IOS_MATCH_PASSWORD }}
|
|
||||||
IOS_MATCH_REPO_SSH_KEY_BASE64: ${{ secrets.IOS_MATCH_REPO_SSH_KEY_BASE64 }}
|
IOS_MATCH_REPO_SSH_KEY_BASE64: ${{ secrets.IOS_MATCH_REPO_SSH_KEY_BASE64 }}
|
||||||
|
IOS_MATCH_PASSWORD: ${{ secrets.IOS_MATCH_PASSWORD }}
|
||||||
IOS_GOOGLE_MAPS_API_KEY: ${{ secrets.IOS_GOOGLE_MAPS_API_KEY }}
|
IOS_GOOGLE_MAPS_API_KEY: ${{ secrets.IOS_GOOGLE_MAPS_API_KEY }}
|
||||||
needs: get-version
|
needs: build-android # technically not needed, but this prevents the builds from running in parallel
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ defaults:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos
|
runs-on: macos-14
|
||||||
env:
|
env:
|
||||||
# $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
|
# $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
|
||||||
BUNDLE_GEMFILE: ${{ gitea.workspace }}/frontend/android/Gemfile
|
BUNDLE_GEMFILE: ${{ gitea.workspace }}/frontend/android/Gemfile
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ defaults:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos
|
runs-on: macos-14
|
||||||
env:
|
env:
|
||||||
# $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
|
# $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
|
||||||
BUNDLE_GEMFILE: ${{ gitea.workspace }}/frontend/ios/Gemfile
|
BUNDLE_GEMFILE: ${{ gitea.workspace }}/frontend/ios/Gemfile
|
||||||
@@ -53,9 +53,13 @@ jobs:
|
|||||||
ruby-version: 3.3
|
ruby-version: 3.3
|
||||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||||
|
|
||||||
|
- uses: GuillaumeFalourd/setup-rsync@v1.2
|
||||||
|
# rsync is required by the google maps ios tools
|
||||||
|
|
||||||
- name: Install dependencies and clean up
|
- name: Install dependencies and clean up
|
||||||
run: |
|
run: |
|
||||||
flutter pub get
|
flutter pub get
|
||||||
|
flutter precache --ios
|
||||||
bundle exec pod install --allow-root
|
bundle exec pod install --allow-root
|
||||||
flutter clean
|
flutter clean
|
||||||
bundle exec pod cache clean --all --allow-root
|
bundle exec pod cache clean --all --allow-root
|
||||||
@@ -69,6 +73,11 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
MATCH_REPO_SSH_KEY: ${{ secrets.IOS_MATCH_REPO_SSH_KEY_BASE64 }}
|
MATCH_REPO_SSH_KEY: ${{ secrets.IOS_MATCH_REPO_SSH_KEY_BASE64 }}
|
||||||
|
|
||||||
|
- name: Replace API Key from secret
|
||||||
|
# on a macOS runner, sed requires a replacement suffix after the -i flag
|
||||||
|
run: |
|
||||||
|
sed -i '' -e "s/IOS_GOOGLE_MAPS_API_KEY/${{ secrets.IOS_GOOGLE_MAPS_API_KEY }}/g" Runner/AppDelegate.swift
|
||||||
|
|
||||||
- name: Run fastlane lane
|
- name: Run fastlane lane
|
||||||
run: bundle exec fastlane deploy_${{ inputs.build_type }}
|
run: bundle exec fastlane deploy_${{ inputs.build_type }}
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
overlay:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
secrets:
|
|
||||||
KUBE_CONFIG:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
|
|
||||||
name: Deploy the newly built container
|
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: Deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
|
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
|
|
||||||
- name: setup kubectl
|
|
||||||
uses: https://github.com/azure/setup-kubectl@v4
|
|
||||||
|
|
||||||
- name: Set kubeconfig
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.KUBE_CONFIG }}" > kubeconfig
|
|
||||||
|
|
||||||
- name: Deploy to k8s
|
|
||||||
run: |
|
|
||||||
kubectl apply -k backend/deployment/overlays/${{ inputs.overlay }} --kubeconfig=kubeconfig
|
|
||||||
kubectl -n anyway-backend rollout restart deployment/anyway-backend-${{ inputs.overlay }} --kubeconfig=kubeconfig
|
|
||||||
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
cache/
|
cache/
|
||||||
|
.direnv/
|
||||||
|
|||||||
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
|||||||
[submodule "backend/deployment"]
|
|
||||||
path = backend/deployment
|
|
||||||
url = https://git.kluster.moll.re/anydev/anyway-backend-deployment
|
|
||||||
8
.vscode/launch.json
vendored
@@ -9,9 +9,7 @@
|
|||||||
"name": "Backend - debug",
|
"name": "Backend - debug",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"env": {
|
"envFile": "${workspaceFolder}/backend/debug.env",
|
||||||
"DEBUG": "true"
|
|
||||||
},
|
|
||||||
"jinja": true,
|
"jinja": true,
|
||||||
"cwd": "${workspaceFolder}/backend",
|
"cwd": "${workspaceFolder}/backend",
|
||||||
"module": "fastapi",
|
"module": "fastapi",
|
||||||
@@ -25,9 +23,7 @@
|
|||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "src/tester.py",
|
"program": "src/tester.py",
|
||||||
"env": {
|
"envFile": "${workspaceFolder}/backend/debug.env",
|
||||||
"DEBUG": "true"
|
|
||||||
},
|
|
||||||
"cwd": "${workspaceFolder}/backend"
|
"cwd": "${workspaceFolder}/backend"
|
||||||
},
|
},
|
||||||
// frontend - flutter app
|
// frontend - flutter app
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"nixEnvSelector.nixFile": "${workspaceFolder}/default.nix"
|
||||||
|
}
|
||||||
8
backend/.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
# all .env files
|
||||||
|
*.env
|
||||||
|
|
||||||
# osm-cache
|
# osm-cache
|
||||||
cache_XML/
|
cache_XML/
|
||||||
|
|
||||||
@@ -12,6 +15,9 @@ __pycache__/
|
|||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
# Pytest reports
|
||||||
|
report.html
|
||||||
|
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
@@ -128,7 +134,7 @@ celerybeat.pid
|
|||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
*.env
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
|
|||||||
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12.9
|
||||||
@@ -1,11 +1,29 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
# The installer requires curl (and certificates) to download the release archive
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates
|
||||||
|
|
||||||
|
# Download the latest installer
|
||||||
|
ADD https://astral.sh/uv/install.sh /uv-installer.sh
|
||||||
|
|
||||||
|
# Run the installer then remove it
|
||||||
|
RUN sh /uv-installer.sh && rm /uv-installer.sh
|
||||||
|
|
||||||
|
# Ensure the installed binary is on the `PATH`
|
||||||
|
ENV PATH="/root/.local/bin/:$PATH"
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY Pipfile Pipfile.lock .
|
|
||||||
|
|
||||||
RUN pip install pipenv
|
# Copy uv files
|
||||||
RUN pipenv install --deploy --system
|
COPY pyproject.toml pyproject.toml
|
||||||
|
COPY uv.lock uv.lock
|
||||||
|
COPY .python-version .python-version
|
||||||
|
|
||||||
|
# Sync the venv
|
||||||
|
RUN uv sync --frozen --no-cache --no-dev
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
COPY src src
|
COPY src src
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
@@ -17,4 +35,4 @@ ENV MEMCACHED_HOST_PATH=none
|
|||||||
ENV LOKI_URL=none
|
ENV LOKI_URL=none
|
||||||
|
|
||||||
# explicitly use a string instead of an argument list to force a shell and variable expansion
|
# explicitly use a string instead of an argument list to force a shell and variable expansion
|
||||||
CMD fastapi run src/main.py --port 8000 --workers $NUM_WORKERS
|
CMD uv run fastapi run src/main.py --port 8000 --workers $NUM_WORKERS
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
[[source]]
|
|
||||||
url = "https://pypi.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
name = "pypi"
|
|
||||||
|
|
||||||
[dev-packages]
|
|
||||||
pylint = "*"
|
|
||||||
pytest = "*"
|
|
||||||
tomli = "*"
|
|
||||||
httpx = "*"
|
|
||||||
exceptiongroup = "*"
|
|
||||||
pytest-html = "*"
|
|
||||||
typing-extensions = "*"
|
|
||||||
dill = "*"
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
numpy = "*"
|
|
||||||
fastapi = "*"
|
|
||||||
pydantic = "*"
|
|
||||||
shapely = "*"
|
|
||||||
pymemcache = "*"
|
|
||||||
fastapi-cli = "*"
|
|
||||||
scikit-learn = "*"
|
|
||||||
loki-logger-handler = "*"
|
|
||||||
pulp = "*"
|
|
||||||
scipy = "*"
|
|
||||||
requests = "*"
|
|
||||||
1246
backend/Pipfile.lock
generated
@@ -1,363 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"name": "Chinatown",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7554934,
|
|
||||||
4.8444852
|
|
||||||
],
|
|
||||||
"osm_type": "way",
|
|
||||||
"osm_id": 996515596,
|
|
||||||
"attractiveness": 129,
|
|
||||||
"n_tags": 0,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": null,
|
|
||||||
"wiki_url": null,
|
|
||||||
"keywords": {},
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "285d159c-68ee-4b37-8d71-f27ee3d38b02",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Galeries Lafayette",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7627107,
|
|
||||||
4.8556833
|
|
||||||
],
|
|
||||||
"osm_type": "way",
|
|
||||||
"osm_id": 1069872743,
|
|
||||||
"attractiveness": 197,
|
|
||||||
"n_tags": 11,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": "http://www.galerieslafayette.com/",
|
|
||||||
"wiki_url": null,
|
|
||||||
"keywords": null,
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "28f1bc30-10d3-4944-8861-0ed9abca012d",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Muji",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7615971,
|
|
||||||
4.8543781
|
|
||||||
],
|
|
||||||
"osm_type": "way",
|
|
||||||
"osm_id": 1044165817,
|
|
||||||
"attractiveness": 259,
|
|
||||||
"n_tags": 14,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": "https://www.muji.com/fr/",
|
|
||||||
"wiki_url": null,
|
|
||||||
"keywords": null,
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": "Muji",
|
|
||||||
"uuid": "957f86a5-6c00-41a2-815d-d6f739052be4",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "HEMA",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7619133,
|
|
||||||
4.8565239
|
|
||||||
],
|
|
||||||
"osm_type": "way",
|
|
||||||
"osm_id": 1069872750,
|
|
||||||
"attractiveness": 156,
|
|
||||||
"n_tags": 9,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": "https://fr.westfield.com/lapartdieu/store/HEMA/www.hema.fr",
|
|
||||||
"wiki_url": null,
|
|
||||||
"keywords": null,
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "8dae9d3e-e4c4-4e80-941d-0b106e22c85b",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Cordeliers",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7622752,
|
|
||||||
4.8337998
|
|
||||||
],
|
|
||||||
"osm_type": "node",
|
|
||||||
"osm_id": 5545183519,
|
|
||||||
"attractiveness": 813,
|
|
||||||
"n_tags": 0,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": null,
|
|
||||||
"wiki_url": null,
|
|
||||||
"keywords": {},
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "ba02adb5-e28f-4645-8c2d-25ead6232379",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Halles de Lyon Paul Bocuse",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7628282,
|
|
||||||
4.8505601
|
|
||||||
],
|
|
||||||
"osm_type": "relation",
|
|
||||||
"osm_id": 971529,
|
|
||||||
"attractiveness": 272,
|
|
||||||
"n_tags": 12,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": "https://www.halles-de-lyon-paulbocuse.com/",
|
|
||||||
"wiki_url": "fr:Halles de Lyon-Paul Bocuse",
|
|
||||||
"keywords": {
|
|
||||||
"importance": "national",
|
|
||||||
"height": null,
|
|
||||||
"place_type": "marketplace",
|
|
||||||
"date": null
|
|
||||||
},
|
|
||||||
"description": "Halles de Lyon Paul Bocuse is a marketplace of national importance.",
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "bbd50de3-aa91-425d-90c2-d4abfd1b4abe",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Grand Bazar",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7632141,
|
|
||||||
4.8361975
|
|
||||||
],
|
|
||||||
"osm_type": "way",
|
|
||||||
"osm_id": 82399951,
|
|
||||||
"attractiveness": 93,
|
|
||||||
"n_tags": 7,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": null,
|
|
||||||
"wiki_url": null,
|
|
||||||
"keywords": null,
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "3de9131c-87c5-4efb-9fa8-064896fb8b29",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Shopping Area",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7673452,
|
|
||||||
4.8438683
|
|
||||||
],
|
|
||||||
"osm_type": "node",
|
|
||||||
"osm_id": 0,
|
|
||||||
"attractiveness": 156,
|
|
||||||
"n_tags": 0,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": null,
|
|
||||||
"wiki_url": null,
|
|
||||||
"keywords": {},
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "df2482a8-7e2e-4536-aad3-564899b2fa65",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Cour Oxyg\u00e8ne",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7620905,
|
|
||||||
4.8568873
|
|
||||||
],
|
|
||||||
"osm_type": "way",
|
|
||||||
"osm_id": 132673030,
|
|
||||||
"attractiveness": 63,
|
|
||||||
"n_tags": 5,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": null,
|
|
||||||
"wiki_url": null,
|
|
||||||
"keywords": null,
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "ed134f76-9a02-4bee-9c10-78454f7bc4ce",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "P\u00f4le de Commerces et de Loisirs Confluence",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7410414,
|
|
||||||
4.8171031
|
|
||||||
],
|
|
||||||
"osm_type": "way",
|
|
||||||
"osm_id": 440270633,
|
|
||||||
"attractiveness": 259,
|
|
||||||
"n_tags": 14,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": "https://www.confluence.fr/",
|
|
||||||
"wiki_url": null,
|
|
||||||
"keywords": null,
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "dd7e2f5f-0e60-4560-b903-e5ded4b6e36a",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Grand H\u00f4tel-Dieu",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7586955,
|
|
||||||
4.8364597
|
|
||||||
],
|
|
||||||
"osm_type": "relation",
|
|
||||||
"osm_id": 300128,
|
|
||||||
"attractiveness": 546,
|
|
||||||
"n_tags": 22,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": "https://grand-hotel-dieu.com",
|
|
||||||
"wiki_url": "fr:H\u00f4tel-Dieu de Lyon",
|
|
||||||
"keywords": {
|
|
||||||
"importance": "international",
|
|
||||||
"height": null,
|
|
||||||
"place_type": "building",
|
|
||||||
"date": "C17"
|
|
||||||
},
|
|
||||||
"description": "Grand H\u00f4tel-Dieu is an internationally famous building. It was constructed in C17.",
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "a91265a8-ffbd-44f7-a7ab-3ff75f08fbab",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Westfield La Part-Dieu",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.761331,
|
|
||||||
4.855676
|
|
||||||
],
|
|
||||||
"osm_type": "way",
|
|
||||||
"osm_id": 62338376,
|
|
||||||
"attractiveness": 546,
|
|
||||||
"n_tags": 22,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": "https://fr.westfield.com/lapartdieu",
|
|
||||||
"wiki_url": "fr:La Part-Dieu (centre commercial)",
|
|
||||||
"keywords": null,
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "7d60316f-d689-4fcf-be68-ffc09353b826",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Ainay",
|
|
||||||
"type": "shopping",
|
|
||||||
"location": [
|
|
||||||
45.7553105,
|
|
||||||
4.8312084
|
|
||||||
],
|
|
||||||
"osm_type": "node",
|
|
||||||
"osm_id": 5545126047,
|
|
||||||
"attractiveness": 132,
|
|
||||||
"n_tags": 0,
|
|
||||||
"image_url": null,
|
|
||||||
"website_url": null,
|
|
||||||
"wiki_url": null,
|
|
||||||
"keywords": {},
|
|
||||||
"description": null,
|
|
||||||
"duration": 30,
|
|
||||||
"name_en": null,
|
|
||||||
"uuid": "ad214f3d-a4b9-4078-876a-446caa7ab01c",
|
|
||||||
"must_do": false,
|
|
||||||
"must_avoid": false,
|
|
||||||
"is_secondary": false,
|
|
||||||
"time_to_reach_next": 0,
|
|
||||||
"next_uuid": null,
|
|
||||||
"is_viewpoint": false,
|
|
||||||
"is_place_of_worship": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
6
backend/main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from backend!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
55
backend/pyproject.toml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
[project]
|
||||||
|
name = "backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"annotated-types==0.7.0 ; python_full_version >= '3.8'",
|
||||||
|
"anyio==4.8.0 ; python_full_version >= '3.9'",
|
||||||
|
"certifi==2024.12.14 ; python_full_version >= '3.6'",
|
||||||
|
"charset-normalizer==3.4.1 ; python_full_version >= '3.7'",
|
||||||
|
"click==8.1.8 ; python_full_version >= '3.7'",
|
||||||
|
"fastapi==0.115.7 ; python_full_version >= '3.8'",
|
||||||
|
"fastapi-cli==0.0.7 ; python_full_version >= '3.8'",
|
||||||
|
"h11==0.14.0 ; python_full_version >= '3.7'",
|
||||||
|
"httptools==0.6.4",
|
||||||
|
"idna==3.10 ; python_full_version >= '3.6'",
|
||||||
|
"joblib==1.4.2 ; python_full_version >= '3.8'",
|
||||||
|
"loki-logger-handler==1.1.0 ; python_full_version >= '2.7'",
|
||||||
|
"markdown-it-py==3.0.0 ; python_full_version >= '3.8'",
|
||||||
|
"mdurl==0.1.2 ; python_full_version >= '3.7'",
|
||||||
|
"numpy==2.2.2 ; python_full_version >= '3.10'",
|
||||||
|
"pulp==2.9.0 ; python_full_version >= '3.7'",
|
||||||
|
"pydantic==2.10.6 ; python_full_version >= '3.8'",
|
||||||
|
"pydantic-core==2.27.2 ; python_full_version >= '3.8'",
|
||||||
|
"pygments==2.19.1 ; python_full_version >= '3.8'",
|
||||||
|
"pymemcache==4.0.0 ; python_full_version >= '3.7'",
|
||||||
|
"python-dotenv==1.0.1",
|
||||||
|
"pyyaml==6.0.2",
|
||||||
|
"requests==2.32.3 ; python_full_version >= '3.8'",
|
||||||
|
"rich==13.9.4 ; python_full_version >= '3.8'",
|
||||||
|
"rich-toolkit==0.13.2 ; python_full_version >= '3.8'",
|
||||||
|
"scikit-learn==1.6.1 ; python_full_version >= '3.9'",
|
||||||
|
"scipy==1.15.1 ; python_full_version >= '3.10'",
|
||||||
|
"shapely==2.0.6 ; python_full_version >= '3.7'",
|
||||||
|
"shellingham==1.5.4 ; python_full_version >= '3.7'",
|
||||||
|
"sniffio==1.3.1 ; python_full_version >= '3.7'",
|
||||||
|
"starlette==0.45.3 ; python_full_version >= '3.9'",
|
||||||
|
"threadpoolctl==3.5.0 ; python_full_version >= '3.8'",
|
||||||
|
"typer==0.15.1 ; python_full_version >= '3.7'",
|
||||||
|
"typing-extensions==4.12.2 ; python_full_version >= '3.8'",
|
||||||
|
"urllib3==2.3.0 ; python_full_version >= '3.9'",
|
||||||
|
"uvicorn[standard]==0.34.0 ; python_full_version >= '3.9'",
|
||||||
|
"uvloop==0.21.0",
|
||||||
|
"watchfiles==1.0.4",
|
||||||
|
"websockets==14.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
"ipykernel>=6.30.0",
|
||||||
|
"pytest>=8.4.1",
|
||||||
|
"pytest-html>=4.1.1",
|
||||||
|
]
|
||||||
@@ -102,9 +102,6 @@ class ClusterManager:
|
|||||||
selector = sel,
|
selector = sel,
|
||||||
out = out
|
out = out
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Error fetching clusters: {e}")
|
|
||||||
|
|
||||||
if result is None :
|
if result is None :
|
||||||
self.logger.debug(f"Found no {cluster_type} clusters, overpass query returned no datapoints.")
|
self.logger.debug(f"Found no {cluster_type} clusters, overpass query returned no datapoints.")
|
||||||
self.valid = False
|
self.valid = False
|
||||||
@@ -146,9 +143,14 @@ class ClusterManager:
|
|||||||
self.valid = False
|
self.valid = False
|
||||||
|
|
||||||
else :
|
else :
|
||||||
self.logger.debug(f"Detected 0 {cluster_type} clusters.")
|
self.logger.debug(f"Found 0 {cluster_type} clusters.")
|
||||||
self.valid = False
|
self.valid = False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Could not fetch clusters: {e}")
|
||||||
|
self.valid = False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def generate_clusters(self) -> list[Landmark]:
|
def generate_clusters(self) -> list[Landmark]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import yaml
|
|||||||
|
|
||||||
from ..structs.preferences import Preferences
|
from ..structs.preferences import Preferences
|
||||||
from ..structs.landmark import Landmark
|
from ..structs.landmark import Landmark
|
||||||
from ..utils.take_most_important import take_most_important
|
|
||||||
from .cluster_manager import ClusterManager
|
from .cluster_manager import ClusterManager
|
||||||
from ..overpass.overpass import Overpass, get_base_info
|
from ..overpass.overpass import Overpass, get_base_info
|
||||||
from ..utils.bbox import create_bbox
|
from ..utils.bbox import create_bbox
|
||||||
@@ -23,7 +22,7 @@ class LandmarkManager:
|
|||||||
church_coeff: float # coeff to adjsut score of churches
|
church_coeff: float # coeff to adjsut score of churches
|
||||||
nature_coeff: float # coeff to adjust score of parks
|
nature_coeff: float # coeff to adjust score of parks
|
||||||
overall_coeff: float # coeff to adjust weight of tags
|
overall_coeff: float # coeff to adjust weight of tags
|
||||||
n_important: int # number of important landmarks to consider
|
# n_important: int # number of important landmarks to consider
|
||||||
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -42,7 +41,7 @@ class LandmarkManager:
|
|||||||
self.wikipedia_bonus = parameters['wikipedia_bonus']
|
self.wikipedia_bonus = parameters['wikipedia_bonus']
|
||||||
self.viewpoint_bonus = parameters['viewpoint_bonus']
|
self.viewpoint_bonus = parameters['viewpoint_bonus']
|
||||||
self.pay_bonus = parameters['pay_bonus']
|
self.pay_bonus = parameters['pay_bonus']
|
||||||
self.n_important = parameters['N_important']
|
# self.n_important = parameters['N_important']
|
||||||
|
|
||||||
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
|
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
|
||||||
parameters = yaml.safe_load(f)
|
parameters = yaml.safe_load(f)
|
||||||
@@ -55,7 +54,12 @@ class LandmarkManager:
|
|||||||
self.logger.info('LandmakManager successfully initialized.')
|
self.logger.info('LandmakManager successfully initialized.')
|
||||||
|
|
||||||
|
|
||||||
def generate_landmarks_list(self, center_coordinates: tuple[float, float], preferences: Preferences) -> tuple[list[Landmark], list[Landmark]]:
|
def generate_landmarks_list(
|
||||||
|
self,
|
||||||
|
center_coordinates: tuple[float, float],
|
||||||
|
preferences: Preferences,
|
||||||
|
allow_clusters: bool = True
|
||||||
|
) -> list[Landmark] :
|
||||||
"""
|
"""
|
||||||
Generate and prioritize a list of landmarks based on user preferences.
|
Generate and prioritize a list of landmarks based on user preferences.
|
||||||
|
|
||||||
@@ -63,16 +67,17 @@ class LandmarkManager:
|
|||||||
and current location. It scores and corrects these landmarks, removes duplicates, and then selects the most important
|
and current location. It scores and corrects these landmarks, removes duplicates, and then selects the most important
|
||||||
landmarks based on a predefined criterion.
|
landmarks based on a predefined criterion.
|
||||||
|
|
||||||
Args:
|
Parameters :
|
||||||
center_coordinates (tuple[float, float]): The latitude and longitude of the center location around which to search.
|
center_coordinates (tuple[float, float]): The latitude and longitude of the center location around which to search.
|
||||||
preferences (Preferences): The user's preference settings that influence the landmark selection.
|
preferences (Preferences): The user's preference settings that influence the landmark selection.
|
||||||
|
allow_clusters (bool, optional) : If set to False, no clusters will be fetched. Mainly used for the option to fetch landmarks nearby.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[list[Landmark], list[Landmark]]:
|
tuple[list[Landmark], list[Landmark]]:
|
||||||
- A list of all existing landmarks.
|
- A list of all existing landmarks.
|
||||||
- A list of the most important landmarks based on the user's preferences.
|
- A list of the most important landmarks based on the user's preferences.
|
||||||
"""
|
"""
|
||||||
self.logger.debug('Starting to fetch landmarks...')
|
self.logger.info(f'Starting to fetch landmarks around {center_coordinates}...')
|
||||||
max_walk_dist = int((preferences.max_time_minute/2)/60*self.walking_speed*1000/self.detour_factor)
|
max_walk_dist = int((preferences.max_time_minute/2)/60*self.walking_speed*1000/self.detour_factor)
|
||||||
radius = min(max_walk_dist, int(self.max_bbox_side/2))
|
radius = min(max_walk_dist, int(self.max_bbox_side/2))
|
||||||
|
|
||||||
@@ -89,6 +94,7 @@ class LandmarkManager:
|
|||||||
all_landmarks.update(current_landmarks)
|
all_landmarks.update(current_landmarks)
|
||||||
self.logger.info(f'Found {len(current_landmarks)} sightseeing landmarks')
|
self.logger.info(f'Found {len(current_landmarks)} sightseeing landmarks')
|
||||||
|
|
||||||
|
if allow_clusters :
|
||||||
# special pipeline for historic neighborhoods
|
# special pipeline for historic neighborhoods
|
||||||
neighborhood_manager = ClusterManager(bbox, 'sightseeing')
|
neighborhood_manager = ClusterManager(bbox, 'sightseeing')
|
||||||
historic_clusters = neighborhood_manager.generate_clusters()
|
historic_clusters = neighborhood_manager.generate_clusters()
|
||||||
@@ -113,16 +119,19 @@ class LandmarkManager:
|
|||||||
landmark.duration = 30
|
landmark.duration = 30
|
||||||
all_landmarks.update(current_landmarks)
|
all_landmarks.update(current_landmarks)
|
||||||
|
|
||||||
|
if allow_clusters :
|
||||||
# special pipeline for shopping malls
|
# special pipeline for shopping malls
|
||||||
shopping_manager = ClusterManager(bbox, 'shopping')
|
shopping_manager = ClusterManager(bbox, 'shopping')
|
||||||
shopping_clusters = shopping_manager.generate_clusters()
|
shopping_clusters = shopping_manager.generate_clusters()
|
||||||
all_landmarks.update(shopping_clusters)
|
all_landmarks.update(shopping_clusters)
|
||||||
|
|
||||||
|
|
||||||
landmarks_constrained = take_most_important(all_landmarks, self.n_important)
|
# DETAILS HERE
|
||||||
# self.logger.info(f'All landmarks generated : {len(all_landmarks)} landmarks around {center_coordinates}, and constrained to {len(landmarks_constrained)} most important ones.')
|
# self.logger.info(f'All landmarks generated : {len(all_landmarks)} landmarks around {center_coordinates}, and constrained to {len(landmarks_constrained)} most important ones.')
|
||||||
|
self.logger.info(f'Found {len(all_landmarks)} landmarks in total.')
|
||||||
|
|
||||||
|
return sorted(all_landmarks, key=lambda x: x.attractiveness, reverse=True)
|
||||||
|
|
||||||
return all_landmarks, landmarks_constrained
|
|
||||||
|
|
||||||
def set_landmark_score(self, landmark: Landmark, landmarktype: str, preference_level: int) :
|
def set_landmark_score(self, landmark: Landmark, landmarktype: str, preference_level: int) :
|
||||||
"""
|
"""
|
||||||
@@ -236,6 +245,17 @@ class LandmarkManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
tags = elem.get('tags')
|
tags = elem.get('tags')
|
||||||
|
n_tags=len(tags)
|
||||||
|
|
||||||
|
# Skip this landmark if not suitable
|
||||||
|
if tags.get('building:part') is not None :
|
||||||
|
continue
|
||||||
|
if tags.get('disused') is not None :
|
||||||
|
continue
|
||||||
|
if tags.get('boundary') is not None :
|
||||||
|
continue
|
||||||
|
if tags.get('shop') is not None and landmarktype != 'shopping' :
|
||||||
|
continue
|
||||||
|
|
||||||
# Convert this to Landmark object
|
# Convert this to Landmark object
|
||||||
landmark = Landmark(name=name,
|
landmark = Landmark(name=name,
|
||||||
@@ -244,180 +264,36 @@ class LandmarkManager:
|
|||||||
osm_id=id,
|
osm_id=id,
|
||||||
osm_type=osm_type,
|
osm_type=osm_type,
|
||||||
attractiveness=0,
|
attractiveness=0,
|
||||||
n_tags=len(tags))
|
n_tags=n_tags)
|
||||||
|
|
||||||
# Browse through tags to add information to landmark.
|
# Extract useful information for score calculation later down the road.
|
||||||
for key, value in tags.items():
|
landmark.image_url = tags.get('image')
|
||||||
|
landmark.website_url = tags.get('website')
|
||||||
|
landmark.wiki_url = tags.get('wikipedia')
|
||||||
|
landmark.name_en = tags.get('name:en')
|
||||||
|
|
||||||
# Skip this landmark if not suitable.
|
# Check for place of worship
|
||||||
if key == 'building:part' and value == 'yes' :
|
if tags.get('place_of_worship') is not None :
|
||||||
break
|
|
||||||
if 'disused:' in key :
|
|
||||||
break
|
|
||||||
if 'boundary:' in key :
|
|
||||||
break
|
|
||||||
if 'shop' in key and landmarktype != 'shopping' :
|
|
||||||
break
|
|
||||||
# if value == 'apartments' :
|
|
||||||
# break
|
|
||||||
|
|
||||||
# Fill in the other attributes.
|
|
||||||
if key == 'image' :
|
|
||||||
landmark.image_url = value
|
|
||||||
if key == 'website' :
|
|
||||||
landmark.website_url = value
|
|
||||||
if value == 'place_of_worship' :
|
|
||||||
landmark.is_place_of_worship = True
|
landmark.is_place_of_worship = True
|
||||||
if key == 'wikipedia' :
|
landmark.name_en = tags.get('place_of_worship')
|
||||||
landmark.wiki_url = value
|
|
||||||
if key == 'name:en' :
|
|
||||||
landmark.name_en = value
|
|
||||||
if 'building:' in key or 'pay' in key :
|
|
||||||
landmark.n_tags -= 1
|
|
||||||
|
|
||||||
|
# Set the duration. Needed for the optimization.
|
||||||
# Set the duration.
|
if tags.get('amenity') in ['aquarium', 'planetarium'] or tags.get('tourism') in ['aquarium', 'museum', 'zoo']:
|
||||||
if value in ['museum', 'aquarium', 'planetarium'] :
|
|
||||||
landmark.duration = 60
|
landmark.duration = 60
|
||||||
elif value == 'viewpoint' :
|
elif tags.get('tourism') == 'viewpoint' :
|
||||||
landmark.is_viewpoint = True
|
landmark.is_viewpoint = True
|
||||||
landmark.duration = 10
|
landmark.duration = 10
|
||||||
elif value == 'cathedral' :
|
elif tags.get('building') == 'cathedral' :
|
||||||
landmark.is_place_of_worship = False
|
landmark.is_place_of_worship = False
|
||||||
landmark.duration = 10
|
landmark.duration = 10
|
||||||
|
|
||||||
landmark.description, landmark.keywords = self.description_and_keywords(tags)
|
# Compute the score and add landmark to the list.
|
||||||
self.set_landmark_score(landmark, landmarktype, preference_level)
|
self.set_landmark_score(landmark, landmarktype, preference_level)
|
||||||
landmarks.append(landmark)
|
landmarks.append(landmark)
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
return landmarks
|
return landmarks
|
||||||
|
|
||||||
|
|
||||||
def description_and_keywords(self, tags: dict):
|
|
||||||
"""
|
|
||||||
Generates a description and a set of keywords for a given landmark based on its tags.
|
|
||||||
|
|
||||||
Params:
|
|
||||||
tags (dict): A dictionary containing metadata about the landmark, including its name,
|
|
||||||
importance, height, date of construction, and visitor information.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
description (str): A string description of the landmark.
|
|
||||||
keywords (dict): A dictionary of keywords with fields such as 'importance', 'height',
|
|
||||||
'place_type', and 'date'.
|
|
||||||
"""
|
|
||||||
# Extract relevant fields
|
|
||||||
name = tags.get('name')
|
|
||||||
importance = tags.get('importance', None)
|
|
||||||
n_visitors = tags.get('tourism:visitors', None)
|
|
||||||
height = tags.get('height')
|
|
||||||
place_type = self.get_place_type(tags)
|
|
||||||
date = self.get_date(tags)
|
|
||||||
|
|
||||||
if place_type is None :
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
# Start the description.
|
|
||||||
if importance is None :
|
|
||||||
if len(tags.keys()) < 5 :
|
|
||||||
return None, None
|
|
||||||
if len(tags.keys()) < 10 :
|
|
||||||
description = f"{name} is a well known {place_type}."
|
|
||||||
elif len(tags.keys()) < 17 :
|
|
||||||
importance = 'national'
|
|
||||||
description = f"{name} is a {place_type} of national importance."
|
|
||||||
else :
|
|
||||||
importance = 'international'
|
|
||||||
description = f"{name} is an internationally famous {place_type}."
|
|
||||||
else :
|
|
||||||
description = f"{name} is a {place_type} of {importance} importance."
|
|
||||||
|
|
||||||
if height is not None and date is not None :
|
|
||||||
description += f" This {place_type} was constructed in {date} and is ca. {height} meters high."
|
|
||||||
elif height is not None :
|
|
||||||
description += f" This {place_type} stands ca. {height} meters tall."
|
|
||||||
elif date is not None:
|
|
||||||
description += f" It was constructed in {date}."
|
|
||||||
|
|
||||||
# Format the visitor number
|
|
||||||
if n_visitors is not None :
|
|
||||||
n_visitors = int(n_visitors)
|
|
||||||
if n_visitors < 1000000 :
|
|
||||||
description += f" It welcomes {int(n_visitors/1000)} thousand visitors every year."
|
|
||||||
else :
|
|
||||||
description += f" It welcomes {round(n_visitors/1000000, 1)} million visitors every year."
|
|
||||||
|
|
||||||
# Set the keywords.
|
|
||||||
keywords = {"importance": importance,
|
|
||||||
"height": height,
|
|
||||||
"place_type": place_type,
|
|
||||||
"date": date}
|
|
||||||
|
|
||||||
return description, keywords
|
|
||||||
|
|
||||||
|
|
||||||
def get_place_type(self, data):
|
|
||||||
"""
|
|
||||||
Determines the type of the place based on available tags such as 'amenity', 'building',
|
|
||||||
'historic', and 'leisure'. The priority order is: 'historic' > 'building' (if not generic) >
|
|
||||||
'amenity' > 'leisure'.
|
|
||||||
|
|
||||||
Params:
|
|
||||||
data (dict): A dictionary containing metadata about the place.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
place_type (str): The determined type of the place, or None if no relevant type is found.
|
|
||||||
"""
|
|
||||||
amenity = data.get('amenity', None)
|
|
||||||
building = data.get('building', None)
|
|
||||||
historic = data.get('historic', None)
|
|
||||||
leisure = data.get('leisure')
|
|
||||||
|
|
||||||
if historic and historic != "yes":
|
|
||||||
return historic
|
|
||||||
if building and building not in ["yes", "civic", "government", "apartments", "residential", "commericial", "industrial", "retail", "religious", "public", "service"]:
|
|
||||||
return building
|
|
||||||
if amenity:
|
|
||||||
return amenity
|
|
||||||
if leisure:
|
|
||||||
return leisure
|
|
||||||
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_date(self, data):
|
|
||||||
"""
|
|
||||||
Extracts the most relevant date from the available tags, prioritizing 'construction_date',
|
|
||||||
'start_date', 'year_of_construction', and 'opening_date' in that order.
|
|
||||||
|
|
||||||
Params:
|
|
||||||
data (dict): A dictionary containing metadata about the place.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
date (str): The most relevant date found, or None if no date is available.
|
|
||||||
"""
|
|
||||||
construction_date = data.get('construction_date', None)
|
|
||||||
opening_date = data.get('opening_date', None)
|
|
||||||
start_date = data.get('start_date', None)
|
|
||||||
year_of_construction = data.get('year_of_construction', None)
|
|
||||||
|
|
||||||
# Prioritize based on availability
|
|
||||||
if construction_date:
|
|
||||||
return construction_date
|
|
||||||
if start_date:
|
|
||||||
return start_date
|
|
||||||
if year_of_construction:
|
|
||||||
return year_of_construction
|
|
||||||
if opening_date:
|
|
||||||
return opening_date
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def dict_to_selector_list(d: dict) -> list:
|
def dict_to_selector_list(d: dict) -> list:
|
||||||
"""
|
"""
|
||||||
Convert a dictionary of key-value pairs to a list of Overpass query strings.
|
Convert a dictionary of key-value pairs to a list of Overpass query strings.
|
||||||
|
|||||||
123
backend/src/landmarks/landmarks_router.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Main app for backend api"""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
from fastapi import HTTPException, APIRouter
|
||||||
|
|
||||||
|
from ..structs.landmark import Landmark
|
||||||
|
from ..structs.preferences import Preferences, Preference
|
||||||
|
from .landmarks_manager import LandmarkManager
|
||||||
|
|
||||||
|
|
||||||
|
# Setup the logger and the Landmarks Manager
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
manager = LandmarkManager()
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize the API router
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/get/landmarks")
|
||||||
|
def get_landmarks(
|
||||||
|
preferences: Preferences,
|
||||||
|
start: tuple[float, float],
|
||||||
|
) -> list[Landmark]:
|
||||||
|
"""
|
||||||
|
Function that returns all available landmarks given some preferences and a start position.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preferences : the preferences specified by the user as the post body
|
||||||
|
start : the coordinates of the starting point
|
||||||
|
Returns:
|
||||||
|
list[Landmark] : The full list of fetched landmarks
|
||||||
|
"""
|
||||||
|
if preferences is None:
|
||||||
|
raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
|
||||||
|
if (preferences.shopping.score == 0 and
|
||||||
|
preferences.sightseeing.score == 0 and
|
||||||
|
preferences.nature.score == 0) :
|
||||||
|
raise HTTPException(status_code=406, detail="All preferences are 0.")
|
||||||
|
if start is None:
|
||||||
|
raise HTTPException(status_code=406, detail="Start coordinates not provided")
|
||||||
|
if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180):
|
||||||
|
raise HTTPException(status_code=422, detail="Start coordinates not in range")
|
||||||
|
|
||||||
|
logger.info(f"Requested new trip generation. Details:\n\tCoordinates: {start}\n\tTime: {preferences.max_time_minute}\n\tSightseeing: {preferences.sightseeing.score}\n\tNature: {preferences.nature.score}\n\tShopping: {preferences.shopping.score}")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Generate the landmarks from the start location
|
||||||
|
landmarks = manager.generate_landmarks_list(
|
||||||
|
center_coordinates = start,
|
||||||
|
preferences = preferences
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(landmarks) == 0 :
|
||||||
|
raise HTTPException(status_code=500, detail="No landmarks were found.")
|
||||||
|
|
||||||
|
t_generate_landmarks = time.time() - start_time
|
||||||
|
logger.info(f'Fetched {len(landmarks)} landmarks in \t: {round(t_generate_landmarks,3)} seconds')
|
||||||
|
|
||||||
|
return landmarks
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/get-nearby/landmarks/{lat}/{lon}")
|
||||||
|
def get_landmarks_nearby(
|
||||||
|
lat: float,
|
||||||
|
lon: float
|
||||||
|
) -> list[Landmark] :
|
||||||
|
"""
|
||||||
|
Suggests nearby landmarks based on a given latitude and longitude.
|
||||||
|
|
||||||
|
This endpoint returns a curated list of up to 5 landmarks around the given geographical coordinates. It uses fixed preferences for
|
||||||
|
sightseeing, shopping, and nature, with a maximum time constraint of 30 minutes to limit the number of landmarks returned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lat (float): Latitude of the user's current location.
|
||||||
|
lon (float): Longitude of the user's current location.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[Landmark]: A list of selected nearby landmarks.
|
||||||
|
"""
|
||||||
|
logger.info(f'Fetching landmarks nearby ({lat}, {lon}).')
|
||||||
|
|
||||||
|
# Define fixed preferences:
|
||||||
|
prefs = Preferences(
|
||||||
|
sightseeing = Preference(
|
||||||
|
type='sightseeing',
|
||||||
|
score=5
|
||||||
|
),
|
||||||
|
shopping = Preference(
|
||||||
|
type='shopping',
|
||||||
|
score=2
|
||||||
|
),
|
||||||
|
nature = Preference(
|
||||||
|
type='nature',
|
||||||
|
score=5
|
||||||
|
),
|
||||||
|
max_time_minute=30,
|
||||||
|
detour_tolerance_minute=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the landmarks around the location
|
||||||
|
landmarks_around = manager.generate_landmarks_list(
|
||||||
|
center_coordinates = (lat, lon),
|
||||||
|
preferences = prefs,
|
||||||
|
allow_clusters=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(landmarks_around) == 0 :
|
||||||
|
raise HTTPException(status_code=500, detail="No landmarks were found.")
|
||||||
|
|
||||||
|
# select 8 - 12 landmarks from there
|
||||||
|
if len(landmarks_around) > 8 :
|
||||||
|
n_imp = random.randint(2,5)
|
||||||
|
rest = random.randint(8 - n_imp, min(12, len(landmarks_around))-n_imp)
|
||||||
|
|
||||||
|
print(f'len = {len(landmarks_around)}\nn_imp = {n_imp}\nrest = {rest}')
|
||||||
|
landmarks_around = landmarks_around[:n_imp] + random.sample(landmarks_around[n_imp:], rest)
|
||||||
|
|
||||||
|
logger.info(f'Found {len(landmarks_around)} landmarks to suggest nearby ({lat}, {lon}).')
|
||||||
|
# logger.debug('Suggested landmarks :\n\t' + '\n\t'.join(f'{landmark}' for landmark in landmarks_around))
|
||||||
|
return landmarks_around
|
||||||
@@ -33,14 +33,14 @@ def configure_logging():
|
|||||||
# silence the chatty logs loki generates itself
|
# silence the chatty logs loki generates itself
|
||||||
logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING)
|
logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING)
|
||||||
# no need for time since it's added by loki or can be shown in kube logs
|
# no need for time since it's added by loki or can be shown in kube logs
|
||||||
logging_format = '%(name)s - %(levelname)s - %(message)s'
|
logging_format = '%(name)-55s - %(levelname)-7s - %(message)s'
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# if we are in a debug (local) session, set verbose and rich logging
|
# if we are in a debug (local) session, set verbose and rich logging
|
||||||
from rich.logging import RichHandler
|
from rich.logging import RichHandler
|
||||||
logging_handlers = [RichHandler()]
|
logging_handlers = [RichHandler()]
|
||||||
logging_level = logging.DEBUG if is_debug else logging.INFO
|
logging_level = logging.DEBUG if is_debug else logging.INFO
|
||||||
logging_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
logging_format = '%(asctime)s - %(name)-55s - %(levelname)-7s - %(message)s'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
"""Main app for backend api"""
|
"""Main app for backend api"""
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .structs.landmark import Landmark
|
from .structs.landmark import Landmark
|
||||||
from .structs.preferences import Preferences
|
|
||||||
from .structs.linked_landmarks import LinkedLandmarks
|
from .structs.linked_landmarks import LinkedLandmarks
|
||||||
from .structs.trip import Trip
|
from .structs.trip import Trip
|
||||||
from .landmarks.landmarks_manager import LandmarkManager
|
from .landmarks.landmarks_manager import LandmarkManager
|
||||||
from .toilets.toilet_routes import router as toilets_router
|
from .toilets.toilets_router import router as toilets_router
|
||||||
|
from .optimization.optimization_router import router as optimization_router
|
||||||
|
from .landmarks.landmarks_router import router as landmarks_router, get_landmarks_nearby
|
||||||
from .optimization.optimizer import Optimizer
|
from .optimization.optimizer import Optimizer
|
||||||
from .optimization.refiner import Refiner
|
from .optimization.refiner import Refiner
|
||||||
from .overpass.overpass import fill_cache
|
|
||||||
from .cache import client as cache_client
|
from .cache import client as cache_client
|
||||||
|
|
||||||
|
|
||||||
@@ -37,115 +36,22 @@ app = FastAPI(lifespan=lifespan)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Fetches the global list of landmarks given preferences and start/end coordinates. Two routes
|
||||||
|
# Call with "/get/landmarks/" for main entry point of the trip generation pipeline.
|
||||||
|
# Call with "/get-nearby/landmarks/" for the NEARBY feature.
|
||||||
|
app.include_router(landmarks_router)
|
||||||
|
|
||||||
|
|
||||||
|
# Optimizes the trip given preferences. Second step in the main trip generation pipeline
|
||||||
|
# Call with "/optimize/trip"
|
||||||
|
app.include_router(optimization_router)
|
||||||
|
|
||||||
|
|
||||||
|
# Fetches toilets near given coordinates.
|
||||||
|
# Call with "/get/toilets" for fetching toilets around coordinates
|
||||||
app.include_router(toilets_router)
|
app.include_router(toilets_router)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/trip/new")
|
|
||||||
def new_trip(preferences: Preferences,
|
|
||||||
start: tuple[float, float],
|
|
||||||
end: tuple[float, float] | None = None,
|
|
||||||
background_tasks: BackgroundTasks = None) -> Trip:
|
|
||||||
"""
|
|
||||||
Main function to call the optimizer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
preferences : the preferences specified by the user as the post body
|
|
||||||
start : the coordinates of the starting point
|
|
||||||
end : the coordinates of the finishing point
|
|
||||||
Returns:
|
|
||||||
(uuid) : The uuid of the first landmark in the optimized route
|
|
||||||
"""
|
|
||||||
if preferences is None:
|
|
||||||
raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
|
|
||||||
if (preferences.shopping.score == 0 and
|
|
||||||
preferences.sightseeing.score == 0 and
|
|
||||||
preferences.nature.score == 0) :
|
|
||||||
raise HTTPException(status_code=406, detail="All preferences are 0.")
|
|
||||||
if start is None:
|
|
||||||
raise HTTPException(status_code=406, detail="Start coordinates not provided")
|
|
||||||
if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180):
|
|
||||||
raise HTTPException(status_code=422, detail="Start coordinates not in range")
|
|
||||||
if end is None:
|
|
||||||
end = start
|
|
||||||
logger.info("No end coordinates provided. Using start=end.")
|
|
||||||
|
|
||||||
logger.info(f"Requested new trip generation. Details:\n\tCoordinates: {start}\n\tTime: {preferences.max_time_minute}\n\tSightseeing: {preferences.sightseeing.score}\n\tNature: {preferences.nature.score}\n\tShopping: {preferences.shopping.score}")
|
|
||||||
|
|
||||||
start_landmark = Landmark(name='start',
|
|
||||||
type='start',
|
|
||||||
location=(start[0], start[1]),
|
|
||||||
osm_type='start',
|
|
||||||
osm_id=0,
|
|
||||||
attractiveness=0,
|
|
||||||
duration=0,
|
|
||||||
must_do=True,
|
|
||||||
n_tags = 0)
|
|
||||||
|
|
||||||
end_landmark = Landmark(name='finish',
|
|
||||||
type='finish',
|
|
||||||
location=(end[0], end[1]),
|
|
||||||
osm_type='end',
|
|
||||||
osm_id=0,
|
|
||||||
attractiveness=0,
|
|
||||||
duration=0,
|
|
||||||
must_do=True,
|
|
||||||
n_tags=0)
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# Generate the landmarks from the start location
|
|
||||||
landmarks, landmarks_short = manager.generate_landmarks_list(
|
|
||||||
center_coordinates = start,
|
|
||||||
preferences = preferences
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(landmarks) == 0 :
|
|
||||||
raise HTTPException(status_code=500, detail="No landmarks were found.")
|
|
||||||
|
|
||||||
# insert start and finish to the landmarks list
|
|
||||||
landmarks_short.insert(0, start_landmark)
|
|
||||||
landmarks_short.append(end_landmark)
|
|
||||||
|
|
||||||
t_generate_landmarks = time.time() - start_time
|
|
||||||
logger.info(f'Fetched {len(landmarks)} landmarks in \t: {round(t_generate_landmarks,3)} seconds')
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# First stage optimization
|
|
||||||
try:
|
|
||||||
base_tour = optimizer.solve_optimization(preferences.max_time_minute, landmarks_short)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(f"Trip generation failed: {str(exc)}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Optimization failed: {str(exc)}") from exc
|
|
||||||
|
|
||||||
t_first_stage = time.time() - start_time
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# Second stage optimization
|
|
||||||
# TODO : only if necessary (not enough landmarks for ex.)
|
|
||||||
try :
|
|
||||||
refined_tour = refiner.refine_optimization(landmarks, base_tour,
|
|
||||||
preferences.max_time_minute,
|
|
||||||
preferences.detour_tolerance_minute)
|
|
||||||
except Exception as exc :
|
|
||||||
logger.warning(f"Refiner failed. Proceeding with base trip {str(exc)}")
|
|
||||||
refined_tour = base_tour
|
|
||||||
|
|
||||||
t_second_stage = time.time() - start_time
|
|
||||||
|
|
||||||
logger.debug(f'First stage optimization\t: {round(t_first_stage,3)} seconds')
|
|
||||||
logger.debug(f'Second stage optimization\t: {round(t_second_stage,3)} seconds')
|
|
||||||
logger.info(f'Total computation time\t: {round(t_first_stage + t_second_stage,3)} seconds')
|
|
||||||
linked_tour = LinkedLandmarks(refined_tour)
|
|
||||||
|
|
||||||
# upon creation of the trip, persistence of both the trip and its landmarks is ensured.
|
|
||||||
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
|
||||||
logger.info(f'Generated a trip of {trip.total_time} minutes with {len(refined_tour)} landmarks in {round(t_generate_landmarks + t_first_stage + t_second_stage,3)} seconds.')
|
|
||||||
logger.debug('Detailed trip :\n\t' + '\n\t'.join(f'{landmark}' for landmark in refined_tour))
|
|
||||||
|
|
||||||
background_tasks.add_task(fill_cache)
|
|
||||||
|
|
||||||
return trip
|
|
||||||
|
|
||||||
|
|
||||||
#### For already existing trips/landmarks
|
#### For already existing trips/landmarks
|
||||||
@app.get("/trip/{trip_uuid}")
|
@app.get("/trip/{trip_uuid}")
|
||||||
@@ -224,3 +130,4 @@ def update_trip_time(trip_uuid: str, removed_landmark_uuid: str) -> Trip:
|
|||||||
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
||||||
|
|
||||||
return trip
|
return trip
|
||||||
|
|
||||||
|
|||||||
141
backend/src/optimization/optimization_router.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""API entry point for the trip optimization."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import yaml
|
||||||
|
from fastapi import HTTPException, APIRouter, BackgroundTasks
|
||||||
|
|
||||||
|
from .optimizer import Optimizer
|
||||||
|
from .refiner import Refiner
|
||||||
|
from ..structs.landmark import Landmark
|
||||||
|
from ..structs.preferences import Preferences
|
||||||
|
from ..structs.linked_landmarks import LinkedLandmarks
|
||||||
|
from ..structs.trip import Trip
|
||||||
|
from ..overpass.overpass import fill_cache
|
||||||
|
from ..cache import client as cache_client
|
||||||
|
from ..constants import OPTIMIZER_PARAMETERS_PATH
|
||||||
|
|
||||||
|
|
||||||
|
# Setup the Logger, Optimizer and Refiner
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
optimizer = Optimizer()
|
||||||
|
refiner = Refiner(optimizer=optimizer)
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize the API router
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/optimize/trip")
|
||||||
|
def optimize_trip(
|
||||||
|
preferences: Preferences,
|
||||||
|
landmarks: list[Landmark],
|
||||||
|
start: tuple[float, float],
|
||||||
|
end: tuple[float, float] | None = None,
|
||||||
|
background_tasks: BackgroundTasks = None
|
||||||
|
) -> Trip:
|
||||||
|
"""
|
||||||
|
Main function to call the optimizer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preferences (Preferences) : the preferences specified by the user as the post body.
|
||||||
|
start (tuple[float, float]) : the coordinates of the starting point.
|
||||||
|
end tuple[float, float] : the coordinates of the finishing point.
|
||||||
|
backgroud_tasks (BackgroundTasks) : necessary to fill the cache after the trip has been returned.
|
||||||
|
Returns:
|
||||||
|
(uuid) : The uuid of the first landmark in the optimized route
|
||||||
|
"""
|
||||||
|
if preferences is None:
|
||||||
|
raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
|
||||||
|
if len(landmarks) == 0 :
|
||||||
|
raise HTTPException(status_code=406, detail="No landmarks provided for computing the trip.")
|
||||||
|
if (preferences.shopping.score == 0 and
|
||||||
|
preferences.sightseeing.score == 0 and
|
||||||
|
preferences.nature.score == 0) :
|
||||||
|
raise HTTPException(status_code=406, detail="All preferences are 0.")
|
||||||
|
if start is None:
|
||||||
|
raise HTTPException(status_code=406, detail="Start coordinates not provided")
|
||||||
|
if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180):
|
||||||
|
raise HTTPException(status_code=422, detail="Start coordinates not in range")
|
||||||
|
if end is None:
|
||||||
|
end = start
|
||||||
|
logger.info("No end coordinates provided. Using start=end.")
|
||||||
|
|
||||||
|
# Start the timer
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
logger.info(f"Requested new trip generation. Details:\n\tCoordinates: {start}\n\tTime: {preferences.max_time_minute}\n\tSightseeing: {preferences.sightseeing.score}\n\tNature: {preferences.nature.score}\n\tShopping: {preferences.shopping.score}")
|
||||||
|
|
||||||
|
start_landmark = Landmark(
|
||||||
|
name='start',
|
||||||
|
type='start',
|
||||||
|
location=(start[0], start[1]),
|
||||||
|
osm_type='start',
|
||||||
|
osm_id=0,
|
||||||
|
attractiveness=0,
|
||||||
|
duration=0,
|
||||||
|
must_do=True,
|
||||||
|
n_tags = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
end_landmark = Landmark(
|
||||||
|
name='finish',
|
||||||
|
type='finish',
|
||||||
|
location=(end[0], end[1]),
|
||||||
|
osm_type='end',
|
||||||
|
osm_id=0,
|
||||||
|
attractiveness=0,
|
||||||
|
duration=0,
|
||||||
|
must_do=True,
|
||||||
|
n_tags=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# From the parameters load the length at which to truncate the landmarks list.
|
||||||
|
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
|
||||||
|
parameters = yaml.safe_load(f)
|
||||||
|
n_important = parameters['N_important']
|
||||||
|
|
||||||
|
# Truncate to the most important landmarks for a shorter list
|
||||||
|
landmarks_short = landmarks[:n_important]
|
||||||
|
|
||||||
|
# insert start and finish to the shorter landmarks list
|
||||||
|
landmarks_short.insert(0, start_landmark)
|
||||||
|
landmarks_short.append(end_landmark)
|
||||||
|
|
||||||
|
# First stage optimization
|
||||||
|
try:
|
||||||
|
base_tour = optimizer.solve_optimization(preferences.max_time_minute, landmarks_short)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Trip generation failed: {str(exc)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Optimization failed: {str(exc)}") from exc
|
||||||
|
|
||||||
|
t_first_stage = time.time() - start_time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Second stage optimization
|
||||||
|
try :
|
||||||
|
refined_tour = refiner.refine_optimization(
|
||||||
|
landmarks, base_tour,
|
||||||
|
preferences.max_time_minute,
|
||||||
|
preferences.detour_tolerance_minute
|
||||||
|
)
|
||||||
|
except Exception as exc :
|
||||||
|
logger.warning(f"Refiner failed. Proceeding with base trip {str(exc)}")
|
||||||
|
refined_tour = base_tour
|
||||||
|
|
||||||
|
t_second_stage = time.time() - start_time
|
||||||
|
|
||||||
|
logger.debug(f'First stage optimization\t: {round(t_first_stage,3)} seconds')
|
||||||
|
logger.debug(f'Second stage optimization\t: {round(t_second_stage,3)} seconds')
|
||||||
|
logger.info(f'Total computation time\t: {round(t_first_stage + t_second_stage,3)} seconds')
|
||||||
|
linked_tour = LinkedLandmarks(refined_tour)
|
||||||
|
|
||||||
|
# upon creation of the trip, persistence of both the trip and its landmarks is ensured.
|
||||||
|
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
||||||
|
logger.info(f'Optimized a trip of {trip.total_time} minutes with {len(refined_tour)} landmarks in {round(t_first_stage + t_second_stage,3)} seconds.')
|
||||||
|
logger.info('Detailed trip :\n\t' + '\n\t'.join(f'{landmark}' for landmark in refined_tour))
|
||||||
|
|
||||||
|
background_tasks.add_task(fill_cache)
|
||||||
|
|
||||||
|
return trip
|
||||||
|
|
||||||
@@ -6,7 +6,6 @@ from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull
|
|||||||
|
|
||||||
from ..structs.landmark import Landmark
|
from ..structs.landmark import Landmark
|
||||||
from ..utils.get_time_distance import get_time
|
from ..utils.get_time_distance import get_time
|
||||||
from ..utils.take_most_important import take_most_important
|
|
||||||
from .optimizer import Optimizer
|
from .optimizer import Optimizer
|
||||||
from ..constants import OPTIMIZER_PARAMETERS_PATH
|
from ..constants import OPTIMIZER_PARAMETERS_PATH
|
||||||
|
|
||||||
@@ -238,7 +237,7 @@ class Refiner :
|
|||||||
if self.is_in_area(area, landmark.location) and landmark.name not in visited_names:
|
if self.is_in_area(area, landmark.location) and landmark.name not in visited_names:
|
||||||
second_order_landmarks.append(landmark)
|
second_order_landmarks.append(landmark)
|
||||||
|
|
||||||
return take_most_important(second_order_landmarks, int(self.max_landmarks_refiner*0.75))
|
return sorted(second_order_landmarks, key=lambda x: x.attractiveness, reverse=True)[:int(self.max_landmarks_refiner*0.75)]
|
||||||
|
|
||||||
|
|
||||||
# Try fix the shortest path using shapely
|
# Try fix the shortest path using shapely
|
||||||
|
|||||||
@@ -402,6 +402,8 @@ def fill_cache():
|
|||||||
n_files = 0
|
n_files = 0
|
||||||
total = 0
|
total = 0
|
||||||
|
|
||||||
|
overpass.logger.info('Trip successfully returned, starting to fill cache.')
|
||||||
|
|
||||||
with os.scandir(OSM_CACHE_DIR) as it:
|
with os.scandir(OSM_CACHE_DIR) as it:
|
||||||
for entry in it:
|
for entry in it:
|
||||||
if entry.is_file() and entry.name.startswith('hollow_'):
|
if entry.is_file() and entry.name.startswith('hollow_'):
|
||||||
|
|||||||
@@ -7,5 +7,4 @@ tag_exponent: 1.15
|
|||||||
image_bonus: 1.1
|
image_bonus: 1.1
|
||||||
viewpoint_bonus: 10
|
viewpoint_bonus: 10
|
||||||
wikipedia_bonus: 1.25
|
wikipedia_bonus: 1.25
|
||||||
N_important: 60
|
|
||||||
pay_bonus: -1
|
pay_bonus: -1
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ overshoot: 0.0016
|
|||||||
time_limit: 1
|
time_limit: 1
|
||||||
gap_rel: 0.025
|
gap_rel: 0.025
|
||||||
max_iter: 80
|
max_iter: 80
|
||||||
|
N_important: 60
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ class Landmark(BaseModel) :
|
|||||||
image_url : Optional[str] = None
|
image_url : Optional[str] = None
|
||||||
website_url : Optional[str] = None
|
website_url : Optional[str] = None
|
||||||
wiki_url : Optional[str] = None
|
wiki_url : Optional[str] = None
|
||||||
keywords: Optional[dict] = {}
|
# keywords: Optional[dict] = {}
|
||||||
description : Optional[str] = None
|
# description : Optional[str] = None
|
||||||
duration : Optional[int] = 5
|
duration : Optional[int] = 5
|
||||||
name_en : Optional[str] = None
|
name_en : Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from .landmark import Landmark
|
from .landmark import Landmark
|
||||||
from ..utils.get_time_distance import get_time
|
from ..utils.get_time_distance import get_time
|
||||||
|
from ..utils.description import description_and_keywords
|
||||||
|
|
||||||
class LinkedLandmarks:
|
class LinkedLandmarks:
|
||||||
"""
|
"""
|
||||||
@@ -35,18 +36,23 @@ class LinkedLandmarks:
|
|||||||
Create the links between the landmarks in the list by setting their
|
Create the links between the landmarks in the list by setting their
|
||||||
.next_uuid and the .time_to_next attributes.
|
.next_uuid and the .time_to_next attributes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Mark secondary landmarks as such
|
# Mark secondary landmarks as such
|
||||||
self.update_secondary_landmarks()
|
self.update_secondary_landmarks()
|
||||||
|
|
||||||
|
|
||||||
for i, landmark in enumerate(self._landmarks[:-1]):
|
for i, landmark in enumerate(self._landmarks[:-1]):
|
||||||
|
# Set uuid of the next landmark
|
||||||
landmark.next_uuid = self._landmarks[i + 1].uuid
|
landmark.next_uuid = self._landmarks[i + 1].uuid
|
||||||
|
|
||||||
|
# Adjust time to reach and total time
|
||||||
time_to_next = get_time(landmark.location, self._landmarks[i + 1].location)
|
time_to_next = get_time(landmark.location, self._landmarks[i + 1].location)
|
||||||
landmark.time_to_reach_next = time_to_next
|
landmark.time_to_reach_next = time_to_next
|
||||||
self.total_time += time_to_next
|
self.total_time += time_to_next
|
||||||
self.total_time += landmark.duration
|
self.total_time += landmark.duration
|
||||||
|
|
||||||
|
# Fill in the keywords and description. GOOD IDEA, BAD EXECUTION, tags aren't available anymore at this stage
|
||||||
|
# landmark.description, landmark.keywords = description_and_keywords(tags)
|
||||||
|
|
||||||
|
|
||||||
self._landmarks[-1].next_uuid = None
|
self._landmarks[-1].next_uuid = None
|
||||||
self._landmarks[-1].time_to_reach_next = 0
|
self._landmarks[-1].time_to_reach_next = 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Defines the Preferences used as input for trip generation."""
|
"""Defines the Preferences used as input for trip generation."""
|
||||||
|
|
||||||
from typing import Optional, Literal
|
from typing import Optional, Literal
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
class Preference(BaseModel) :
|
class Preference(BaseModel) :
|
||||||
@@ -15,6 +15,13 @@ class Preference(BaseModel) :
|
|||||||
type: Literal['sightseeing', 'nature', 'shopping', 'start', 'finish']
|
type: Literal['sightseeing', 'nature', 'shopping', 'start', 'finish']
|
||||||
score: int # score could be from 1 to 5
|
score: int # score could be from 1 to 5
|
||||||
|
|
||||||
|
@field_validator("type")
|
||||||
|
@classmethod
|
||||||
|
def validate_type(cls, v):
|
||||||
|
if v not in {'sightseeing', 'nature', 'shopping', 'start', 'finish'}:
|
||||||
|
raise ValueError(f"Invalid type: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
# Input for optimization
|
# Input for optimization
|
||||||
class Preferences(BaseModel) :
|
class Preferences(BaseModel) :
|
||||||
|
|||||||
@@ -19,30 +19,50 @@ def invalid_client():
|
|||||||
([48.8566, 2.3522], {}, 422),
|
([48.8566, 2.3522], {}, 422),
|
||||||
|
|
||||||
# Invalid cases: incomplete preferences.
|
# Invalid cases: incomplete preferences.
|
||||||
([48.084588, 7.280405], {"sightseeing": {"type": "nature", "score": 5}, # no shopping
|
([48.084588, 7.280405], {"sightseeing": {"type": "sightseeing", "score": 5}, # no shopping pref
|
||||||
"nature": {"type": "nature", "score": 5},
|
"nature": {"type": "nature", "score": 5},
|
||||||
}, 422),
|
}, 422),
|
||||||
([48.084588, 7.280405], {"sightseeing": {"type": "nature", "score": 5}, # no nature
|
([48.084588, 7.280405], {"sightseeing": {"type": "sightseeing", "score": 5}, # no nature pref
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
"shopping": {"type": "shopping", "score": 5},
|
||||||
}, 422),
|
}, 422),
|
||||||
([48.084588, 7.280405], {"nature": {"type": "nature", "score": 5}, # no sightseeing
|
([48.084588, 7.280405], {"nature": {"type": "nature", "score": 5}, # no sightseeing pref
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
"shopping": {"type": "shopping", "score": 5},
|
||||||
}, 422),
|
}, 422),
|
||||||
|
([48.084588, 7.280405], {"sightseeing": {"type": "nature", "score": 1}, # mixed up preferences types. TODO: i suggest reducing the complexity by remove the Preference object.
|
||||||
|
"nature": {"type": "shopping", "score": 1},
|
||||||
|
"shopping": {"type": "shopping", "score": 1},
|
||||||
|
}, 422),
|
||||||
|
([48.084588, 7.280405], {"doesnotexist": {"type": "sightseeing", "score": 2}, # non-existing preferences types
|
||||||
|
"nature": {"type": "nature", "score": 2},
|
||||||
|
"shopping": {"type": "shopping", "score": 2},
|
||||||
|
}, 422),
|
||||||
|
([48.084588, 7.280405], {"sightseeing": {"type": "sightseeing", "score": 3}, # non-existing preferences types
|
||||||
|
"nature": {"type": "doesntexisteither", "score": 3},
|
||||||
|
"shopping": {"type": "shopping", "score": 3},
|
||||||
|
}, 422),
|
||||||
|
([48.084588, 7.280405], {"sightseeing": {"type": "sightseeing", "score": -1}, # negative preference value
|
||||||
|
"nature": {"type": "doesntexisteither", "score": 4},
|
||||||
|
"shopping": {"type": "shopping", "score": 4},
|
||||||
|
}, 422),
|
||||||
|
([48.084588, 7.280405], {"sightseeing": {"type": "sightseeing", "score": 10}, # too high preference value
|
||||||
|
"nature": {"type": "doesntexisteither", "score": 4},
|
||||||
|
"shopping": {"type": "shopping", "score": 4},
|
||||||
|
}, 422),
|
||||||
|
|
||||||
# Invalid cases: unexisting coords
|
# Invalid cases: unexisting coords
|
||||||
([91, 181], {"sightseeing": {"type": "nature", "score": 5},
|
([91, 181], {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||||
"nature": {"type": "nature", "score": 5},
|
"nature": {"type": "nature", "score": 5},
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
"shopping": {"type": "shopping", "score": 5},
|
||||||
}, 422),
|
}, 422),
|
||||||
([-91, 181], {"sightseeing": {"type": "nature", "score": 5},
|
([-91, 181], {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||||
"nature": {"type": "nature", "score": 5},
|
"nature": {"type": "nature", "score": 5},
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
"shopping": {"type": "shopping", "score": 5},
|
||||||
}, 422),
|
}, 422),
|
||||||
([91, -181], {"sightseeing": {"type": "nature", "score": 5},
|
([91, -181], {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||||
"nature": {"type": "nature", "score": 5},
|
"nature": {"type": "nature", "score": 5},
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
"shopping": {"type": "shopping", "score": 5},
|
||||||
}, 422),
|
}, 422),
|
||||||
([-91, -181], {"sightseeing": {"type": "nature", "score": 5},
|
([-91, -181], {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||||
"nature": {"type": "nature", "score": 5},
|
"nature": {"type": "nature", "score": 5},
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
"shopping": {"type": "shopping", "score": 5},
|
||||||
}, 422),
|
}, 422),
|
||||||
@@ -53,8 +73,8 @@ def test_input(invalid_client, start, preferences, status_code): # pylint: dis
|
|||||||
Test new trip creation with different sets of preferences and locations.
|
Test new trip creation with different sets of preferences and locations.
|
||||||
"""
|
"""
|
||||||
response = invalid_client.post(
|
response = invalid_client.post(
|
||||||
"/trip/new",
|
"/get/landmarks",
|
||||||
json={
|
json ={
|
||||||
"preferences": preferences,
|
"preferences": preferences,
|
||||||
"start": start
|
"start": start
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,343 +0,0 @@
|
|||||||
"""Collection of tests to ensure correct implementation and track progress. """
|
|
||||||
import time
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .test_utils import load_trip_landmarks, log_trip_details
|
|
||||||
from ..main import app
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def client():
|
|
||||||
"""Client used to call the app."""
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_turckheim(client, request): # pylint: disable=redefined-outer-name
|
|
||||||
"""
|
|
||||||
Test n°1 : Custom test in Turckheim to ensure small villages are also supported.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client:
|
|
||||||
request:
|
|
||||||
"""
|
|
||||||
start_time = time.time() # Start timer
|
|
||||||
duration_minutes = 20
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/trip/new",
|
|
||||||
json={
|
|
||||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
|
||||||
"nature": {"type": "nature", "score": 0},
|
|
||||||
"shopping": {"type": "shopping", "score": 0},
|
|
||||||
"max_time_minute": duration_minutes,
|
|
||||||
"detour_tolerance_minute": 0},
|
|
||||||
"start": [48.084588, 7.280405]
|
|
||||||
# "start": [45.74445023349939, 4.8222687890538865]
|
|
||||||
# "start": [45.75156398104873, 4.827154464827647]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = response.json()
|
|
||||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
|
||||||
|
|
||||||
|
|
||||||
# Get computation time
|
|
||||||
comp_time = time.time() - start_time
|
|
||||||
|
|
||||||
# Add details to report
|
|
||||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
|
||||||
|
|
||||||
|
|
||||||
# checks :
|
|
||||||
assert response.status_code == 200 # check for successful planning
|
|
||||||
assert isinstance(landmarks, list) # check that the return type is a list
|
|
||||||
assert len(landmarks) > 2 # check that there is something to visit
|
|
||||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
|
||||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
# assert 2!= 3
|
|
||||||
|
|
||||||
def test_bellecour(client, request) : # pylint: disable=redefined-outer-name
|
|
||||||
"""
|
|
||||||
Test n°2 : Custom test in Lyon centre to ensure proper decision making in crowded area.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client:
|
|
||||||
request:
|
|
||||||
"""
|
|
||||||
start_time = time.time() # Start timer
|
|
||||||
duration_minutes = 120
|
|
||||||
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/trip/new",
|
|
||||||
json={
|
|
||||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
|
||||||
"nature": {"type": "nature", "score": 5},
|
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
|
||||||
"max_time_minute": duration_minutes,
|
|
||||||
"detour_tolerance_minute": 0},
|
|
||||||
"start": [45.7576485, 4.8330241]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = response.json()
|
|
||||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
|
||||||
|
|
||||||
# Get computation time
|
|
||||||
comp_time = time.time() - start_time
|
|
||||||
|
|
||||||
# Add details to report
|
|
||||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
|
||||||
|
|
||||||
# for elem in landmarks :
|
|
||||||
# print(elem)
|
|
||||||
|
|
||||||
# checks :
|
|
||||||
assert response.status_code == 200 # check for successful planning
|
|
||||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
|
||||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
|
|
||||||
def test_cologne(client, request) : # pylint: disable=redefined-outer-name
|
|
||||||
"""
|
|
||||||
Test n°3 : Custom test in Cologne to ensure proper decision making in crowded area.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client:
|
|
||||||
request:
|
|
||||||
"""
|
|
||||||
start_time = time.time() # Start timer
|
|
||||||
duration_minutes = 240
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/trip/new",
|
|
||||||
json={
|
|
||||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
|
||||||
"nature": {"type": "nature", "score": 5},
|
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
|
||||||
"max_time_minute": duration_minutes,
|
|
||||||
"detour_tolerance_minute": 0},
|
|
||||||
"start": [50.942352665, 6.957777972392]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = response.json()
|
|
||||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
|
||||||
|
|
||||||
# Get computation time
|
|
||||||
comp_time = time.time() - start_time
|
|
||||||
|
|
||||||
# Add details to report
|
|
||||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
|
||||||
|
|
||||||
# for elem in landmarks :
|
|
||||||
# print(elem)
|
|
||||||
|
|
||||||
# checks :
|
|
||||||
assert response.status_code == 200 # check for successful planning
|
|
||||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
|
||||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_strasbourg(client, request) : # pylint: disable=redefined-outer-name
|
|
||||||
"""
|
|
||||||
Test n°4 : Custom test in Strasbourg to ensure proper decision making in crowded area.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client:
|
|
||||||
request:
|
|
||||||
"""
|
|
||||||
start_time = time.time() # Start timer
|
|
||||||
duration_minutes = 180
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/trip/new",
|
|
||||||
json={
|
|
||||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
|
||||||
"nature": {"type": "nature", "score": 5},
|
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
|
||||||
"max_time_minute": duration_minutes,
|
|
||||||
"detour_tolerance_minute": 0},
|
|
||||||
"start": [48.5846589226, 7.74078715721]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = response.json()
|
|
||||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
|
||||||
|
|
||||||
# Get computation time
|
|
||||||
comp_time = time.time() - start_time
|
|
||||||
|
|
||||||
# Add details to report
|
|
||||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
|
||||||
|
|
||||||
# for elem in landmarks :
|
|
||||||
# print(elem)
|
|
||||||
|
|
||||||
# checks :
|
|
||||||
assert response.status_code == 200 # check for successful planning
|
|
||||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
|
||||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_zurich(client, request) : # pylint: disable=redefined-outer-name
|
|
||||||
"""
|
|
||||||
Test n°5 : Custom test in Zurich to ensure proper decision making in crowded area.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client:
|
|
||||||
request:
|
|
||||||
"""
|
|
||||||
start_time = time.time() # Start timer
|
|
||||||
duration_minutes = 180
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/trip/new",
|
|
||||||
json={
|
|
||||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
|
||||||
"nature": {"type": "nature", "score": 5},
|
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
|
||||||
"max_time_minute": duration_minutes,
|
|
||||||
"detour_tolerance_minute": 0},
|
|
||||||
"start": [47.377884227, 8.5395114066]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = response.json()
|
|
||||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
|
||||||
|
|
||||||
# Get computation time
|
|
||||||
comp_time = time.time() - start_time
|
|
||||||
|
|
||||||
# Add details to report
|
|
||||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
|
||||||
|
|
||||||
# for elem in landmarks :
|
|
||||||
# print(elem)
|
|
||||||
|
|
||||||
# checks :
|
|
||||||
assert response.status_code == 200 # check for successful planning
|
|
||||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
|
||||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_paris(client, request) : # pylint: disable=redefined-outer-name
|
|
||||||
"""
|
|
||||||
Test n°6 : Custom test in Paris (les Halles) centre to ensure proper decision making in crowded area.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client:
|
|
||||||
request:
|
|
||||||
"""
|
|
||||||
start_time = time.time() # Start timer
|
|
||||||
duration_minutes = 200
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/trip/new",
|
|
||||||
json={
|
|
||||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
|
||||||
"nature": {"type": "nature", "score": 0},
|
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
|
||||||
"max_time_minute": duration_minutes,
|
|
||||||
"detour_tolerance_minute": 0},
|
|
||||||
"start": [48.85468881798671, 2.3423925755998374]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = response.json()
|
|
||||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
|
||||||
|
|
||||||
# Get computation time
|
|
||||||
comp_time = time.time() - start_time
|
|
||||||
|
|
||||||
# Add details to report
|
|
||||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
|
||||||
|
|
||||||
# for elem in landmarks :
|
|
||||||
# print(elem)
|
|
||||||
|
|
||||||
# checks :
|
|
||||||
assert response.status_code == 200 # check for successful planning
|
|
||||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
|
||||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_new_york(client, request) : # pylint: disable=redefined-outer-name
|
|
||||||
"""
|
|
||||||
Test n°7 : Custom test in New York to ensure proper decision making in crowded area.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client:
|
|
||||||
request:
|
|
||||||
"""
|
|
||||||
start_time = time.time() # Start timer
|
|
||||||
duration_minutes = 600
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/trip/new",
|
|
||||||
json={
|
|
||||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
|
||||||
"nature": {"type": "nature", "score": 5},
|
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
|
||||||
"max_time_minute": duration_minutes,
|
|
||||||
"detour_tolerance_minute": 0},
|
|
||||||
"start": [40.72592726802, -73.9920434795]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = response.json()
|
|
||||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
|
||||||
|
|
||||||
# Get computation time
|
|
||||||
comp_time = time.time() - start_time
|
|
||||||
|
|
||||||
# Add details to report
|
|
||||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
|
||||||
|
|
||||||
# for elem in landmarks :
|
|
||||||
# print(elem)
|
|
||||||
|
|
||||||
# checks :
|
|
||||||
assert response.status_code == 200 # check for successful planning
|
|
||||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
|
||||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_shopping(client, request) : # pylint: disable=redefined-outer-name
|
|
||||||
"""
|
|
||||||
Test n°8 : Custom test in Lyon centre to ensure shopping clusters are found.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client:
|
|
||||||
request:
|
|
||||||
"""
|
|
||||||
start_time = time.time() # Start timer
|
|
||||||
duration_minutes = 240
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/trip/new",
|
|
||||||
json={
|
|
||||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 0},
|
|
||||||
"nature": {"type": "nature", "score": 0},
|
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
|
||||||
"max_time_minute": duration_minutes,
|
|
||||||
"detour_tolerance_minute": 0},
|
|
||||||
"start": [45.7576485, 4.8330241]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = response.json()
|
|
||||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
|
||||||
|
|
||||||
# Get computation time
|
|
||||||
comp_time = time.time() - start_time
|
|
||||||
|
|
||||||
# Add details to report
|
|
||||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
|
||||||
|
|
||||||
# for elem in landmarks :
|
|
||||||
# print(elem)
|
|
||||||
|
|
||||||
# checks :
|
|
||||||
assert response.status_code == 200 # check for successful planning
|
|
||||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
|
||||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
|
||||||
46
backend/src/tests/test_nearby.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Collection of tests to ensure correct implementation and track progress of the get_landmarks_nearby feature. """
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ..main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
"""Client used to call the app."""
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"location,status_code",
|
||||||
|
[
|
||||||
|
([45.7576485, 4.8330241], 200), # Lyon, France
|
||||||
|
([41.4020572, 2.1818985], 200), # Barcelona, Spain
|
||||||
|
([59.3293, 18.0686], 200), # Stockholm, Sweden
|
||||||
|
([43.6532, -79.3832], 200), # Toronto, Canada
|
||||||
|
([38.7223, -9.1393], 200), # Lisbon, Portugal
|
||||||
|
([6.5244, 3.3792], 200), # Lagos, Nigeria
|
||||||
|
([17.3850, 78.4867], 200), # Hyderabad, India
|
||||||
|
([30.0444, 31.2357], 200), # Cairo, Egypt
|
||||||
|
([50.8503, 4.3517], 200), # Brussels, Belgium
|
||||||
|
([35.2271, -80.8431], 200), # Charlotte, USA
|
||||||
|
([10.4806, -66.9036], 200), # Caracas, Venezuela
|
||||||
|
([9.51074, -13.71118], 200), # Conakry, Guinea
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def test_nearby(client, location, status_code): # pylint: disable=redefined-outer-name
|
||||||
|
"""
|
||||||
|
Test n°1 : Verify handling of invalid input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client:
|
||||||
|
request:
|
||||||
|
"""
|
||||||
|
response = client.post(f"/get-nearby/landmarks/{location[0]}/{location[1]}")
|
||||||
|
suggestions = response.json()
|
||||||
|
|
||||||
|
# checks :
|
||||||
|
assert response.status_code == status_code # check for successful planning
|
||||||
|
assert isinstance(suggestions, list) # check that the return type is a list
|
||||||
|
assert len(suggestions) > 0
|
||||||
@@ -30,7 +30,7 @@ def test_invalid_input(client, location, radius, status_code): # pylint: disa
|
|||||||
request:
|
request:
|
||||||
"""
|
"""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/toilets/new",
|
"/get/toilets",
|
||||||
params={
|
params={
|
||||||
"location": location,
|
"location": location,
|
||||||
"radius": radius
|
"radius": radius
|
||||||
@@ -58,7 +58,7 @@ def test_no_toilets(client, location, status_code): # pylint: disable=redefin
|
|||||||
request:
|
request:
|
||||||
"""
|
"""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/toilets/new",
|
"/get/toilets",
|
||||||
params={
|
params={
|
||||||
"location": location
|
"location": location
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ def test_toilets(client, location, status_code): # pylint: disable=redefined-
|
|||||||
request:
|
request:
|
||||||
"""
|
"""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/toilets/new",
|
"/get/toilets",
|
||||||
params={
|
params={
|
||||||
"location": location,
|
"location": location,
|
||||||
"radius" : 600
|
"radius" : 600
|
||||||
|
|||||||
81
backend/src/tests/test_trip_generation.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Collection of tests to ensure correct implementation and track progress."""
|
||||||
|
import time
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .test_utils import load_trip_landmarks, log_trip_details
|
||||||
|
from ..structs.preferences import Preferences, Preference
|
||||||
|
from ..main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
"""Client used to call the app."""
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sightseeing, shopping, nature, max_time_minute, start_coords, end_coords",
|
||||||
|
[
|
||||||
|
# Edge cases
|
||||||
|
(0, 0, 5, 240, [45.7576485, 4.8330241], None), # Lyon, Bellecour - test shopping only
|
||||||
|
|
||||||
|
# Realistic
|
||||||
|
(5, 0, 0, 20, [48.0845881, 7.2804050], None), # Turckheim
|
||||||
|
(5, 5, 5, 120, [45.7576485, 4.8330241], None), # Lyon, Bellecour
|
||||||
|
(5, 2, 5, 240, [50.9423526, 6.9577780], None), # Cologne, centre
|
||||||
|
(3, 5, 0, 180, [48.5846589226, 7.74078715721], None), # Strasbourg, centre
|
||||||
|
(2, 4, 5, 180, [47.377884227, 8.5395114066], None), # Zurich, centre
|
||||||
|
(5, 0, 5, 200, [48.85468881798671, 2.3423925755998374], None), # Paris, centre
|
||||||
|
(5, 5, 5, 600, [40.72592726802, -73.9920434795], None), # New York, Lower Manhattan
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def test_trip(client, request, sightseeing, shopping, nature, max_time_minute, start_coords, end_coords):
|
||||||
|
|
||||||
|
start_time = time.time() # Start timer
|
||||||
|
|
||||||
|
prefs = Preferences(
|
||||||
|
sightseeing=Preference(type='sightseeing', score=sightseeing),
|
||||||
|
shopping=Preference(type='shopping', score=shopping),
|
||||||
|
nature=Preference(type='nature', score=nature),
|
||||||
|
max_time_minute=max_time_minute,
|
||||||
|
detour_tolerance_minute=0,
|
||||||
|
)
|
||||||
|
start = start_coords
|
||||||
|
end = end_coords
|
||||||
|
|
||||||
|
# Step 1: request the list of landmarks in the vicinty of the starting point
|
||||||
|
response = client.post(
|
||||||
|
"/get/landmarks",
|
||||||
|
json={
|
||||||
|
"preferences": prefs.model_dump(),
|
||||||
|
"start": start_coords,
|
||||||
|
"end": end_coords,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
landmarks = response.json()
|
||||||
|
|
||||||
|
# Step 2: Feed the landmarks to the optimizer to compute the trip
|
||||||
|
response = client.post(
|
||||||
|
"/optimize/trip",
|
||||||
|
json={
|
||||||
|
"preferences": prefs.model_dump(),
|
||||||
|
"landmarks": landmarks,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = response.json()
|
||||||
|
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||||
|
|
||||||
|
# Get computation time
|
||||||
|
comp_time = time.time() - start_time
|
||||||
|
|
||||||
|
# Add details to report
|
||||||
|
log_trip_details(request, landmarks, result['total_time'], prefs.max_time_minute)
|
||||||
|
|
||||||
|
# checks :
|
||||||
|
assert response.status_code == 200 # check for successful planning
|
||||||
|
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||||
|
assert prefs.max_time_minute*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {prefs.max_time_minute}"
|
||||||
|
assert prefs.max_time_minute*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {prefs.max_time_minute}"
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Helper methods for testing."""
|
"""Helper methods for testing."""
|
||||||
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
from functools import wraps
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from ..structs.landmark import Landmark
|
|
||||||
from ..cache import client as cache_client
|
from ..cache import client as cache_client
|
||||||
|
from ..structs.landmark import Landmark
|
||||||
|
from ..structs.preferences import Preferences, Preference
|
||||||
|
|
||||||
|
|
||||||
def landmarks_to_osmid(landmarks: list[Landmark]) -> list[int] :
|
def landmarks_to_osmid(landmarks: list[Landmark]) -> list[int] :
|
||||||
@@ -91,3 +94,34 @@ def log_trip_details(request, landmarks: list[Landmark], duration: int, target_d
|
|||||||
request.node.trip_details = trip_string
|
request.node.trip_details = trip_string
|
||||||
request.node.trip_duration = str(duration) # result['total_time']
|
request.node.trip_duration = str(duration) # result['total_time']
|
||||||
request.node.target_duration = str(target_duration)
|
request.node.target_duration = str(target_duration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def trip_params(
|
||||||
|
sightseeing: int,
|
||||||
|
shopping: int,
|
||||||
|
nature: int,
|
||||||
|
max_time_minute: int,
|
||||||
|
start_coords: tuple[float, float] = None,
|
||||||
|
end_coords: tuple[float, float] = None,
|
||||||
|
):
|
||||||
|
def decorator(test_func):
|
||||||
|
@wraps(test_func)
|
||||||
|
def wrapper(client, request):
|
||||||
|
prefs = Preferences(
|
||||||
|
sightseeing=Preference(type='sightseeing', score=sightseeing),
|
||||||
|
shopping=Preference(type='shopping', score=shopping),
|
||||||
|
nature=Preference(type='nature', score=nature),
|
||||||
|
max_time_minute=max_time_minute,
|
||||||
|
detour_tolerance_minute=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
start = start_coords
|
||||||
|
end = end_coords
|
||||||
|
|
||||||
|
# Inject into test function
|
||||||
|
return test_func(client, request, prefs, start, end)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
@@ -70,6 +70,8 @@ class ToiletsManager:
|
|||||||
|
|
||||||
toilets_list = self.to_toilets(result)
|
toilets_list = self.to_toilets(result)
|
||||||
|
|
||||||
|
self.logger.debug(f'Found {len(toilets_list)} toilets around {self.location}')
|
||||||
|
|
||||||
return toilets_list
|
return toilets_list
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
"""Defines the endpoint for fetching toilet locations."""
|
"""API entry point for fetching toilet locations."""
|
||||||
|
|
||||||
from fastapi import HTTPException, APIRouter, Query
|
from fastapi import HTTPException, APIRouter, Query
|
||||||
|
|
||||||
from ..structs.toilets import Toilets
|
|
||||||
from .toilets_manager import ToiletsManager
|
from .toilets_manager import ToiletsManager
|
||||||
|
from ..structs.toilets import Toilets
|
||||||
|
|
||||||
|
|
||||||
# Define the API router
|
# Initialize the API router
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/toilets/new")
|
@router.post("/get/toilets")
|
||||||
def get_toilets(location: tuple[float, float] = Query(...), radius: int = 500) -> list[Toilets] :
|
def get_toilets(
|
||||||
|
location: tuple[float, float] = Query(...),
|
||||||
|
radius: int = 500
|
||||||
|
) -> list[Toilets] :
|
||||||
"""
|
"""
|
||||||
Endpoint to find toilets within a specified radius from a given location.
|
Endpoint to find toilets within a specified radius from a given location.
|
||||||
|
|
||||||
@@ -35,4 +39,5 @@ def get_toilets(location: tuple[float, float] = Query(...), radius: int = 500) -
|
|||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail="No toilets found") from exc
|
raise HTTPException(status_code=404, detail="No toilets found") from exc
|
||||||
|
|
||||||
|
|
||||||
return toilets_list
|
return toilets_list
|
||||||
123
backend/src/utils/description.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Add more information about the landmarks by writing a short description and keywords. """
|
||||||
|
|
||||||
|
|
||||||
|
def description_and_keywords(tags: dict):
|
||||||
|
"""
|
||||||
|
Generates a description and a set of keywords for a given landmark based on its tags.
|
||||||
|
|
||||||
|
Params:
|
||||||
|
tags (dict): A dictionary containing metadata about the landmark, including its name,
|
||||||
|
importance, height, date of construction, and visitor information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
description (str): A string description of the landmark.
|
||||||
|
keywords (dict): A dictionary of keywords with fields such as 'importance', 'height',
|
||||||
|
'place_type', and 'date'.
|
||||||
|
"""
|
||||||
|
# Extract relevant fields
|
||||||
|
name = tags.get('name')
|
||||||
|
importance = tags.get('importance', None)
|
||||||
|
n_visitors = tags.get('tourism:visitors', None)
|
||||||
|
height = tags.get('height')
|
||||||
|
place_type = get_place_type(tags)
|
||||||
|
date = get_date(tags)
|
||||||
|
|
||||||
|
if place_type is None :
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Start the description.
|
||||||
|
if importance is None :
|
||||||
|
if len(tags.keys()) < 5 :
|
||||||
|
return None, None
|
||||||
|
if len(tags.keys()) < 10 :
|
||||||
|
description = f"{name} is a well known {place_type}."
|
||||||
|
elif len(tags.keys()) < 17 :
|
||||||
|
importance = 'national'
|
||||||
|
description = f"{name} is a {place_type} of national importance."
|
||||||
|
else :
|
||||||
|
importance = 'international'
|
||||||
|
description = f"{name} is an internationally famous {place_type}."
|
||||||
|
else :
|
||||||
|
description = f"{name} is a {place_type} of {importance} importance."
|
||||||
|
|
||||||
|
if height is not None and date is not None :
|
||||||
|
description += f" This {place_type} was constructed in {date} and is ca. {height} meters high."
|
||||||
|
elif height is not None :
|
||||||
|
description += f" This {place_type} stands ca. {height} meters tall."
|
||||||
|
elif date is not None:
|
||||||
|
description += f" It was constructed in {date}."
|
||||||
|
|
||||||
|
# Format the visitor number
|
||||||
|
if n_visitors is not None :
|
||||||
|
n_visitors = int(n_visitors)
|
||||||
|
if n_visitors < 1000000 :
|
||||||
|
description += f" It welcomes {int(n_visitors/1000)} thousand visitors every year."
|
||||||
|
else :
|
||||||
|
description += f" It welcomes {round(n_visitors/1000000, 1)} million visitors every year."
|
||||||
|
|
||||||
|
# Set the keywords.
|
||||||
|
keywords = {"importance": importance,
|
||||||
|
"height": height,
|
||||||
|
"place_type": place_type,
|
||||||
|
"date": date}
|
||||||
|
|
||||||
|
return description, keywords
|
||||||
|
|
||||||
|
|
||||||
|
def get_place_type(tags):
|
||||||
|
"""
|
||||||
|
Determines the type of the place based on available tags such as 'amenity', 'building',
|
||||||
|
'historic', and 'leisure'. The priority order is: 'historic' > 'building' (if not generic) >
|
||||||
|
'amenity' > 'leisure'.
|
||||||
|
|
||||||
|
Params:
|
||||||
|
tags (dict): A dictionary containing metadata about the place.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
place_type (str): The determined type of the place, or None if no relevant type is found.
|
||||||
|
"""
|
||||||
|
amenity = tags.get('amenity', None)
|
||||||
|
building = tags.get('building', None)
|
||||||
|
historic = tags.get('historic', None)
|
||||||
|
leisure = tags.get('leisure')
|
||||||
|
|
||||||
|
if historic and historic != "yes":
|
||||||
|
return historic
|
||||||
|
if building and building not in ["yes", "civic", "government", "apartments", "residential", "commericial", "industrial", "retail", "religious", "public", "service"]:
|
||||||
|
return building
|
||||||
|
if amenity:
|
||||||
|
return amenity
|
||||||
|
if leisure:
|
||||||
|
return leisure
|
||||||
|
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_date(tags):
|
||||||
|
"""
|
||||||
|
Extracts the most relevant date from the available tags, prioritizing 'construction_date',
|
||||||
|
'start_date', 'year_of_construction', and 'opening_date' in that order.
|
||||||
|
|
||||||
|
Params:
|
||||||
|
tags (dict): A dictionary containing metadata about the place.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
date (str): The most relevant date found, or None if no date is available.
|
||||||
|
"""
|
||||||
|
construction_date = tags.get('construction_date', None)
|
||||||
|
opening_date = tags.get('opening_date', None)
|
||||||
|
start_date = tags.get('start_date', None)
|
||||||
|
year_of_construction = tags.get('year_of_construction', None)
|
||||||
|
|
||||||
|
# Prioritize based on availability
|
||||||
|
if construction_date:
|
||||||
|
return construction_date
|
||||||
|
if start_date:
|
||||||
|
return start_date
|
||||||
|
if year_of_construction:
|
||||||
|
return year_of_construction
|
||||||
|
if opening_date:
|
||||||
|
return opening_date
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Helper function to return only the major landmarks from a large list."""
|
|
||||||
from ..structs.landmark import Landmark
|
|
||||||
|
|
||||||
def take_most_important(landmarks: list[Landmark], n_important) -> list[Landmark]:
|
|
||||||
"""
|
|
||||||
Given a list of landmarks, return the n_important most important landmarks
|
|
||||||
Args:
|
|
||||||
landmarks: list[Landmark] - list of landmarks
|
|
||||||
n_important: int - number of most important landmarks to return
|
|
||||||
Returns:
|
|
||||||
list[Landmark] - list of the n_important most important landmarks
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Sort landmarks by attractiveness (descending)
|
|
||||||
sorted_landmarks = sorted(landmarks, key=lambda x: x.attractiveness, reverse=True)
|
|
||||||
|
|
||||||
return sorted_landmarks[:n_important]
|
|
||||||
1330
backend/uv.lock
generated
Normal file
17
default.nix
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{ pkgs ? import <nixpkgs> { config.android_sdk.accept_license = true; config.allowUnfree = true; } }:
|
||||||
|
|
||||||
|
pkgs.mkShell {
|
||||||
|
buildInputs = [
|
||||||
|
pkgs.flutter
|
||||||
|
#pkgs.android-tools # for adb
|
||||||
|
#pkgs.openjdk # required for Android builds
|
||||||
|
];
|
||||||
|
|
||||||
|
# Set up Android SDK paths if needed
|
||||||
|
shellHook = ''
|
||||||
|
export ANDROID_SDK_ROOT=${pkgs.androidsdk}/libexec/android-sdk
|
||||||
|
export PATH=$PATH:${pkgs.androidsdk}/libexec/android-sdk/platform-tools
|
||||||
|
echo "Flutter dev environment ready. 'adb' and 'flutter' are available."
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: macos-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up ruby env
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
ruby-version: 3.2.1
|
|
||||||
bundler-cache: true
|
|
||||||
|
|
||||||
- name: Setup java for android build
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
java-version: '17'
|
|
||||||
distribution: 'zulu'
|
|
||||||
|
|
||||||
- name: Setup android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- name: Install Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: stable
|
|
||||||
flutter-version: 3.22.0
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Infer version number from git tag
|
|
||||||
id: version
|
|
||||||
env:
|
|
||||||
REF_NAME: ${{ github.ref_name }}
|
|
||||||
run:
|
|
||||||
# remove the 'v' prefix from the tag name
|
|
||||||
echo "BUILD_NAME=${REF_NAME//v}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Put selected secrets into files
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.ANDROID_SECRET_PROPERTIES_BASE64 }}" | base64 -d > secrets.properties
|
|
||||||
echo "${{ secrets.ANDROID_GOOGLE_PLAY_JSON_BASE64 }}" | base64 -d > google-key.json
|
|
||||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > release.keystore
|
|
||||||
working-directory: android
|
|
||||||
|
|
||||||
- name: Install fastlane
|
|
||||||
run: bundle install
|
|
||||||
working-directory: android
|
|
||||||
|
|
||||||
- name: Run fastlane lane
|
|
||||||
run: bundle exec fastlane deploy_release
|
|
||||||
working-directory: android
|
|
||||||
env:
|
|
||||||
BUILD_NUMBER: ${{ github.run_number }}
|
|
||||||
# BUILD_NAME is implicitly available
|
|
||||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
|
||||||
64
frontend/.github/workflows/build_app_ios.yaml
vendored
@@ -1,64 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: macos-latest
|
|
||||||
env:
|
|
||||||
# $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
|
|
||||||
BUNDLE_GEMFILE: ${{ github.workspace }}/ios/Gemfile
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up ruby env
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
ruby-version: 3.3
|
|
||||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
|
||||||
|
|
||||||
- name: Install Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: stable
|
|
||||||
flutter-version: 3.22.0
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Infer version number from git tag
|
|
||||||
id: version
|
|
||||||
env:
|
|
||||||
REF_NAME: ${{ github.ref_name }}
|
|
||||||
run:
|
|
||||||
# remove the 'v' prefix from the tag name
|
|
||||||
echo "BUILD_NAME=${REF_NAME//v}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Setup SSH key for match git repo
|
|
||||||
# and mark the host as known
|
|
||||||
run: |
|
|
||||||
echo $MATCH_REPO_SSH_KEY | base64 --decode > ~/.ssh/id_rsa
|
|
||||||
chmod 600 ~/.ssh/id_rsa
|
|
||||||
ssh-keyscan -p 2222 git.kluster.moll.re > ~/.ssh/known_hosts
|
|
||||||
env:
|
|
||||||
MATCH_REPO_SSH_KEY: ${{ secrets.IOS_MATCH_REPO_SSH_KEY_BASE64 }}
|
|
||||||
|
|
||||||
- name: Install dependencies and clean up
|
|
||||||
run: |
|
|
||||||
flutter pub get
|
|
||||||
bundle exec pod install
|
|
||||||
flutter clean
|
|
||||||
bundle exec pod cache clean --all
|
|
||||||
working-directory: ios
|
|
||||||
|
|
||||||
- name: Run fastlane lane
|
|
||||||
run: bundle exec fastlane deploy_release --verbose
|
|
||||||
working-directory: ios
|
|
||||||
env:
|
|
||||||
BUILD_NUMBER: ${{ github.run_number }}
|
|
||||||
# BUILD_NAME is implicitly available
|
|
||||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
|
||||||
IOS_ASC_KEY_ID: ${{ secrets.IOS_ASC_KEY_ID }}
|
|
||||||
IOS_ASC_ISSUER_ID: ${{ secrets.IOS_ASC_ISSUER_ID }}
|
|
||||||
IOS_ASC_KEY: ${{ secrets.IOS_ASC_KEY }}
|
|
||||||
MATCH_PASSWORD: ${{ secrets.IOS_MATCH_PASSWORD }}
|
|
||||||
IOS_GOOGLE_MAPS_API_KEY: ${{ secrets.IOS_GOOGLE_MAPS_API_KEY }}
|
|
||||||
@@ -17,7 +17,7 @@ platform :android do
|
|||||||
)
|
)
|
||||||
|
|
||||||
upload_to_play_store(
|
upload_to_play_store(
|
||||||
track: 'alpha',
|
track: 'beta',
|
||||||
# upload aab files intstead
|
# upload aab files intstead
|
||||||
skip_upload_apk: true,
|
skip_upload_apk: true,
|
||||||
skip_upload_changelogs: true,
|
skip_upload_changelogs: true,
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
AnyWay is an application that helps you plan truly unique city trips. When planning a new trip, you can specify <your> preferences and constraints and anyway generates a personalized itinerary just for you.
|
|
||||||
|
|
||||||
Anyway follows these core principles:
|
|
||||||
- **Personalization**: Trips should be match your interests - not just the most popular destinations.
|
|
||||||
- **Efficiency**: Don't just walk in circles! Anyway creates the most efficient route for you.
|
|
||||||
- **Flexibility**: Vacations are the time to be spontaneous. Anyway lets you update your plans on the go.
|
|
||||||
- **Discoverability**: Tourism means exploration. Anyway encourages you to take detours and make spontaneous decisions.
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
AnyWay is an application that helps you plan truly unique city trips. When planning a new trip, you can specify your preferences and constraints and anyway generates a personalized itinerary just for you.
|
||||||
|
|
||||||
|
Anyway follows these core principles:
|
||||||
|
- Personalization: Trips should be match your interests - not just the most popular destinations.
|
||||||
|
- Efficiency: Don't just walk in circles! Anyway creates the most efficient route for you.
|
||||||
|
- Flexibility: Vacations are the time to be spontaneous. Anyway lets you update your plans on the go.
|
||||||
|
- Discoverability: Tourism means exploration. Anyway encourages you to take detours and make spontaneous decisions.
|
||||||
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -1,4 +1,4 @@
|
|||||||
app_identifier("info.anydev.testing") # The bundle identifier of your app
|
app_identifier("info.anydev.anyway") # The bundle identifier of your app
|
||||||
apple_id("me@moll.re") # Your Apple Developer Portal username
|
apple_id("me@moll.re") # Your Apple Developer Portal username
|
||||||
|
|
||||||
itc_team_id("127439860") # App Store Connect Team ID
|
itc_team_id("127439860") # App Store Connect Team ID
|
||||||
|
|||||||
@@ -28,17 +28,6 @@ platform :ios do
|
|||||||
readonly: true,
|
readonly: true,
|
||||||
)
|
)
|
||||||
|
|
||||||
# replace secrets by real values, the stupid way
|
|
||||||
sh(
|
|
||||||
"sed",
|
|
||||||
"-i",
|
|
||||||
"",
|
|
||||||
"s/IOS_GOOGLE_MAPS_API_KEY/#{ENV["IOS_GOOGLE_MAPS_API_KEY"]}/g",
|
|
||||||
"../Runner/AppDelegate.swift"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
sh(
|
sh(
|
||||||
"flutter",
|
"flutter",
|
||||||
"build",
|
"build",
|
||||||
@@ -74,15 +63,6 @@ platform :ios do
|
|||||||
readonly: true,
|
readonly: true,
|
||||||
)
|
)
|
||||||
|
|
||||||
# replace secrets by real values, the stupid way
|
|
||||||
sh(
|
|
||||||
"sed",
|
|
||||||
"-i",
|
|
||||||
"",
|
|
||||||
"s/IOS_GOOGLE_MAPS_API_KEY/#{ENV["IOS_GOOGLE_MAPS_API_KEY"]}/g",
|
|
||||||
"../Runner/AppDelegate.swift"
|
|
||||||
)
|
|
||||||
|
|
||||||
sh(
|
sh(
|
||||||
"flutter",
|
"flutter",
|
||||||
"build",
|
"build",
|
||||||
@@ -101,9 +81,9 @@ platform :ios do
|
|||||||
upload_to_app_store(
|
upload_to_app_store(
|
||||||
overwrite_screenshots: true,
|
overwrite_screenshots: true,
|
||||||
metadata_path: "fastlane/metadata",
|
metadata_path: "fastlane/metadata",
|
||||||
screenshot_path: "fastlane/screenshots",
|
screenshots_path: "fastlane/screenshots",
|
||||||
precheck_include_in_app_purchases: false,
|
precheck_include_in_app_purchases: false,
|
||||||
|
force: true, # Skip HTMl report verification
|
||||||
submit_for_review: true,
|
submit_for_review: true,
|
||||||
automatic_release: true,
|
automatic_release: true,
|
||||||
# automatically release the app after review
|
# automatically release the app after review
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
|
2025 anydev
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
AnyWay is an application that helps you plan truly unique city trips. When planning a new trip, you can specify <our> preferences and constraints and anyway generates a personalized itinerary just for you.
|
AnyWay is an application that helps you plan truly unique city trips. When planning a new trip, you can specify your preferences and constraints and anyway generates a personalized itinerary just for you.
|
||||||
|
|
||||||
Anyway follows these core principles:
|
Anyway follows these core principles:
|
||||||
- **Personalization**: Trips should be match your interests - not just the most popular destinations.
|
- Personalization: Trips should be match your interests - not just the most popular destinations.
|
||||||
- **Efficiency**: Don't just walk in circles! Anyway creates the most efficient route for you.
|
- Efficiency: Don't just walk in circles! Anyway creates the most efficient route for you.
|
||||||
- **Flexibility**: Vacations are the time to be spontaneous. Anyway lets you update your plans on the go.
|
- Flexibility: Vacations are the time to be spontaneous. Anyway lets you update your plans on the go.
|
||||||
- **Discoverability**: Tourism means exploration. Anyway encourages you to take detours and make spontaneous decisions.
|
- Discoverability: Tourism means exploration. Anyway encourages you to take detours and make spontaneous decisions.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
@@ -1 +1 @@
|
|||||||
AnyWay
|
Any.Way
|
||||||
@@ -1 +1 @@
|
|||||||
|
TRAVEL
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
|
anydev.anyway@gmail.com
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
|
Remy
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
|
Moll
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
|
+4915128785827
|
||||||
|
|||||||
BIN
frontend/ios/fastlane/screenshots/en-US/iOS Phones 6.9-01.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 626 KiB After Width: | Height: | Size: 626 KiB |
|
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 758 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 574 KiB After Width: | Height: | Size: 574 KiB |
|
Before Width: | Height: | Size: 800 KiB After Width: | Height: | Size: 800 KiB |