12 Commits

Author SHA1 Message Date
bf8b64aacf i am stoopid
Some checks failed
Run testing on the backend code / Build (pull_request) Has been cancelled
Build and deploy the backend to staging / Deploy to staging (pull_request) Has been cancelled
Build and deploy the backend to staging / Build and push image (pull_request) Has been cancelled
Run linting on the backend code / Build (pull_request) Successful in 19s
2025-07-27 18:32:24 +02:00
44cd983fb8 fixed linter 4 real
Some checks failed
Build and deploy the backend to staging / Deploy to staging (pull_request) Has been cancelled
Build and deploy the backend to staging / Build and push image (pull_request) Has been cancelled
Run testing on the backend code / Build (pull_request) Has been cancelled
Run linting on the backend code / Build (pull_request) Failing after 16s
2025-07-27 18:31:30 +02:00
89c95063dd fixed linter
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m23s
Run linting on the backend code / Build (pull_request) Failing after 15s
Run testing on the backend code / Build (pull_request) Failing after 38s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 30s
2025-07-27 18:27:49 +02:00
e41d3f5e3a added supabase routes and payment handling
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m34s
Run linting on the backend code / Build (pull_request) Failing after 18s
Run testing on the backend code / Build (pull_request) Failing after 38s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 1m5s
2025-07-27 18:18:24 +02:00
f5cedbc5a0 fixed README 2025-07-27 17:33:06 +02:00
88dc5dd323 removed reports from tracking 2025-07-27 17:27:57 +02:00
c6bb0cddb7 Added field validation for preferences
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m31s
Run linting on the backend code / Build (pull_request) Failing after 19s
Run testing on the backend code / Build (pull_request) Failing after 19m47s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 33s
2025-07-27 17:22:38 +02:00
9ccf68d983 fixed the toilets and works with uv now
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m58s
Run linting on the backend code / Build (pull_request) Failing after 20s
Run testing on the backend code / Build (pull_request) Failing after 22m6s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 1m8s
2025-07-27 17:13:11 +02:00
132aa5a19b changed to no dev when building the docker image
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 2m24s
Run linting on the backend code / Build (pull_request) Failing after 21s
Run testing on the backend code / Build (pull_request) Failing after 22m41s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 36s
2025-07-26 12:44:41 +02:00
19b0c37a97 fixed the missing dependency in the refiner and changed the test run to using uv 2025-07-26 12:44:12 +02:00
ecdef605a7 cleanup and removed pipenv files 2025-07-26 12:41:58 +02:00
e2a918112b changed to uv fo managing dependencies 2025-07-26 12:41:15 +02:00
25 changed files with 2387 additions and 2777 deletions

View File

@@ -15,18 +15,18 @@ jobs:
- uses: https://gitea.com/actions/checkout@v4
- name: Install dependencies
- name: Install pylint
run: |
apt-get update && apt-get install -y python3 python3-pip
pip install pipenv
pip install pylint
- name: Install packages
run: |
ls -la
# only install dev-packages
pipenv install --categories=dev-packages
working-directory: backend
# - name: Install packages
# run: |
# ls -la
# # only install dev-packages
# uv sync
# working-directory: backend
- name: Run linter
run: pipenv run pylint src --fail-under=9
run: pylint src --fail-under=9
working-directory: backend

View File

@@ -18,17 +18,17 @@ jobs:
- name: Install dependencies
run: |
apt-get update && apt-get install -y python3 python3-pip
pip install pipenv
pip install uv
- name: Install packages
run: |
ls -la
# install all packages, including dev-packages
pipenv install --dev
uv sync
working-directory: backend
- 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
- name: Upload HTML report

4
backend/.gitignore vendored
View File

@@ -12,8 +12,8 @@ __pycache__/
# C extensions
*.so
# Pytest reports
report.html
# Pytest html reports
*.html
# Distribution / packaging
.Python

1
backend/.python-version Normal file
View File

@@ -0,0 +1 @@
3.12.9

View File

