First version.

This commit is contained in:
Oleksandr Kozachuk 2025-06-04 00:00:44 +02:00
commit 0331bdb9a9
2 changed files with 1050 additions and 0 deletions

861
index.html Normal file
View File

@ -0,0 +1,861 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TourVote - Futuristic Tour Planning</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #1a1a2e;
--bg-card: rgba(255, 255, 255, 0.03);
--text-primary: #ffffff;
--text-secondary: #a0a0b8;
--accent-primary: #00d4ff;
--accent-secondary: #7b2ff7;
--accent-gradient: linear-gradient(135deg, #00d4ff, #7b2ff7);
--border-color: rgba(255, 255, 255, 0.1);
--glow: 0 0 20px rgba(0, 212, 255, 0.5);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
position: relative;
}
/* Animated background */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(circle at 20% 50%, rgba(0, 212, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(123, 47, 247, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, rgba(0, 212, 255, 0.05) 0%, transparent 50%);
pointer-events: none;
z-index: -1;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px 20px 40px;
}
/* Header */
.header {
text-align: center;
padding: 60px 0 20px;
position: relative;
}
.logo {
font-size: 3rem;
font-weight: 900;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-transform: uppercase;
letter-spacing: 3px;
margin-bottom: 10px;
text-shadow: var(--glow);
}
.subtitle {
color: var(--text-secondary);
font-size: 1.1rem;
letter-spacing: 1px;
}
/* Glassmorphism cards */
.glass-card {
background: var(--bg-card);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 30px;
max-width: 700px;
margin: 0 auto 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
}
.glass-card h2 {
margin-bottom: 20px; /* pushes the next formgroup down */
}
.glass-card h3 {
margin-bottom: 16px; /* pushes the next formgroup down */
}
.glass-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 40px rgba(0, 212, 255, 0.2);
border-color: rgba(0, 212, 255, 0.3);
}
.date-row {
display: -webkit-flex;
display: flex;
gap: 24px;
}
.date-row .form-group {
-webkit-flex: 0 0 calc((100% - 42px) / 2);
flex: 0 0 calc((100% - 42px) / 2);
min-width: 0;
}
/* Forms */
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: var(--text-secondary);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
}
input, textarea {
width: 100%;
padding: 15px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
border-radius: 10px;
color: var(--text-primary);
font-size: 1rem;
transition: all 0.3s ease;
box-sizing: border-box; /* ← ensure padding is included in width */
}
input:focus, textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
}
textarea {
resize: vertical;
min-height: 100px;
}
/* Buttons */
.btn {
background: var(--accent-gradient);
color: white;
border: none;
padding: 15px 30px;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 1px;
display: inline-block;
position: relative;
overflow: hidden;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: var(--glow);
}
.btn:active {
transform: translateY(0);
}
.btn-secondary {
background: transparent;
border: 2px solid var(--accent-primary);
color: var(--accent-primary);
}
.btn-secondary:hover {
background: var(--accent-primary);
color: var(--bg-primary);
}
.btn-small {
padding: 8px 16px;
font-size: 0.9rem;
}
.ideas-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 30px;
}
.idea-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-color);
border-radius: 15px;
padding: 20px;
transition: all 0.3s ease;
}
.idea-card h3 {
margin-bottom: 20px; /* pushes the next formgroup down */
}
.idea-card:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(0, 212, 255, 0.3);
transform: translateY(-3px);
}
.idea-title {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 10px;
color: var(--accent-primary);
}
.idea-description {
color: var(--text-secondary);
margin-bottom: 15px;
line-height: 1.6;
}
.voters-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.voter-chip {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
color: var(--accent-primary);
}
.vote-section {
display: flex;
gap: 10px;
align-items: center;
margin-top: 15px;
}
.vote-count {
font-size: 1.1rem;
font-weight: 700;
color: var(--accent-primary);
}
/* Tour info */
.tour-info {
background: rgba(123, 47, 247, 0.1);
border: 1px solid rgba(123, 47, 247, 0.3);
border-radius: 15px;
padding: 20px;
margin-bottom: 30px;
}
.tour-title {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 10px;
}
.tour-date {
color: var(--accent-primary);
font-size: 1.1rem;
margin-bottom: 10px;
}
/* Hidden class */
.hidden {
display: none !important;
}
/* Loading spinner */
.loader {
width: 50px;
height: 50px;
border: 3px solid var(--border-color);
border-top: 3px solid var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 50px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 768px) {
.logo {
font-size: 2rem;
}
.container {
padding: 15px;
}
.glass-card {
padding: 20px;
}
.ideas-grid {
grid-template-columns: 1fr;
}
}
/* Matrix view */
.matrix-view {
overflow-x: auto;
margin-top: 30px;
}
.matrix-table {
width: 100%;
border-collapse: collapse;
min-width: 600px;
}
.matrix-table th, .matrix-table td {
padding: 12px;
text-align: left;
border: 1px solid var(--border-color);
}
.matrix-table th {
background: rgba(0, 212, 255, 0.1);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.9rem;
}
.matrix-table td {
background: var(--bg-card);
}
.vote-cell {
text-align: center;
font-size: 1.2rem;
color: var(--accent-primary);
}
.toggle-view {
display: flex;
gap: 10px;
justify-content: center;
margin-bottom: 20px;
}
.date-badge {
background: var(--accent-gradient);
padding: 6px 12px;
border-radius: 12px;
display: inline-block;
font-size: 0.9rem;
font-weight: 600;
color: var(--bg-primary);
margin-right: 8px;
text-shadow: var(--glow);
letter-spacing: 0.5px;
}
.date-label {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 4px;
display: block;
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1 class="logo"><a href=".">TourVote</a></h1>
<p class="subtitle">Collaborative Tour Planning Platform</p>
</header>
<div id="createTourSection" class="glass-card">
<h2>Create a New Tour</h2>
<form id="createTourForm">
<div class="form-group">
<label for="tourName">Tour Name</label>
<input type="text" id="tourName" required placeholder="Enter tour name">
</div>
<div class="date-row">
<div class="form-group">
<label class="date-label" for="tourStartDate">Start Date</label>
<input type="date" id="tourStartDate">
</div>
<div class="form-group">
<label class="date-label" for="tourEndDate">End Date</label>
<input type="date" id="tourEndDate">
</div>
</div>
<div class="form-group">
<label for="tourDescription">Description</label>
<textarea id="tourDescription" required placeholder="Describe your tour"></textarea>
</div>
<button type="submit" class="btn">Create Tour</button>
</form>
</div>
<!-- Tour View Section -->
<div id="tourViewSection" class="hidden">
<!-- Tour Info -->
<div id="tourInfo" class="tour-info"></div>
<!-- Add Idea Section -->
<div class="glass-card">
<h3>Add New Idea</h3>
<form id="addIdeaForm">
<div class="form-group">
<label for="ideaName">Idea Name</label>
<input type="text" id="ideaName" required placeholder="Enter idea name">
</div>
<div class="form-group">
<label for="ideaDescription">Description</label>
<textarea id="ideaDescription" required placeholder="Describe your idea"></textarea>
</div>
<div class="date-row">
<div class="form-group">
<label class="date-label" for="ideaStartTime">Start Time</label>
<input type="datetime-local" id="ideaStartTime">
</div>
<div class="form-group">
<label class="date-label" for="ideaEndTime">End Time</label>
<input type="datetime-local" id="ideaEndTime">
</div>
</div>
<button type="submit" class="btn">Add Idea</button>
</form>
</div>
<!-- View Toggle -->
<div class="toggle-view">
<button class="btn btn-secondary btn-small" onclick="showView('cards')">Card View</button>
<button class="btn btn-secondary btn-small" onclick="showView('matrix')">Matrix View</button>
</div>
<!-- Ideas Display -->
<div id="ideasContainer">
<div id="cardsView" class="ideas-grid"></div>
<div id="matrixView" class="matrix-view hidden"></div>
</div>
</div>
<!-- Loading -->
<div id="loader" class="loader hidden"></div>
</div>
<script>
// Mock API URL - Replace with your actual API endpoint
const API_URL = 'https://kaizenkodo.no/tour/v1';
// State management
let currentTour = null;
let currentView = 'cards';
// Initialize app
document.addEventListener('DOMContentLoaded', () => {
checkForTourId();
setupEventListeners();
});
// Check if tour ID is in URL
function checkForTourId() {
const tourId = window.location.hash.slice(1);
if (tourId) {
loadTour(tourId);
}
}
// Setup event listeners
function setupEventListeners() {
document.getElementById('createTourForm').addEventListener('submit', createTour);
document.getElementById('addIdeaForm').addEventListener('submit', addIdea);
// Listen for hash changes
window.addEventListener('hashchange', checkForTourId);
}
// Create new tour
async function createTour(e) {
e.preventDefault();
const startDate = document.getElementById('tourStartDate').value;
const endDate = document.getElementById('tourEndDate').value;
const payload = {
name: document.getElementById('tourName').value,
description: document.getElementById('tourDescription').value,
...(startDate ? { start_date: startDate } : {}),
...(endDate ? { end_date: endDate } : {})
};
showLoader();
try {
const response = await fetch(`${API_URL}/tours`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
// FastAPI returns the full Tour JSON (with id, createdAt, ideas=[])
const tourData = await response.json();
currentTour = tourData;
// Push the new tours ID into the URL hash so loadTour() can pick it up
window.location.hash = tourData.id;
showTourView();
} catch (error) {
console.error('Error creating tour:', error);
alert('Failed to create tour. Please try again.');
} finally {
hideLoader();
}
}
// Load existing tour
async function loadTour(tourId) {
showLoader();
try {
const response = await fetch(`${API_URL}/tours/${tourId}`);
if (response.status === 404) {
throw new Error('Not Found');
}
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
const tourData = await response.json();
currentTour = tourData;
showTourView();
} catch (error) {
console.error('Error loading tour:', error);
alert('Tour not found or access denied.');
window.location.hash = '';
} finally {
hideLoader();
}
}
// Add new idea
async function addIdea(e) {
e.preventDefault();
// Only send what FastAPIs IdeaCreate expects:
const name = document.getElementById('ideaName').value;
const desc = document.getElementById('ideaDescription').value;
const startTime = document.getElementById('ideaStartTime').value;
const endTime = document.getElementById('ideaEndTime').value;
const payload = {
name,
description: desc,
...(startTime ? { start_time: startTime } : {}),
...(endTime ? { end_time: endTime } : {})
};
showLoader();
try {
const response = await fetch(
`${API_URL}/tours/${currentTour.id}/ideas`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}
);
if (response.status === 404) {
throw new Error('Tour not found');
}
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
// FastAPI sends back the full Idea object, including id and empty voters
const newIdea = await response.json();
currentTour.ideas.push(newIdea);
renderIdeas();
document.getElementById('addIdeaForm').reset();
} catch (error) {
console.error('Error adding idea:', error);
alert('Failed to add idea. Please try again.');
} finally {
hideLoader();
}
}
// Vote for idea
async function voteForIdea(ideaId) {
const voterName = prompt('Enter your name to vote:');
if (!voterName) return;
showLoader();
try {
const response = await fetch(
`${API_URL}/tours/${currentTour.id}/ideas/${ideaId}/vote`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voterName })
}
);
if (response.status === 404) {
throw new Error('Tour or Idea not found');
}
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
// FastAPI returns the Idea object (with updated voters list)
const updatedIdea = await response.json();
// Replace the old idea in currentTour.ideas with the updated one
currentTour.ideas = currentTour.ideas.map(i =>
i.id === updatedIdea.id ? updatedIdea : i
);
renderIdeas();
} catch (error) {
console.error('Error voting:', error);
alert('Failed to vote. Please try again.');
} finally {
hideLoader();
}
}
// Show tour view
function showTourView() {
document.getElementById('createTourSection').classList.add('hidden');
document.getElementById('tourViewSection').classList.remove('hidden');
// Display tour info
const tourInfo = document.getElementById('tourInfo');
// Build a friendly daterange display:
let dateHTML = "";
if (currentTour.start_date && currentTour.end_date) {
const s = new Date(currentTour.start_date).toLocaleDateString('nb-NO', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
const e = new Date(currentTour.end_date).toLocaleDateString('nb-NO', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
dateHTML = `<span class="date-badge">📅 ${s} ${e}</span>`;
} else if (currentTour.start_date) {
const s = new Date(currentTour.start_date).toLocaleDateString('nb-NO', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
dateHTML = `<span class="date-badge">📅 Starts ${s}</span>`;
} else if (currentTour.end_date) {
const e = new Date(currentTour.end_date).toLocaleDateString('nb-NO', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
dateHTML = `<span class="date-badge">📅 Ends ${e}</span>`;
} else {
dateHTML = `<span class="date-badge">📅 TBD</span>`;
}
tourInfo.innerHTML = `
<h2 class="tour-title">${currentTour.name}</h2>
${dateHTML}
<p style="margin-top: 12px;">${currentTour.description}</p>
`;
renderIdeas();
}
// Render ideas
function renderIdeas() {
if (currentView === 'cards') {
renderCardsView();
} else {
renderMatrixView();
}
}
// Render cards view
function renderCardsView() {
const container = document.getElementById('cardsView');
container.innerHTML = '';
currentTour.ideas.forEach(idea => {
const card = document.createElement('div');
card.className = 'idea-card';
let timeBadge = "";
if (idea.start_time && idea.end_time) {
const s = new Date(idea.start_time).toLocaleDateString('nb-NO', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const e = new Date(idea.end_time).toLocaleDateString('nb-NO', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
timeBadge = `<span class="date-badge">${s} → ${e}</span>`;
} else if (idea.start_time) {
const s = new Date(idea.start_time).toLocaleDateString('nb-NO', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
timeBadge = `<span class="date-badge">🕒 Starts ${s}</span>`;
} else if (idea.end_time) {
const e = new Date(idea.end_time).toLocaleDateString('nb-NO', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
timeBadge = `<span class="date-badge">🕒 Ends ${e}</span>`;
}
card.innerHTML = `
<h3 class="idea-title">${idea.name}</h3>
${timeBadge}
<p class="idea-description" style="margin-top: 8px;">${idea.description}</p>
<div class="vote-section">
<span class="vote-count">👍 ${idea.voters.length} votes</span>
<button class="btn btn-secondary btn-small" onclick="voteForIdea('${idea.id}')">Vote</button>
</div>
<div class="voters-list">
${idea.voters.map(voter => `<span class="voter-chip">${voter}</span>`).join('')}
</div>
`;
container.appendChild(card);
});
}
// Render matrix view
function renderMatrixView() {
const container = document.getElementById('matrixView');
const allVoters = [...new Set(currentTour.ideas.flatMap(idea => idea.voters))];
let html = '<table class="matrix-table"><thead><tr>'
+ '<th>Idea</th><th>Time Range</th>';
allVoters.forEach(voter => {
html += `<th>${voter}</th>`;
});
html += '<th>Total</th></tr></thead><tbody>';
currentTour.ideas.forEach(idea => {
// Format time only (HH:mm) in Norwegian:
let timeCell = "";
if (idea.start_time && idea.end_time) {
const s = new Date(idea.start_time).toLocaleDateString('nb-NO', {
hour: '2-digit', minute: '2-digit', hour12: false
});
const e = new Date(idea.end_time).toLocaleDateString('nb-NO', {
hour: '2-digit', minute: '2-digit', hour12: false
});
timeCell = `<td>${s} → ${e}</td>`;
} else if (idea.start_time) {
const s = new Date(idea.start_time).toLocaleDateString('nb-NO', {
hour: '2-digit', minute: '2-digit', hour12: false
});
timeCell = `<td>Starts ${s}</td>`;
} else if (idea.end_time) {
const e = new Date(idea.end_time).toLocaleDateString('nb-NO', {
hour: '2-digit', minute: '2-digit', hour12: false
});
timeCell = `<td>Ends ${e}</td>`;
} else {
timeCell = `<td style="color: var(--text-secondary);"></td>`;
}
html += `<tr>`
+ `<td><strong>${idea.name}</strong><br><small>${idea.description}</small></td>`
+ timeCell;
allVoters.forEach(voter => {
html += `<td class="vote-cell">${idea.voters.includes(voter) ? '✓' : ''}</td>`;
});
html += `<td class="vote-cell">${idea.voters.length}</td></tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// Switch view
function showView(view) {
currentView = view;
if (view === 'cards') {
document.getElementById('cardsView').classList.remove('hidden');
document.getElementById('matrixView').classList.add('hidden');
} else {
document.getElementById('cardsView').classList.add('hidden');
document.getElementById('matrixView').classList.remove('hidden');
}
renderIdeas();
}
// Utility functions
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function showLoader() {
document.getElementById('loader').classList.remove('hidden');
}
function hideLoader() {
document.getElementById('loader').classList.add('hidden');
}
</script>
</body>
</html>

189
server.py Normal file
View File

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