diff --git a/.bashrc b/.bashrc new file mode 100644 index 000000000..f0b9f212e --- /dev/null +++ b/.bashrc @@ -0,0 +1,161 @@ +# ~/.bashrc: executed by bash(1) for non-login shells. +# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc) +# for examples + +# # If not running interactively, don't do anything +# case $- in +# *i*) ;; +# *) return;; +# esac + +# don't put duplicate lines or lines starting with space in the history. +# See bash(1) for more options +HISTCONTROL=ignoreboth + +# append to the history file, don't overwrite it +shopt -s histappend + +# for setting history length see HISTSIZE and HISTFILESIZE in bash(1) +HISTSIZE=1000 +HISTFILESIZE=2000 + +# check the window size after each command and, if necessary, +# update the values of LINES and COLUMNS. +shopt -s checkwinsize + +# If set, the pattern "**" used in a pathname expansion context will +# match all files and zero or more directories and subdirectories. +#shopt -s globstar + +# make less more friendly for non-text input files, see lesspipe(1) +[ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)" + +# set variable identifying the chroot you work in (used in the prompt below) +if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then + debian_chroot=$(cat /etc/debian_chroot) +fi + +# set a fancy prompt (non-color, unless we know we "want" color) +case "$TERM" in + xterm-color|*-256color) color_prompt=yes;; +esac + +# uncomment for a colored prompt, if the terminal has the capability; turned +# off by default to not distract the user: the focus in a terminal window +# should be on the output of commands, not on the prompt +#force_color_prompt=yes + +if [ -n "$force_color_prompt" ]; then + if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then + # We have color support; assume it's compliant with Ecma-48 + # (ISO/IEC-6429). (Lack of such support is extremely rare, and such + # a case would tend to support setf rather than setaf.) + color_prompt=yes + else + color_prompt= + fi +fi + +if [ "$color_prompt" = yes ]; then + PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ ' +else + PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ ' +fi +unset color_prompt force_color_prompt + +# If this is an xterm set the title to user@host:dir +case "$TERM" in +xterm*|rxvt*) + PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\h: \w\a\]$PS1" + ;; +*) + ;; +esac + +# enable color support of ls and also add handy aliases +if [ -x /usr/bin/dircolors ]; then + test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)" + alias ls='ls --color=auto' + #alias dir='dir --color=auto' + #alias vdir='vdir --color=auto' + + alias grep='grep --color=auto' + alias fgrep='fgrep --color=auto' + alias egrep='egrep --color=auto' +fi + +# colored GCC warnings and errors +#export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01' + +# some more ls aliases +alias ll='ls -alF' +alias la='ls -A' +alias l='ls -CF' + +# Add an "alert" alias for long running commands. Use like so: +# sleep 10; alert +alias alert='notify-send --urgency=low -i "$([ $? = 0 ] && echo terminal || echo error)" "$(history|tail -n1|sed -e '\''s/^\s*[0-9]\+\s*//;s/[;&|]\s*alert$//'\'')"' + +# Alias definitions. +# You may want to put all your additions into a separate file like +# ~/.bash_aliases, instead of adding them here directly. +# See /usr/share/doc/bash-doc/examples in the bash-doc package. + +if [ -f ~/.bash_aliases ]; then + . ~/.bash_aliases +fi + +# enable programmable completion features (you don't need to enable +# this, if it's already enabled in /etc/bash.bashrc and /etc/profile +# sources /etc/bash.bashrc). +if ! shopt -oq posix; then + if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion + elif [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi +fi + +# >>> conda initialize >>> +# !! Contents within this block are managed by 'conda init' !! +#__conda_setup="$('/home/sean/miniconda3/bin/conda' 'shell.bash' 'hook' 2> /dev/null)" +#if [ $? -eq 0 ]; then +# eval "$__conda_setup" +#else +# if [ -f "/home/sean/miniconda3/etc/profile.d/conda.sh" ]; then +# . "/home/sean/miniconda3/etc/profile.d/conda.sh" +# else +# export PATH="/home/sean/miniconda3/bin:$PATH" +# fi +#fi +#unset __conda_setup +# <<< conda initialize <<< + +#export DISPLAY=:0.0 + +# Make Python aliases +alias python=python3 +alias pip=pip3 + +# Make Docker aliases +alias dc=docker-compose + +# At yarn's request +# alias node=nodejs + +# # Activate the virtual environment, whose environment variable was created in the Dockerfile +# . $VIRTUAL_ENV/bin/activate + +# Add project directory to PYTHONPATH so Python can find my files +# export PYTHONPATH=$PATH:/workspace/app + +# The following is also done when NMV and NodeJS are installed, but it must be done each time a Bash terminal is loaded +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm +[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion + +# Some more alias to avoid making mistakes: +alias rm='rm -i' +alias cp='cp -i' +alias mv='mv -i' + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..2d96e90ee --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,72 @@ +// For format details, see https://aka.ms/vscode-remote/devcontainer.json or the definition README at +// https://github.com/microsoft/vscode-dev-containers/tree/master/containers/python-3-miniconda +{ + "name": "flask_admin_dev", + // Path that the Docker build should be run from relative to devcontainer.json. + // For example, a value of ".." would allow you to reference content in sibling directories. Defaults to "." + // Use either the Dockerfile or docker-compose.yml to create the Docker container + // "dockerFile": "Dockerfile", + // "cacheFrom": "ghcr.io/example/example-devcontainer" + "dockerComposeFile": "../docker-compose.dev.yml", + // Required if using dockerComposeFile. The name of the service VS Code should connect to once running. + // The 'service' property is the name of the service for the container that VS Code should + // use. Update this value and .devcontainer/docker-compose.yml to the real service name. + "service": "flask_admin_dev", + // The optional 'workspaceFolder' property is the path VS Code should open by default when + // connected. This is typically a file mount in .devcontainer/docker-compose.yml + // This way if you click "Reopen in container", it knows which workspace to use. + // "workspaceFolder": "/home/user/workspace", + // "workspaceFolder": "/project", + "workspaceFolder": "/workspace", + // "remoteUser": "user", + // "containerUser": "user", + // Forward Docker container port 5005 to Docker host port 8080 with -p 8080:5005 (host port localhost:8080 or http://127.0.0.1/:8080) + // "forwardPorts": [5005], + // "appPort": ["0.0.0.0:5002:5005"], + // Map the Docker sockets with -v argument + // Run as --privileged + // Set environment variables with -e argument + // "runArgs": [ + // "-v", "/var/run/docker.sock:/var/run/docker.sock", + // // "-v", "${env:HOME}${env:USERPROFILE}:/c-users-sean", + // // Avoid reinstalling extensions on container rebuilds (only works if only one VS Code instance is doing this at a time) + // // "-v", "extensions_volume:/root/.vscode-server", + // "--privileged",], + // Uncomment the next line to use a non-root user. See https://aka.ms/vscode-remote/containers/non-root-user. + // "runArgs": [ "-u", "1000" ], + // "runArgs": ["-u", "vscode"], + "customizations": { + "vscode": { + "settings": { + // "python.pythonPath": "/app/venv/bin/python", + "remote.extensionKind": { + "ms-azuretools.vscode-docker": "workspace" + }, + // "macros": { // requires macros extension by publisher:"geddski" + // "pythonExecSelectionAndCursorDown": [ + // "python.execSelectionInTerminal", + // //"vscode.window.activeTextEditor.show()", + // "cursorDown" + // ] + // }, + "git.enableSmartCommit": true, + "git.autofetch": true, + }, + "extensions": [ + "ms-python.python", + "ms-python.isort", + "ms-python.pylint", + "dbaeumer.vscode-eslint", + "ms-azuretools.vscode-docker", + "ms-python.vscode-pylance", + "ms-python.black-formatter", + "samuelcolvin.jinjahtml", + "GitHub.copilot", + "ms-python.flake8", + "ms-toolsai.jupyter" + ] + } + } + // Run commands after the container is created. + // "postCreateCommand": "cd /home/users/upload_to_aws/client && npm install && npm run build" +} diff --git a/.devcontainer/noop.txt b/.devcontainer/noop.txt new file mode 100644 index 000000000..abee19541 --- /dev/null +++ b/.devcontainer/noop.txt @@ -0,0 +1,3 @@ +This file is copied into the container along with environment.yml* from the +parent folder. This is done to prevent the Dockerfile COPY instruction from +failing if no environment.yml is found. \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..486730e7c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,130 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + // Regular Python file debugger to run the current file + { + "name": "Python Run Current File", + "type": "python", + "request": "launch", + // Run whichever file is open (perfect for running different examples) + "program": "${file}", + "console": "integratedTerminal", + "jinja": true, + "justMyCode": false, + "env": { + "PYTHONPATH": "/home/user/workspace" + } + // "stopOnEntry": true, + }, + // Debug with Flask + { + "name": "flask run --no-debugger --no-reload", + "type": "python", + "request": "launch", + // "program": "${file}", + // "console": "integratedTerminal", + "module": "flask", + "env": { + // "FLASK_APP": "examples/sqla-images-postgres-x-editable-ajax/app:app", + "FLASK_APP": "${file}:app", + "FLASK_ENV": "development", + "FLASK_DEBUG": "0" + }, + "args": [ + "run", + "--no-debugger", + "--no-reload", + // "--with-threads" + // "--without-threads" + ], + "jinja": true, + "justMyCode": false, + // "stopOnEntry": true, + }, + // Pytest all files + { + "name": "Pytest All Files", + "type": "python", + "request": "launch", + "module": "pytest", + "console": "integratedTerminal", + "args": [ + "/home/user/workspace/flask_admin/tests/", + "-v", + // "--lf", + "--durations=0", + // // Debugger doesn't always stop on breakpoints with coverage enabled + // "--no-cov", + ], + "jinja": true, + "justMyCode": false, + // "stopOnEntry": true, + }, + // Pytest all files last-failed + { + "name": "Pytest All Files Last-Failed", + "type": "python", + "request": "launch", + "module": "pytest", + "console": "integratedTerminal", + "args": [ + "/home/user/workspace/flask_admin/tests/", + "-v", + "--lf", + "--durations=0", + // // Debugger doesn't always stop on breakpoints with coverage enabled + // "--no-cov", + ], + "jinja": true, + "justMyCode": false, + // "stopOnEntry": true, + }, + // Pytest run the current file only + { + "name": "Pytest Current File", + "type": "python", + "request": "launch", + "module": "pytest", + "console": "integratedTerminal", + "args": [ + "${file}", + "-v", + // "--lf", + "--durations=0", + // // Debugger doesn't always stop on breakpoints with coverage enabled + // "--no-cov", + ], + "jinja": true, + "justMyCode": false, + // "stopOnEntry": true, + "env": { + "_PYTEST_RAISE": "1" + }, + }, + // Pytest run the current file only, with only the last-failed tests + { + "name": "Pytest Current File Last-Failed", + "type": "python", + "request": "launch", + "module": "pytest", + "console": "integratedTerminal", + "args": [ + "${file}", + "-v", + "--lf", + "--durations=0", + // // Debugger doesn't always stop on breakpoints with coverage enabled + // "--no-cov", + ], + "jinja": true, + "justMyCode": false, + // "stopOnEntry": true, + "env": { + "_PYTEST_RAISE": "1" + }, + }, + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..eb2b09285 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,47 @@ +{ + "files.eol": "\n", + "python.languageServer": "Pylance", + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + "app" + ], + "editor.formatOnSave": false, + "[python]": { + "editor.tabSize": 4, + "editor.formatOnSave": false, + "editor.formatOnPaste": false, + }, + "[html]": { + "editor.defaultFormatter": "vscode.html-language-features", + "editor.tabSize": 2, + "editor.formatOnSave": false, + }, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features", + "editor.tabSize": 4, + "editor.formatOnSave": false, + }, + "[json]": { + "editor.defaultFormatter": "vscode.json-language-features", + "editor.tabSize": 2, + "editor.formatOnSave": false, + }, + "[jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features", + "editor.tabSize": 2, + "editor.formatOnSave": false, + }, + "[css]": { + "editor.defaultFormatter": "vscode.css-language-features", + "editor.tabSize": 2, + "editor.formatOnSave": false, + }, + "editor.detectIndentation": false, + "emmet.includeLanguages": { + "jinja-html": "html" + }, + "files.associations": { + "*.html": "jinja-html" + } +} \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..5522d77d6 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,122 @@ +# FROM python:3.8-slim-buster +# FROM python:3.11 +# FROM python:3.11-slim-bullseye +FROM python:3.11.8-slim-bookworm +# FROM node:7.8.0-alpine +# FROM node:17.0-bullseye-slim +# FROM node:17.0-buster-slim +# FROM nikolaik/python-nodejs:python3.8-nodejs17-slim +# FROM nikolaik/python-nodejs:python3.8-nodejs14-slim + +# Use Docker BuildKit for better caching and faster builds +ARG DOCKER_BUILDKIT=1 +ARG BUILDKIT_INLINE_CACHE=1 +# Enable BuildKit for Docker-Compose +ARG COMPOSE_DOCKER_CLI_BUILD=1 + +# # curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE +# # ARG CHROMEDRIVER_VERSION=99.0.4844.51 +# ARG CHROMEDRIVER_VERSION=98.0.4758.102 + +# Configure apt and install packages +# I had to add --insecure since curl didn't work... +RUN apt-get update && \ + apt-get install -y --no-install-recommends apt-utils dialog curl iputils-ping unzip dos2unix gcc 2>&1 && \ + # Install AWS CLI + curl --insecure "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \ + unzip awscliv2.zip && \ + ./aws/install && \ + # Verify git, process tools, lsb-release (common in install instructions for CLIs) installed + apt-get install -y --no-install-recommends sudo git redis-server libpq-dev sass \ + procps iproute2 lsb-release gnupg apt-transport-https \ + g++ protobuf-compiler libprotobuf-dev && \ + # Clean up + apt-get autoremove -y && \ + apt-get clean -y && \ + rm -rf /var/lib/apt/lists/* + +# # Install Google Chrome (google-chrome) for Selenium WebDriver integration testing +# RUN curl --insecure https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \ +# sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' && \ +# apt-get update && \ +# apt-get install -y --no-install-recommends google-chrome-stable && \ +# # Clean up +# apt-get autoremove -y && \ +# apt-get clean -y && \ +# rm -rf /var/lib/apt/lists/* + +# # Install Chromedriver (see CHROMEDRIVER_VERSION arg above) for Selenium +# RUN curl --insecure http://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION}/chromedriver_linux64.zip -o /tmp/chromedriver.zip && \ +# unzip /tmp/chromedriver.zip chromedriver -d /usr/local/bin/ + +# # Set display port to avoid crash in Selenium WebDriver integration testing +# ENV DISPLAY=:99 + +# # Add a new non-root user +# ARG USER_UID_NEW=1000 +# ARG USER_GID_NEW=$USER_UID_NEW +# ARG USERNAME_NEW=user + +# RUN groupadd --system --gid $USER_GID_NEW $USERNAME_NEW && \ +# useradd --system --uid $USER_UID_NEW --gid $USER_GID_NEW --home /home/$USERNAME_NEW -m $USERNAME_NEW + +# RUN echo $USERNAME_NEW ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME_NEW && \ +# chmod 0440 /etc/sudoers.d/$USERNAME_NEW + +# WORKDIR /home/user/workspace +WORKDIR /workspace + +# Install NodeJS, yarn, NPM, and Poetry with the root user +ENV POETRY_HOME=/usr/local +# ENV POETRY_HOME=/home/user/workspace +# ENV PATH="/home/user/workspace/bin:$PATH" +# RUN curl -sS https://deb.nodesource.com/setup_16.x | bash - && \ +RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && \ + # Install nodejs and yarn + apt-get update && \ + apt-get install -yqqf nodejs && \ + # Install the latest version of npm (7) + # npm install --global npm@^7 && \ + npm install --global npm@^9 && \ + # Ensure pip is the latest version + pip install --upgrade pip && \ + # Install Poetry + # curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py | python - && \ + curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python && \ + cd /usr/local/bin && \ + ln -s /opt/poetry/bin/poetry && \ + poetry config virtualenvs.create false && \ + # Clean up + apt-get autoremove -y && \ + apt-get clean -y && \ + rm -rf /var/lib/apt/lists/* + +# BEFORE installing packages with Poetry (so "user" has permissions in future), +# set the user so nobody can run as root on the Docker host (security) +# USER $USER_UID_OLD +# USER $USER_UID_NEW +# ARG TEST=testing + +# ENV PATH="/home/user/workspace:$PATH" +COPY poetry.lock pyproject.toml ./ +RUN \ + # in-project .venv makes it very slow since it's sharing files with Windows/WSL... + # poetry config virtualenvs.in-project true + # These settings get put into the ~/.config./pyconfig/config.toml file + poetry config virtualenvs.create false && \ + poetry config repositories.ijack_private https://pypi.myijack.com + # The following username/password setup doesn't seem to work for some reason... + # poetry config http-basic.ijack_private $PYPI_USERNAME_PRIVATE $PYPI_PASSWORD_PRIVATE + # && echo "Running poetry install..." && \ + # poetry install --no-interaction --no-ansi + +ENV HOST 0.0.0.0 +EXPOSE 3000 + +# Copy my preferred .bashrc to /root/ so that it's automatically "sourced" when the container starts +COPY .bashrc /root/ +# COPY .bashrc /home/user/workspace + +# NODE stuff +# ENV PATH="/home/user/workspace/node_modules/.bin:$PATH" +ENV PATH="/workspace/node_modules/.bin:$PATH" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 000000000..34baf35d9 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,66 @@ +version: '3.7' +services: + + redis: + image: "redis:6.0-rc1-alpine" + restart: unless-stopped + networks: + - flask_admin_dev + + flask_admin_dev: + build: + # context: where should docker compose look for the Dockerfile? + # i.e. either a path to a directory containing a Dockerfile, or a url to a git repository + context: . + dockerfile: Dockerfile.dev + args: + secret=id: secret_envs,src=.env + INSTALL_PYTHON_VERSION: 3.11.8-slim-bookworm + env_file: .env + environment: + DOCKER_BUILDKIT: 1 + COMPOSE_DOCKER_CLI_BUILD: 1 + BUILDKIT_INLINE_CACHE: 1 + FLASK_CONFIG: development + FLASK_ENV: development + # Just for database testing + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + OAUTHLIB_INSECURE_TRANSPORT: 1 + # For Google changing scopes + # Indicates that it's OK for Google to return different OAuth scopes than requested + OAUTHLIB_RELAX_TOKEN_SCOPE: 1 + # Forwards port 0.0.0.0:5002 from the Docker host (e.g. Windows desktop) to the dev environment container's port 5005 + volumes: + # Mount the root folder that contains .git + # - ..:/home/user/workspace:cached + # - ..:/home/user/workspace + - .:/workspace:cached + # # Windows home folder: + # - C:\Users\seanm\:/c_users_sean + # [Optional] For reusing Git SSH keys. + # - ~/.ssh:/root/.ssh-local:ro + ports: + # Dev server running in VS Code uses 5005, + # and the site can be accessed from 5002 outside the container + - 0.0.0.0:5002:5005 + # - 0.0.0.0:5005:5005 + # - 0.0.0.0:85:85 + # # npm start + # - 0.0.0.0:3001:3000 + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + # command: /bin/sh -c "while sleep 1000; do :; done" + # links: + # - container-2 + networks: + - flask_admin_dev + +networks: + + # Just for development unit tests, + # so we don't use the main AWS RDS production database + flask_admin_dev: + +# volumes: +# timescale_dev_volume: diff --git a/examples/forms-files-images/app.py b/examples/forms-files-images/app.py index ce7929455..eba852aa3 100644 --- a/examples/forms-files-images/app.py +++ b/examples/forms-files-images/app.py @@ -6,6 +6,7 @@ from redis import Redis from wtforms import fields, widgets +from sqlalchemy.orm import relationship from sqlalchemy.event import listens_for from markupsafe import Markup @@ -25,6 +26,7 @@ app.config['DATABASE_FILE'] = 'sample_db.sqlite' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + app.config['DATABASE_FILE'] app.config['SQLALCHEMY_ECHO'] = True +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) # Create directory for file fields to use @@ -54,6 +56,24 @@ def __unicode__(self): return self.name +# many-to-many relationship between User and Address +user_address_rel = db.Table( + "user_address_rel", + db.Column( + "user_id", + db.Integer, + db.ForeignKey("user.id", ondelete="CASCADE"), + primary_key=True, + ), + db.Column( + "address_id", + db.Integer, + db.ForeignKey("address.id", ondelete="CASCADE"), + primary_key=True, + ), +) + + class User(db.Model): id = db.Column(db.Integer, primary_key=True) first_name = db.Column(db.Unicode(64)) @@ -61,10 +81,37 @@ class User(db.Model): email = db.Column(db.Unicode(128)) phone = db.Column(db.Unicode(32)) city = db.Column(db.Unicode(128)) + state = db.Column(db.Unicode(128)) country = db.Column(db.Unicode(128)) + continent = db.Column(db.Unicode(128)) notes = db.Column(db.UnicodeText) is_admin = db.Column(db.Boolean, default=False) + # many-to-many relationship + addresses = relationship( + "Address", + secondary=user_address_rel, + back_populates="users", + cascade="all, delete", + passive_deletes=True, + ) + + +class Address(db.Model): + id = db.Column(db.Integer, primary_key=True) + street = db.Column(db.Unicode(128)) + city = db.Column(db.Unicode(128)) + country = db.Column(db.Unicode(128)) + + # many-to-many relationship + users = relationship( + "User", + secondary=user_address_rel, + back_populates="addresses", + cascade="all, delete", + passive_deletes=True, + ) + class Page(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -166,16 +213,38 @@ class UserView(sqla.ModelView): """ This class demonstrates the use of 'rules' for controlling the rendering of forms. """ + can_view_details = True + form_create_rules = [ # Header and four fields. Email field will go above phone field. - rules.FieldSet(('first_name', 'last_name', 'email', 'phone', 'is_admin'), 'Personal'), + rules.FieldSet(('first_name', 'last_name', 'email', 'phone', ), 'Personal'), + rules.Field('is_admin'), # Separate header and few fields rules.Header('Location'), - rules.Field('city'), # String is resolved to form field, so there's no need to explicitly use `rules.Field` - 'country', + rules.Row('city', 'state'), + # many-to-many field (multi-select) + 'addresses', + rules.Row('country', 'continent'), # Show macro that's included in the templates - rules.Container('rule_demo.wrap', rules.Field('notes')) + # rules.Container('rule_demo.wrap', rules.Field('notes')), + rules.Field('notes'), + # Bootstrap container with embedded row and columns + rules.BSContainer( + rules=[ + rules.BSRow( + rules=[ + rules.BSCol( + rules=["email"], + classes="col-xs-6" + ), + "phone" + ], + classes="justify-content-center" + ) + ], + classes="container-fluid" + ) ] # Use same rule set for edit page @@ -188,6 +257,28 @@ class UserView(sqla.ModelView): "is_admin": "Is this an admin user?", } + column_list = [ + 'first_name', 'last_name', 'email', 'phone', 'is_admin', + 'city', 'state', 'country', 'continent', 'addresses', 'notes' + ] + + form_columns = column_list + column_editable_list = form_columns + + # ensure the many-to-many "addresses" is in the details list + column_details_list = column_list + + inline_models = [ + ( + Address, + # Note, the primary key "id" MUST be included in this list to avoid errors! + # {"form_columns": ["street", "city"]} + {"form_columns": ["street", "city", "id"]} + ) + ] + +class AddressView(sqla.ModelView): + """Address records view""" # Flask views @app.route('/') @@ -203,6 +294,7 @@ def index(): admin.add_view(UserView(User, db.session)) admin.add_view(PageView(Page, db.session)) admin.add_view(rediscli.RedisCli(Redis())) +admin.add_view(sqla.ModelView(Address, db.session)) def build_sample_db(): @@ -278,6 +370,12 @@ def build_sample_db(): file.path = "example_" + str(i) + ".pdf" db.session.add(file) + address = Address() + address.street = "Example street " + str(i) + address.city = "Example city " + str(i) + address.country = "Example country " + str(i) + db.session.add(address) + sample_text = "
Create HTML content in a text area field with the help of WTForms and CKEditor.
" db.session.add(Page(name="Test Page", text=sample_text)) @@ -295,4 +393,4 @@ def build_sample_db(): build_sample_db() # Start app - app.run(debug=True) + app.run(debug=False) diff --git a/examples/sqla-custom-inline-forms/app.py b/examples/sqla-custom-inline-forms/app.py index 686130526..65ae0c8a7 100644 --- a/examples/sqla-custom-inline-forms/app.py +++ b/examples/sqla-custom-inline-forms/app.py @@ -1,6 +1,6 @@ import os import os.path as op - +from pathlib import Path from werkzeug.utils import secure_filename from sqlalchemy import event @@ -11,6 +11,7 @@ import flask_admin as admin from flask_admin.contrib.sqla.ajax import QueryAjaxModelLoader +from flask_admin.contrib.sqla.ajax import QueryAjaxModelLoader from flask_admin.form import RenderTemplateWidget from flask_admin.model.form import InlineFormAdmin from flask_admin.contrib.sqla import ModelView @@ -54,6 +55,22 @@ def __repr__(self) -> str: return self.name +class ImageType(db.Model): + """ + Just so the LocationImage can have another foreign key, + so we can test the "form_ajax_refs" inside the "inline_models" + """ + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(64)) + + def __repr__(self) -> str: + """ + Represent this model as a string + (e.g. in the Image Type list dropdown when creating an inline model) + """ + return self.name + + class LocationImage(db.Model): id = db.Column(db.Integer, primary_key=True) alt = db.Column(db.Unicode(128)) @@ -65,6 +82,9 @@ class LocationImage(db.Model): image_type_id = db.Column(db.Integer, db.ForeignKey(ImageType.id)) image_type = db.relation(ImageType, backref='images') + image_type_id = db.Column(db.Integer, db.ForeignKey(ImageType.id)) + image_type = db.relation(ImageType, backref='images') + # Register after_delete handler which will delete image file after model gets deleted @event.listens_for(Location, 'after_delete') @@ -88,6 +108,8 @@ class CustomInlineModelFormList(InlineModelFormList): widget = CustomInlineFieldListWidget() def display_row_controls(self, field): + """Whether to display the edit/delete/duplicate controls""" + # return field.get_pk() is not None return False @@ -97,6 +119,7 @@ class CustomInlineModelConverter(InlineModelConverter): # Customized inline form handler +class LocationImageInlineModelForm(InlineFormAdmin): class LocationImageInlineModelForm(InlineFormAdmin): form_excluded_columns = ('path',) @@ -115,6 +138,19 @@ class LocationImageInlineModelForm(InlineFormAdmin): ) } + # Setup AJAX lazy-loading for the ImageType inside the inline model + form_ajax_refs = { + "image_type": QueryAjaxModelLoader( + name="image_type", + session=db.session, + model=ImageType, + fields=("name",), + order_by="name", + placeholder="Please use an AJAX query to select an image type for the image", + minimum_input_length=0, + ) + } + def __init__(self): super(LocationImageInlineModelForm, self).__init__(LocationImage) @@ -134,6 +170,7 @@ def on_model_change(self, form, model, is_created): class LocationAdmin(ModelView): inline_model_form_converter = CustomInlineModelConverter + inline_models = (LocationImageInlineModelForm(),) inline_models = (LocationImageInlineModelForm(),) def __init__(self): @@ -171,11 +208,61 @@ def first_time_setup(): pass # Create DB - first_time_setup() + db.drop_all() + db.create_all() + + # Add some image types for the form_ajax_refs inside the inline_model + image_types = ("JPEG", "PNG", "GIF") + for image_type in image_types: + model = ImageType(name=image_type) + db.session.add(model) + + db.session.commit() + + return + + +# if __name__ == '__main__': +# Create upload directory +try: + os.mkdir(base_path) +except OSError: + pass +def first_time_setup(): + """Run this to setup the database for the first time""" + # Create DB + db.drop_all() + db.create_all() + + # Add some image types for the form_ajax_refs inside the inline_model + image_types = ("JPEG", "PNG", "GIF") + for image_type in image_types: + model = ImageType(name=image_type) + db.session.add(model) + + db.session.commit() + + return + + +# if __name__ == '__main__': +# Create upload directory +try: + os.mkdir(base_path) +except OSError: + pass + +# Create admin +admin = admin.Admin(app, name='Example: Inline Models') + +# Add views +admin.add_view(LocationAdmin()) - # Create admin - admin = admin.Admin(app, name='Example: Inline Models') - admin.add_view(LocationAdmin()) +# Create DB +first_time_setup() +# Create DB +first_time_setup() - # Start app - app.run(debug=True) +# Start app +app.run(debug=True) +print("Started") diff --git a/examples/sqla-images-postgres-x-editable-ajax/README.rst b/examples/sqla-images-postgres-x-editable-ajax/README.rst new file mode 100644 index 000000000..69e57c1e7 --- /dev/null +++ b/examples/sqla-images-postgres-x-editable-ajax/README.rst @@ -0,0 +1,24 @@ +This example shows how to: +1. Use x-editable-ajax to speed up the list view, and speed up editing of records. +2. Store images in a PostgreSQL database, instead of as a file +3. Use custom inline forms for uploading your image + +To run this example: + +1. Clone the repository:: + + git clone https://github.com/flask-admin/flask-admin.git + cd flask-admin + +2. Create and activate a virtual environment:: + + virtualenv env + source env/bin/activate + +3. Install requirements:: + + pip install -r 'examples/sqla-images-postgres-x-editable-ajax/requirements.txt' + +4. Run the application:: + + python examples/sqla-images-postgres-x-editable-ajax/app.py diff --git a/examples/sqla-images-postgres-x-editable-ajax/app.py b/examples/sqla-images-postgres-x-editable-ajax/app.py new file mode 100644 index 000000000..93e1f9dd4 --- /dev/null +++ b/examples/sqla-images-postgres-x-editable-ajax/app.py @@ -0,0 +1,262 @@ +import base64 +from pathlib import Path +from flask import Flask, render_template +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import relationship + +import flask_admin as admin +from flask_admin.form import RenderTemplateWidget +from flask_admin.form.upload import ImageUploadFieldDB +from flask_admin.model.fields import ColorField +from flask_admin.model.form import InlineFormAdmin +from flask_admin.contrib.sqla import ModelView +from flask_admin.contrib.sqla.ajax import QueryAjaxModelLoader +from flask_admin.contrib.sqla.form import InlineModelConverter +from flask_admin.contrib.sqla.fields import InlineModelFormList + +# Create application +app = Flask(__name__) + +# Create dummy secret key so we can use sessions +app.config['SECRET_KEY'] = '123456790' + +# Create in-memory database +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.sqlite' +app.config['SQLALCHEMY_ECHO'] = True +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +# Add filter function to jinja2 environment +def b64encode(binary): + """Read the image into Pillow and convert to a string for the browser""" + image = ImageUploadFieldDB.binary_to_image(binary) + image_bytes = ImageUploadFieldDB.image_to_bytes(image, format=image.format) + data_uri = base64.b64encode(image_bytes).decode("utf-8") + return data_uri + +app.jinja_env.filters['b64encode'] = b64encode + +db = SQLAlchemy(app) + + +# Create models + +# many-to-many relationship between User and Image classes/tables +user_image_rel = db.Table( + "user_image_rel", + db.Column( + "user_id", + db.Integer, + db.ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + ), + db.Column( + "image_id", + db.Integer, + db.ForeignKey("images.id", ondelete="CASCADE"), + primary_key=True, + ), +) + + +class User(db.Model): + """Locations table, for which we may have images""" + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.Unicode(64)) + favorite_color = db.Column(db.String(6)) + + images = relationship("Image", secondary=user_image_rel, back_populates="users") + + +class Location(db.Model): + """Locations table, for which we may have images""" + __tablename__ = "locations" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.Unicode(64)) + + images = relationship("Image", back_populates="location") + + +class Image(db.Model): + """ + Create a table into which we can upload images + """ + __tablename__ = "images" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(60), nullable=False) + image = db.Column(db.LargeBinary, nullable=False) + + # Many-to-one relationship with locations table + location_id = db.Column(db.Integer, db.ForeignKey(Location.id)) + location = relationship("Location", back_populates="images") + + # Many-to-many relationship with users table + users = relationship("User", secondary=user_image_rel, back_populates="images") + + + +class CustomInlineFieldListWidget(RenderTemplateWidget): + """This widget uses custom template for inline field list""" + def __init__(self): + super(CustomInlineFieldListWidget, self).__init__('field_list.html') + + +class CustomInlineModelFormList(InlineModelFormList): + """This InlineModelFormList will use our custom widget and hide row controls""" + widget = CustomInlineFieldListWidget() + + def display_row_controls(self, field): + return False + + +class CustomInlineModelConverter(InlineModelConverter): + """Create custom InlineModelConverter and tell it to use our InlineModelFormList""" + inline_field_list_type = CustomInlineModelFormList + + +class InlineModelForm(InlineFormAdmin): + """Customized inline form handler""" + + form_label = 'Image' + + form_extra_fields = { + "image": ImageUploadFieldDB("Image") + } + + def __init__(self): + return super(InlineModelForm, self).__init__(Image) + + +class LocationAdmin(ModelView): + """Administrative class for viewing location records""" + can_view_details = True + + inline_model_form_converter = CustomInlineModelConverter + + inline_models = (InlineModelForm(),) + + def __init__(self): + super(LocationAdmin, self).__init__(Location, db.session, name='Locations') + + +class UserAdmin(ModelView): + """Administrative class for viewing location-image records""" + + # Demonstrate many-to-many relationship between users and images, + # with x-editable select-multiple dropdown. + # Also demonstrate the x-editable color-picker. + column_editable_list = ["images", "favorite_color"] + + # For displaying a thumbnail in the list view + column_formatters = {"images": ImageUploadFieldDB.display_thumbnail} + + # Use AJAX with x-editable in the list view, to speed up list view display + form_ajax_refs = { + "images": QueryAjaxModelLoader( + "images", + db.session, + Image, + fields=["name"], + order_by="name", + placeholder="Please select an image", + ), + } + + # The color field is an Input(type="color") field + form_overrides = { + "favorite_color": ColorField, + } + + def __init__(self): + super(UserAdmin, self).__init__(User, db.session, name='Users') + + +class ImageAdmin(ModelView): + """Administrative class for viewing location-image records""" + + # Location is x-editable in the list view + column_editable_list = ["location"] + + # For displaying a thumbnail + column_formatters = {"image": ImageUploadFieldDB.display_thumbnail} + + # This field uploads large binary data directly to a database, instead of to a file + form_extra_fields = { + "image": ImageUploadFieldDB("Image") + } + + # Location is x-editable in the list view, and still we can use AJAX + form_ajax_refs = { + "location": QueryAjaxModelLoader( + "location", + db.session, + Location, + fields=["name"], + order_by="name", + placeholder="Please select a location", + **{ + "minimum_input_length": 0, + }, + ), + } + + def __init__(self): + super(ImageAdmin, self).__init__(Image, db.session, name='Images') + + +@app.route('/') +def index(): + """Simple page to show images""" + images = db.session.query(Image).all() + thumbnails = [ + ImageUploadFieldDB.display_thumbnail(None, None, image, "image") + for image in images + ] + return render_template('locations.html', thumbnails=thumbnails) + + +def first_time_setup(): + """Run this to setup the database for the first time""" + # Create DB + db.drop_all() + db.create_all() + + # Upload the test image to the database + test_image = Path(__file__).parent.joinpath("static").joinpath("test_image.jpg") + with open(test_image, "rb") as file: + image_data = file.read() + image = ImageUploadFieldDB.binary_to_image(image_data) + image_bytes = ImageUploadFieldDB.image_to_bytes(image, image.format) + image = Image(name="test image", image=image_bytes) + db.session.add(image) + + # Upload a test location, with image + location = Location(name="first location", images=[image]) + db.session.add(location) + + # Upload a test user, with image and favorite color + user = User(name="Test User", favorite_color="#007BFF", images=[image]) + db.session.add(user) + + db.session.commit() + + return + + +if __name__ == '__main__': + # Create admin + admin = admin.Admin(app, name='Example: Images in Database', template_mode="bootstrap4") + + # Add views + admin.add_view(UserAdmin()) + admin.add_view(LocationAdmin()) + admin.add_view(ImageAdmin()) + + # Setup the database + first_time_setup() + + # Start app + app.run(debug=True) diff --git a/examples/sqla-images-postgres-x-editable-ajax/requirements.txt b/examples/sqla-images-postgres-x-editable-ajax/requirements.txt new file mode 100644 index 000000000..60c396631 --- /dev/null +++ b/examples/sqla-images-postgres-x-editable-ajax/requirements.txt @@ -0,0 +1,5 @@ +Flask +Flask-Admin +Flask-SQLAlchemy +WTForms==1.0.5 +Pillow diff --git a/examples/sqla-images-postgres-x-editable-ajax/static/test_image.jpg b/examples/sqla-images-postgres-x-editable-ajax/static/test_image.jpg new file mode 100644 index 000000000..dd3a5458c Binary files /dev/null and b/examples/sqla-images-postgres-x-editable-ajax/static/test_image.jpg differ diff --git a/examples/sqla-images-postgres-x-editable-ajax/templates/field_list.html b/examples/sqla-images-postgres-x-editable-ajax/templates/field_list.html new file mode 100644 index 000000000..c2cc6d92a --- /dev/null +++ b/examples/sqla-images-postgres-x-editable-ajax/templates/field_list.html @@ -0,0 +1,13 @@ +{% import 'admin/model/inline_list_base.html' as base with context %} + +{% macro render_field(field) %} + {% set model = field.object_data %} + {% if model and model.image %} + {{ field.form.id }} +