@@ -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
COPY Pipfile Pipfile.lock .
RUN pip install pipenv
RUN pipenv install --deploy --system
# Copy uv files
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
EXPOSE 8000
@@ -17,4 +35,4 @@ ENV MEMCACHED_HOST_PATH=none
ENV LOKI_URL=none
# 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -6,31 +6,31 @@ This repository contains the backend code for the application. It utilizes **Fas
### Directory Structure
- The code for the Python application is located in the `src` directory.
- Package management is handled with **pipenv**, and the dependencies are listed in the `Pipfile`.
- Package management is handled with **uv**, and the dependencies are listed in the `pyproject.toml` file.
- Since the application is designed to be deployed in a container, the `Dockerfile` is provided to build the image.
### Setting Up the Development Environment
To set up your development environment using **pipenv**, follow these steps:
To set up your development environment using **uv**, follow these steps:
1. Install `pipenv` by running:
1. Make sure you find yourself in the `backend` directory:
```bash
sudo apt install pipenv
cd backend
```
2. Create and activate a virtual environment:
1. Install `uv` by running:
```bash
pipenv shell
curl -LsSf https://astral.sh/uv/install.sh | sh
```
3. Install the dependencies listed in the `Pipfile`:
3. Install the dependencies listed in `pyproject.toml` and create the virtual environment at the same time:
```bash
pipenv install
uv sync
```
4. The virtual environment will be created under:
```bash
~/.local/share/virtualenvs/...
backend/.venv/...
```
### Deployment

View File

@@ -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
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from backend!")
if __name__ == "__main__":
main()

57
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,57 @@
[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'",
"paypalrestsdk>=1.13.3",
"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'",
"supabase>=2.16.0",
"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",
]

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,11 @@ This folder defines the commonly used data structures used within the project. T
### src/tests
This folder contains unit tests and test cases for the application's various modules. It is used to ensure the correctness and stability of the code.
Run the unit tests with the following command:
```bash
uv run pytest src --log-cli-level=DEBUG --html=report.html --self-contained-html
```
### src/utils
The `utils` folder contains utility classes and functions that provide core functionality for the application. The main component in this folder is the `LandmarkManager`, which is central to the process of fetching and organizing landmarks.

View File

@@ -10,7 +10,8 @@ from .structs.trip import Trip
from .landmarks.landmarks_manager import LandmarkManager
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 .landmarks.landmarks_router import router as landmarks_router
from .payments.payment_router import router as payment_router
from .optimization.optimizer import Optimizer
from .optimization.refiner import Refiner
from .cache import client as cache_client
@@ -42,16 +43,18 @@ app = FastAPI(lifespan=lifespan)
app.include_router(landmarks_router)
# Optimizes the trip given preferences. Second step in the main trip generation pipeline
# 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
# Call with "/get/toilets" for fetching toilets around coordinates.
app.include_router(toilets_router)
# Include the payment router for interacting with paypal sdk.
# See src/payment/payment_router.py for more information on how to call.
app.include_router(payment_router)
#### For already existing trips/landmarks
@app.get("/trip/{trip_uuid}")

View File

@@ -3,10 +3,11 @@
import logging
import time
import yaml
from fastapi import HTTPException, APIRouter, BackgroundTasks
from fastapi import HTTPException, APIRouter, BackgroundTasks, Body
from .optimizer import Optimizer
from .refiner import Refiner
from ..supabase.supabase import SupabaseClient
from ..structs.landmark import Landmark
from ..structs.preferences import Preferences
from ..structs.linked_landmarks import LinkedLandmarks
@@ -20,6 +21,7 @@ from ..constants import OPTIMIZER_PARAMETERS_PATH
logger = logging.getLogger(__name__)
optimizer = Optimizer()
refiner = Refiner(optimizer=optimizer)
supabase = SupabaseClient()
# Initialize the API router
@@ -28,10 +30,11 @@ router = APIRouter()
@router.post("/optimize/trip")
def optimize_trip(
preferences: Preferences,
landmarks: list[Landmark],
start: tuple[float, float],
end: tuple[float, float] | None = None,
user_id: str = Body(...),
preferences: Preferences = Body(...),
landmarks: list[Landmark] = Body(...),
start: tuple[float, float] = Body(...),
end: tuple[float, float] | None = Body(None),
background_tasks: BackgroundTasks = None
) -> Trip:
"""
@@ -45,6 +48,22 @@ def optimize_trip(
Returns:
(uuid) : The uuid of the first landmark in the optimized route
"""
# Check for valid user balance
try:
if not supabase.check_balance(user_id=user_id):
logger.warning('Insufficient credits to perform this action.')
raise HTTPException(status_code=418, detail='Insufficient credits')
except SyntaxError as se :
logger.error(f'SyntaxError: {se}')
raise HTTPException(status_code=400, detail=str(se)) from se
except ValueError as ve :
logger.error(f'SyntaxError: {ve}')
raise HTTPException(status_code=406, detail=str(ve)) from ve
except Exception as exc:
logger.error(f'SyntaxError: {exc}')
raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(exc)}") from exc
# Check for invalid input
if preferences is None:
raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
if len(landmarks) == 0 :
@@ -114,9 +133,11 @@ def optimize_trip(
# Second stage optimization
try :
refined_tour = refiner.refine_optimization(landmarks, base_tour,
preferences.max_time_minute,
preferences.detour_tolerance_minute)
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
@@ -133,7 +154,11 @@ def optimize_trip(
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))
# Add the cache fill as background task
background_tasks.add_task(fill_cache)
# Finally, decrement the user balance
supabase.decrement_credit_balance(user_id=user_id)
return trip

