mirror of
https://github.com/tmdinosaurcenter/kiosk-guestbook.git
synced 2025-04-20 02:42:26 -06:00
Compare commits
21 Commits
Author | SHA1 | Date | |
---|---|---|---|
fbfa935706 | |||
6ca863e15d | |||
df10a307f1 | |||
73984635e1 | |||
e6d73c8c0a | |||
f85c05ca9a | |||
e25cdca466 | |||
9f462c4c0a | |||
f4466b8a67 | |||
|
c1f9ab05b4 | ||
61a45200a5 | |||
|
ab83c39c91 | ||
bd706c8a81 | |||
de6938d278 | |||
e0a0c397cc | |||
db2acddbc6 | |||
|
2ed6e7c7b0 | ||
|
9b244e1661 | ||
|
3411b48dcf | ||
|
35ac31be7f | ||
babbc43806 |
32
.github/workflows/docker-image.yml
vendored
Normal file
32
.github/workflows/docker-image.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: Log in to DockerHub
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ vars.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Build the Docker image
|
||||||
|
id: build-image
|
||||||
|
run: |
|
||||||
|
IMAGE_TAG=my-image-name:${{ github.sha }}
|
||||||
|
docker build . --file Dockerfile --tag $IMAGE_TAG
|
||||||
|
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||||
|
# Uncomment below to push the image to Docker Hub (or another registry)
|
||||||
|
- name: Push the Docker image
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: |
|
||||||
|
docker tag $IMAGE_TAG snachodog/kiosk-guestbook:latest
|
||||||
|
docker push snachodog/kiosk-guestbook:latest
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -184,3 +184,5 @@ cython_debug/
|
|||||||
# VS Code
|
# VS Code
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
.env
|
||||||
|
docker-compose.yml
|
||||||
|
15
Dockerfile
15
Dockerfile
@ -4,18 +4,25 @@ FROM python:3.9-slim
|
|||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
# Install system dependencies (including gettext for envsubst)
|
||||||
|
RUN apt-get update && apt-get install -y gettext && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy the application code
|
# Copy the application code and template files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Copy the entrypoint script into the container and make it executable
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Set environment variables (can be overridden by .env)
|
# Set environment variables (can be overridden by .env)
|
||||||
ENV FLASK_ENV=production
|
ENV FLASK_ENV=production
|
||||||
|
|
||||||
# Expose the port (Gunicorn will run on 8000)
|
# Expose the port (Gunicorn will run on 8000)
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run the app with Gunicorn; use 3 workers (can be tuned via .env)
|
# Use the entrypoint script as the container's command
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app", "--workers", "3"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Montana Dinosaur Center
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
132
README.md
132
README.md
@ -8,38 +8,17 @@ A simple Flask-based guestbook application designed for an internal museum kiosk
|
|||||||
|
|
||||||
- Dynamic Form Behavior:
|
- Dynamic Form Behavior:
|
||||||
The comment field is hidden by default and only revealed when the first name, last name, and location fields each contain at least 3 characters.
|
The comment field is hidden by default and only revealed when the first name, last name, and location fields each contain at least 3 characters.
|
||||||
-Input Validation:
|
- Input Validation:
|
||||||
Ensures required fields (first name, last name, and location) are filled.
|
Ensures required fields (first name, last name, and location) are filled and validates email format (if provided).
|
||||||
- Validates email format (if provided).
|
|
||||||
Uses a profanity filter loaded from en.txt to prevent inappropriate language in comments.
|
Uses a profanity filter loaded from en.txt to prevent inappropriate language in comments.
|
||||||
- Logging:
|
- Logging:
|
||||||
Logs key events and validation errors to help with debugging and monitoring.
|
Logs key events and validation errors for debugging and monitoring.
|
||||||
- SQLite Database:
|
- SQLite Database:
|
||||||
Stores guest entries locally, with persistence ensured by mounting a Docker volume.
|
Stores guest entries locally, with persistence ensured by mounting a Docker volume.
|
||||||
- Containerized Deployment:
|
- Containerized Deployment:
|
||||||
Uses Docker and Docker Compose to create a production-ready environment with Gunicorn as the WSGI server.
|
Uses Docker and Docker Compose for a production-ready environment with Gunicorn as the WSGI server.
|
||||||
|
- Configurable Template:
|
||||||
## Project Structure
|
The application’s title and logo can be dynamically configured via environment variables without rebuilding the image.
|
||||||
|
|
||||||
``` bash
|
|
||||||
kiosk-guestbook/
|
|
||||||
├── scripts/
|
|
||||||
│ ├── guestbook_export.py # Script to export guest entries (e.g., for Mailchimp)
|
|
||||||
│ └── guestbook.db # SQLite database file (if stored here, mainly for development)
|
|
||||||
├── static/
|
|
||||||
│ └── images/
|
|
||||||
│ └── logo.png # Logo for display in the application
|
|
||||||
├── templates/
|
|
||||||
│ └── index.html # Main HTML template for the guestbook
|
|
||||||
├── .env # Environment variables for Docker Compose (production settings)
|
|
||||||
├── app.py # Main Flask application code
|
|
||||||
├── docker-compose.yml # Docker Compose configuration for container orchestration
|
|
||||||
├── Dockerfile # Default Dockerfile (development or general usage)
|
|
||||||
├── en.txt # Profanity list file (one banned word per line)
|
|
||||||
├── production.Dockerfile # Optional Dockerfile optimized for production
|
|
||||||
├── README.md # Project documentation
|
|
||||||
└── requirements.txt # Python dependencies (Flask, Gunicorn, etc.)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@ -47,40 +26,95 @@ kiosk-guestbook/
|
|||||||
|
|
||||||
- Docker
|
- Docker
|
||||||
- Docker Compose
|
- Docker Compose
|
||||||
|
- Optionally, Portainer for GUI-based container management
|
||||||
|
|
||||||
### Building and Running the Application
|
## Running the Application
|
||||||
|
|
||||||
### Build and Start Containers
|
Before proceeding, you’ll need to have the example configuration files. These files—example.docker-compose.yml and example.env—are included in the repository. You can download them by cloning the entire repository:
|
||||||
|
|
||||||
1. From the project root, run:
|
```bash
|
||||||
`docker-compose up --build -d`
|
git clone https://github.com/tmdinosaurcenter/kiosk-guestbook.git
|
||||||
This command will build the Docker image, start the container in detached mode, and mount the persistent volume at `/data` for the SQLite database.
|
cd kiosk-guestbook
|
||||||
|
```
|
||||||
|
|
||||||
2. Access the Application:
|
If you don’t wish to clone the entire repo, you can also download the two files individually from GitHub. Once you have them, follow the steps below.
|
||||||
Open a web browser and navigate to `http://<your-server-ip>:8000` (or the port specified in your .env file).
|
|
||||||
|
|
||||||
### Deployment with Docker Compose
|
### Method 1: Using Docker on the CLI
|
||||||
|
|
||||||
The `docker-compose.yml` is configured to:
|
1. Copy Example Files:
|
||||||
|
From the project root, copy the example files:
|
||||||
|
|
||||||
- Build the image from the Dockerfile.
|
``` bash
|
||||||
- Expose the service on the specified port.
|
cp example.docker-compose.yml docker-compose.yml
|
||||||
- Mount a volume (named `guestbook_data`) at `/data` to persist your database.
|
cp example.env .env
|
||||||
- Load environment variables from the `.env` file
|
```
|
||||||
|
|
||||||
### Logging and Monitoring
|
2. **Edit the `.env` File (Optional)**
|
||||||
|
|
||||||
|
Modify `.env` to customize settings such as `SITE_TITLE`, `LOGO_URL`, `PORT`, etc.
|
||||||
|
|
||||||
|
3. **Start the Application**
|
||||||
|
|
||||||
|
Run the following command to pull (or use) the pre-built image and start the container:
|
||||||
|
|
||||||
|
`docker-compose up -d`
|
||||||
|
|
||||||
|
This command starts the container in detached mode, mounts the persistent volume for the SQLite database, and uses your environment variable settings.
|
||||||
|
|
||||||
|
4. **Access the Application**
|
||||||
|
|
||||||
|
Open your browser and navigate to `http://<your-server-ip>:8000` (or the port specified in your `.env` file).
|
||||||
|
|
||||||
|
### Method 2: Running in Portainer
|
||||||
|
|
||||||
|
1. **Copy Example Files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp example.docker-compose.yml docker-compose.yml
|
||||||
|
cp example.env stack.env
|
||||||
|
```
|
||||||
|
|
||||||
|
*Note*: Portainer expects the environment file to be named stack.env rather than .env
|
||||||
|
|
||||||
|
2. **Edit `docker-compose.yml`**
|
||||||
|
In the `docker-compose.yml` file, update the environment file reference from `.env` to `stack.env`
|
||||||
|
|
||||||
|
3. **Deploy via Portainer**
|
||||||
|
|
||||||
|
- Log in to Portainer and navigate to the "Stacks" section.
|
||||||
|
- Create a new stack and upload or paste your modified docker-compose.yml along with the `stack.env` file.
|
||||||
|
- Deploy the stack. Portainer will use stack.env for the environment variables.
|
||||||
|
|
||||||
|
4. **Access the Application:**
|
||||||
|
Once deployed, open your browser and navigate to http://<your-server-ip>:8000 (or your specified port) to view the application.
|
||||||
|
|
||||||
|
## Logging and Monitoring
|
||||||
|
|
||||||
- The application uses Python's built-in logging module.
|
- The application uses Python's built-in logging module.
|
||||||
- Key events (like database initialization, form submissions, and validation errors) are logged.
|
- Key events, such as database initialization, form submissions, and validation errors, are logged.
|
||||||
- Logs can be viewed by running:
|
- View logs with:
|
||||||
|
|
||||||
`docker-compose logs -f`
|
`docker-compose logs -f`
|
||||||
|
|
||||||
|
## API Access
|
||||||
|
|
||||||
|
Access the API endpoint to export guest entries by navigating to:
|
||||||
|
|
||||||
|
`http://your-server-ip:8000/guests/api`
|
||||||
|
|
||||||
|
This endpoint can be integrated with on-prem automation tools like n8n.
|
||||||
|
|
||||||
## Additional Notes
|
## Additional Notes
|
||||||
|
|
||||||
- Intranet-Only Deployment:
|
- **Intranet-Only Deployment**
|
||||||
This application is designed for internal use only. It is not exposed to the public internet.
|
This application is designed for internal use only and is not exposed to the public internet.
|
||||||
- Database Persistence:
|
|
||||||
The SQLite database is stored in a Docker volume (guestbook_data), ensuring that data persists even if containers are rebuilt.
|
|
||||||
- Production Considerations:
|
|
||||||
|
|
||||||
The app runs with Gunicorn as a production-ready WSGI server. Make sure to adjust worker counts and resource limits as needed based on your server’s specifications.
|
- **Database Persistence**
|
||||||
|
The SQLite database is stored in a Docker volume (guestbook_data), ensuring that data persists even if containers are rebuilt.
|
||||||
|
|
||||||
|
- **Production Considerations**
|
||||||
|
The app runs with Gunicorn as a production-ready WSGI server. Adjust worker counts and resource limits as needed based on your server’s specifications.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the [MIT License](LICENSE).
|
||||||
|
74
app.py
74
app.py
@ -1,24 +1,17 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for
|
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Set up basic logging
|
# Set up logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Use an environment variable for the database path (defaulting to 'guestbook.db')
|
|
||||||
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
|
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
|
||||||
|
|
||||||
def load_banned_words():
|
def load_banned_words():
|
||||||
"""Load a set of banned words from a local file.
|
|
||||||
|
|
||||||
Expects 'en.txt' to be in the same directory as this script.
|
|
||||||
If the file is missing, a minimal fallback set is used.
|
|
||||||
"""
|
|
||||||
banned_words = set()
|
banned_words = set()
|
||||||
file_path = os.path.join(os.path.dirname(__file__), 'en.txt')
|
file_path = os.path.join(os.path.dirname(__file__), 'en.txt')
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
@ -33,15 +26,13 @@ def load_banned_words():
|
|||||||
logger.error("Error reading banned words file: %s", e)
|
logger.error("Error reading banned words file: %s", e)
|
||||||
banned_words = {"fuck", "shit", "damn", "bitch", "asshole", "cunt", "dick", "piss", "crap", "hell"}
|
banned_words = {"fuck", "shit", "damn", "bitch", "asshole", "cunt", "dick", "piss", "crap", "hell"}
|
||||||
else:
|
else:
|
||||||
logger.warning("Banned words file not found. Using fallback minimal list.")
|
logger.warning("Banned words file not found. Using fallback list.")
|
||||||
banned_words = {"fuck", "shit", "damn", "bitch", "asshole", "cunt", "dick", "piss", "crap", "hell"}
|
banned_words = {"fuck", "shit", "damn", "bitch", "asshole", "cunt", "dick", "piss", "crap", "hell"}
|
||||||
return banned_words
|
return banned_words
|
||||||
|
|
||||||
# Load the banned words using the helper function.
|
|
||||||
BANNED_WORDS = load_banned_words()
|
BANNED_WORDS = load_banned_words()
|
||||||
|
|
||||||
def contains_banned_words(text):
|
def contains_banned_words(text):
|
||||||
"""Check if the provided text contains any banned words."""
|
|
||||||
words = text.lower().split()
|
words = text.lower().split()
|
||||||
for word in words:
|
for word in words:
|
||||||
word_clean = word.strip(".,!?;:\"'")
|
word_clean = word.strip(".,!?;:\"'")
|
||||||
@ -50,7 +41,6 @@ def contains_banned_words(text):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Initialize the SQLite database and create the guests table if it doesn't exist."""
|
|
||||||
conn = sqlite3.connect(DATABASE)
|
conn = sqlite3.connect(DATABASE)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute('''
|
c.execute('''
|
||||||
@ -61,6 +51,7 @@ def init_db():
|
|||||||
email TEXT,
|
email TEXT,
|
||||||
location TEXT NOT NULL,
|
location TEXT NOT NULL,
|
||||||
comment TEXT,
|
comment TEXT,
|
||||||
|
newsletter_opt_in BOOLEAN DEFAULT 1,
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
@ -69,35 +60,34 @@ def init_db():
|
|||||||
logger.info("Database initialized.")
|
logger.info("Database initialized.")
|
||||||
|
|
||||||
def is_valid_email(email):
|
def is_valid_email(email):
|
||||||
"""Simple regex-based email validation."""
|
|
||||||
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
|
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
|
||||||
return re.match(pattern, email)
|
return re.match(pattern, email)
|
||||||
|
|
||||||
@app.before_first_request
|
@app.before_first_request
|
||||||
def initialize_database():
|
def initialize_database():
|
||||||
"""Ensure the database is initialized before handling the first request."""
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
@app.route('/', methods=['GET', 'POST'])
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
def index():
|
def index():
|
||||||
error = None
|
error = None
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
logger.info("Received POST request with form data.")
|
logger.info("Received POST request.")
|
||||||
first_name = request.form.get('first_name', '').strip()
|
first_name = request.form.get('first_name', '').strip()
|
||||||
last_name = request.form.get('last_name', '').strip()
|
last_name = request.form.get('last_name', '').strip()
|
||||||
email = request.form.get('email', '').strip()
|
email = request.form.get('email', '').strip()
|
||||||
location = request.form.get('location', '').strip()
|
location = request.form.get('location', '').strip()
|
||||||
comment = request.form.get('comment', '').strip()
|
comment = request.form.get('comment', '').strip()
|
||||||
|
newsletter_opt_in = request.form.get('newsletter_opt_in') == 'on'
|
||||||
|
|
||||||
if not (first_name and last_name and location):
|
if not (first_name and last_name and location):
|
||||||
error = "First name, last name, and location are required."
|
error = "First name, last name, and location are required."
|
||||||
logger.warning("Validation error: Missing required fields.")
|
logger.warning("Missing required fields.")
|
||||||
elif email and not is_valid_email(email):
|
elif email and not is_valid_email(email):
|
||||||
error = "Invalid email address."
|
error = "Invalid email address."
|
||||||
logger.warning("Validation error: Invalid email address '%s'.", email)
|
logger.warning("Invalid email: %s", email)
|
||||||
elif comment and contains_banned_words(comment):
|
elif comment and contains_banned_words(comment):
|
||||||
error = "Your comment contains inappropriate language. Please revise."
|
error = "Your comment contains inappropriate language. Please revise."
|
||||||
logger.warning("Validation error: Inappropriate language detected in comment.")
|
logger.warning("Profanity detected in comment.")
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
conn = sqlite3.connect(DATABASE)
|
conn = sqlite3.connect(DATABASE)
|
||||||
@ -110,25 +100,57 @@ def index():
|
|||||||
conn = sqlite3.connect(DATABASE)
|
conn = sqlite3.connect(DATABASE)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute(
|
c.execute(
|
||||||
'INSERT INTO guests (first_name, last_name, email, location, comment) VALUES (?, ?, ?, ?, ?)',
|
'''
|
||||||
(first_name, last_name, email, location, comment)
|
INSERT INTO guests (first_name, last_name, email, location, comment, newsletter_opt_in)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
''',
|
||||||
|
(first_name, last_name, email, location, comment, newsletter_opt_in)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
logger.info("New guest entry added: %s from %s.", first_name, location)
|
logger.info("Added guest: %s %s from %s", first_name, last_name, location)
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
# For GET requests, retrieve guest entries to display.
|
|
||||||
conn = sqlite3.connect(DATABASE)
|
conn = sqlite3.connect(DATABASE)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC')
|
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC')
|
||||||
guests = c.fetchall()
|
guests = c.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
logger.info("Rendering guestbook page with %d entries.", len(guests))
|
logger.info("Rendering index with %d guests.", len(guests))
|
||||||
return render_template('index.html', error=error, guests=guests)
|
return render_template('index.html', error=error, guests=guests)
|
||||||
|
|
||||||
|
@app.route('/api/guests', methods=['GET'])
|
||||||
|
def api_guests():
|
||||||
|
api_key = request.headers.get('X-API-Key')
|
||||||
|
if api_key != os.environ.get("API_KEY"):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DATABASE)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
SELECT first_name, last_name, email, location, comment, newsletter_opt_in, timestamp
|
||||||
|
FROM guests
|
||||||
|
WHERE email IS NOT NULL AND email != ''
|
||||||
|
ORDER BY id DESC
|
||||||
|
''')
|
||||||
|
rows = c.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
guests = [
|
||||||
|
{
|
||||||
|
"first_name": row[0],
|
||||||
|
"last_name": row[1],
|
||||||
|
"email": row[2],
|
||||||
|
"location": row[3],
|
||||||
|
"comment": row[4],
|
||||||
|
"newsletter_opt_in": bool(row[5]),
|
||||||
|
"timestamp": row[6]
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return jsonify(guests)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# For development use; production (gunicorn) will not execute this block.
|
|
||||||
init_db()
|
init_db()
|
||||||
logger.info("Starting Flask app on host 0.0.0.0, port 8000.")
|
logger.info("Starting development server at http://0.0.0.0:8000")
|
||||||
app.run(host='0.0.0.0', port=8000)
|
app.run(host='0.0.0.0', port=8000)
|
||||||
|
7
entrypoint.sh
Normal file
7
entrypoint.sh
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Process index.html.template to create index.html
|
||||||
|
# Adjust the path if your template is located somewhere else
|
||||||
|
envsubst < /app/templates/index.html.template > /app/templates/index.html
|
||||||
|
|
||||||
|
# Start Gunicorn; using an environment variable for workers (default is 3)
|
||||||
|
exec gunicorn --bind 0.0.0.0:8000 app:app --workers ${WORKERS:-3}
|
@ -1,15 +1,15 @@
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
guestbook:
|
guestbook:
|
||||||
build: .
|
image: snachodog/kiosk-guestbook:latest
|
||||||
container_name: guestbook
|
container_name: guestbook
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-8000}:8000"
|
- "${PORT:-8000}:8000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
# Mount a named volume at /data so that the database file (configured in .env) persists
|
# Mount your local directory to persist data; adjust if you prefer a named volume
|
||||||
- /home/steve/kiosk-guestbook:/data
|
- /path/to/guestbook_data:/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
guestbook_data:
|
guestbook_data:
|
@ -3,9 +3,10 @@ PORT=8000
|
|||||||
# Flask environment setting (production)
|
# Flask environment setting (production)
|
||||||
FLASK_ENV=production
|
FLASK_ENV=production
|
||||||
# Path to the SQLite database (this file will be stored in the mounted /data volume)
|
# Path to the SQLite database (this file will be stored in the mounted /data volume)
|
||||||
DATABASE_PATH=/data/scripts/guestbook.db
|
DATABASE_PATH=/data/guestbook.db
|
||||||
# Number of Gunicorn workers (adjust as needed)
|
# Number of Gunicorn workers (adjust as needed)
|
||||||
GUNICORN_WORKERS=3
|
GUNICORN_WORKERS=3
|
||||||
PID=1000
|
PID=1000
|
||||||
GID=1000
|
GID=1000
|
||||||
|
SITE_TITLE="The Montana Dinosaur Center Visitor Log"
|
||||||
|
LOGO_URL="/static/images/logo.png"
|
@ -1,14 +0,0 @@
|
|||||||
FROM python:3.9-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy and install Python dependencies.
|
|
||||||
COPY requirements.txt requirements.txt
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy the rest of the app code.
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
CMD ["python", "app.py"]
|
|
@ -2,11 +2,12 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>The Montana Dinosaur Center Visitor Log</title>
|
<title>${SITE_TITLE}</title>
|
||||||
|
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
/* Scrolling marquee styles */
|
/* Scrolling marquee styles */
|
||||||
.scrolling-wrapper {
|
.scrolling-wrapper {
|
||||||
@ -40,8 +41,8 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container mt-5 mb-5">
|
<div class="container mt-5 mb-5">
|
||||||
<header class="d-flex align-items-center mb-4">
|
<header class="d-flex align-items-center mb-4">
|
||||||
<img src="static/images/logo.png" alt="Museum Logo" class="me-3" style="height: 50px;">
|
<img src="${LOGO_URL}" alt="Logo" class="me-3" style="height: 50px;" />
|
||||||
<h1 class="h3 mb-0">The Montana Dinosaur Center Visitor Log</h1>
|
<h1 class="h3 mb-0">${SITE_TITLE}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Brief instructions for the form -->
|
<!-- Brief instructions for the form -->
|
||||||
@ -59,25 +60,38 @@
|
|||||||
<form method="post" action="/" class="mb-4">
|
<form method="post" action="/" class="mb-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="first_name" class="form-label">First Name(s):</label>
|
<label for="first_name" class="form-label">First Name(s):</label>
|
||||||
<input type="text" class="form-control" id="first_name" name="first_name" required>
|
<input type="text" class="form-control" id="first_name" name="first_name" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="last_name" class="form-label">Last Name:</label>
|
<label for="last_name" class="form-label">Last Name:</label>
|
||||||
<input type="text" class="form-control" id="last_name" name="last_name" required>
|
<input type="text" class="form-control" id="last_name" name="last_name" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Email + Newsletter Block (fully fixed) -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="email" class="form-label">Email (Optional):</label>
|
<label for="email" class="form-label">Email (Optional):</label>
|
||||||
<input type="email" class="form-control" id="email" name="email">
|
<input type="email" class="form-control" id="email" name="email" />
|
||||||
|
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="newsletter_opt_in" id="newsletter_opt_in"
|
||||||
|
checked />
|
||||||
|
<label class="form-check-label" for="newsletter_opt_in">
|
||||||
|
Subscribe our newsletter
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="location" class="form-label">Location:</label>
|
<label for="location" class="form-label">Location:</label>
|
||||||
<input type="text" class="form-control" id="location" name="location" required>
|
<input type="text" class="form-control" id="location" name="location" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Comment field hidden by default -->
|
<!-- Comment field hidden by default -->
|
||||||
<div class="mb-3" id="comment-field" style="display: none;">
|
<div class="mb-3" id="comment-field" style="display: none;">
|
||||||
<label for="comment" class="form-label">Comment (Optional):</label>
|
<label for="comment" class="form-label">Comment (Optional):</label>
|
||||||
<textarea class="form-control" id="comment" name="comment" rows="3"></textarea>
|
<textarea class="form-control" id="comment" name="comment" rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Submit</button>
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -96,24 +110,26 @@
|
|||||||
<!-- JavaScript to reveal the comment field -->
|
<!-- JavaScript to reveal the comment field -->
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const firstNameInput = document.getElementById('first_name');
|
const firstNameInput = document.getElementById("first_name");
|
||||||
const lastNameInput = document.getElementById('last_name');
|
const lastNameInput = document.getElementById("last_name");
|
||||||
const locationInput = document.getElementById('location');
|
const locationInput = document.getElementById("location");
|
||||||
const commentField = document.getElementById('comment-field');
|
const commentField = document.getElementById("comment-field");
|
||||||
|
|
||||||
function checkFields() {
|
function checkFields() {
|
||||||
if (firstNameInput.value.trim().length >= 3 &&
|
if (
|
||||||
|
firstNameInput.value.trim().length >= 3 &&
|
||||||
lastNameInput.value.trim().length >= 3 &&
|
lastNameInput.value.trim().length >= 3 &&
|
||||||
locationInput.value.trim().length >= 3) {
|
locationInput.value.trim().length >= 3
|
||||||
commentField.style.display = 'block';
|
) {
|
||||||
|
commentField.style.display = "block";
|
||||||
} else {
|
} else {
|
||||||
commentField.style.display = 'none';
|
commentField.style.display = "none";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
firstNameInput.addEventListener('input', checkFields);
|
firstNameInput.addEventListener("input", checkFields);
|
||||||
lastNameInput.addEventListener('input', checkFields);
|
lastNameInput.addEventListener("input", checkFields);
|
||||||
locationInput.addEventListener('input', checkFields);
|
locationInput.addEventListener("input", checkFields);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
Loading…
x
Reference in New Issue
Block a user