Compare commits

...

14 Commits
main ... master

Author SHA1 Message Date
Kiyomichi Kosaka
046e84e996
Merge pull request #4 from ok2/codex/hide-idea-input-on-decision
Add decided badge to idea cards
2025-06-08 21:56:34 +02:00
Kiyomichi Kosaka
3281811b50 Show decided badge on finalized ideas 2025-06-08 21:56:17 +02:00
Kiyomichi Kosaka
5a1f4ca7fa
Merge pull request #3 from ok2/codex/add-feature-to-prioritize--decided--ideas
Implement finalized idea support
2025-06-08 21:37:44 +02:00
Kiyomichi Kosaka
7caa899137 Fix duplication and update front end 2025-06-08 21:36:41 +02:00
Kiyomichi Kosaka
70beda9eeb
Merge pull request #1 from ok2/codex/create-requirements.txt-for-server.py
Add requirements.txt
2025-06-08 16:55:57 +02:00
Kiyomichi Kosaka
bede2873ea
Merge pull request #2 from ok2/codex/sort-ideas-by-number-of-votes-desc
Sort ideas by vote count
2025-06-08 16:55:44 +02:00
Kiyomichi Kosaka
0d4a4d8ef2 Sort ideas by vote count 2025-06-08 16:29:50 +02:00
Kiyomichi Kosaka
261bf8c5dc Add requirements file 2025-06-08 16:29:37 +02:00
Oleksandr Kozachuk
f077bf1f0f Complete the repo. 2025-06-08 13:49:38 +02:00
Oleksandr Kozachuk
94ea328614 Merge with gitea repo. 2025-06-08 13:40:07 +02:00
Oleksandr Kozachuk
fdf214c7d4 Fixes and service.sh. 2025-06-08 13:23:54 +02:00
Oleksandr Kozachuk
c66772fd67 Fix several bugs and race conditions. 2025-06-04 10:46:51 +02:00
Oleksandr Kozachuk
eb9b29d301 Handle links properly. 2025-06-04 10:28:00 +02:00
Oleksandr Kozachuk
0331bdb9a9 First version. 2025-06-04 00:00:44 +02:00
7 changed files with 1690 additions and 144 deletions

169
.gitignore vendored
View File

@ -1,170 +1,55 @@
# ---> Python # Byte-compiled files
# Byte-compiled / optimized / DLL files
__pycache__/ **pycache**/
*.py[cod] *.py[cod]
*$py.class *$py.class
# C extensions # Virtual environments
*.so
.env/
.venv/
env/
venv/
# Distribution
# Distribution / packaging
.Python
build/ build/
develop-eggs/
dist/ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg
*.egg *.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs # Installer logs
pip-log.txt pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt
# Unit test / coverage reports # Python coverage
htmlcov/ htmlcov/
.tox/ .tox/
.nox/
.coverage .coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/ .pytest_cache/
cover/ .nox/
# Translations # Logs
*.mo
*.pot
# Django stuff:
*.log *.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff: # IDEs
instance/
.webassets-cache
# Scrapy stuff: .vscode/
.scrapy .idea/
# Sphinx documentation # macOS
docs/_build/
# PyBuilder .DS_Store
.pybuilder/
target/
# Jupyter Notebook # Linux
.ipynb_checkpoints
# IPython *~
profile_default/
ipython_config.py
# pyenv # Data files and locks
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
data/
*.json
*.lock

343
README.md
View File