View File

@@ -6,7 +6,6 @@ from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull
from ..structs.landmark import Landmark
from ..utils.get_time_distance import get_time
from ..utils.take_most_important import take_most_important
from .optimizer import Optimizer
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:
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

View File

@@ -0,0 +1,70 @@
from typing import Literal
import paypalrestsdk
from pydantic import BaseModel
from fastapi import HTTPException
import logging
# Model for payment request body
class PaymentRequest(BaseModel):
user_id: str
credit_amount: Literal[10, 50, 100]
currency: Literal["USD", "EUR", "CHF"]
description: str = "Purchase of credits"
# Payment handler class for managing PayPal payments
class PaymentHandler:
payment_id: str
def __init__(self, transaction_details: PaymentRequest):
self.details = transaction_details
self.logger = logging.getLogger(__name__)
# Only support purchase of credit 'bundles': 10, 50 or 100 credits worth of trip generation
def fetch_price(self) -> float:
"""
Fetches the price of credits in the specified currency.
"""
result = self.supabase.table("prices").select("credit_amount").eq("currency", self.details.currency).single().execute()
if result.data:
return result.data.get("price")
else:
self.logger.error(f"Unsupported currency: {self.details.currency}")
return None
def create_paypal_payment(self) -> str:
"""
Creates a PayPal payment and returns the approval URL.
"""
price = self.fetch_price()
payment = paypalrestsdk.Payment({
"intent": "sale",
"payer": {
"payment_method": "paypal"
},
"transactions": [{
"amount": {
"total": f"{price:.2f}",
"currency": self.details.currency
},
"description": self.details.description
}],
"redirect_urls": {
"return_url": "http://localhost:8000/payment/success",
"cancel_url": "http://localhost:8000/payment/cancel"
}
})
if payment.create():
self.logger.info("Payment created successfully")
self.payment_id = payment.id
# Get the approval URL and return it for the user to approve
for link in payment.links:
if link.rel == "approval_url":
return link.href
else:
self.logger.error(f"Failed to create payment: {payment.error}")
raise HTTPException(status_code=500, detail="Payment creation failed")

View File

@@ -0,0 +1,79 @@
import logging
import paypalrestsdk
from fastapi import HTTPException, APIRouter
from ..supabase.supabase import SupabaseClient
from .payment_handler import PaymentRequest, PaymentHandler
# Set up logging and supabase
logger = logging.getLogger(__name__)
supabase = SupabaseClient()
# Configure PayPal SDK
paypalrestsdk.configure({
"mode": "sandbox", # Use 'live' for production
"client_id": "YOUR_PAYPAL_CLIENT_ID",
"client_secret": "YOUR_PAYPAL_SECRET"
})
# Define the API router
router = APIRouter()
@router.post("/purchase/credits")
def purchase_credits(payment_request: PaymentRequest):
"""
Handles token purchases. Calculates the number of tokens based on the amount paid,
updates the user's balance, and processes PayPal payment.
"""
payment_handler = PaymentHandler(payment_request)
# Create PayPal payment and get the approval URL
approval_url = payment_handler.create_paypal_payment()
return {
"message": "Purchase initiated successfully",
"payment_id": payment_handler.payment_id,
"credits": payment_request.credit_amount,
"approval_url": approval_url,
}
@router.get("/payment/success")
def payment_success(paymentId: str, PayerID: str):
"""
Handles successful PayPal payment.
"""
payment = paypalrestsdk.Payment.find(paymentId)
if payment.execute({"payer_id": PayerID}):
logger.info("Payment executed successfully")
# Retrieve transaction details from the database
result = supabase.table("pending_payments").select("*").eq("payment_id", paymentId).single().execute()
if not result.data:
raise HTTPException(status_code=404, detail="Transaction not found")
# Extract the necessary information
user_id = result.data["user_id"]
credit_amount = result.data["credit_amount"]
# Update the user's balance
supabase.increment_credit_balance(user_id, amount=credit_amount)
# Optionally, delete the pending payment entry since the transaction is completed
supabase.table("pending_payments").delete().eq("payment_id", paymentId).execute()
return {"message": "Payment completed successfully"}
else:
logger.error(f"Payment execution failed: {payment.error}")
raise HTTPException(status_code=500, detail="Payment execution failed")
@router.get("/payment/cancel")
def payment_cancel():
"""
Handles PayPal payment cancellation.
"""
return {"message": "Payment was cancelled"}

