Compare commits
39 Commits
96b0718081
...
backend/mi
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bc6b03a91 | |||
| a9641f4a1c | |||
| ec28d67fca | |||
| 5441861cb3 | |||
| 6fbd6a8545 | |||
| 4404eb6f77 | |||
| 40e5ba084b | |||
| 51b7117c6d | |||
| 9c930996c7 | |||
| d9724ff07d | |||
| a884b9ee14 | |||
| bfc0c9adae | |||
| 510aabcb0a | |||
| fe1b42fff9 | |||
| b4cac3a357 | |||
| 54f541382e | |||
| 29ac462725 | |||
| d374dc333f | |||
| ab03cee3e3 | |||
| f86174bc11 | |||
| 3bdcdea850 | |||
| 5549f8b0e5 | |||
| b201dfe97c | |||
| b65d184f48 | |||
| 16b35ab5af | |||
| 011671832a | |||
| f2237bd721 | |||
| bf8b64aacf | |||
| 44cd983fb8 | |||
| 89c95063dd | |||
| e41d3f5e3a | |||
| f5cedbc5a0 | |||
| 88dc5dd323 | |||
| c6bb0cddb7 | |||
| 9ccf68d983 | |||
| 132aa5a19b | |||
| 19b0c37a97 | |||
| ecdef605a7 | |||
| e2a918112b |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
6
backend/.gitignore
vendored
6
backend/.gitignore
vendored
@@ -2,7 +2,7 @@
|
||||
cache_XML/
|
||||
|
||||
# secrets
|
||||
*secrets.yaml
|
||||
*.env
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -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
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12.9
|
||||
@@ -1,11 +1,21 @@
|
||||
FROM python:3.11-slim
|
||||
# use python 3.12 as a base image
|
||||
FROM docker.io/python:3.12-alpine
|
||||
|
||||
# use the latest version of uv, independently of the python version
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
# 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 +27,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
|
||||
|
||||
@@ -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
1246
backend/Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
58
backend/pyproject.toml
Normal file
58
backend/pyproject.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
[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'",
|
||||
"dotenv>=0.9.9",
|
||||
"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",
|
||||
]
|
||||
1094
backend/report.html
1094
backend/report.html
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Module used for handling cache"""
|
||||
import hashlib
|
||||
|
||||
from pymemcache import serde
|
||||
from pymemcache.client.base import Client
|
||||
|
||||
@@ -73,3 +75,62 @@ else:
|
||||
encoding='utf-8',
|
||||
serde=serde.pickle_serde
|
||||
)
|
||||
|
||||
|
||||
#### Cache for payment architecture
|
||||
|
||||
def make_credit_cache_key(user_id: str, order_id: str) -> str:
|
||||
"""
|
||||
Generate a cache key from user_id and order_id using md5.
|
||||
|
||||
Args:
|
||||
user_id (str): The user's ID.
|
||||
order_id (str): The PayPal order ID.
|
||||
|
||||
Returns:
|
||||
str: A unique cache key.
|
||||
"""
|
||||
# Concatenate and hash to avoid collisions and keep key size small
|
||||
raw_key = f"{user_id}:{order_id}"
|
||||
return hashlib.md5(raw_key.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
class CreditCache:
|
||||
"""
|
||||
Handles storing and retrieving credits to grant for a user/order.
|
||||
|
||||
Methods:
|
||||
set_credits(user_id, order_id, credits):
|
||||
Store the credits for a user/order.
|
||||
|
||||
get_credits(user_id, order_id):
|
||||
Retrieve the credits for a user/order.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def set_credits(user_id: str, order_id: str, credits_to_grant: int) -> None:
|
||||
"""
|
||||
Store the credits to be granted for a user/order.
|
||||
|
||||
Args:
|
||||
user_id (str): The user's ID.
|
||||
order_id (str): The PayPal order ID.
|
||||
credits (int): The amount of credits to grant.
|
||||
"""
|
||||
cache_key = make_credit_cache_key(user_id, order_id)
|
||||
client.set(cache_key, credits_to_grant)
|
||||
|
||||
@staticmethod
|
||||
def get_credits(user_id: str, order_id: str) -> int | None:
|
||||
"""
|
||||
Retrieve the credits to be granted for a user/order.
|
||||
|
||||
Args:
|
||||
user_id (str): The user's ID.
|
||||
order_id (str): The PayPal order ID.
|
||||
|
||||
Returns:
|
||||
int | None: The credits to grant, or None if not found.
|
||||
"""
|
||||
cache_key = make_credit_cache_key(user_id, order_id)
|
||||
return client.get(cache_key)
|
||||
|
||||
0
backend/src/configuration/__init__.py
Normal file
0
backend/src/configuration/__init__.py
Normal file
26
backend/src/configuration/environment.py
Normal file
26
backend/src/configuration/environment.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""This module is for loading variables from the environment and passes them throughout the code using the Environment dataclass"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
# Load variables from environment
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Environment :
|
||||
|
||||
# Load supabase secrets
|
||||
supabase_url = os.environ['SUPABASE_URL']
|
||||
supabase_admin_key = os.environ['SUPABASE_ADMIN_KEY']
|
||||
supabase_test_user_id = os.environ['SUPABASE_TEST_USER_ID']
|
||||
|
||||
# Load paypal secrets
|
||||
paypal_id_sandbox = os.environ['PAYPAL_ID_SANDBOX']
|
||||
paypal_key_sandbox = os.environ['PAYPAL_KEY_SANDBOX']
|
||||
paypal_id_prod = os.environ['PAYPAL_ID_PROD']
|
||||
paypal_key_prod = os.environ['PAYPAL_KEY_PROD']
|
||||
|
||||
@@ -22,7 +22,6 @@ class LandmarkManager:
|
||||
church_coeff: float # coeff to adjsut score of churches
|
||||
nature_coeff: float # coeff to adjust score of parks
|
||||
overall_coeff: float # coeff to adjust weight of tags
|
||||
# n_important: int # number of important landmarks to consider
|
||||
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -41,7 +40,6 @@ class LandmarkManager:
|
||||
self.wikipedia_bonus = parameters['wikipedia_bonus']
|
||||
self.viewpoint_bonus = parameters['viewpoint_bonus']
|
||||
self.pay_bonus = parameters['pay_bonus']
|
||||
# self.n_important = parameters['N_important']
|
||||
|
||||
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
|
||||
parameters = yaml.safe_load(f)
|
||||
@@ -187,6 +185,7 @@ class LandmarkManager:
|
||||
|
||||
# caution, when applying a list of selectors, overpass will search for elements that match ALL selectors simultaneously
|
||||
# we need to split the selectors into separate queries and merge the results
|
||||
# TODO: this can be multi-threaded once the Overpass rate-limit is not a problem anymore
|
||||
for sel in dict_to_selector_list(amenity_selector):
|
||||
# self.logger.debug(f"Current selector: {sel}")
|
||||
|
||||
|
||||
@@ -64,9 +64,10 @@ def get_landmarks(
|
||||
|
||||
@router.post("/get-nearby/landmarks/{lat}/{lon}")
|
||||
def get_landmarks_nearby(
|
||||
lat: float,
|
||||
lon: float
|
||||
) -> list[Landmark] :
|
||||
lat: float,
|
||||
lon: float,
|
||||
allow_clusters: bool = False
|
||||
) -> list[Landmark] :
|
||||
"""
|
||||
Suggests nearby landmarks based on a given latitude and longitude.
|
||||
|
||||
@@ -76,6 +77,7 @@ def get_landmarks_nearby(
|
||||
Args:
|
||||
lat (float): Latitude of the user's current location.
|
||||
lon (float): Longitude of the user's current location.
|
||||
allow_clusters (bool): Whether or not to allow the search for shopping/historical clusters when looking for nearby landmarks.
|
||||
|
||||
Returns:
|
||||
list[Landmark]: A list of selected nearby landmarks.
|
||||
@@ -104,7 +106,7 @@ def get_landmarks_nearby(
|
||||
landmarks_around = manager.generate_landmarks_list(
|
||||
center_coordinates = (lat, lon),
|
||||
preferences = prefs,
|
||||
allow_clusters=False,
|
||||
allow_clusters=allow_clusters,
|
||||
)
|
||||
|
||||
if len(landmarks_around) == 0 :
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
"""Main app for backend api"""
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .logging_config import configure_logging
|
||||
from .structs.landmark import Landmark
|
||||
from .structs.linked_landmarks import LinkedLandmarks
|
||||
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 .optimization.optimizer import Optimizer
|
||||
from .optimization.refiner import Refiner
|
||||
from .cache import client as cache_client
|
||||
from .landmarks.landmarks_router import router as landmarks_router
|
||||
from .payments.payment_router import router as payment_router
|
||||
from .trips.trips_router import router as trips_router
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
manager = LandmarkManager()
|
||||
optimizer = Optimizer()
|
||||
refiner = Refiner(optimizer=optimizer)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -32,6 +23,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("Shutting down logging")
|
||||
|
||||
|
||||
# Create the fastapi app
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
@@ -42,92 +34,26 @@ 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)
|
||||
|
||||
|
||||
|
||||
#### For already existing trips/landmarks
|
||||
@app.get("/trip/{trip_uuid}")
|
||||
def get_trip(trip_uuid: str) -> Trip:
|
||||
"""
|
||||
Look-up the cache for a trip that has been previously generated using its identifier.
|
||||
|
||||
Args:
|
||||
trip_uuid (str) : unique identifier for a trip.
|
||||
|
||||
Returns:
|
||||
(Trip) : the corresponding trip.
|
||||
"""
|
||||
try:
|
||||
trip = cache_client.get(f"trip_{trip_uuid}")
|
||||
return trip
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to fetch trip with UUID {trip_uuid}: {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail="Trip not found") from exc
|
||||
# Include the payment router for interacting with paypal sdk.
|
||||
# See src/payment/payment_router.py for more information on how to call.
|
||||
# Call with "/orders/new" to initiate a payment with an order request (step 1)
|
||||
# Call with "/orders/{order_id}/{user_id}capture" to capture a payment and grant the user the due credits (step 2)
|
||||
app.include_router(payment_router)
|
||||
|
||||
|
||||
@app.get("/landmark/{landmark_uuid}")
|
||||
def get_landmark(landmark_uuid: str) -> Landmark:
|
||||
"""
|
||||
Returns a Landmark from its unique identifier.
|
||||
|
||||
Args:
|
||||
landmark_uuid (str) : unique identifier for a Landmark.
|
||||
|
||||
Returns:
|
||||
(Landmark) : the corresponding Landmark.
|
||||
"""
|
||||
try:
|
||||
landmark = cache_client.get(f"landmark_{landmark_uuid}")
|
||||
return landmark
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to fetch landmark with UUID {landmark_uuid}: {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail="Landmark not found") from exc
|
||||
|
||||
|
||||
@app.post("/trip/recompute-time/{trip_uuid}/{removed_landmark_uuid}")
|
||||
def update_trip_time(trip_uuid: str, removed_landmark_uuid: str) -> Trip:
|
||||
"""
|
||||
Updates the reaching times of a given trip when removing a landmark.
|
||||
|
||||
Args:
|
||||
landmark_uuid (str) : unique identifier for a Landmark.
|
||||
|
||||
Returns:
|
||||
(Landmark) : the corresponding Landmark.
|
||||
"""
|
||||
# First, fetch the trip in the cache.
|
||||
try:
|
||||
trip = cache_client.get(f'trip_{trip_uuid}')
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to update trip with UUID {trip_uuid} (trip not found): {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail='Trip not found') from exc
|
||||
|
||||
landmarks = []
|
||||
next_uuid = trip.first_landmark_uuid
|
||||
|
||||
# Extract landmarks
|
||||
try :
|
||||
while next_uuid is not None:
|
||||
landmark = cache_client.get(f'landmark_{next_uuid}')
|
||||
# Filter out the removed landmark.
|
||||
if next_uuid != removed_landmark_uuid :
|
||||
landmarks.append(landmark)
|
||||
next_uuid = landmark.next_uuid # Prepare for the next iteration
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to update trip with UUID {trip_uuid} : {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail=f'landmark {next_uuid} not found') from exc
|
||||
|
||||
# Re-link every thing and compute times again
|
||||
linked_tour = LinkedLandmarks(landmarks)
|
||||
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
||||
|
||||
return trip
|
||||
# Endpoint for putting together a trip, fetching landmarks by UUID and updating trip times. Three routes
|
||||
# Call with "/trip/{trip_uuid}" for getting trip by UUID.
|
||||
# Call with "/landmark/{landmark_uuid}" for getting landmark by UUID.
|
||||
# Call with "/trip//trip/recompute-time/{trip_uuid}/{removed_landmark_uuid}" for updating trip times.
|
||||
app.include_router(trips_router)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
0
backend/src/payments/__init__.py
Normal file
0
backend/src/payments/__init__.py
Normal file
328
backend/src/payments/payment_handler.py
Normal file
328
backend/src/payments/payment_handler.py
Normal file
@@ -0,0 +1,328 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Literal
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from ..supabase.supabase import SupabaseClient
|
||||
from ..structs.shop import Item, BasketItem
|
||||
from ..configuration.environment import Environment
|
||||
from ..cache import CreditCache, make_credit_cache_key
|
||||
|
||||
|
||||
# Intialize the logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define the base URL, might move that to toml file
|
||||
BASE_URL_PROD = 'https://api-m.paypal.com'
|
||||
BASE_URL_SANDBOX = 'https://api-m.sandbox.paypal.com'
|
||||
|
||||
|
||||
|
||||
class OrderRequest(BaseModel):
|
||||
"""
|
||||
Represents an order request from the frontend.
|
||||
|
||||
Attributes:
|
||||
user_id (str): The ID of the user placing the order.
|
||||
basket (list[BasketItem]): List of basket items.
|
||||
currency (str): The currency code for the order.
|
||||
created_at (datetime): Timestamp when the order was created.
|
||||
updated_at (datetime): Timestamp when the order was last updated.
|
||||
items (list[Item]): List of item details loaded from the database.
|
||||
total_price (float): Total price of the order.
|
||||
"""
|
||||
user_id: str
|
||||
basket: list[BasketItem]
|
||||
currency: Literal['CHF', 'EUR', 'USD']
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
items: list[Item] = Field(default_factory=list)
|
||||
total_price: float = None
|
||||
total_credits: int = None
|
||||
|
||||
|
||||
# @field_validator('basket')
|
||||
def validate_basket(cls, v):
|
||||
"""Validates the basket items.
|
||||
|
||||
Args:
|
||||
v (list): List of basket items.
|
||||
|
||||
Raises:
|
||||
ValueError: If basket does not contain valid BasketItem objects.
|
||||
|
||||
Returns:
|
||||
list: The validated basket.
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("Basket cannot be empty")
|
||||
|
||||
# Pydantic already converts dict -> BasketItem, so isinstance works
|
||||
if not all(isinstance(i, BasketItem) for i in v):
|
||||
raise ValueError("Basket must contain BasketItem objects")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
def load_items_and_price(self, supabase: SupabaseClient):
|
||||
# This should be automatic upon initialization of the class
|
||||
"""
|
||||
Loads item details from database and calculates the total price as well as the total credits to be granted.
|
||||
"""
|
||||
self.items = []
|
||||
self.total_price = 0
|
||||
self.total_credits = 0
|
||||
for basket_item in self.basket:
|
||||
item = supabase.get_item(basket_item.item_id, self.currency)
|
||||
self.items.append(item)
|
||||
self.total_price += item.unit_price * basket_item.quantity # increment price
|
||||
self.total_credits += item.unit_credits * basket_item.quantity # increment credit balance
|
||||
|
||||
|
||||
def to_paypal_items(self):
|
||||
"""
|
||||
Converts items to the PayPal API item format.
|
||||
|
||||
Returns:
|
||||
list: List of items formatted for PayPal API.
|
||||
"""
|
||||
item_list = []
|
||||
|
||||
for basket_item, item in zip(self.basket, self.items):
|
||||
item_list.append({
|
||||
'id': item.item_id,
|
||||
'name': item.name,
|
||||
'description': item.description,
|
||||
'quantity': str(basket_item.quantity),
|
||||
'unit_amount': {
|
||||
'currency_code': self.currency,
|
||||
'value': str(item.unit_price)
|
||||
}
|
||||
})
|
||||
return item_list
|
||||
|
||||
|
||||
# Payment handler class for managing PayPal payments
|
||||
class PaypalClient:
|
||||
"""
|
||||
Handles PayPal payment operations.
|
||||
|
||||
Attributes:
|
||||
sandbox (bool): Whether to use the sandbox environment.
|
||||
id (str): PayPal client ID.
|
||||
key (str): PayPal client secret.
|
||||
base_url (str): Base URL for PayPal API.
|
||||
_token_cache (dict): Cache for the PayPal OAuth access token.
|
||||
"""
|
||||
|
||||
_token_cache = {
|
||||
"access_token": None,
|
||||
"expires_at": 0
|
||||
}
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sandbox_mode: bool = False
|
||||
):
|
||||
"""
|
||||
Initializes the handler.
|
||||
|
||||
Args:
|
||||
sandbox_mode (bool): Whether to use sandbox credentials.
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.sandbox = sandbox_mode
|
||||
|
||||
# PayPal keys
|
||||
if sandbox_mode :
|
||||
self.id = Environment.paypal_id_sandbox
|
||||
self.key = Environment.paypal_key_sandbox
|
||||
self.base_url = BASE_URL_SANDBOX
|
||||
else :
|
||||
self.id = Environment.paypal_id_prod
|
||||
self.key = Environment.paypal_key_prod
|
||||
self.base_url = BASE_URL_PROD
|
||||
|
||||
def _get_access_token(self) -> str | None:
|
||||
"""
|
||||
Gets (and caches) a PayPal access token.
|
||||
|
||||
Returns:
|
||||
str | None: The access token if successful, None otherwise.
|
||||
"""
|
||||
now = datetime.now()
|
||||
# Check if token is still valid
|
||||
if (
|
||||
self._token_cache["access_token"] is not None
|
||||
and self._token_cache["expires_at"] > now
|
||||
):
|
||||
self.logger.info('Returning (cached) access token.')
|
||||
return self._token_cache["access_token"]
|
||||
|
||||
# Request new token
|
||||
validation_data = {'grant_type': 'client_credentials'}
|
||||
|
||||
try:
|
||||
# pass the request
|
||||
validation_response = requests.post(
|
||||
url = f'{self.base_url}/v1/oauth2/token',
|
||||
data = validation_data,
|
||||
auth =(self.id, self.key)
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
self.logger.error(f'Error while requesting access token: {exc}')
|
||||
return None
|
||||
|
||||
data = validation_response.json()
|
||||
access_token = data.get("access_token")
|
||||
expires_in = int(data.get("expires_in", 3600)) # seconds, default 1 hour
|
||||
|
||||
# Cache the token and its expiry
|
||||
self._token_cache["access_token"] = access_token
|
||||
self._token_cache["expires_at"] = now + timedelta(seconds=expires_in - 60) # buffer 1 min
|
||||
|
||||
self.logger.info('Returning (new) access token.')
|
||||
return access_token
|
||||
|
||||
|
||||
def order(
|
||||
self,
|
||||
order_request: OrderRequest,
|
||||
supabase_client: SupabaseClient,
|
||||
return_url_success: str,
|
||||
return_url_failure: str
|
||||
):
|
||||
"""
|
||||
Creates a new PayPal order.
|
||||
|
||||
Args:
|
||||
order_request (OrderRequest): The order request.
|
||||
|
||||
Returns:
|
||||
dict | None: PayPal order response JSON, or None if failed.
|
||||
"""
|
||||
|
||||
# Fetch details of order from mart database and compute total credits and price
|
||||
order_request.load_items_and_price(supabase=supabase_client)
|
||||
|
||||
# Prepare payload for post request to paypal API
|
||||
order_data = {
|
||||
'intent': 'CAPTURE',
|
||||
'purchase_units': [
|
||||
{
|
||||
'items': order_request.to_paypal_items(),
|
||||
'amount': {
|
||||
'currency_code': order_request.currency,
|
||||
'value': str(order_request.total_price),
|
||||
'breakdown': {
|
||||
'item_total': {
|
||||
'currency_code': order_request.currency,
|
||||
'value': str(order_request.total_price)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
# No redirect from paypal
|
||||
'application_context': {
|
||||
# 'return_url': f'https://anydev.info/orders/{json.loads(order_response.text)["id"]}/{order_request.user_id}capture', # This returns to backend capture-payment URL
|
||||
# 'cancel_url': return_url_failure
|
||||
'return_url': 'https://anydev.info/api/paypal/capture',
|
||||
'cancel_url': return_url_failure
|
||||
}
|
||||
}
|
||||
|
||||
# Get the access_token:
|
||||
access_token = self._get_access_token()
|
||||
|
||||
try:
|
||||
order_response = requests.post(
|
||||
url = f'{self.base_url}/v2/checkout/orders',
|
||||
headers = {'Authorization': f'Bearer {access_token}'},
|
||||
json = order_data,
|
||||
)
|
||||
|
||||
# Raise HTTP Exception if request was unsuccessful.
|
||||
except Exception as exc:
|
||||
self.logger.error(f'Error creating PayPal order: {exc}')
|
||||
return None
|
||||
|
||||
try:
|
||||
order_response.raise_for_status()
|
||||
except:
|
||||
return RedirectResponse(url=return_url_failure)
|
||||
|
||||
user_id = order_request.user_id
|
||||
order_id = json.loads(order_response.text)["id"]
|
||||
|
||||
# TODO Now that we have the order ID, we can inscribe the details in sql database using the order id given by paypal
|
||||
# DB for storing the transaction records:
|
||||
|
||||
# order_id (key): json.loads(order_response.text)["id"]
|
||||
# user_id : order_request.user_id
|
||||
# created_at : order_request.created_at
|
||||
# status : PENDING
|
||||
# basket (json) : OrderDetails.jsonify()
|
||||
# total_price : order_request.total_price
|
||||
# currency : order_request.currency
|
||||
# updated_at : order_request.created_at
|
||||
|
||||
# Create a cache item for credits to be granted to user
|
||||
CreditCache.set_credits(
|
||||
user_id = user_id,
|
||||
order_id = order_id,
|
||||
credits_to_grant = order_request.total_credits)
|
||||
|
||||
|
||||
# return order_response.json()
|
||||
return RedirectResponse(url=f'https://anydev.info/orders/{order_id}/{user_id}capture')
|
||||
|
||||
|
||||
# Standalone function to capture a payment
|
||||
def capture(self, user_id: str, order_id: str):
|
||||
"""
|
||||
Captures payment for a PayPal order.
|
||||
|
||||
Args:
|
||||
order_id (str): The PayPal order ID.
|
||||
|
||||
Returns:
|
||||
dict | None: PayPal capture response JSON, or None if failed.
|
||||
"""
|
||||
# Get the access_token:
|
||||
access_token = self._get_access_token()
|
||||
|
||||
try:
|
||||
capture_response = requests.post(
|
||||
url = f'{self.base_url}/v2/checkout/orders/{order_id}/capture',
|
||||
headers = {'Authorization': f'Bearer {access_token}'},
|
||||
json = {},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f'Error while requesting access token: {exc}')
|
||||
return None
|
||||
|
||||
# Raise exception if API call failed
|
||||
capture_response.raise_for_status()
|
||||
|
||||
|
||||
|
||||
# print(capture_response.text)
|
||||
|
||||
# TODO: update status to PAID in sql database
|
||||
|
||||
# where order_id (key) = order_id
|
||||
# status : 'PAID'
|
||||
# updated_at : datetime.now()
|
||||
|
||||
|
||||
# Not sure yet if/how to implement that
|
||||
def cancel(self):
|
||||
|
||||
pass
|
||||
169
backend/src/payments/payment_router.py
Normal file
169
backend/src/payments/payment_router.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Body
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from .payment_handler import PaypalClient, OrderRequest
|
||||
from ..supabase.supabase import SupabaseClient
|
||||
from ..structs.shop import BasketItem
|
||||
from ..cache import CreditCache, make_credit_cache_key
|
||||
|
||||
|
||||
# Create a PayPal & Supabase client
|
||||
paypal_client = PaypalClient(sandbox_mode=False)
|
||||
supabase = SupabaseClient()
|
||||
|
||||
# Initialize the API router
|
||||
router = APIRouter()
|
||||
|
||||
# Initialize the logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TODO: add the return url in the API payload to redirect the user to the app.
|
||||
@router.post("/orders/new")
|
||||
def create_order(
|
||||
user_id: str = Query(...),
|
||||
currency: str = Query(...),
|
||||
return_url_success: str = Query('https://anydev.info/orders/success'),
|
||||
return_url_failure: str = Query('https://anydev.info/orders/failed'),
|
||||
basket: list[BasketItem] = Body(...),
|
||||
):
|
||||
"""
|
||||
Creates a new PayPal order.
|
||||
|
||||
Args:
|
||||
user_id (str): The ID of the user placing the order.
|
||||
basket (list): The basket items.
|
||||
currency (str): The currency code.
|
||||
|
||||
Returns:
|
||||
dict: The PayPal order details.
|
||||
"""
|
||||
|
||||
# Create order :
|
||||
order = OrderRequest(
|
||||
user_id = user_id,
|
||||
basket=basket,
|
||||
currency=currency,
|
||||
supabase_client=supabase
|
||||
)
|
||||
|
||||
# Process the order and return the details
|
||||
return paypal_client.order(
|
||||
order_request=order,
|
||||
return_url_success=return_url_success,
|
||||
return_url_failure=return_url_failure)
|
||||
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/{user_id}capture")
|
||||
def capture_order(order_id: str, user_id: str):
|
||||
"""
|
||||
Captures payment for an existing PayPal order.
|
||||
|
||||
Args:
|
||||
order_id (str): The PayPal order ID.
|
||||
|
||||
Returns:
|
||||
dict: The PayPal capture response.
|
||||
"""
|
||||
# Capture the payment
|
||||
result = paypal_client.capture(order_id)
|
||||
|
||||
# Grant the user the correct amount of credits:
|
||||
credits = CreditCache.get_credits(user_id, order_id)
|
||||
if credits:
|
||||
supabase.increment_credit_balance(
|
||||
user_id=user_id,
|
||||
amount=credits
|
||||
)
|
||||
logger.info(f'Payment capture succeeded: incrementing balance of user {user_id} by {credits}.')
|
||||
return RedirectResponse(url='https://anydev.info/orders/success')
|
||||
|
||||
logger.error(f'Capture payment failed. Could not find cache key for user {user_id} and order {order_id}')
|
||||
|
||||
return RedirectResponse(url='https://anydev.info/orders/failed')
|
||||
|
||||
|
||||
|
||||
|
||||
# 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"}
|
||||
|
||||
111
backend/src/payments/test.py
Normal file
111
backend/src/payments/test.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#%%
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# username and password
|
||||
load_dotenv(override=True)
|
||||
username = os.environ['PAYPAL_ID_SANDBOX']
|
||||
password = os.environ['PAYPAL_KEY_SANDBOX']
|
||||
|
||||
|
||||
# DOCUMENTATION AT : https://developer.paypal.com/api/rest/requests/
|
||||
|
||||
|
||||
#%%
|
||||
######## STEP 1: Validation ########
|
||||
# url for validation post request
|
||||
validation_url = "https://api-m.sandbox.paypal.com/v1/oauth2/token"
|
||||
validation_url_prod = "https://api-m.paypal.com/v1/oauth2/token"
|
||||
|
||||
# payload for the post request
|
||||
validation_data = {'grant_type': 'client_credentials'}
|
||||
|
||||
# pass the request
|
||||
validation_response = requests.post(
|
||||
url=validation_url,
|
||||
data=validation_data,
|
||||
auth=(username, password)
|
||||
)
|
||||
|
||||
# todo check status code + try except. Status code 201 ?
|
||||
print(f'Reponse status code: {validation_response.status_code}')
|
||||
print(f'Access token: {json.loads(validation_response.text)["access_token"]}')
|
||||
access_token = json.loads(validation_response.text)["access_token"]
|
||||
|
||||
|
||||
#%%
|
||||
######## STEP 2: Create Order ########
|
||||
# url for post request
|
||||
order_url = "https://api-m.sandbox.paypal.com/v2/checkout/orders"
|
||||
order_url_prod = "https://api-m.paypal.com/v2/checkout/orders"
|
||||
|
||||
# payload for the request
|
||||
order_data = {
|
||||
"intent": "CAPTURE",
|
||||
"purchase_units": [
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "AnyWay Credits",
|
||||
"description": "50 pack of credits",
|
||||
"quantity": 1,
|
||||
"unit_amount": {
|
||||
"currency_code": "CHF",
|
||||
"value": "1.50"
|
||||
}
|
||||
|
||||
}
|
||||
],
|
||||
"amount": {
|
||||
"currency_code": "CHF",
|
||||
"value": "1.50",
|
||||
"breakdown": {
|
||||
"item_total": {
|
||||
"currency_code": "CHF",
|
||||
"value": "1.50"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"application_context": {
|
||||
"return_url": "https://anydev.info",
|
||||
"cancel_url": "https://anydev.info"
|
||||
}
|
||||
}
|
||||
|
||||
order_response = requests.post(
|
||||
url=order_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"}, ## need access token here?
|
||||
json=order_data,
|
||||
auth=(username, password)
|
||||
)
|
||||
|
||||
# Send the redirect link to the user
|
||||
# print(order_response.json())
|
||||
for link_obj in order_response.json()['links']:
|
||||
if link_obj['rel'] == 'approve':
|
||||
forward_to_user_link = link_obj['href']
|
||||
print(f'Reponse status code: {order_response.status_code}')
|
||||
print(f'Follow this link to proceed to payment: {forward_to_user_link}')
|
||||
order_id = json.loads(order_response.text)["id"]
|
||||
|
||||
|
||||
#%%
|
||||
######## STEP 3: capture payment
|
||||
# url for post request
|
||||
capture_url = f"https://api-m.sandbox.paypal.com/v2/checkout/orders/{order_id}/capture"
|
||||
# capture_url_prod = f"https://api-m.paypal.com/v2/checkout/orders/{order_id}/capture"
|
||||
|
||||
capture_response = requests.post(
|
||||
url=capture_url,
|
||||
json={},
|
||||
auth=(username, password)
|
||||
)
|
||||
|
||||
# todo check status code + try except
|
||||
print(f'Reponse status code: {capture_response.status_code}')
|
||||
print(capture_response.text)
|
||||
# order_id = json.loads(response.text)["id"]
|
||||
@@ -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}.')
|
||||
32
backend/src/structs/shop.py
Normal file
32
backend/src/structs/shop.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Module to handle classes related to online shop"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BasketItem(BaseModel):
|
||||
"""
|
||||
Represents a single item in the user's basket.
|
||||
|
||||
Attributes:
|
||||
item_id (int): The unique identifier for the item.
|
||||
quantity (int): The number of units of the item.
|
||||
"""
|
||||
item_id: int
|
||||
quantity: int
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
"""
|
||||
Represents an item available in the shop.
|
||||
|
||||
Attributes:
|
||||
item_id (int): The unique identifier for the item.
|
||||
name (str): The name of the item.
|
||||
description (str): The description of the item.
|
||||
unit_price (float): The unit price of the item.
|
||||
"""
|
||||
item_id: int
|
||||
name: str
|
||||
description: str
|
||||
unit_price: float
|
||||
unit_credits: int
|
||||
currency: str
|
||||
0
backend/src/supabase/__init__.py
Normal file
0
backend/src/supabase/__init__.py
Normal file
248
backend/src/supabase/supabase.py
Normal file
248
backend/src/supabase/supabase.py
Normal file
@@ -0,0 +1,248 @@
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from supabase import create_client, Client, ClientOptions
|
||||
|
||||
from ..structs.shop import Item
|
||||
from ..configuration.environment import Environment
|
||||
|
||||
# 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):
|
||||
|
||||
self.SUPABASE_URL = Environment.supabase_url
|
||||
self.SUPABASE_ADMIN_KEY = Environment.supabase_admin_key
|
||||
self.SUPABASE_TEST_USER_ID = Environment.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.")
|
||||
|
||||
|
||||
def get_item(self, item_id: int, currency: str) -> Item | None:
|
||||
"""
|
||||
Fetch full item info (name, description) and price/credits for a given currency.
|
||||
Returns an Item pydantic model.
|
||||
"""
|
||||
# First, validate the currency
|
||||
self.validate_currency(currency=currency)
|
||||
|
||||
try:
|
||||
# Fetch from items table
|
||||
base_item = (
|
||||
self.supabase
|
||||
.table("items")
|
||||
.select("*")
|
||||
.eq("item_id", item_id)
|
||||
.single()
|
||||
.execute()
|
||||
).data
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to fetch item {item_id}: {e}')
|
||||
raise ValueError(f"Failed to fetch item {item_id}: {e}")
|
||||
|
||||
try:
|
||||
# Fetch price for this currency and item_id
|
||||
price = (
|
||||
self.supabase
|
||||
.table("item_prices")
|
||||
.select("*")
|
||||
.eq("item_id", item_id)
|
||||
.eq("currency", currency.upper())
|
||||
.single()
|
||||
.execute()
|
||||
).data
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to fetch price for {item_id} in {currency}: {e}')
|
||||
raise ValueError(f"Failed to fetch price for {item_id} in {currency}: {e}")
|
||||
|
||||
# Return Item model
|
||||
return Item(
|
||||
item_id=base_item["item_id"],
|
||||
name=base_item["name"],
|
||||
description=base_item["description"],
|
||||
unit_price=price["unit_price"],
|
||||
unit_credits=price["unit_credits"],
|
||||
currency=price["currency"]
|
||||
)
|
||||
|
||||
|
||||
def validate_currency(self, currency: str) -> bool:
|
||||
"""
|
||||
Validates that a currency exists in the available_currencies table
|
||||
and is active.
|
||||
|
||||
Args:
|
||||
currency (str): Currency code (e.g. 'EUR', 'USD', 'CHF').
|
||||
|
||||
Returns:
|
||||
dict: The currency row.
|
||||
|
||||
Raises:
|
||||
ValueError: If currency does not exist or is inactive.
|
||||
"""
|
||||
try:
|
||||
result = (
|
||||
self.supabase
|
||||
.table("available_currencies")
|
||||
.select("*")
|
||||
.eq("currency", currency.upper())
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.error(f'Unable to validate currency {currency}, most likely not supported yet: {exc}')
|
||||
raise ValueError(f"Could not validate '{currency}', most likely not supported yet: {exc}")
|
||||
|
||||
if result.data['active'] is not True:
|
||||
raise ValueError(f"Currency '{currency}' is currently not supported.")
|
||||
|
||||
return True
|
||||
53
backend/src/tests/test_payment.py
Normal file
53
backend/src/tests/test_payment.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Collection of tests to ensure correct implementation and track progress of paypal payments."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from ..main import app
|
||||
from ..supabase.supabase import SupabaseClient
|
||||
|
||||
|
||||
# Create a supabase client
|
||||
supabase = SupabaseClient()
|
||||
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""Client used to call the app."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_nearby(client): # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Test n°1 : Verify handling of invalid input.
|
||||
|
||||
Args:
|
||||
client:
|
||||
request:
|
||||
"""
|
||||
response = client.post(
|
||||
url=f"/orders/new/",
|
||||
json={
|
||||
'user_id': supabase.SUPABASE_TEST_USER_ID,
|
||||
'basket': {
|
||||
{
|
||||
'id': '1873672819',
|
||||
'quantity': 1982
|
||||
},
|
||||
{
|
||||
'id': '9876789',
|
||||
'quantity': 1982
|
||||
}
|
||||
},
|
||||
'currency': 'CHF',
|
||||
'return_url_success': 'https://anydev.info',
|
||||
'return_url_failure': 'https://anydev.info'
|
||||
}
|
||||
)
|
||||
suggestions = response.json()
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert isinstance(suggestions, list) # check that the return type is a list
|
||||
assert len(suggestions) > 0
|
||||
@@ -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 :
|
||||
|
||||
@@ -4,10 +4,15 @@ 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 ..main import app
|
||||
|
||||
|
||||
# Create a supabase client
|
||||
supabase = SupabaseClient()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""Client used to call the app."""
|
||||
@@ -51,21 +56,34 @@ def test_trip(client, request, sightseeing, shopping, nature, max_time_minute, s
|
||||
"preferences": prefs.model_dump(),
|
||||
"start": start_coords,
|
||||
"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 +93,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}"
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"""Helper methods for testing."""
|
||||
import time
|
||||
import logging
|
||||
from functools import wraps
|
||||
from fastapi import HTTPException
|
||||
|
||||
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] :
|
||||
@@ -94,34 +91,3 @@ def log_trip_details(request, landmarks: list[Landmark], duration: int, target_d
|
||||
request.node.trip_details = trip_string
|
||||
request.node.trip_duration = str(duration) # result['total_time']
|
||||
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)
|
||||
|
||||
self.logger.debug(f'Found {len(toilets_list)} toilets around {self.location}')
|
||||
|
||||
return toilets_list
|
||||
|
||||
|
||||
|
||||
@@ -38,5 +38,6 @@ def get_toilets(
|
||||
toilets_list = toilets_manager.generate_toilet_list()
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="No toilets found") from exc
|
||||
|
||||
|
||||
return toilets_list
|
||||
|
||||
0
backend/src/trips/__init__.py
Normal file
0
backend/src/trips/__init__.py
Normal file
104
backend/src/trips/trips_router.py
Normal file
104
backend/src/trips/trips_router.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import logging
|
||||
from fastapi import HTTPException, APIRouter
|
||||
|
||||
from ..structs.landmark import Landmark
|
||||
from ..structs.linked_landmarks import LinkedLandmarks
|
||||
from ..structs.trip import Trip
|
||||
from ..landmarks.landmarks_manager import LandmarkManager
|
||||
from ..optimization.optimizer import Optimizer
|
||||
from ..optimization.refiner import Refiner
|
||||
from ..cache import client as cache_client
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
manager = LandmarkManager()
|
||||
optimizer = Optimizer()
|
||||
refiner = Refiner(optimizer=optimizer)
|
||||
|
||||
|
||||
# Initialize the API router
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
#### For already existing trips/landmarks
|
||||
@router.get("/trip/{trip_uuid}")
|
||||
def get_trip(trip_uuid: str) -> Trip:
|
||||
"""
|
||||
Look-up the cache for a trip that has been previously generated using its identifier.
|
||||
|
||||
Args:
|
||||
trip_uuid (str) : unique identifier for a trip.
|
||||
|
||||
Returns:
|
||||
(Trip) : the corresponding trip.
|
||||
"""
|
||||
try:
|
||||
trip = cache_client.get(f"trip_{trip_uuid}")
|
||||
return trip
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to fetch trip with UUID {trip_uuid}: {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail="Trip not found") from exc
|
||||
|
||||
|
||||
# Fetch a landmark from memcached by its uuid
|
||||
@router.get("/landmark/{landmark_uuid}")
|
||||
def get_landmark(landmark_uuid: str) -> Landmark:
|
||||
"""
|
||||
Returns a Landmark from its unique identifier.
|
||||
|
||||
Args:
|
||||
landmark_uuid (str) : unique identifier for a Landmark.
|
||||
|
||||
Returns:
|
||||
(Landmark) : the corresponding Landmark.
|
||||
"""
|
||||
try:
|
||||
landmark = cache_client.get(f"landmark_{landmark_uuid}")
|
||||
return landmark
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to fetch landmark with UUID {landmark_uuid}: {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail="Landmark not found") from exc
|
||||
|
||||
|
||||
# Update the times between landmarks when removing an item from the list
|
||||
@router.post("/trip/recompute-time/{trip_uuid}/{removed_landmark_uuid}")
|
||||
def update_trip_time(trip_uuid: str, removed_landmark_uuid: str) -> Trip:
|
||||
"""
|
||||
Updates the reaching times of a given trip when removing a landmark.
|
||||
|
||||
Args:
|
||||
landmark_uuid (str) : unique identifier for a Landmark.
|
||||
|
||||
Returns:
|
||||
(Landmark) : the corresponding Landmark.
|
||||
"""
|
||||
# First, fetch the trip in the cache.
|
||||
try:
|
||||
trip = cache_client.get(f'trip_{trip_uuid}')
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to update trip with UUID {trip_uuid} (trip not found): {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail='Trip not found') from exc
|
||||
|
||||
landmarks = []
|
||||
next_uuid = trip.first_landmark_uuid
|
||||
|
||||
# Extract landmarks
|
||||
try :
|
||||
while next_uuid is not None:
|
||||
landmark = cache_client.get(f'landmark_{next_uuid}')
|
||||
# Filter out the removed landmark.
|
||||
if next_uuid != removed_landmark_uuid :
|
||||
landmarks.append(landmark)
|
||||
next_uuid = landmark.next_uuid # Prepare for the next iteration
|
||||
except KeyError as exc:
|
||||
logger.error(f"Failed to update trip with UUID {trip_uuid} : {str(exc)}")
|
||||
raise HTTPException(status_code=404, detail=f'landmark {next_uuid} not found') from exc
|
||||
|
||||
# Re-link every thing and compute times again
|
||||
linked_tour = LinkedLandmarks(landmarks)
|
||||
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
||||
|
||||
return trip
|
||||
|
||||
1889
backend/uv.lock
generated
Normal file
1889
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user