diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34becff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +__pycache__ +.cache +.coverage +.coverage.* +.dockerignore +.drone.yml +.git +.gitignore +.Python +.tox +*,cover +*.log +*.pyc +*.pyd +*.pyo +coverage.xml +db.sqlite3 +docker-compose.yml +Dockerfile +env +pip-delete-this-directory.txt +pip-log.txt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..34c40d2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# EditorConfig is awesome: http://EditorConfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +#trim_trailing_whitespace = true +insert_final_newline = true + +[*.sh] +indent_style = tabs +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.js,*.json] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4749e73 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Required environment variables — copy to .env and fill in values before running. + +# REQUIRED: Flask secret key. Generate with: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY= + +# OPTIONAL: Flask environment (development | testing | production). Default: development +FLASK_ENV=development + +# OPTIONAL: Port to bind the dev server to. Default: 8080 +PORT=8080 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..51d7f23 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +# Issue + + +# How was it fixed ? + + +# How is it being tested ? + + +# Out of Scope + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..af03dfd --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,168 @@ +# GitHub Copilot Instructions — flask-mvc + +## Project overview + +A Flask boilerplate following the **MVC pattern** designed as a base skeleton for new Python web +applications. The primary goal is clarity and correctness over complexity. + +--- + +## Architecture + +### Application Factory + +The app is created via `create_app(config_name)` in `project/__init__.py`. Never import a global +`app` object — always go through the factory. The factory: +1. Loads the correct `Config` class from `project/config.py` +2. Initialises Flask extensions (e.g. `DebugToolbarExtension`) +3. Registers all Blueprints + +### MVC layout + +``` +project/ +├── __init__.py # create_app() factory +├── config.py # Config classes (Development / Testing / Production) +├── controllers/ # Flask Blueprints — one file per resource +│ └── printer.py # printer_bp Blueprint +├── models/ # Plain Python classes, no ORM currently +│ └── printer.py +├── static/ +│ └── css/style.css +└── templates/ + ├── layout/ + │ ├── layout.html # base template + │ └── macros.html # reusable Jinja2 macros (render_error, etc.) + └── printer/ + ├── index.html + └── print.html +``` + +### Blueprints + +Every controller is a Blueprint registered in `create_app()`. Do **not** use the old glob +auto-import pattern. To add a new resource: + +1. Create `project/controllers/.py` with a `_bp = Blueprint(...)`. +2. Register it in `create_app()`: + ```python + from project.controllers. import _bp + app.register_blueprint(_bp) + ``` + +--- + +## Conventions + +### Python style + +- **PEP 8** throughout. `flake8` enforces it with `max-line-length = 100`. +- Module filenames are always **`snake_case.py`** — never PascalCase. +- Imports at the **top** of every file — never inside functions or routes. +- Classes use **PascalCase**; functions and variables use **snake_case**. + +### Configuration + +- All runtime config comes from **environment variables**. No secrets in source code. +- `SECRET_KEY` is **required** at startup; the app raises `ValueError` immediately if missing. +- Use `FLASK_ENV` to select the config profile: `development` (default), `testing`, `production`. +- Reference `project/config.py` before adding any new config key. + +### Controllers + +- One Blueprint per resource in `project/controllers/`. +- Always use **POST/Redirect/GET** after a successful form submission to prevent double-posts: + ```python + return redirect(url_for('.')) + ``` +- Form classes live in the same file as the Blueprint that owns them. +- Keep controllers thin — delegate logic to model classes. + +### Models + +- Plain Python classes. No Flask imports except `flash` / `current_app` where unavoidable. +- Models do **not** duplicate validation that the form layer already enforces. + +### Templates + +- All templates extend `layout/layout.html`. +- Reusable Jinja2 macros belong in `layout/macros.html` and are imported with + `{% from "layout/macros.html" import %}`. +- Use `url_for('blueprint_name.view_name')` — never hardcode URLs. +- Write valid HTML5 (``, ``, ``). + +--- + +## Environment variables + +| Variable | Required | Default | Description | +|------------|----------|---------------|--------------------------------------| +| `SECRET_KEY` | Yes | — | Flask session / CSRF signing key | +| `FLASK_ENV` | No | `development` | Config profile to load | +| `PORT` | No | `8080` | Port for the development server | + +Generate a secure key with: +```bash +python -c "import secrets; print(secrets.token_hex(32))" +``` + +--- + +## Development setup + +```bash +cp .env.example .env # fill in SECRET_KEY at minimum +python -m venv .venv +source .venv/bin/activate +pip install -r requirements-test.txt +``` + +Run the dev server: +```bash +SECRET_KEY= python runserver.py +# or: make run (reads from .env if you source it first) +``` + +--- + +## Testing + +```bash +SECRET_KEY=test-secret pytest tests/ -v --cov=project +``` + +- Tests live in `tests/`. Fixtures (`app`, `client`) are in `tests/conftest.py`. +- Use `TestingConfig` which sets `WTF_CSRF_ENABLED = False` and a fallback `SECRET_KEY`. +- Every new controller must have a corresponding test file `tests/test_.py`. +- Cover: GET renders correct template, POST with valid data redirects, POST with invalid data + returns the form with errors. +- The `conftest.py` `app` fixture calls `create_app('testing')` — do not create a new app + instance inside individual test files. + +--- + +## Adding a new resource (checklist) + +- [ ] `project/models/.py` — model class +- [ ] `project/controllers/.py` — Blueprint + form + routes +- [ ] Register Blueprint in `project/__init__.py → create_app()` +- [ ] `project/templates//` — HTML templates +- [ ] `tests/test_.py` — test suite +- [ ] Export any new env vars in `.env.example` + +--- + +## What is intentionally excluded + +This boilerplate is minimal by design. The following are **not wired up yet** but can be added +when needed: + +- Database / SQLAlchemy — postgres is already running in `docker-compose.yml` on port 5432 + (`flask_dev` db, user `flask`, password `flask`). Add Flask-SQLAlchemy + Flask-Migrate and + set `DATABASE_URL` in `.env` to connect. +- Authentication (Flask-Login, Flask-Security) +- REST API layer (Flask-RESTful) +- Task queue (Celery / Flower) +- Frontend asset pipeline (Flask-Assets, cssmin, jsmin) + +If you need one of these, add it in isolation with its own Blueprint and tests. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9889861 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: "daily" + time: "10:00" + timezone: "Europe/Berlin" + commit-message: + prefix: "deps" + prefix-development: "deps-dev" + include: "scope" + open-pull-requests-limit: 1 + rebase-strategy: "auto" + groups: + batch-updates: + patterns: + - "*" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "monthly" + time: "10:00" + timezone: "Europe/Berlin" + commit-message: + prefix: "docker" + open-pull-requests-limit: 1 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + time: "10:00" + timezone: "Europe/Berlin" + commit-message: + prefix: "ci" + open-pull-requests-limit: 1 \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..d0a6d0e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,101 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: '24 18 * * 6' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@65216971a11ded447a6b76263d5a144519e5eee1 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@65216971a11ded447a6b76263d5a144519e5eee1 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml new file mode 100644 index 0000000..ba64ccb --- /dev/null +++ b/.github/workflows/master.yml @@ -0,0 +1,73 @@ +name: Flask-mvc + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +permissions: + contents: read + +jobs: + lint: + name: Lint & format + runs-on: ubuntu-latest + + steps: + # actions/checkout@v5 (93cb6efe18208431cddfb8368fd83d5badbf9bfd) + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Set up Python + # actions/setup-python@v5 (a26af69be951a213d495a4c3e4e4022e16d87065) + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-test.txt + + - name: Install lint dependencies + run: | + python -m pip install --upgrade pip + pip install black ruff + + - name: Format check (black) + run: black --check --diff . + + - name: Lint (ruff) + run: ruff check . + + test: + name: Tests & coverage + runs-on: ubuntu-latest + needs: lint + + steps: + # actions/checkout@v5 (93cb6efe18208431cddfb8368fd83d5badbf9bfd) + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Set up Python + # actions/setup-python@v5 (a26af69be951a213d495a4c3e4e4022e16d87065) + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-test.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + + - name: Run tests with coverage + env: + SECRET_KEY: ${{ secrets.SECRET_KEY || 'ci-test-secret-key-not-for-production' }} + run: pytest tests/ -v --cov=project --cov-branch --cov-report=xml + + - name: Upload coverage to Codecov + # codecov/codecov-action@v6 (57e3a136b779b570ffcdbf80b3bdc90e7fab3de2) + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d210770 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.idea +*.pyc +*/__pycache__ +*.DS_STORE +config/config.py +!config/config.py.example +.*-venv*/ +data/* +temp/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..0104088 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14.4 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..628e985 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.14.4 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9558880 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM python:3.14-alpine + +LABEL maintainer="me@salimane.com" \ + vendor="salimane" \ + name="salimane/flask-mvc" \ + description="Python boilerplate application following MVC pattern using Flask." \ + com.salimane.component.name="flask-mvc" \ + com.salimane.component.distribution-scope="public" \ + com.salimane.component.changelog-url="https://github.com/salimane/flask-mvc/releases" \ + com.salimane.component.url="https://github.com/salimane/flask-mvc" + +ARG BUILD_DATE +ARG VCS_REF +ARG VCS_REF_MSG +ARG VCS_URL +ARG VERSION + +LABEL com.salimane.component.build-date="$BUILD_DATE" \ + com.salimane.component.vcs-url="$VCS_URL" \ + com.salimane.component.vcs-ref="$VCS_REF" \ + com.salimane.component.vcs-ref-msg="$VCS_REF_MSG" \ + com.salimane.component.version="$VERSION" + +ENV LANG=en_US.UTF-8 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apk --no-cache add gcc musl-dev libffi-dev openssl-dev \ + postgresql-dev libpq && \ + rm -rf /var/cache/apk/* + +WORKDIR /opt/flask + +# Install dependencies first (cached layer unless requirements.txt changes) +COPY requirements.txt . +RUN pip install --no-cache-dir -U pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application source +COPY . . + +EXPOSE 16000 + +CMD ["gunicorn", "runserver:app", "--bind", "0.0.0.0:16000", "--workers", "4", "--threads", "2", "--worker-class", "sync"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4c4f5b0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Salimane Adjao Moustapha + +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b68b190 --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ +.PHONY: all check-version docker-build docker-push docker-run run set-revision clean setup test lint fmt + +VERSION := $(strip $(shell [ -d .git ] && git describe --always --tags --dirty)) +BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%S%Z") +VCS_URL := $(shell [ -d .git ] && git config --get remote.origin.url) +VCS_REF := $(strip $(shell [ -d .git ] && git rev-parse --short HEAD)) +# IS_TAG := $(shell [ -d .git ] && git describe --exact-match HEAD || echo 'no tags') +VCS_REF_MSG := $(shell if [ "$(IS_TAG)" != "" ]; then git tag -l -n1 $(IS_TAG) | awk '{$$1 = ""; print $$0;}'; else git log --format='%s' -n 1 $(VCS_REF); fi) + +all: docker-build; + +check-version: + $(info $(VERSION)) +ifneq (,$(findstring dirty,$(VERSION))) + $(error Working copy dirty, aborting) +endif + +docker-build: + docker build \ + --build-arg BUILD_DATE="$(BUILD_DATE)" \ + --build-arg VERSION="$(VERSION)" \ + --build-arg VCS_URL="$(VCS_URL)" \ + --build-arg VCS_REF="$(VCS_REF)" \ + --build-arg VCS_REF_MSG="$(VCS_REF_MSG)" \ + --compress \ + -t salimane/flask-mvc:latest . + +docker-push: #check-version + if [ "$(IS_TAG)" != "" ]; then \ + docker tag salimane/flask-mvc:latest salimane/flask-mvc:$(VERSION);\ + fi + docker push salimane/flask-mvc + +docker-run: + docker-compose up -d + +run: + python runserver.py + +set-revision: + echo $(BUILD_DATE) > BUILD_TIME + if [ -d ".git" ]; then echo "$(VERSION)"; fi > BUILD_REVISION + +clean: + find . -name '*venv*' | xargs rm -rf + rm -rf "htmlcov" ".cache" ".coverage" + +setup: + script/setup + +test: + script/test + +lint: + ruff check . + +fmt: + black . + ruff check --fix . + diff --git a/Procfile b/Procfile index 597f8b6..79ad41d 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: python runserver.py +web: gunicorn "runserver:app" --bind 0.0.0.0:$PORT --workers 2 diff --git a/README.markdown b/README.markdown deleted file mode 100644 index 2a423a3..0000000 --- a/README.markdown +++ /dev/null @@ -1,18 +0,0 @@ -A simple boilerplate application following the MVC pattern using Flask micro python framework. -It basically here to be my base skeleton for new python web applications - -Demo : http://flask-mvc-salimane.herokuapp.com/ - -Dependencies : - - git clone git://github.com/salimane/flask-mvc.git - cd flask-mvc - pip install -r requirements.txt - -To run: - - python runserver.py - - -[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/salimane/flask-mvc/trend.png)](https://bitdeli.com/free "Bitdeli Badge") - diff --git a/README.md b/README.md new file mode 100644 index 0000000..37c2c9f --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# Flask-MVC + +[![Build Status](https://github.com/salimane/flask-mvc/actions/workflows/master.yml/badge.svg)](https://github.com/salimane/flask-mvc/actions) +[![codecov](https://codecov.io/gh/salimane/flask-mvc/branch/master/graph/badge.svg)](https://codecov.io/gh/salimane/flask-mvc) +[![Maintenance](https://img.shields.io/maintenance/yes/2026.svg)](https://github.com/salimane/flask-mvc/commits/master) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +A minimal Flask boilerplate following the **MVC pattern** — designed as a clean starting point +for new Python web applications. + +--- + +## Features + +- **Application Factory** (`create_app()`) — safe for testing and multiple configs +- **Blueprint-based controllers** — one file per resource, explicit registration +- **Environment-based configuration** — `Development`, `Testing`, `Production` config classes +- **POST/Redirect/GET** form handling — prevents double-submit on refresh +- **Jinja2 macro library** — shared `render_error` and helpers in `layout/macros.html` +- **Test suite** — 96% coverage out of the box with pytest + pytest-cov +- **Docker-ready** — optimised multi-layer `Dockerfile` and `docker-compose.yml` + +--- + +## Project structure + +``` +flask-mvc/ +├── project/ +│ ├── __init__.py # create_app() factory +│ ├── config.py # Dev / Testing / Production config classes +│ ├── controllers/ +│ │ └── printer.py # printer Blueprint + form +│ ├── models/ +│ │ └── printer.py # Printer model +│ ├── static/css/ +│ └── templates/ +│ ├── layout/ +│ │ ├── layout.html # base template +│ │ └── macros.html # reusable Jinja2 macros +│ └── printer/ +├── tests/ +│ ├── conftest.py # app / client fixtures +│ └── test_printer.py +├── .env.example # required env vars — copy to .env before running +├── Dockerfile +├── docker-compose.yml +├── requirements.txt +├── requirements-test.txt +└── runserver.py +``` + +--- + +## Prerequisites + +- Python 3.14+ +- pip + +Optional (for Python version management): + +```bash +brew install asdf +asdf plugin add python +asdf install python 3.14.4 +``` + +--- + +## Setup + +```bash +# 1. Clone +git clone git@github.com:salimane/flask-mvc.git +cd flask-mvc + +# 2. Create and activate a virtual environment +python -m venv .venv +source .venv/bin/activate + +# 3. Install dependencies +pip install -r requirements-test.txt + +# 4. Configure environment +cp .env.example .env +# Open .env and set SECRET_KEY — generate one with: +# python -c "import secrets; print(secrets.token_hex(32))" +``` + +--- + +## Running + +```bash +# Development server (reads PORT from env, default 8080) +source .env && python runserver.py + +# Or via Make +make run +``` + +### Docker + +```bash +cp .env.example .env # fill in SECRET_KEY +docker-compose up --build +``` + +The app is served by **gunicorn** on port `16000` inside the container. + +--- + +## Testing + +```bash +SECRET_KEY=test pytest tests/ -v --cov=project --cov-report=term-missing +``` + +Or via the helper script (creates an isolated virtualenv automatically): + +```bash +script/test +``` + +### Coverage + +``` +Name Stmts Miss Cover +----------------------------------------------------- +project/__init__.py 15 1 93% +project/config.py 20 1 95% +project/controllers/printer.py 18 0 100% +project/models/printer.py 4 0 100% +----------------------------------------------------- +TOTAL 57 2 96% +``` + +--- + +## Configuration + +All configuration is driven by environment variables. See `.env.example` for the full list. + +| Variable | Required | Default | Description | +|--------------|----------|---------------|----------------------------------| +| `SECRET_KEY` | **Yes** | — | Flask session / CSRF signing key | +| `FLASK_ENV` | No | `development` | Config profile (`development`, `testing`, `production`) | +| `PORT` | No | `8080` | Dev server port | + +Generate a secure key: + +```bash +python -c "import secrets; print(secrets.token_hex(32))" +``` + +--- + +## Extending the boilerplate + +To add a new resource (e.g. `user`): + +1. **Model** — `project/models/user.py` +2. **Controller** — `project/controllers/user.py` with `user_bp = Blueprint('user', __name__)` +3. **Register** — add `app.register_blueprint(user_bp)` in `create_app()` (`project/__init__.py`) +4. **Templates** — `project/templates/user/` +5. **Tests** — `tests/test_user.py` + +See [`.github/copilot-instructions.md`](.github/copilot-instructions.md) for the full development +guide and coding conventions. + +--- + +## Contributing + +Issues and pull requests are welcome. Please keep PRs focused — one concern per PR. + +## Maintainers + +[Salimane Adjao Moustapha](https://github.com/salimane) + +## License + +Copyright © 2026 Salimane Adjao Moustapha. Licensed under the [MIT License](LICENSE). + diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..3f2d27d --- /dev/null +++ b/codecov.yml @@ -0,0 +1,4 @@ +coverage: + ignore: # files and folders for processing + - .*test/.* + - .*test.py diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..99f6398 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' +services: + web: + build: . + ports: + - "16000:16000" + environment: + - SECRET_KEY=${SECRET_KEY} + - FLASK_ENV=${FLASK_ENV:-production} + env_file: + - .env + depends_on: + - postgres + + postgres: + image: postgres:15.2-alpine + ports: + - "5432:5432" + environment: + POSTGRES_DB: flask_dev + POSTGRES_USER: flask + POSTGRES_PASSWORD: flask + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/project/__init__.py b/project/__init__.py index e73a1ec..c1e27c9 100644 --- a/project/__init__.py +++ b/project/__init__.py @@ -1,9 +1,26 @@ -# -*- coding: utf-8 -*- -__version__ = '0.1' +__version__ = "0.1" + from flask import Flask from flask_debugtoolbar import DebugToolbarExtension -app = Flask('project') -app.config['SECRET_KEY'] = 'random' -app.debug = True -toolbar = DebugToolbarExtension(app) -from project.controllers import * + +from project.config import config + +toolbar = DebugToolbarExtension() + + +def create_app(config_name=None): + import os + + if config_name is None: + config_name = os.environ.get("FLASK_ENV", "default") + + app = Flask("project") + app.config.from_object(config[config_name]) + + toolbar.init_app(app) + + from project.controllers.printer import printer_bp + + app.register_blueprint(printer_bp) + + return app diff --git a/project/config.py b/project/config.py new file mode 100644 index 0000000..3dc0203 --- /dev/null +++ b/project/config.py @@ -0,0 +1,36 @@ +import os + + +class Config: + SECRET_KEY = os.environ.get("SECRET_KEY") + if not SECRET_KEY: + raise ValueError("SECRET_KEY environment variable is not set") + + WTF_CSRF_ENABLED = True + DEBUG_TB_ENABLED = False + DEBUG_TB_INTERCEPT_REDIRECTS = False + + +class DevelopmentConfig(Config): + DEBUG = True + DEBUG_TB_ENABLED = True + + +class TestingConfig(Config): + SECRET_KEY = os.environ.get("SECRET_KEY", "test-secret-key-not-for-production") + TESTING = True + WTF_CSRF_ENABLED = False + DEBUG_TB_ENABLED = False + + +class ProductionConfig(Config): + DEBUG = False + TESTING = False + + +config = { + "development": DevelopmentConfig, + "testing": TestingConfig, + "production": ProductionConfig, + "default": DevelopmentConfig, +} diff --git a/project/controllers/__init__.py b/project/controllers/__init__.py index 844f750..6651888 100644 --- a/project/controllers/__init__.py +++ b/project/controllers/__init__.py @@ -1,4 +1 @@ -import os -import glob -__all__ = [os.path.basename( - f)[:-3] for f in glob.glob(os.path.dirname(__file__) + "/*.py")] +# Blueprints are registered explicitly in project/__init__.py via create_app(). diff --git a/project/controllers/printer.py b/project/controllers/printer.py index ac3c472..ca707d1 100644 --- a/project/controllers/printer.py +++ b/project/controllers/printer.py @@ -1,24 +1,26 @@ -# -*- coding: utf-8 -*- -from project import app -from flask import render_template, request -from flask.ext.wtf import Form, TextField, validators +from flask import Blueprint, redirect, render_template, request, url_for +from flask_wtf import FlaskForm +from wtforms import StringField +from wtforms.validators import DataRequired +from project.models.printer import Printer -class CreateForm(Form): - text = TextField(u'Text:', [validators.Length(min=1, max=20)]) +printer_bp = Blueprint("printer", __name__) -@app.route('/') +class CreateForm(FlaskForm): + text = StringField("name", validators=[DataRequired()]) + + +@printer_bp.route("/") def start(): - return render_template('printer/index.html') + return render_template("printer/index.html") -@app.route('/print', methods=['GET', 'POST']) +@printer_bp.route("/print", methods=["GET", "POST"]) def printer(): form = CreateForm(request.form) - if request.method == 'POST' and form.validate(): - from project.models.Printer import Printer - printer = Printer() - printer.show_string(form.text.data) - return render_template('printer/index.html') - return render_template('printer/print.html', form=form) + if request.method == "POST" and form.validate(): + Printer().show_string(form.text.data) + return redirect(url_for("printer.start")) + return render_template("printer/print.html", form=form) diff --git a/project/models/Printer.py b/project/models/Printer.py deleted file mode 100644 index 54ff974..0000000 --- a/project/models/Printer.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -from flask import flash - - -class Printer(object): - - def show_string(self, text): - if text == '': - flash("You didn't enter any text to flash") - else: - flash(text + "!!!") diff --git a/project/models/printer.py b/project/models/printer.py new file mode 100644 index 0000000..ff2486b --- /dev/null +++ b/project/models/printer.py @@ -0,0 +1,7 @@ +from flask import flash + + +class Printer: + + def show_string(self, text): + flash(text + "!!!") diff --git a/project/templates/layout/layout.html b/project/templates/layout/layout.html index a026d6c..23f68a6 100644 --- a/project/templates/layout/layout.html +++ b/project/templates/layout/layout.html @@ -1,22 +1,22 @@ + + Flask-MVC - - Home - About + +