@ -1,3 +1,342 @@
# TourPlanner # Tour Voting App
Tour Planner is a lightweight FastAPI-based service that lets you create “tours,” propose ideas for each tour, and vote on those ideas. It stores each tour as a JSON file in a configurable data directory (with filelevel locking for safe concurrent access), and ships with a minimal static HTML/JavaScript frontend along with helper scripts for local development or deployment under Supervisor. Tour Voting App is a full-stack web application for planning tours, proposing ideas, and voting on those ideas. It consists of:
* **Backend API** built with FastAPI
* **Data persistence** using per-tour JSON files in a configurable directory, with concurrency safety via file locks
* **Frontend**: Single-page HTML/JavaScript application that consumes the API
* **Helper scripts** for local development or Supervisor-based deployment
## Table of Contents
1. Features
2. Tech Stack
3. Prerequisites
4. Installation
5. Configuration
6. Development
7. Running in Production
8. Frontend Usage
9. API Reference
* Models
* Endpoints
10. Directory Structure
11. Contributing
12. License
## Features
* Create and manage tours with metadata (name, start/end dates, description)
* Add multiple ideas per tour, each with its own name, description, and optional time window
* Vote on ideas by recording unique voter names
* No external database: JSON filebased persistence
* Concurrency-safe via file locking
* Minimal static HTML/JavaScript frontend for user interaction
* Helper scripts for local development or Supervisor deployment
## Tech Stack
* **Backend**: Python 3.10+, FastAPI, Uvicorn, Pydantic, filelock
* **Frontend**: HTML, CSS, JavaScript (Fetch API)
* **Process Control (optional)**: Supervisor
* **Data storage**: JSON files, one per tour
## Prerequisites
* Python 3.10 or higher
* pip (Python package installer)
* (Optional) Supervisor for process management
## Installation
1. **Clone the repository**
git clone [https://github.com/your-organization/tour-voting-app.git](https://github.com/your-organization/tour-voting-app.git)
cd tour-voting-app
2. **Create virtual environment and install dependencies**
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
If `requirements.txt` is not provided, install directly:
pip install fastapi uvicorn pydantic filelock
## Configuration
Configure via environment variables or command-line flags:
* **DATA\_DIR**: directory to store tour JSON files (default: `./data`)
* **HOST**: address to bind the API (default: `0.0.0.0`)
* **PORT**: port for the API (default: `8000`)
Example using environment variables:
```
export DATA_DIR=./data
export HOST=0.0.0.0
export PORT=8000
```
Or via flags:
```
python server.py --data-dir ./data --host 0.0.0.0 --port 8000
```
## Development
1. Activate your virtual environment.
2. Start the server in reload mode:
```
uvicorn server:app --reload --host 0.0.0.0 --port 8000
```
3. Open `index.html` in your browser (or serve it via a local HTTP server to avoid CORS issues).
## Running in Production
Use the provided `service.sh` script and Supervisor config:
**service.sh**
```bash
#!/usr/bin/env bash
cd /path/to/tour-voting-app || exit 1
exec uvicorn server:app \
--host 0.0.0.0 \
--port 3002 \
--loop uvloop \
--workers 2 \
--access-log \
--log-level info
```
**tour.ini** (Supervisor)
```ini
[program:tour_voting_app]
command=/path/to/service.sh
directory=/path/to/tour-voting-app
autostart=true
autorestart=true
stderr_logfile=/var/log/tour_voting_app.err.log
stdout_logfile=/var/log/tour_voting_app.out.log
```
## Frontend Usage
The frontend is a single `index.html` file that interacts with the API. By default, `API_URL` is set to `http://localhost:8000/tour/v1`. To point it at a remote server, edit the `API_URL` constant near the top of `index.html`.
## API Reference
Base path: `/tour/v1`
### Models
**Tour**
```json
{
"id": "string",
"name": "string",
"description": "string",
"startDate": "ISO-8601 timestamp",
"endDate": "ISO-8601 timestamp",
"createdAt": "ISO-8601 timestamp",
"ideas": [ Idea ]
}
```
**Idea**
```json
{
"id": "string",
"name": "string",
"description": "string",
"startTime": "ISO-8601 timestamp | null",
"endTime": "ISO-8601 timestamp | null",
"voters": [ "string" ]
}
```
### Endpoints
#### 1. List Tours
* **Method**: GET
* **URL**: `/tours`
* **Response**: `200 OK`
```json
[ { Tour }, ... ]
```
#### 2. Create a Tour
* **Method**: POST
* **URL**: `/tours`
* **Request Body**:
```json
{
"name": "Europe Explorer",
"description": "A tour across Europe",
"startDate": "2025-07-01T00:00:00Z",
"endDate": "2025-07-15T00:00:00Z"
}
```
* **Response**: `201 Created`
```json
{ Tour }
```
#### 3. Get Tour Details
* **Method**: GET
* **URL**: `/tours/{tour_id}`
* **Path Parameters**:
* `tour_id` (string): ID of the tour
* **Response**: `200 OK`
```json
{ Tour }
```
* **Error**: `404 Not Found` if tour does not exist.
#### 4. Add an Idea to a Tour
* **Method**: POST
* **URL**: `/tours/{tour_id}/ideas`
* **Path Parameters**:
* `tour_id` (string): ID of the tour
* **Request Body**:
```json
{
"name": "Visit the Louvre",
"description": "Guided museum tour",
"startTime": "2025-07-03T10:00:00Z",
"endTime": "2025-07-03T14:00:00Z"
}
```
* **Response**: `201 Created`
```json
{ Idea }
```
* **Error**: `404 Not Found` if tour does not exist.
#### 5. Vote for an Idea
* **Method**: POST
* **URL**: `/tours/{tour_id}/ideas/{idea_id}/vote`
* **Path Parameters**:
* `tour_id` (string): ID of the tour
* `idea_id` (string): ID of the idea
* **Request Body**:
```json
{ "voterName": "alice@example.com" }
```
* **Response**: `200 OK`
```json
{ Idea } // Updated idea with new voter
```
* **Errors**:
* `404 Not Found` if tour or idea does not exist.
* `400 Bad Request` if voter name is missing or already voted.
## Directory Structure
```
tour-voting-app/
├── server.py # FastAPI application
├── index.html # Static frontend
├── service.sh # Startup helper script
├── tour.ini # Supervisor config
├── data/ # JSON files for tours (runtime)
├── requirements.txt # Python dependencies (optional)
└── README.md # Project documentation
```
## Contributing
1. Fork the repository.
2. Create a feature branch:
git checkout -b feature/my-feature
3. Commit your changes:
git commit -m "Add my feature"
4. Push to your branch:
git push origin feature/my-feature
5. Open a pull request.
## License
This project is licensed under the WTFPL License.
---
.gitignore
# Byte-compiled files
**pycache**/
\*.py\[cod]
\*\$py.class
# Virtual environments
.env/
.venv/
env/
venv/
# Distribution
build/
dist/
\*.egg-info/
\*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Python coverage
htmlcov/
.tox/
.coverage
.pytest\_cache/
.nox/
# Logs
\*.log
# IDEs
.vscode/
.idea/
# macOS
.DS\_Store
# Linux
\*\~
# Data files and locks
data/
\*.json
\*.lock

1080
index.html Normal file

File diff suppressed because it is too large Load Diff

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
fastapi
uvicorn
pydantic
filelock

231
server.py Normal file
View File

@ -0,0 +1,231 @@
import os
import json
import uuid
from typing import Optional
from datetime import datetime
from fastapi import FastAPI, HTTPException, Path
from pydantic import BaseModel, Field
from filelock import FileLock
import argparse
import uvicorn
app = FastAPI(title="Tour Voting API")
# ─── Pydantic MODELS ──────────────────────────────────────────────────────────
class Idea(BaseModel):
id: str
name: str
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
description: str
voters: list[str] = Field(default_factory=list)
decided: bool = False
class Tour(BaseModel):
id: str
name: str
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
description: str
ideas: list[Idea] = Field(default_factory=list)
createdAt: datetime
class TourCreate(BaseModel):
name: str
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
description: str
class IdeaCreate(BaseModel):
name: str
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
description: str
class Vote(BaseModel):
voterName: str
# ─── GLOBAL CONFIG ────────────────────────────────────────────────────────────
DATA_DIR = "" # will be overridden via command line
def get_tour_filepath(tour_id: str) -> str:
fname = f"{tour_id}.json"
full = os.path.abspath(os.path.join(DATA_DIR, fname))
if not full.startswith(os.path.abspath(DATA_DIR) + os.sep):
raise HTTPException(status_code=400, detail="Invalid tour ID")
return full
# ─── ENDPOINT: CREATE TOUR ─────────────────────────────────────────────────────
@app.post("/tour/v1/tours", response_model=Tour)
def create_tour(tour: TourCreate):
"""
Create a new tour. Generates its own UUID and timestamp, then writes to {tour_id}.json.
"""
tour_id = str(uuid.uuid4())
created_at = datetime.utcnow()
new_tour = Tour(
id=tour_id,
name=tour.name,
start_date=tour.start_date,
end_date=tour.end_date,
description=tour.description,
ideas=[],
createdAt=created_at,
)
filepath = get_tour_filepath(tour_id)
with open(filepath, "w") as f:
# Using default=str so that datetime objects become ISO strings
json.dump(new_tour.model_dump(), f, default=str)
return new_tour
# ─── ENDPOINT: GET TOUR ────────────────────────────────────────────────────────
@app.get("/tour/v1/tours/{tour_id}", response_model=Tour)
def get_tour(tour_id: str = Path(...)):
"""
Retrieve an existing tour by ID. Loads {tour_id}.json and parses back into a Tour.
"""
filepath = get_tour_filepath(tour_id)
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="Tour not found")
with open(filepath) as f:
data = json.load(f)
if data.get("start_date"):
data["start_date"] = datetime.fromisoformat(data["start_date"])
if data.get("end_date"):
data["end_date"] = datetime.fromisoformat(data["end_date"])
data["createdAt"] = datetime.fromisoformat(data["createdAt"])
# Convert nested idea timestamps as well
for idea in data["ideas"]:
if idea.get("start_time"):
idea["start_time"] = datetime.fromisoformat(idea["start_time"])
if idea.get("end_time"):
idea["end_time"] = datetime.fromisoformat(idea["end_time"])
return Tour(**data)
# ─── ENDPOINT: ADD IDEA ─────────────────────────────────────────────────────────
@app.post("/tour/v1/tours/{tour_id}/ideas", response_model=Idea)
def add_idea(tour_id: str, idea: IdeaCreate):
"""
Add a new idea to the given tour. Generates a new UUID for the idea and appends it.
"""
filepath = get_tour_filepath(tour_id)
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="Tour not found")
lock = FileLock(filepath + ".lock")
with lock:
with open(filepath) as f:
data = json.load(f)
if any(i.get("decided") for i in data["ideas"]):
raise HTTPException(status_code=403, detail="Idea already decided")
idea_id = str(uuid.uuid4())
new_idea = Idea(
id=idea_id,
name=idea.name,
description=idea.description,
voters=[],
start_time=idea.start_time,
end_time=idea.end_time,
decided=False,
)
data["ideas"].append(new_idea.model_dump())
with open(filepath, "w") as f:
json.dump(data, f, default=str)
return new_idea
# ─── ENDPOINT: VOTE FOR IDEA ───────────────────────────────────────────────────
@app.post("/tour/v1/tours/{tour_id}/ideas/{idea_id}/vote", response_model=Idea)
def vote_idea(tour_id: str, idea_id: str, vote: Vote):
"""
Cast a vote on a specific idea. Adds voterName if not already present.
"""
filepath = get_tour_filepath(tour_id)
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="Tour not found")
lock = FileLock(filepath + ".lock")
with lock:
with open(filepath) as f:
data = json.load(f)
if any(i.get("decided") for i in data["ideas"]):
raise HTTPException(status_code=403, detail="Idea already decided")
for idea in data["ideas"]:
if idea["id"] == idea_id:
if vote.voterName not in idea["voters"]:
idea["voters"].append(vote.voterName)
# Persist the change
with open(filepath, "w") as f:
json.dump(data, f, default=str)
return Idea(**idea)
raise HTTPException(status_code=404, detail="Idea not found")
# ─── ENDPOINT: DECIDE IDEA ────────────────────────────────────────────────────
@app.post("/tour/v1/tours/{tour_id}/ideas/{idea_id}/decide", response_model=Idea)
def decide_idea(tour_id: str, idea_id: str):
"""Mark an idea as decided. Only one idea can be decided per tour."""
filepath = get_tour_filepath(tour_id)
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="Tour not found")
lock = FileLock(filepath + ".lock")
with lock:
with open(filepath) as f:
data = json.load(f)
if any(i.get("decided") for i in data["ideas"]):
raise HTTPException(status_code=403, detail="Idea already decided")
for idea in data["ideas"]:
if idea["id"] == idea_id:
idea["decided"] = True
with open(filepath, "w") as f:
json.dump(data, f, default=str)
return Idea(**idea)
raise HTTPException(status_code=404, detail="Idea not found")
# ─── MAIN: RUN UVIDORN WITH COMMAND-LINE DATA DIR ──────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run Tour Voting API server")
parser.add_argument(
"--data-dir",
required=True,
help="Directory in which to store tour JSON files (one file per tour_id)",
)
parser.add_argument(
"--host",
default="0.0.0.0",
help="Host to run the server on",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port to run the server on",
)
args = parser.parse_args()
DATA_DIR = args.data_dir
os.makedirs(DATA_DIR, exist_ok=True)
uvicorn.run(app, host=args.host, port=args.port, reload=False)

4
service.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
cd /home/kaizen/repos/tourplanner || exit 1
exec python3.11 server.py --port=3002 --data-dir=/home/kaizen/repos/tourplanner/data

3
tour.ini Normal file
View File

@ -0,0 +1,3 @@
[program:tour]
command=/home/kaizen/repos/tourplanner/service.sh
startsecs=60