View File

@@ -39,3 +39,16 @@ class Preferences(BaseModel) :
max_time_minute: Optional[int] = 3*60
detour_tolerance_minute: Optional[int] = 0
def model_post_init(self, __context):
"""
Method to validate proper initialization of individual Preferences.
Raises ValueError if the Preference type does not match with the field name.
"""
if self.sightseeing.type != 'sightseeing':
raise ValueError(f'The sightseeing preference cannot be {self.sightseeing.type}.')
if self.nature.type != 'nature':
raise ValueError(f'The nature preference cannot be {self.nature.type}.')
if self.shopping.type != 'shopping':
raise ValueError(f'The shopping preference cannot be {self.shopping.type}.')

View File

@@ -0,0 +1,169 @@
import os
import logging
import yaml
from fastapi import HTTPException, status
from supabase import create_client, Client, ClientOptions
from ..constants import PARAMETERS_DIR
# Silence the supabase logger
logging.getLogger("httpx").setLevel(logging.CRITICAL)
logging.getLogger("hpack").setLevel(logging.CRITICAL)
logging.getLogger("httpcore").setLevel(logging.CRITICAL)
class SupabaseClient:
logger = logging.getLogger(__name__)
def __init__(self):
with open(os.path.join(PARAMETERS_DIR, 'secrets.yaml')) as f:
secrets = yaml.safe_load(f)
self.SUPABASE_URL = secrets['SUPABASE_URL']
self.SUPABASE_ADMIN_KEY = secrets['SUPABASE_ADMIN_KEY']
self.SUPABASE_TEST_USER_ID = secrets['SUPABASE_TEST_USER_ID']
self.supabase = create_client(
self.SUPABASE_URL,
self.SUPABASE_ADMIN_KEY,
options=ClientOptions(schema='public')
)
self.logger.info('Supabase client initialized.')
def check_balance(self, user_id: str) -> bool:
"""
Checks if the user has enough 'credit' for generating a new trip.
Args:
user_id (str): The ID of the current user.
Returns:
bool: True if the balance is positive, False otherwise.
"""
try:
# Query the public.credits table to get the user's credits
response = (
self.supabase.table("credits")
.select('*')
.eq('id', user_id)
.single()
.execute()
)
except Exception as e:
if e.code == '22P02' :
self.logger.error(f"Failed querying credits : {str(e)}")
raise SyntaxError(f"Failed querying credits : {str(e)}") from e
if e.code == 'PGRST116' :
self.logger.error(f"User not found : {str(e)}")
raise ValueError(f"User not found : {str(e)}") from e
else :
self.logger.error(f"An unexpected error occured while checking user balance : {str(e)}")
raise Exception(f"An unexpected error occured while checking user balance : {str(e)}") from e
# Proceed to check the user's credit balance
credits = response.data['credit_amount']
self.logger.debug(f'Credits of user {user_id}: {credits}')
if credits > 0:
self.logger.info(f'Credit balance is positive for user {user_id}. Proceeding with trip generation.')
return True
self.logger.warning(f'Insufficient balance for user {user_id}. Trip generation cannot proceed.')
return False
def decrement_credit_balance(self, user_id: str, amount: int=1) -> bool:
"""
Decrements the user's credit balance by 1.
Args:
user_id (str): The ID of the current user.
"""
try:
# Query the public.credits table to get the user's current credits
response = (
self.supabase.table("credits")
.select('*')
.eq('id', user_id)
.single()
.execute()
)
except Exception as e:
if e.code == '22P02' :
self.logger.error(f"Failed decrementing credits : {str(e)}")
raise SyntaxError(f"Failed decrementing credits : {str(e)}") from e
if e.code == 'PGRST116' :
self.logger.error(f"User not found : {str(e)}")
raise ValueError(f"User not found : {str(e)}") from e
else :
self.logger.error(f"An unexpected error occured while decrementing user balance : {str(e)}")
raise Exception(f"An unexpected error occured while decrementing user balance : {str(e)}") from e
current_credits = response.data['credit_amount']
updated_credits = current_credits - amount
# Update the user's credits in the table
update_response = (
self.supabase.table('credits')
.update({'credit_amount': updated_credits})
.eq('id', user_id)
.execute()
)
# Check if the update was successful
if update_response.data:
self.logger.debug(f'Credit balance successfully decremented.')
return True
else:
raise Exception("Error decrementing credit balance.")
def increment_credit_balance(self, user_id: str, amount: int=1) -> bool:
"""
Increments the user's credit balance by 1.
Args:
user_id (str): The ID of the current user.
"""
try:
# Query the public.credits table to get the user's current credits
response = (
self.supabase.table("credits")
.select('*')
.eq('id', user_id)
.single()
.execute()
)
except Exception as e:
if e.code == '22P02' :
self.logger.error(f"Failed incrementing credits : {str(e)}")
raise SyntaxError(f"Failed incrementing credits : {str(e)}") from e
if e.code == 'PGRST116' :
self.logger.error(f"User not found : {str(e)}")
raise ValueError(f"User not found : {str(e)}") from e
else :
self.logger.error(f"An unexpected error occured while incrementing user balance : {str(e)}")
raise Exception(f"An unexpected error occured while incrementing user balance : {str(e)}") from e
current_credits = response.data['credit_amount']
updated_credits = current_credits + amount
# Update the user's credits in the table
update_response = (
self.supabase.table('credits')
.update({'credit_amount': updated_credits})
.eq('id', user_id)
.execute()
)
# Check if the update was successful
if update_response.data:
self.logger.debug(f'Credit balance successfully incremented.')
return True
else:
raise Exception("Error incrementing credit balance.")