Flask MVC Boilerplate

{% for message in get_flashed_messages() %}
{{ message }}
{% endfor %} - {% macro render_error(field) %} - {% if field.errors %} - {% for error in field.errors %}{{ error }}{% endfor %} - {% endif %} - {% endmacro %} {% block body %}{% endblock %}
+ diff --git a/project/templates/layout/macros.html b/project/templates/layout/macros.html new file mode 100644 index 0000000..12b6037 --- /dev/null +++ b/project/templates/layout/macros.html @@ -0,0 +1,5 @@ +{% macro render_error(field) %} + {% if field.errors %} + {% for error in field.errors %}{{ error }}{% endfor %} + {% endif %} +{% endmacro %} diff --git a/project/templates/printer/index.html b/project/templates/printer/index.html index 00e9c5e..40293bf 100644 --- a/project/templates/printer/index.html +++ b/project/templates/printer/index.html @@ -1,8 +1,7 @@ {% extends "layout/layout.html" %} {% block body %} - +

+ Click here to print! +

{% endblock %} + diff --git a/project/templates/printer/print.html b/project/templates/printer/print.html index 6053f54..aa8d331 100644 --- a/project/templates/printer/print.html +++ b/project/templates/printer/print.html @@ -1,6 +1,7 @@ {% extends "layout/layout.html" %} +{% from "layout/macros.html" import render_error %} {% block body %} -
+ {{ form.csrf_token }}
{{ form.text.label }} {{ form.text(size=20) }} {{ render_error(form.text) }}
@@ -8,3 +9,4 @@
{% endblock %} + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..322978b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,101 @@ +[project] +name = "flask-mvc" +version = "0.1.0" +description = "Flask MVC boilerplate following the MVC pattern" +readme = "README.md" +license = { file = "LICENSE" } +authors = [{ name = "Salimane Adjao Moustapha", email = "me@salimane.com" }] +requires-python = ">=3.14" +keywords = ["flask", "mvc", "boilerplate", "python", "web"] +dependencies = [ + "Flask==3.1.3", + "Flask-WTF==1.3.0", + "gunicorn==26.0.0", +] + +[project.urls] +Homepage = "https://github.com/salimane/flask-mvc" +Repository = "https://github.com/salimane/flask-mvc" +Changelog = "https://github.com/salimane/flask-mvc/releases" + +# --------------------------------------------------------------------------- +# Black +# --------------------------------------------------------------------------- +[tool.black] +line-length = 100 +target-version = ["py314"] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.venv + | .*venv.* + | __pycache__ + | \.eggs + | dist + | build +)/ +''' + +# --------------------------------------------------------------------------- +# Ruff (replaces flake8 + isort + pyupgrade) +# --------------------------------------------------------------------------- +[tool.ruff] +line-length = 100 +target-version = "py314" +indent-width = 4 +fix = true +exclude = [ + ".git", + ".venv", + ".*venv.*", + "__pycache__", + ".eggs", + ".pytest_cache", + ".ruff_cache", + "dist", + "build", +] + +[tool.ruff.lint] +# E/W = pycodestyle F = pyflakes I = isort B = flake8-bugbear +# C4 = flake8-comprehensions UP = pyupgrade SIM = flake8-simplify +select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM"] +# E501 line-length is Black's job; avoid double-reporting +ignore = [ + "E501", + # Magic values in assertions/views are self-explanatory in a boilerplate + "SIM108", +] +fixable = ["ALL"] +unfixable = [] +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[tool.ruff.lint.isort] +known-first-party = ["project"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = [ + "S101", # assert statements are fine in tests + "B011", # assert False is fine in tests +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +# --------------------------------------------------------------------------- +# Coverage +# --------------------------------------------------------------------------- +[tool.coverage.run] +source = ["project"] +branch = true +omit = ["tests/*", ".*venv*/*"] + +[tool.coverage.report] +show_missing = true +skip_covered = false +fail_under = 90 + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9855d94 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..7c5ba26 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,6 @@ +-r requirements.txt +black>=26.5.1 +ruff>=0.15.17 +pytest>=9.1.0 +pytest-cov>=7.1.0 +mock>=5.2.0 diff --git a/requirements.txt b/requirements.txt index eab2e2d..eb978db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,29 @@ -Flask==0.10 -Flask-WTF==0.8.3 -Jinja2==2.7 -Flask-DebugToolbar==0.8.0 +Flask==3.1.3 +Flask-Admin==2.2.0 +Flask-Assets==2.1.0 +Flask-Babel==4.0.0 +Flask-Bcrypt==1.0.1 +Flask-Cors==6.0.5 +Flask-DebugToolbar==0.16.0 +Flask-Limiter==4.1.1 +Flask-Login==0.6.3 +Flask-Mail==0.10.0 +Flask-Migrate==4.1.0 +Flask-Principal==0.4.0 +Flask-QueryInspect==0.1.2 +Flask-RESTful==0.3.10 +Flask-Security==5.8.1 +Flask-SQLAlchemy==3.1.1 +Flask-Uploads==0.2.1 +Flask-WTF==1.3.0 +Jinja2==3.1.6 +gunicorn==26.0.0 + +# jobs +Celery==5.6.3 +flower==2.0.1 + +# other +itsdangerous==2.2.0 +cssmin==0.2.0 +jsmin==3.0.1 diff --git a/runserver.py b/runserver.py index 80400ce..987e2ed 100644 --- a/runserver.py +++ b/runserver.py @@ -1,8 +1,10 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- import os -from project import app -if __name__ == '__main__': +from project import create_app + +app = create_app(os.environ.get("FLASK_ENV", "development")) + +if __name__ == "__main__": port = int(os.environ.get("PORT", 8080)) - app.run('0.0.0.0', port=port) + app.run("0.0.0.0", port=port) diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..dd05fc9 --- /dev/null +++ b/script/setup @@ -0,0 +1,11 @@ +#!/bin/sh +set -e -x + +export REPO_DIR=$( cd $(dirname "`dirname $0`") && pwd ) +if [ -z "$PYTHON_VIRTUALENV" ]; then + export PYTHON_VIRTUALENV=".flask-mvc-venv-`uname -s`-`uname -m`" +fi +export VIRTUAL_ENV_DIR="$REPO_DIR/$PYTHON_VIRTUALENV" +python -m venv $PYTHON_VIRTUALENV +echo "Using virtualenv located in : $VIRTUAL_ENV_DIR" +pip install -r $REPO_DIR/requirements.txt diff --git a/script/test b/script/test new file mode 100755 index 0000000..41af9ed --- /dev/null +++ b/script/test @@ -0,0 +1,23 @@ +#!/bin/sh +set -e -x + +export REPO_DIR=$( cd $(dirname "`dirname $0`") && pwd ) +if [ -z "$PYTHON_VIRTUALENV" ]; then + export PYTHON_VIRTUALENV=".test-venv-`uname -s`-`uname -m`" +fi +export VIRTUAL_ENV_DIR="$REPO_DIR/$PYTHON_VIRTUALENV" +python -m venv $PYTHON_VIRTUALENV +echo "Using virtualenv located in : $VIRTUAL_ENV_DIR" +. "$VIRTUAL_ENV_DIR/bin/activate" +pip install -q -r $REPO_DIR/requirements-test.txt + +cd $REPO_DIR +export PYTHONPATH=".:`pwd`" + +echo "==> Format check (black)" +black --check --diff . +echo "==> Lint (ruff)" +ruff check . +echo "==> Executing tests with code coverage collection" +pytest --cov=project --cov-report=html --cov-report=term-missing tests/ + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d20729 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import os + +import pytest + +os.environ.setdefault("SECRET_KEY", "test-secret-key-not-for-production") + +from project import create_app # noqa: E402 + + +@pytest.fixture +def app(): + app = create_app("testing") + yield app + + +@pytest.fixture +def client(app): + return app.test_client() diff --git a/tests/test_printer.py b/tests/test_printer.py new file mode 100644 index 0000000..85fac57 --- /dev/null +++ b/tests/test_printer.py @@ -0,0 +1,40 @@ +class TestStart: + def test_get_returns_200(self, client): + response = client.get("/") + assert response.status_code == 200 + + def test_get_contains_link_to_print(self, client): + response = client.get("/") + assert b"/print" in response.data + + +class TestPrinterGet: + def test_get_returns_200(self, client): + response = client.get("/print") + assert response.status_code == 200 + + def test_get_renders_form(self, client): + response = client.get("/print") + assert b"