Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
*.tar
.venv/
__pycache__/
*.pyc
.idea/
*.log
.git/
.ruff_cache/
.ty_cache/
.pytest_cache/
tests/result/
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.14"
- run: uv sync
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run ty check
- run: uv run codespell
- run: uv run bandit -r . -c pyproject.toml -q

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.14"
- run: uv sync
- run: uv run pytest -m "not slow"
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,12 @@

image.tar
*.png

app.log
__pycache__/
*.pyc
.venv/
.pytest_cache/
.ruff_cache/
.ty_cache/
tests/result/
54 changes: 54 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
default_install_hook_types: [pre-commit, post-merge]
exclude: 'static/'

repos:
- repo: local
hooks:
- id: uv-lock-check
name: uv lock --check
entry: uv lock --check
language: system
pass_filenames: false
files: ^(pyproject\.toml|uv\.lock)$

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace

- repo: local
hooks:
- id: ruff-check
name: ruff check
entry: uv run ruff check
language: system
types_or: [python, pyi]
pass_filenames: false
always_run: true
- id: ruff-format
name: ruff format
entry: uv run ruff format --check
language: system
types_or: [python, pyi]
pass_filenames: false
always_run: true
- id: codespell
name: codespell
entry: uv run codespell
language: system
types_or: [python, markdown, yaml]
pass_filenames: false
always_run: true
- id: ty-check
name: ty check
entry: uv run ty check
language: system
pass_filenames: false
always_run: true
- id: bandit
name: bandit
entry: uv run bandit -r . -c pyproject.toml -q
language: system
pass_filenames: false
always_run: true
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14.5
14 changes: 5 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
FROM python:3.12
FROM python:3.14-slim
LABEL authors="AM"
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
COPY requirements.txt ./

RUN python -m pip install --upgrade pip && \
pip install -r requirements.txt

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . .

CMD ["python", "app.py"]

CMD ["uv", "run", "--no-sync", "python", "app.py"]
15 changes: 15 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.PHONY: up down restart logs build

up:
docker compose -f docker-compose-traefik.yml up -d --build

down:
docker compose -f docker-compose-traefik.yml down

restart: down up

logs:
docker logs -f qr_web

build:
docker compose -f docker-compose-traefik.yml build
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ The purpose of this project is to demonstrate how to integrate and test the Segn

## Getting Started:

### Local development

