From 0331bdb9a9a7fa5ecf63895a4249667260cf454c Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk Date: Wed, 4 Jun 2025 00:00:44 +0200 Subject: [PATCH] First version. --- index.html | 861 +++++++++++++++++++++++++++++++++++++++++++++++++++++ server.py | 189 ++++++++++++ 2 files changed, 1050 insertions(+) create mode 100644 index.html create mode 100644 server.py diff --git a/index.html b/index.html new file mode 100644 index 0000000..942f951 --- /dev/null +++ b/index.html @@ -0,0 +1,861 @@ + + + + + + TourVote - Futuristic Tour Planning + + + +
+
+

TourVote

+

Collaborative Tour Planning Platform

+
+ +
+

Create a New Tour

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + + + + + +
+ + + + diff --git a/server.py b/server.py new file mode 100644 index 0000000..e16b8b6 --- /dev/null +++ b/server.py @@ -0,0 +1,189 @@ +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 +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) + +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: + return os.path.join(DATA_DIR, f"{tour_id}.json") + +# ─── 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.dict(), 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") + + with open(filepath) as f: + data = json.load(f) + + 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, + ) + + data["ideas"].append(new_idea.dict()) + 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") + + with open(filepath) as f: + data = json.load(f) + + 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") + +# ─── 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)