Compare commits
12 Commits
96b0718081
...
backend/mi
Author | SHA1 | Date | |
---|---|---|---|
bf8b64aacf | |||
44cd983fb8 | |||
89c95063dd | |||
e41d3f5e3a | |||
f5cedbc5a0 | |||
88dc5dd323 | |||
c6bb0cddb7 | |||
9ccf68d983 | |||
132aa5a19b | |||
19b0c37a97 | |||
ecdef605a7 | |||
e2a918112b |
@@ -15,18 +15,18 @@ jobs:
|
|||||||
|
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install pylint
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y python3 python3-pip
|
apt-get update && apt-get install -y python3 python3-pip
|
||||||
pip install pipenv
|
pip install pylint
|
||||||
|
|
||||||
- name: Install packages
|
# - name: Install packages
|
||||||
run: |
|
# run: |
|
||||||
ls -la
|
# ls -la
|
||||||
# only install dev-packages
|
# # only install dev-packages
|
||||||
pipenv install --categories=dev-packages
|
# uv sync
|
||||||
working-directory: backend
|
# working-directory: backend
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: pipenv run pylint src --fail-under=9
|
run: pylint src --fail-under=9
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
@@ -18,17 +18,17 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y python3 python3-pip
|
apt-get update && apt-get install -y python3 python3-pip
|
||||||
pip install pipenv
|
pip install uv
|
||||||
|
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: |
|
run: |
|
||||||
ls -la
|
ls -la
|
||||||
# install all packages, including dev-packages
|
# install all packages, including dev-packages
|
||||||
pipenv install --dev
|
uv sync
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: pipenv run pytest src --html=report.html --self-contained-html --log-cli-level=DEBUG
|
run: uv run pytest src --html=report.html --self-contained-html --log-cli-level=DEBUG
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
- name: Upload HTML report
|
- name: Upload HTML report
|
||||||
|
4
backend/.gitignore
vendored
4
backend/.gitignore
vendored
@@ -12,8 +12,8 @@ __pycache__/
|
|||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
# Pytest reports
|
# Pytest html reports
|
||||||
report.html
|
*.html
|
||||||
|
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
|
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12.9
|
@@ -1,11 +1,29 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
# The installer requires curl (and certificates) to download the release archive
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates
|
||||||
|
|
||||||
|
# Download the latest installer
|
||||||
|
ADD https://astral.sh/uv/install.sh /uv-installer.sh
|
||||||
|
|
||||||
|
# Run the installer then remove it
|
||||||
|
RUN sh /uv-installer.sh && rm /uv-installer.sh
|
||||||
|
|
||||||
|
# Ensure the installed binary is on the `PATH`
|
||||||
|
ENV PATH="/root/.local/bin/:$PATH"
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY Pipfile Pipfile.lock .
|
|
||||||
|
|
||||||
RUN pip install pipenv
|
# Copy uv files
|
||||||
RUN pipenv install --deploy --system
|
COPY pyproject.toml pyproject.toml
|
||||||
|
COPY uv.lock uv.lock
|
||||||
|
COPY .python-version .python-version
|
||||||
|
|
||||||
|
# Sync the venv
|
||||||
|
RUN uv sync --frozen --no-cache --no-dev
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
COPY src src
|
COPY src src
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
@@ -17,4 +35,4 @@ ENV MEMCACHED_HOST_PATH=none
|
|||||||
ENV LOKI_URL=none
|
ENV LOKI_URL=none
|
||||||
|
|
||||||
# explicitly use a string instead of an argument list to force a shell and variable expansion
|
# explicitly use a string instead of an argument list to force a shell and variable expansion
|
||||||
CMD fastapi run src/main.py --port 8000 --workers $NUM_WORKERS
|
CMD uv run fastapi run src/main.py --port 8000 --workers $NUM_WORKERS
|
||||||
|
@@ -1,27 +0,0 @@
|
|||||||
[[source]]
|
|
||||||
url = "https://pypi.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
name = "pypi"
|
|
||||||
|
|
||||||
[dev-packages]
|
|
||||||
pylint = "*"
|
|
||||||
pytest = "*"
|
|
||||||
tomli = "*"
|
|
||||||
httpx = "*"
|
|
||||||
exceptiongroup = "*"
|
|
||||||
pytest-html = "*"
|
|
||||||
typing-extensions = "*"
|
|
||||||
dill = "*"
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
numpy = "*"
|
|
||||||
fastapi = "*"
|
|
||||||
pydantic = "*"
|
|
||||||
shapely = "*"
|
|
||||||
pymemcache = "*"
|
|
||||||
fastapi-cli = "*"
|
|
||||||
scikit-learn = "*"
|
|
||||||
loki-logger-handler = "*"
|
|
||||||
pulp = "*"
|
|
||||||
scipy = "*"
|
|
||||||
requests = "*"
|
|
1246
backend/Pipfile.lock
generated
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
|
### Directory Structure
|
||||||
- The code for the Python application is located in the `src` directory.
|
- 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.
|
- Since the application is designed to be deployed in a container, the `Dockerfile` is provided to build the image.
|
||||||
|
|
||||||
### Setting Up the Development Environment
|
### 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
|
```bash
|
||||||
sudo apt install pipenv
|
cd backend
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Create and activate a virtual environment:
|
1. Install `uv` by running:
|
||||||
```bash
|
```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
|
```bash
|
||||||
pipenv install
|
uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
4. The virtual environment will be created under:
|
4. The virtual environment will be created under:
|
||||||
```bash
|
```bash
|
||||||
~/.local/share/virtualenvs/...
|
backend/.venv/...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deployment
|
### 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
|
|
||||||
}
|
|
||||||
]
|
|
6
backend/main.py
Normal file
6
backend/main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from backend!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
57
backend/pyproject.toml
Normal file
57
backend/pyproject.toml
Normal 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",
|
||||||
|
]
|
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
|
### 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.
|
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
|
### 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.
|
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.
|
||||||
|
|
||||||
|
@@ -10,7 +10,8 @@ from .structs.trip import Trip
|
|||||||
from .landmarks.landmarks_manager import LandmarkManager
|
from .landmarks.landmarks_manager import LandmarkManager
|
||||||
from .toilets.toilets_router import router as toilets_router
|
from .toilets.toilets_router import router as toilets_router
|
||||||
from .optimization.optimization_router import router as optimization_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.optimizer import Optimizer
|
||||||
from .optimization.refiner import Refiner
|
from .optimization.refiner import Refiner
|
||||||
from .cache import client as cache_client
|
from .cache import client as cache_client
|
||||||
@@ -42,16 +43,18 @@ app = FastAPI(lifespan=lifespan)
|
|||||||
app.include_router(landmarks_router)
|
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"
|
# Call with "/optimize/trip"
|
||||||
app.include_router(optimization_router)
|
app.include_router(optimization_router)
|
||||||
|
|
||||||
|
|
||||||
# Fetches toilets near given coordinates.
|
# 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)
|
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
|
#### For already existing trips/landmarks
|
||||||
@app.get("/trip/{trip_uuid}")
|
@app.get("/trip/{trip_uuid}")
|
||||||
|
@@ -3,10 +3,11 @@
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import HTTPException, APIRouter, BackgroundTasks
|
from fastapi import HTTPException, APIRouter, BackgroundTasks, Body
|
||||||
|
|
||||||
from .optimizer import Optimizer
|
from .optimizer import Optimizer
|
||||||
from .refiner import Refiner
|
from .refiner import Refiner
|
||||||
|
from ..supabase.supabase import SupabaseClient
|
||||||
from ..structs.landmark import Landmark
|
from ..structs.landmark import Landmark
|
||||||
from ..structs.preferences import Preferences
|
from ..structs.preferences import Preferences
|
||||||
from ..structs.linked_landmarks import LinkedLandmarks
|
from ..structs.linked_landmarks import LinkedLandmarks
|
||||||
@@ -20,6 +21,7 @@ from ..constants import OPTIMIZER_PARAMETERS_PATH
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
optimizer = Optimizer()
|
optimizer = Optimizer()
|
||||||
refiner = Refiner(optimizer=optimizer)
|
refiner = Refiner(optimizer=optimizer)
|
||||||
|
supabase = SupabaseClient()
|
||||||
|
|
||||||
|
|
||||||
# Initialize the API router
|
# Initialize the API router
|
||||||
@@ -28,10 +30,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.post("/optimize/trip")
|
@router.post("/optimize/trip")
|
||||||
def optimize_trip(
|
def optimize_trip(
|
||||||
preferences: Preferences,
|
user_id: str = Body(...),
|
||||||
landmarks: list[Landmark],
|
preferences: Preferences = Body(...),
|
||||||
start: tuple[float, float],
|
landmarks: list[Landmark] = Body(...),
|
||||||
end: tuple[float, float] | None = None,
|
start: tuple[float, float] = Body(...),
|
||||||
|
end: tuple[float, float] | None = Body(None),
|
||||||
background_tasks: BackgroundTasks = None
|
background_tasks: BackgroundTasks = None
|
||||||
) -> Trip:
|
) -> Trip:
|
||||||
"""
|
"""
|
||||||
@@ -45,6 +48,22 @@ def optimize_trip(
|
|||||||
Returns:
|
Returns:
|
||||||
(uuid) : The uuid of the first landmark in the optimized route
|
(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:
|
if preferences is None:
|
||||||
raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
|
raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
|
||||||
if len(landmarks) == 0 :
|
if len(landmarks) == 0 :
|
||||||
@@ -114,9 +133,11 @@ def optimize_trip(
|
|||||||
|
|
||||||
# Second stage optimization
|
# Second stage optimization
|
||||||
try :
|
try :
|
||||||
refined_tour = refiner.refine_optimization(landmarks, base_tour,
|
refined_tour = refiner.refine_optimization(
|
||||||
preferences.max_time_minute,
|
landmarks, base_tour,
|
||||||
preferences.detour_tolerance_minute)
|
preferences.max_time_minute,
|
||||||
|
preferences.detour_tolerance_minute
|
||||||
|
)
|
||||||
except Exception as exc :
|
except Exception as exc :
|
||||||
logger.warning(f"Refiner failed. Proceeding with base trip {str(exc)}")
|
logger.warning(f"Refiner failed. Proceeding with base trip {str(exc)}")
|
||||||
refined_tour = base_tour
|
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(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))
|
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)
|
background_tasks.add_task(fill_cache)
|
||||||
|
|
||||||
|
# Finally, decrement the user balance
|
||||||
|
supabase.decrement_credit_balance(user_id=user_id)
|
||||||
|
|
||||||
return trip
|
return trip
|
||||||
|
|
||||||
|
@@ -6,7 +6,6 @@ from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull
|
|||||||
|
|
||||||
from ..structs.landmark import Landmark
|
from ..structs.landmark import Landmark
|
||||||
from ..utils.get_time_distance import get_time
|
from ..utils.get_time_distance import get_time
|
||||||
from ..utils.take_most_important import take_most_important
|
|
||||||
from .optimizer import Optimizer
|
from .optimizer import Optimizer
|
||||||
from ..constants import OPTIMIZER_PARAMETERS_PATH
|
from ..constants import OPTIMIZER_PARAMETERS_PATH
|
||||||
|
|
||||||
@@ -238,7 +237,7 @@ class Refiner :
|
|||||||
if self.is_in_area(area, landmark.location) and landmark.name not in visited_names:
|
if self.is_in_area(area, landmark.location) and landmark.name not in visited_names:
|
||||||
second_order_landmarks.append(landmark)
|
second_order_landmarks.append(landmark)
|
||||||
|
|
||||||
return take_most_important(second_order_landmarks, int(self.max_landmarks_refiner*0.75))
|
return sorted(second_order_landmarks, key=lambda x: x.attractiveness, reverse=True)[:int(self.max_landmarks_refiner*0.75)]
|
||||||
|
|
||||||
|
|
||||||
# Try fix the shortest path using shapely
|
# Try fix the shortest path using shapely
|
||||||
|
70
backend/src/payments/payment_handler.py
Normal file
70
backend/src/payments/payment_handler.py
Normal 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")
|
79
backend/src/payments/payment_router.py
Normal file
79
backend/src/payments/payment_router.py
Normal 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"}
|
||||||
|
|
@@ -39,3 +39,16 @@ class Preferences(BaseModel) :
|
|||||||
|
|
||||||
max_time_minute: Optional[int] = 3*60
|
max_time_minute: Optional[int] = 3*60
|
||||||
detour_tolerance_minute: Optional[int] = 0
|
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}.')
|
169
backend/src/supabase/supabase.py
Normal file
169
backend/src/supabase/supabase.py
Normal 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.")
|
@@ -36,7 +36,6 @@ def test_invalid_input(client, location, radius, status_code): # pylint: disa
|
|||||||
"radius": radius
|
"radius": radius
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
print(response.json())
|
|
||||||
|
|
||||||
# checks :
|
# checks :
|
||||||
assert response.status_code == status_code
|
assert response.status_code == status_code
|
||||||
@@ -64,7 +63,6 @@ def test_no_toilets(client, location, status_code): # pylint: disable=redefin
|
|||||||
"location": location
|
"location": location
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
print(response.json())
|
|
||||||
toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()]
|
toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()]
|
||||||
|
|
||||||
# checks :
|
# checks :
|
||||||
@@ -95,8 +93,6 @@ def test_toilets(client, location, status_code): # pylint: disable=redefined-
|
|||||||
"radius" : 600
|
"radius" : 600
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
print(response.json())
|
|
||||||
toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()]
|
toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()]
|
||||||
|
|
||||||
# checks :
|
# checks :
|
||||||
|
@@ -1,13 +1,21 @@
|
|||||||
"""Collection of tests to ensure correct implementation and track progress."""
|
"""Collection of tests to ensure correct implementation and track progress."""
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
import yaml
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from .test_utils import load_trip_landmarks, log_trip_details
|
from .test_utils import load_trip_landmarks, log_trip_details
|
||||||
|
from ..supabase.supabase import SupabaseClient
|
||||||
from ..structs.preferences import Preferences, Preference
|
from ..structs.preferences import Preferences, Preference
|
||||||
|
from ..constants import PARAMETERS_DIR
|
||||||
from ..main import app
|
from ..main import app
|
||||||
|
|
||||||
|
|
||||||
|
# Create a supabase client
|
||||||
|
supabase = SupabaseClient()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def client():
|
def client():
|
||||||
"""Client used to call the app."""
|
"""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,
|
"end": end_coords,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
landmarks = response.json()
|
landmarks = response.json()
|
||||||
|
|
||||||
# Step 2: Feed the landmarks to the optimizer to compute the trip
|
# Step 2: Feed the landmarks to the optimizer to compute the trip
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/optimize/trip",
|
"/optimize/trip",
|
||||||
json={
|
json={
|
||||||
|
"user_id": supabase.SUPABASE_TEST_USER_ID,
|
||||||
"preferences": prefs.model_dump(),
|
"preferences": prefs.model_dump(),
|
||||||
"landmarks": landmarks,
|
"landmarks": landmarks,
|
||||||
"start": start,
|
"start": start,
|
||||||
"end": end,
|
"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()
|
result = response.json()
|
||||||
|
# print(result)
|
||||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||||
|
|
||||||
# Get computation time
|
# 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)
|
log_trip_details(request, landmarks, result['total_time'], prefs.max_time_minute)
|
||||||
|
|
||||||
# checks :
|
# 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 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*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}"
|
assert prefs.max_time_minute*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {prefs.max_time_minute}"
|
||||||
|
@@ -70,6 +70,8 @@ class ToiletsManager:
|
|||||||
|
|
||||||
toilets_list = self.to_toilets(result)
|
toilets_list = self.to_toilets(result)
|
||||||
|
|
||||||
|
self.logger.debug(f'Found {len(toilets_list)} toilets around {self.location}')
|
||||||
|
|
||||||
return toilets_list
|
return toilets_list
|
||||||
|
|
||||||
|
|
||||||
|
@@ -39,4 +39,5 @@ def get_toilets(
|
|||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail="No toilets found") from exc
|
raise HTTPException(status_code=404, detail="No toilets found") from exc
|
||||||
|
|
||||||
|
|
||||||
return toilets_list
|
return toilets_list
|
||||||
|
1876
backend/uv.lock
generated
Normal file
1876
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user