Python Web Application to Download a Grist Document

This is the Python with Flask code to create a web page with a big “Download” button on it so end users can easily pull a copy of their Grist document down in SQLite format. I then Dockerized the app so it can run on our Docker server. This is all internal so we are just running with the built in Flask web server. This should NOT be placed Internet facing, ever. In use the application looks like this (except with the Grist Doc ID, etc. filled in below.

To use it, simply run the Python app and fill in the Grist related Document ID, API Key, etc. and then click the Save button. That stores the information off in a simple text file which gets loaded whenever the application starts. This means there is no real security for things like the API key, although it is obscured on the web page entry area.

It does have a simple backup mechanism in that it renames the prior downloaded document file with the day of the week(i.e. 0 to 6). This cheesy backup mechanism means there will never be more than 7 copies of the data but doesn’t give a consistent backup window. Feel free to replace or remove that bit of the code.

The special treatment of the “external” directory is because “external” is a storage location outside of the container in my Docker setup and so is treated special. It should run with or without Docker and this code can be removed or altered. Also the host IP (, a Docker standard) and port number 5000 can be altered as needed.

Python Code:

from flask import Flask, render_template, request, flash, session
from datetime import datetime
import os
import requests

app = Flask(__name__)
app.secret_key = os.urandom(24)

# Define the parameters dictionary with default values
params = {
    "team": "",
    "doc_id": "",
    "api_key": "",
    "save_directory": "./",
    "filename": ""

# Define a path for the config file
CONFIG_FILE_PATH = "./gristdownloadconfig.txt"

# Load saved parameters from the file on startup

def load_parameters():
    if os.path.exists(CONFIG_FILE_PATH):
        with open(CONFIG_FILE_PATH, 'r') as file:
            lines = file.readlines()
            for line in lines:
                key, value = line.strip().split('=', 1)
                params[key] = value


@app.route("/", methods=["GET", "POST"])
def index():
    global params

    # Clear existing flash messages
    session.pop('_flashes', None)

    if request.method == "POST":
        if "save" in request.form:
            params["team"] = request.form["team"]
            params["doc_id"] = request.form["doc_id"]
            params["api_key"] = request.form["api_key"]
            params["save_directory"] = request.form["save_directory"]
            params["filename"] = request.form["filename"]

            # Save parameters to the file when they are updated
            with open(CONFIG_FILE_PATH, 'w') as file:
                for key, value in params.items():

            flash("Parameters saved successfully!")

        if "download" in request.form:
            status = download_grist_document(**params)
            flash(status)   # Update message

    return render_template("index.html", params=params)

def download_grist_document(team, doc_id, api_key, save_directory, filename):
    # If the save_directory starts with '/external', it means we are saving
    # outside the container in the host machine's directory.
    if save_directory.startswith('/external'):
        actual_save_path = save_directory
        actual_save_path = os.path.join(os.getcwd(), save_directory)

    headers = {"Authorization": f"Bearer {api_key}"}

    if len(team) == 0:
        url = f"{doc_id}/download"
        url = f"https://{team}{doc_id}/download"

    response = requests.get(url, headers=headers)
    if response.status_code == 200:

        # if the path specified doesn't exist, create it
        if not os.path.exists(actual_save_path):

        file_path = os.path.join(actual_save_path, filename)

        # Check if the file already exists and back it up, if so
        if os.path.exists(file_path):
            # Rename the existing file with the numeric day of the week
            # This insures no more than 7 backups are retained
            day_of_week =
            backup_path = os.path.join(actual_save_path, f"{filename}_{day_of_week}")

            # If an existing file exists with the new name, delete it
            if os.path.exists(backup_path):

            # Finally rename the current file to make it a backup
            os.rename(file_path, backup_path)

        with open(file_path, "wb") as file:
        return "Document downloaded successfully!"
        return "Error downloading the document."

if __name__ == "__main__":"", port=5000)

index.html file:
NOTE: It contains some Javascript to clear the results message after 20 seconds, this time can be adjusted by changing the milisecond number 20000 something else.

<!DOCTYPE html>
    <title>Download Grist Document  (version 1.08)</title>
        /* Basic styling for the body and form */
        body {
            font-family: Arial, sans-serif;
            padding: 20px;
            background-color: #f4f4f4;
        form {
            background-color: #fff;
            padding: 30px;
            border-radius: 8px;
            box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.1);
            max-width: 780px;
            margin: 0 auto;
        h1, h2 {
            text-align: center;
        .input-group {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 20px;
        label {
            margin-right: 10px;
        input[type="text"], input[type="password"] {
            flex: 1;
            padding: 10px;
            border-radius: 4px;
            border: 1px solid #ccc;
        input[type="submit"] {
            width: 100%;
            padding: 10px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            margin-bottom: 20px;
        input[type="submit"][name="download"] {
            background-color: red;
            color: #fff;
            font-size: 20px;
            margin-top: 20px;
        ul {
            background-color: #fff8e1;
            border-radius: 4px;
            padding: 10px;
    <h1>Download Grist Document</h1>
    <form action="/" method="post">
        <input type="submit" name="download" value="DOWNLOAD DOCUMENT"/>

    {% with messages = get_flashed_messages() %}
    {% if messages %}
        {% for message in messages %}
        <li class="flash">{{ message }}</li>
        {% endfor %}
    {% endif %}
    {% endwith %}

    <h2>- Enter Grist Configuration Below -</h2>
    <form action="/" method="post">
        <div class="input-group">
            <label>Team (leave blank if no team):</label>
            <input type="text" name="team" value="{{ }}" />

        <div class="input-group">
            <label>Document ID:</label>
            <input type="text" name="doc_id" value="{{ params.doc_id }}" />

        <div class="input-group">
            <label>API Key:</label>
            <input type="password" name="api_key" value="{{ params.api_key }}" />

        <div class="input-group">
            <label>Save Directory (/external/ is for Docker usage):</label>
            <input type="text" name="save_directory" value="{{ params.save_directory }}" />

        <div class="input-group">
            <input type="text" name="filename" value="{{ params.filename }}" />

        <input type="submit" name="save" value="Save" />

        setTimeout(function() {
            const flashMessages = document.querySelectorAll('.flash');
            flashMessages.forEach(function(message) {
       = 'none';
        }, 20000);  // 20,000 milliseconds = 20 seconds

gristdownloadconfig.txt file default contents:





FROM python:3.12.0-slim

WORKDIR /gristwebdownload

COPY . .

RUN pip install -r requirements.txt

CMD ["python", "./"]