Requires [uv](https://docs.astral.sh/uv/) and [just](https://just.systems/).

```powershell
just install # install all dependencies (including dev)
just runserver # start Flask on http://127.0.0.1:5000/
just test # run all tests (excluding slow)
just lint # ruff, ty, codespell, bandit
just git-precommit # run pre-commit hooks on all files
```

> **Note:** `docker-compose.yml` mounts the working directory into the container (`.:/app`) for development convenience. Remove or replace the `volumes` section before using it in production.

### Commands reminder:

Expand All @@ -38,7 +51,7 @@ minikube tunnel
#### Checks:

```powershell
kubectl get pods
kubectl get pods
docker images
```

Expand Down
213 changes: 0 additions & 213 deletions app.log

This file was deleted.

102 changes: 72 additions & 30 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,69 +1,111 @@
import io
import os
import secrets

from flask import Flask, abort, jsonify, render_template, request, send_file
from flask_wtf import CSRFProtect
from flask_wtf.csrf import CSRFError

from flask import Flask, render_template, request, send_file, jsonify
from utils.logger import AppLogger
from utils.qr_generator import QRCodeGenerator

from utils.result_store import ResultStore

AppLogger.configure_logger()

app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY') or secrets.token_hex(32)
app.config['MAX_CONTENT_LENGTH'] = 6 * 1024 * 1024 # 5 MB image + multipart headroom
csrf = CSRFProtect(app)
store = ResultStore()


@app.errorhandler(413)
def request_too_large(e):
return jsonify({'success': False, 'errors': ['File is too large. Maximum request size is 6 MB.']}), 413

def before_request_index():
file_names = ('qr', 'qr_with_image')
current_dir = os.listdir()

for filename in current_dir:
for name in file_names:
if filename.startswith(name):
try:
os.remove(filename)
except Exception as e:
AppLogger.logger.error(f"Error removing file {filename}: {e}")
@app.errorhandler(CSRFError)
def handle_csrf_error(e):
return jsonify({'success': False, 'errors': ['Security token missing or expired. Please reload the page.']}), 400


@app.route('/')
def index():
before_request_index()
return render_template('index.html')


@app.route('/generate', methods=['POST'])
def generate():
text = request.form['text']
size = int(request.form['size'])
image = request.files['image'] if 'image' in request.files else None
color = request.form['color'] if 'color' in request.form else None
text = request.form.get('text', '')
try:
size = int(request.form.get('size', ''))
except TypeError, ValueError:
return jsonify({'success': False, 'errors': ['Size must be a valid integer.']}), 400
raw_image = request.files.get('image')
image = raw_image if (raw_image and raw_image.filename) else None
color = request.form.get('color') or None
bg_color = request.form.get('bg_color') or '#ffffff'
micro = request.form.get('micro') == 'on'

generator = QRCodeGenerator()
success, errors = generator.generate_qr(text, size, image, color)
success, errors = generator.generate_qr(text, size, image, color, bg_color, micro)

if success:
return jsonify({'success': True})
else:
if not success:
return jsonify({'success': False, 'errors': errors})

token = store.put(
{
'qr': generator.qr_png,
'artistic': generator.artistic_png,
'artistic_ext': generator.artistic_ext,
}
)
return jsonify({'success': True, 'id': token})


@app.route('/result')
def result():
qr_with_image_exists = any(filename.startswith('qr_with_image') for filename in os.listdir('.'))
return render_template('result.html', qr_with_image_exists=qr_with_image_exists)
token = request.args.get('id', '')
payload = store.get(token)
if payload is None:
abort(404)
return render_template(
'result.html',
token=token,
qr_with_image_exists=payload['artistic'] is not None,
)


@app.route('/download')
def download():
return send_file('qr.png', as_attachment=True)
token = request.args.get('id', '')
payload = store.get(token)
if payload is None:
abort(404)
return send_file(
io.BytesIO(payload['qr']),
mimetype='image/png',
as_attachment=False,
download_name='qr.png',
)


@app.route('/download_with_image')
def download_with_image():
for filename in os.listdir('.'):
if filename.startswith('qr_with_image'):
return send_file(filename, as_attachment=True)

return "File not found", 404
token = request.args.get('id', '')
payload = store.get(token)
if payload is None or payload['artistic'] is None:
abort(404)
ext = payload['artistic_ext']
mimetype = 'image/gif' if ext == 'gif' else 'image/png'
return send_file(
io.BytesIO(payload['artistic']),
mimetype=mimetype,
as_attachment=False,
download_name=f'qr_with_image.{ext}',
)


if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)
debug = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
app.run(debug=debug, host='0.0.0.0', port=5000)
15 changes: 15 additions & 0 deletions docker-compose-traefik.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
services:
web:
build: .
container_name: qr_web
networks:
- proxy
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.qr.rule=Host(`qrcode.home`)
- traefik.http.routers.qr.entrypoints=web
- traefik.http.services.qr.loadbalancer.server.port=5000
networks:
proxy:
external: true
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ services:
ports:
- "5000:5000"
volumes:
- .:/app
- .:/app # dev-mode live reload; remove for production
5 changes: 2 additions & 3 deletions ingress.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-service
annotations:
nginx.ingress.kubernetes.io/ingress-class: "nginx"
spec:
ingressClassName: nginx
rules:
- http:
paths:
Expand All @@ -14,4 +13,4 @@ spec:
service:
name: qrgen
port:
number: 5000
number: 5000
Loading
Loading