View File

@@ -36,7 +36,6 @@ def test_invalid_input(client, location, radius, status_code): # pylint: disa
"radius": radius
}
)
print(response.json())
# checks :
assert response.status_code == status_code
@@ -64,7 +63,6 @@ def test_no_toilets(client, location, status_code): # pylint: disable=redefin
"location": location
}
)
print(response.json())
toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()]
# checks :
@@ -95,8 +93,6 @@ def test_toilets(client, location, status_code): # pylint: disable=redefined-
"radius" : 600
}
)
print(response.json())
toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()]
# checks :

View File

@@ -1,13 +1,21 @@
"""Collection of tests to ensure correct implementation and track progress."""
import os
import time
import yaml
from fastapi.testclient import TestClient
import pytest
from .test_utils import load_trip_landmarks, log_trip_details
from ..supabase.supabase import SupabaseClient
from ..structs.preferences import Preferences, Preference
from ..constants import PARAMETERS_DIR
from ..main import app
# Create a supabase client
supabase = SupabaseClient()
@pytest.fixture(scope="module")
def client():
"""Client used to call the app."""
@@ -53,19 +61,32 @@ def test_trip(client, request, sightseeing, shopping, nature, max_time_minute, s
"end": end_coords,
}
)
assert response.status_code == 200
landmarks = response.json()
# Step 2: Feed the landmarks to the optimizer to compute the trip
response = client.post(
"/optimize/trip",
json={
"user_id": supabase.SUPABASE_TEST_USER_ID,
"preferences": prefs.model_dump(),
"landmarks": landmarks,
"start": start,
"end": end,
}
)
assert response.status_code == 200
# Increment the user balance again
supabase.increment_credit_balance(
supabase.SUPABASE_TEST_USER_ID,
amount=1
)
# Parse the response
result = response.json()
# print(result)
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
# Get computation time
@@ -75,7 +96,6 @@ def test_trip(client, request, sightseeing, shopping, nature, max_time_minute, s
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}"

View File

@@ -70,6 +70,8 @@ class ToiletsManager:
toilets_list = self.to_toilets(result)
self.logger.debug(f'Found {len(toilets_list)} toilets around {self.location}')
return toilets_list

View File

@@ -39,4 +39,5 @@ def get_toilets(
except KeyError as exc:
raise HTTPException(status_code=404, detail="No toilets found") from exc
return toilets_list

1876
backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff