├── .github └── workflows │ ├── app.yml │ ├── documentation.yml │ ├── manager.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .dockerignore ├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .vscode │ ├── extensions.json │ └── settings.json ├── Dockerfile ├── index.html ├── nginx.conf ├── package.json ├── pnpm-lock.yaml ├── public │ └── fine-weather-gallery.ico ├── src │ ├── App.vue │ ├── components │ │ ├── ImageAsync.vue │ │ ├── ImageCard.vue │ │ └── ImageDetail.vue │ ├── main.js │ ├── style.css │ └── views │ │ └── FineWeather.vue └── vite.config.js ├── docker-compose.yml └── manager ├── .flaskenv ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── README.md ├── app.py ├── docs ├── _assets │ └── fine-weather-gallery.ico ├── changelog.md ├── features.md ├── index.md └── zh │ ├── changelog.md │ ├── features.md │ └── index.md ├── fw_manager ├── __init__.py ├── blueprints │ ├── __init__.py │ ├── error.py │ ├── manager.py │ └── retriever.py ├── commands.py ├── forms.py ├── models.py ├── static │ └── favicon.svg ├── templates │ ├── base.html │ ├── manager.html │ └── settings.html └── utils.py ├── mkdocs.yml ├── pdm.lock ├── pyproject.toml ├── pytest.ini ├── requirements.txt └── tests ├── conftest.py ├── resources └── picture.png └── test_manager.py /.github/workflows/app.yml: -------------------------------------------------------------------------------- 1 | name: App Workflow 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | lint: 14 | name: App Lint 15 | runs-on: ubuntu-latest 16 | defaults: 17 | run: 18 | working-directory: app 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | 23 | - uses: pnpm/action-setup@v2 24 | with: 25 | version: 8 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: '20' 31 | cache: 'pnpm' 32 | cache-dependency-path: 'app/pnpm-lock.yaml' 33 | 34 | - name: Install dependencies 35 | run: pnpm install 36 | 37 | - name: Lint 38 | run: pnpm run lint 39 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation Workflow 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Move manager out 17 | run: mv ./manager/* ./ 18 | - name: Setup PDM 19 | uses: pdm-project/setup-pdm@v3 20 | with: 21 | python-version: '3.12' 22 | cache: true 23 | - name: Install dependencies 24 | run: pdm install -Gdoc 25 | - name: Build documentation 26 | run: pdm run mkdocs build --clean 27 | - name: Upload artifact 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: documentation 31 | path: site 32 | 33 | deploy: 34 | needs: build 35 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 36 | runs-on: ubuntu-latest 37 | permissions: 38 | pages: write 39 | id-token: write 40 | environment: 41 | name: github-pages 42 | steps: 43 | - name: Download artifact 44 | uses: actions/download-artifact@v4 45 | with: 46 | name: documentation 47 | path: site 48 | - name: Upload to GitHub Pages 49 | uses: actions/upload-pages-artifact@v3 50 | with: 51 | path: site 52 | - name: Deploy to GitHub Pages 53 | uses: actions/deploy-pages@v4 54 | -------------------------------------------------------------------------------- /.github/workflows/manager.yml: -------------------------------------------------------------------------------- 1 | name: Manager Workflow 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | name: Testing 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Move manager out 16 | run: mv ./manager/* ./ 17 | - name: Setup PDM 18 | uses: pdm-project/setup-pdm@v3 19 | with: 20 | python-version: '3.12' 21 | cache: true 22 | - name: Install dependencies 23 | run: pdm install -Gtest 24 | - name: Run tests 25 | run: pdm test 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Make release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | release-note: 10 | name: Generate Release Notes 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 18 23 | 24 | - run: npx changelogithub 25 | env: 26 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | manager_data/ 3 | .idea 4 | .vscode 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 CodeKitchen Community 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 |

Fine Weather

5 |

6 | 7 | ## Introduction 8 | 9 | Fine Weather is a photo album application based on [Vue](https://github.com/vuejs/core) and [BootstrapFlask](https://github.com/helloflask/bootstrap-flask), which is built to collect fine-weather moments of life. 10 | 11 | Documentation is [here](https://codekitchen-community.github.io/fine-weather/). 12 | Community deployment is [here](https://fineweather.pythonanywhere.com/static/app). 13 | 14 | ## Quick Start 15 | 16 | 1. Clone the repository 17 | ```bash 18 | git clone https://github.com/codekitchen-community/fine-weather.git && cd fine-weather 19 | ``` 20 | 21 | 2. Update your auth configuration in `docker-compose.yml` 22 | 23 | 3. Start up with docker-compose 24 | ```bash 25 | docker compose up -d 26 | ``` 27 | 28 | 4. Visit endpoints for photos managing or browsing. 29 | By default: 30 | - The app: **http://localhost** 31 | - The manager: **http://localhost:20090** 32 | 33 | ## Contributing 34 | 35 | Any form of contribution is welcome! Please submit a Pull Request or create an Issue to report problems or suggest improvements. 36 | 37 | ## Credits 38 | 39 | - [Fine Weather Gallery](https://github.com/tkzt/fine-weather-gallery) - The original project 40 | 41 | ## License 42 | 43 | This project is licensed under the [MIT License](https://github.com/codekitchen-community/fine-weather/blob/main/LICENSE). 44 | -------------------------------------------------------------------------------- /app/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | VITE_IMG_FETCH_BASE=/api 2 | VITE_BASE=/ 3 | -------------------------------------------------------------------------------- /app/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | ], 9 | parserOptions: { 10 | ecmaVersion: 'latest', 11 | sourceType: 'module', 12 | }, 13 | plugins: [ 14 | 'vue', 15 | ], 16 | rules: { 17 | }, 18 | settings: { 19 | 'import/resolver': { 20 | alias: { 21 | map: [ 22 | ['@', './src/'], 23 | ], 24 | }, 25 | }, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | !.vscode/settings.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | .env 28 | -------------------------------------------------------------------------------- /app/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /app/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "Vue.volar", 3 | "[json]": { 4 | "editor.defaultFormatter": "vscode.json-language-features" 5 | }, 6 | "[vue]": { 7 | "editor.defaultFormatter": "Vue.volar", 8 | }, 9 | "javascript.format.semicolons": "remove", 10 | "javascript.preferences.quoteStyle": "auto", 11 | "html.format.wrapLineLength": 98, 12 | } 13 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | COPY . /app 4 | WORKDIR /app 5 | 6 | RUN npm install -g pnpm 7 | RUN pwd && ls -l && pnpm i && VITE_IMG_FETCH_BASE=/api pnpm build 8 | 9 | FROM nginx:alpine 10 | 11 | COPY --from=0 /app/dist /usr/share/nginx/html/ 12 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ... 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/nginx.conf: -------------------------------------------------------------------------------- 1 | #user nobody; 2 | worker_processes 1; 3 | 4 | #error_log logs/error.log; 5 | #error_log logs/error.log notice; 6 | #error_log logs/error.log info; 7 | 8 | #pid logs/nginx.pid; 9 | events { 10 | worker_connections 1024; 11 | } 12 | 13 | http { 14 | include mime.types; 15 | default_type application/octet-stream; 16 | 17 | #log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 18 | # '$status $body_bytes_sent "$http_referer" ' 19 | # '"$http_user_agent" "$http_x_forwarded_for"'; 20 | #access_log logs/access.log main; 21 | sendfile on; 22 | #tcp_nopush on; 23 | #keepalive_timeout 0; 24 | keepalive_timeout 120; 25 | #gzip on; 26 | server { 27 | client_max_body_size 20M; 28 | 29 | location / { 30 | root /usr/share/nginx/html; 31 | index index.html; 32 | } 33 | 34 | location ~* ^/api/manager/?$ { 35 | return 302 http://$host:20090; 36 | break; 37 | } 38 | 39 | location /api { 40 | proxy_pass http://manager:5000/; 41 | proxy_redirect off; 42 | proxy_set_header Host $host; 43 | proxy_set_header X-Real-IP $remote_addr; 44 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 45 | proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fine-weather", 3 | "type": "module", 4 | "version": "1.0.3", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite", 8 | "lint": "eslint --ext .js,.vue src", 9 | "build": "vite build", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@fingerprintjs/fingerprintjs": "^4.5.1", 14 | "@vueuse/core": "^9.13.0", 15 | "animate.css": "^4.1.1", 16 | "blurhash": "^2.0.5", 17 | "emoji-reaction": "^2.1.1", 18 | "leancloud-storage": "^4.15.2", 19 | "vue": "^3.5.13" 20 | }, 21 | "devDependencies": { 22 | "@iconify-json/line-md": "^1.2.4", 23 | "@iconify-json/mdi": "^1.2.2", 24 | "@unocss/transformer-directives": "^0.53.6", 25 | "@vitejs/plugin-vue": "^4.6.2", 26 | "eslint": "^8.57.1", 27 | "eslint-import-resolver-alias": "^1.1.2", 28 | "eslint-plugin-import": "^2.31.0", 29 | "eslint-plugin-vue": "^9.32.0", 30 | "unocss": "^0.53.6", 31 | "vite": "^4.5.5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/public/fine-weather-gallery.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codekitchen-community/fine-weather/5f4cd4d8c572c72f1e4422bd9e02a2402873c5e5/app/public/fine-weather-gallery.ico -------------------------------------------------------------------------------- /app/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /app/src/components/ImageAsync.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 38 | -------------------------------------------------------------------------------- /app/src/components/ImageCard.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 85 | 86 | 92 | -------------------------------------------------------------------------------- /app/src/components/ImageDetail.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 135 | 136 | 175 | -------------------------------------------------------------------------------- /app/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import 'virtual:uno.css' 4 | import './style.css' 5 | 6 | createApp(App).mount('#app') 7 | -------------------------------------------------------------------------------- /app/src/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | --at-apply: bg-#f2f3f8; 4 | --at-apply: "dark:bg-#131130"; 5 | } 6 | 7 | :root { 8 | --er-primary-light: #4c1d9525; 9 | --er-primary: #4c1d9545; 10 | --er-primary-dark: #4c1d9562; 11 | } 12 | 13 | html.dark { 14 | --er-primary-light: rgba(109, 40, 217); 15 | --er-primary-dark: rgba(76, 29, 149); 16 | --er-primary: rgba(91, 33, 182); 17 | } 18 | -------------------------------------------------------------------------------- /app/src/views/FineWeather.vue: -------------------------------------------------------------------------------- 1 | 103 | 104 | 218 | 219 | 233 | -------------------------------------------------------------------------------- /app/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import UnoCSS from 'unocss/vite' 4 | import { presetUno, presetIcons } from 'unocss' 5 | import { resolve } from 'path' 6 | import transformerDirectives from '@unocss/transformer-directives' 7 | import { loadEnv } from 'vite' 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig(({ mode }) => { 11 | const env = loadEnv(mode, process.cwd()) 12 | return { 13 | plugins: [vue(), UnoCSS({ 14 | presets: [presetIcons(), presetUno()], 15 | transformers: [transformerDirectives()], 16 | })], 17 | resolve: { 18 | alias: { 19 | '@': resolve(__dirname, 'src'), 20 | }, 21 | }, 22 | server: { 23 | proxy: { 24 | '^/api': { 25 | target: 'http://localhost:20090', 26 | rewrite: (path) => path.replace(/^\/api/, ''), 27 | changeOrigin: true, 28 | followRedirects: true, 29 | bypass: (req, res) => { 30 | if (req.url === '/api/manager') { 31 | res.writeHead(302, { 32 | Location: 'http://localhost:20090' 33 | }) 34 | res.end() 35 | } 36 | }, 37 | } 38 | } 39 | }, 40 | base: env.VITE_BASE ?? "/" 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | manager: 3 | build: ./manager 4 | working_dir: /manager 5 | container_name: fine-weather-manager 6 | environment: 7 | - FLASK_SQLALCHEMY_DATABASE_URI=sqlite:///data.db 8 | - FLASK_SECRET_KEY=b3c8540e418c42418350264e30700036 9 | - IMG_FOLDER_NAME=img 10 | - THUMBNAIL_FOLDER=thumbnail 11 | - THUMBNAIL_MAX_WIDTH=600 12 | command: /bin/sh -c "flask create-tables --username admin --password 123456 && gunicorn -w 4 app:app -b 0.0.0.0:5000" 13 | volumes: 14 | - ./manager:/manager 15 | ports: 16 | - 20090:5000 17 | 18 | app: 19 | build: ./app 20 | container_name: fine-weather 21 | ports: 22 | - 80:80 23 | volumes: 24 | - ./app/nginx.conf:/etc/nginx/nginx.conf 25 | depends_on: 26 | - manager 27 | -------------------------------------------------------------------------------- /manager/.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_APP=app.py 2 | FLASK_DEBUG=1 3 | -------------------------------------------------------------------------------- /manager/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | .pdm-python 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # PyCharm 105 | .idea 106 | 107 | # VSCode 108 | .vscode/ 109 | 110 | # Other 111 | .DS_Store 112 | 113 | fw_manager/static/* 114 | !fw_manager/static/favicon.svg 115 | -------------------------------------------------------------------------------- /manager/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | 4 | repos: 5 | - repo: https://github.com/pdm-project/pdm 6 | rev: 2.12.3 7 | hooks: 8 | - id: pdm-export 9 | files: pdm.lock 10 | entry: bash -c "cd manager && pdm export -o requirements.txt --without-hashes" 11 | 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v4.5.0 14 | hooks: 15 | - id: trailing-whitespace 16 | - id: end-of-file-fixer 17 | - id: debug-statements 18 | 19 | - repo: https://github.com/astral-sh/ruff-pre-commit 20 | rev: 'v0.2.0' 21 | hooks: 22 | - id: ruff 23 | args: [--fix, --exit-non-zero-on-fix, --show-fixes] 24 | - id: ruff-format 25 | 26 | - repo: https://github.com/pre-commit/mirrors-mypy 27 | rev: v1.8.0 28 | hooks: 29 | - id: mypy 30 | -------------------------------------------------------------------------------- /manager/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | 3 | COPY requirements.txt . 4 | 5 | RUN apk update && apk add gcc libc-dev libffi-dev 6 | RUN pip install gunicorn 7 | RUN pip install -r requirements.txt 8 | -------------------------------------------------------------------------------- /manager/README.md: -------------------------------------------------------------------------------- 1 | # Images manager for Fine Weather 2 | 3 | - Upload image 4 | - Edit image info (e.g. title, description) 5 | - Generate and provide a JSON output of images 6 | -------------------------------------------------------------------------------- /manager/app.py: -------------------------------------------------------------------------------- 1 | from fw_manager import create_app 2 | 3 | import os 4 | 5 | from dotenv import load_dotenv 6 | 7 | dotenv_path = os.path.join(os.path.dirname(__file__), '.env') 8 | if os.path.exists(dotenv_path): 9 | load_dotenv(dotenv_path) 10 | 11 | app = create_app() 12 | -------------------------------------------------------------------------------- /manager/docs/_assets/fine-weather-gallery.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codekitchen-community/fine-weather/5f4cd4d8c572c72f1e4422bd9e02a2402873c5e5/manager/docs/_assets/fine-weather-gallery.ico -------------------------------------------------------------------------------- /manager/docs/changelog.md: -------------------------------------------------------------------------------- 1 | ## Version 0.2.0 2 | 3 | Released: 2024/08/04 4 | 5 | - Fixes one paging bug ([PR #13](https://github.com/codekitchen-community/fine-weather/pull/13)) 6 | - Configures deployment of the manager and the app ([PR #14](https://github.com/codekitchen-community/fine-weather/pull/14)) 7 | 8 | ## Version 0.1.0 9 | 10 | Released: 2024/07/17 11 | 12 | - Basic functions. 13 | -------------------------------------------------------------------------------- /manager/docs/features.md: -------------------------------------------------------------------------------- 1 | - Responsive design 2 | - Easy to use 3 | - Easy to deploy 4 | -------------------------------------------------------------------------------- /manager/docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 |

Fine Weather

5 |

6 | 7 | ## Introduction 8 | 9 | Fine Weather is a photo album application based on [Vue](https://github.com/vuejs/core) and [BootstrapFlask](https://github.com/helloflask/bootstrap-flask), which is built to collect fine-weather moments of life. 10 | 11 | ## Quick Start 12 | 13 | 1. Clone the repository 14 | 15 | ```bash 16 | git clone https://github.com/codekitchen-community/fine-weather.git && cd fine-weather 17 | ``` 18 | 19 | 2. Update your auth configuration in `docker-compose.yml` 20 | 21 | 3. Start up with docker-compose 22 | 23 | ```bash 24 | docker compose up -d 25 | ``` 26 | 27 | 4. Visit endpoints for photos managing or browsing. 28 | By default: 29 | - The app: **http://localhost** 30 | - The manager: **http://localhost:20090** 31 | 32 | ## Contributing 33 | 34 | Any form of contribution is welcome! Please submit a Pull Request or create an Issue to report problems or suggest improvements. 35 | 36 | ## Credits 37 | 38 | - [Fine Weather Gallery](https://tkzt.cn/blogs/fine_weather_gallery) - The original project 39 | 40 | ## License 41 | 42 | This project is licensed under the [MIT License](https://github.com/codekitchen-community/fine-weather/blob/main/LICENSE). 43 | -------------------------------------------------------------------------------- /manager/docs/zh/changelog.md: -------------------------------------------------------------------------------- 1 | ## Version 0.2.0 2 | 3 | Released: 2024/08/04 4 | 5 | - Fixes one paging bug ([PR #13](https://github.com/codekitchen-community/fine-weather/pull/13)) 6 | - Configures deployment of the manager and the app ([PR #14](https://github.com/codekitchen-community/fine-weather/pull/14)) 7 | 8 | ## Version 0.1.0 9 | 10 | Released: 2024/07/17 11 | 12 | - Basic functions. 13 | -------------------------------------------------------------------------------- /manager/docs/zh/features.md: -------------------------------------------------------------------------------- 1 | - 响应式设计 2 | - 简单易用 3 | - 部署方便 4 | -------------------------------------------------------------------------------- /manager/docs/zh/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to MkDocs 2 | 3 | For full documentation visit [mkdocs.org](https://www.mkdocs.org). 4 | 5 | ## Commands 6 | 7 | * `mkdocs new [dir-name]` - Create a new project. 8 | * `mkdocs serve` - Start the live-reloading docs server. 9 | * `mkdocs build` - Build the documentation site. 10 | * `mkdocs -h` - Print help message and exit. 11 | 12 | ## Project layout 13 | 14 | mkdocs.yml # The configuration file. 15 | docs/ 16 | index.md # The documentation homepage. 17 | ... # Other markdown pages, images and other files. 18 | -------------------------------------------------------------------------------- /manager/fw_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from datetime import datetime 3 | 4 | from flask_bootstrap import Bootstrap5 5 | from flask import Flask 6 | from flask_wtf import CSRFProtect 7 | from flask_cors import CORS 8 | 9 | from . import commands, blueprints 10 | from .models import db 11 | 12 | 13 | def create_app(): 14 | app = Flask(__name__, instance_path=Path("./instance").absolute()) 15 | app.config.from_prefixed_env() 16 | 17 | Bootstrap5(app) 18 | CSRFProtect(app) 19 | CORS(app) 20 | commands.init_app(app) 21 | db.init_app(app) 22 | blueprints.init_app(app) 23 | 24 | @app.context_processor 25 | def make_template_context(): 26 | return dict( 27 | year=datetime.now().year, 28 | ) 29 | 30 | return app 31 | -------------------------------------------------------------------------------- /manager/fw_manager/blueprints/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, Blueprint, redirect, url_for 2 | 3 | from fw_manager.blueprints.manager import manager_bp 4 | from fw_manager.blueprints.retriever import retriever_bp 5 | from fw_manager.blueprints.error import error_bp 6 | 7 | index_bp = Blueprint("index", __name__) 8 | 9 | 10 | @index_bp.get("") 11 | def index(): 12 | return redirect(url_for("manager.images_page")) 13 | 14 | 15 | def init_app(app: Flask): 16 | app.register_blueprint(index_bp, url_prefix="/") 17 | app.register_blueprint(manager_bp, url_prefix="/manager") 18 | app.register_blueprint(retriever_bp, url_prefix="/images") 19 | app.register_blueprint(error_bp) 20 | -------------------------------------------------------------------------------- /manager/fw_manager/blueprints/error.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from fw_manager.utils import make_resp 4 | 5 | from loguru import logger 6 | 7 | error_bp = Blueprint("error", __name__) 8 | 9 | 10 | @error_bp.app_errorhandler(400) 11 | def bad_request(err): 12 | logger.error(f"Bad request: {err!r}") 13 | return make_resp(err_code="BAD_REQUEST", msg="Bad request"), 400 14 | 15 | 16 | @error_bp.app_errorhandler(403) 17 | def not_authorized(err): 18 | logger.error(f"Not authorized: {err!r}") 19 | return make_resp(err_code="NOT_AUTHORIZED", msg="Not authorized"), 403 20 | 21 | 22 | @error_bp.app_errorhandler(404) 23 | def not_found(err): 24 | logger.error(f"Not found: {err!r}") 25 | return make_resp(err_code="NOT_FOUND", msg="404 not found"), 404 26 | 27 | 28 | @error_bp.app_errorhandler(500) 29 | def internal_server_error(err): 30 | logger.error(f"An error occurred: {err!r}") 31 | return make_resp(err_code="INTERNAL_ERROR", msg="An error occurred"), 500 32 | -------------------------------------------------------------------------------- /manager/fw_manager/blueprints/manager.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from pathlib import Path 3 | from uuid import uuid4 4 | 5 | import blurhash 6 | from flask import render_template, request, redirect, url_for, g, Blueprint, current_app 7 | from flask_httpauth import HTTPBasicAuth 8 | from loguru import logger 9 | from PIL import Image as PImage 10 | from sqlalchemy import or_ 11 | from werkzeug.security import check_password_hash 12 | 13 | from ..utils import make_resp 14 | from ..models import User, Image, Site, db 15 | from ..forms import UploadImageForm, EditImageForm, SettingsForm 16 | 17 | IMG_FOLDER = os.environ.get("IMG_FOLDER_NAME", "img") 18 | THUMBNAIL_FOLDER = os.environ.get("THUMBNAIL_FOLDER_NAME", "thumbnail") 19 | THUMBNAIL_MAX_WIDTH = int(os.environ.get("THUMBNAIL_MAX_WIDTH", 600)) 20 | DEFAULT_NO_IMAGE_TIP = os.environ.get("DEFAULT_NO_IMAGE_TIP", "No image.") 21 | 22 | 23 | manager_bp = Blueprint("manager", __name__) 24 | auth = HTTPBasicAuth() 25 | 26 | 27 | @auth.verify_password 28 | def verify_password(username, password): 29 | user = User.query.filter_by(username=username).first() 30 | if not user: 31 | return False 32 | g.current_user = user 33 | return check_password_hash(user.password_hash, password) 34 | 35 | 36 | def _gen_thumbnail(src_img: PImage.Image) -> tuple[PImage.Image, str]: 37 | img = src_img.copy() 38 | w, h = img.size 39 | img.thumbnail((THUMBNAIL_MAX_WIDTH, round(THUMBNAIL_MAX_WIDTH / w * h))) 40 | img_hash = blurhash.encode( 41 | img.copy(), 4, 4 42 | ) # `blurhash.encode` will close the img passed in 43 | return img, img_hash 44 | 45 | 46 | @manager_bp.get("") 47 | @auth.login_required 48 | def images_page(): 49 | """Images page.""" 50 | page = request.args.get("page", 1, type=int) 51 | page_size = request.args.get("page_size", 10, type=int) 52 | keyword = request.args.get("keyword", "") 53 | pagination = ( 54 | Image.query.filter( 55 | or_( 56 | Image.title.contains(keyword), 57 | Image.description.contains(keyword), 58 | Image.position.contains(keyword), 59 | Image.time.contains(keyword), 60 | ) 61 | ) 62 | .order_by(Image.updated_at.desc()) 63 | .paginate(page=page, per_page=page_size, error_out=False) 64 | ) 65 | if page > pagination.pages > 0: 66 | return redirect( 67 | url_for("get_images", page=1, page_size=page_size, keyword=keyword) 68 | ) 69 | return render_template( 70 | "manager.html", 71 | pagination=pagination, 72 | add_form=UploadImageForm(), 73 | edit_form=EditImageForm(), 74 | ) 75 | 76 | 77 | @manager_bp.route("/settings", methods=["GET", "POST"]) 78 | @auth.login_required 79 | def settings_page(): 80 | form = SettingsForm() 81 | site = Site.query.first() 82 | if form.validate_on_submit(): 83 | site.title = form.site_title.data 84 | site.description = form.site_description.data 85 | site.no_image_tip = form.no_image_tip.data 86 | db.session.commit() 87 | return redirect(url_for(".images_page")) 88 | if request.method == "GET": 89 | form.site_title.data = site.title 90 | form.site_description.data = site.description 91 | form.no_image_tip.data = site.no_image_tip or DEFAULT_NO_IMAGE_TIP 92 | return render_template("settings.html", form=form) 93 | 94 | 95 | @manager_bp.post("/images") 96 | @auth.login_required 97 | def add_image(): 98 | """Add one image.""" 99 | img_folder = Path(current_app.static_folder) / IMG_FOLDER 100 | thumbnail_folder = img_folder / THUMBNAIL_FOLDER 101 | thumbnail_folder.mkdir(exist_ok=True, parents=True) 102 | 103 | form_data = request.form 104 | 105 | # check repetition 106 | img_exist = db.session.scalar(db.select(Image).filter_by(title=form_data["title"])) 107 | if img_exist: 108 | return make_resp(err_code="REPEAT_TITLE", msg="Image with same title exists.") 109 | 110 | # save image/thumbnail 111 | [(_, img_file)] = request.files.items() 112 | 113 | img_name = f"{uuid4().hex}_{img_file.filename}" 114 | img_uri = img_folder / img_name 115 | pil_img = PImage.open(img_file) 116 | 117 | logger.info("Generating thumbnail...") 118 | thumbnail_uri = thumbnail_folder / img_name 119 | thumbnail, img_hash = _gen_thumbnail(pil_img) 120 | logger.info("Done.") 121 | 122 | logger.info("Saving file...") 123 | if ( 124 | any([img_uri.name.lower().endswith(suffix) for suffix in ["jpg", "jpeg"]]) 125 | and pil_img.mode == "RGBA" 126 | ): 127 | pil_img = pil_img.convert("RGB") 128 | thumbnail = thumbnail.convert("RGB") 129 | thumbnail.save(thumbnail_uri) 130 | pil_img.save(img_uri) 131 | logger.info("Done.") 132 | 133 | # commit db record 134 | logger.info("Saving to db...") 135 | w, h = pil_img.size 136 | img = Image( 137 | uri=img_uri.relative_to(current_app.root_path).as_posix(), 138 | thumbnail_uri=thumbnail_uri.relative_to(current_app.root_path).as_posix(), 139 | title=form_data["title"], 140 | position=form_data["position"], 141 | time=form_data["time"], 142 | description=form_data["description"], 143 | blurhash=img_hash, 144 | width=w, 145 | height=h, 146 | ) 147 | db.session.add(img) 148 | db.session.commit() 149 | 150 | logger.info("Done.") 151 | return make_resp(img.id), 201 152 | 153 | 154 | @manager_bp.put("/images/") 155 | @auth.login_required 156 | def update_image(image_id): 157 | """Update info of one certain image.""" 158 | form_data = request.form 159 | 160 | # check existence 161 | img = db.session.get(Image, image_id) 162 | if not img: 163 | return make_resp(err_code="INVALID_IMAGE", msg="Target image does not exist.") 164 | 165 | # check repetition 166 | img_repeat = db.session.scalar( 167 | db.select(Image) 168 | .filter_by(title=form_data["title"]) 169 | .filter(Image.id.isnot(image_id)) 170 | ) 171 | if img_repeat: 172 | return make_resp(err_code="REPEAT_TITLE", msg="Image with same title exists.") 173 | 174 | img.title = form_data["title"] 175 | img.position = form_data["position"] 176 | img.time = form_data["time"] 177 | img.description = form_data["description"] 178 | 179 | db.session.commit() 180 | return make_resp() 181 | 182 | 183 | @manager_bp.delete("/images/") 184 | @auth.login_required 185 | def delete_image(image_id): 186 | """Delete one certain image.""" 187 | img = db.session.get(Image, image_id) 188 | if not img: 189 | return make_resp(err_code="INVALID_IMAGE", msg="Target image does not exist.") 190 | 191 | logger.info(f"Deleting db record: {image_id}...") 192 | db.session.delete(img) 193 | db.session.commit() 194 | logger.info("Done.") 195 | 196 | logger.info("Deleting file...") 197 | root_folder = Path(current_app.root_path) 198 | (root_folder / img.uri).unlink(missing_ok=True) 199 | (root_folder / img.thumbnail_uri).unlink(missing_ok=True) 200 | logger.info("Done.") 201 | return "", 204 202 | -------------------------------------------------------------------------------- /manager/fw_manager/blueprints/retriever.py: -------------------------------------------------------------------------------- 1 | from flask import request, Blueprint 2 | 3 | from ..models import db, Image, Site 4 | 5 | 6 | retriever_bp = Blueprint("retriever", __name__) 7 | 8 | 9 | @retriever_bp.get("") 10 | def get_images(): 11 | """Fetch images in JSON.""" 12 | page = request.args.get("page", 1, type=int) 13 | page_size = request.args.get("page_size", 10, type=int) 14 | pagination = Image.query.order_by(Image.updated_at).paginate( 15 | page=page, per_page=page_size, error_out=False 16 | ) 17 | images = [p.as_dict() for p in pagination.items] 18 | site = db.session.scalar(db.select(Site)) 19 | return { 20 | "images": images, 21 | "pages": pagination.pages, 22 | "total": pagination.total, 23 | "site_title": site.title, 24 | "site_description": site.description, 25 | "no_image_tip": site.no_image_tip, 26 | } 27 | -------------------------------------------------------------------------------- /manager/fw_manager/commands.py: -------------------------------------------------------------------------------- 1 | import click 2 | from flask import Flask 3 | from werkzeug.security import generate_password_hash 4 | 5 | from .models import db, User, Site 6 | 7 | 8 | @click.command(name="create-tables") 9 | @click.option("--username", prompt=True, help="Username used to login.") 10 | @click.option( 11 | "--password", 12 | prompt=True, 13 | hide_input=True, 14 | confirmation_prompt=True, 15 | help="Password used to login.", 16 | ) 17 | def create_tables(username, password) -> None: 18 | """Create all tables.""" 19 | db.create_all() 20 | click.echo("Tables created") 21 | user = User.query.first() 22 | if user is not None: 23 | click.echo("Updating admin...") 24 | user.username = username 25 | user.password_hash = generate_password_hash(password) 26 | else: 27 | click.echo("Creating admin...") 28 | user = User(username=username, password_hash=generate_password_hash(password)) 29 | db.session.add(user) 30 | site = Site(title="Fine Weather") 31 | db.session.add(site) 32 | click.echo("Done.") 33 | db.session.commit() 34 | 35 | 36 | @click.command(name="drop-tables") 37 | def drop_tables() -> None: 38 | """Drop all tables.""" 39 | db.drop_all() 40 | click.echo("Tables dropped") 41 | 42 | 43 | def init_app(app: Flask) -> None: 44 | app.cli.add_command(create_tables) 45 | app.cli.add_command(drop_tables) 46 | -------------------------------------------------------------------------------- /manager/fw_manager/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from flask_wtf.file import FileRequired 3 | from wtforms.fields.simple import StringField, FileField, SubmitField, TextAreaField 4 | from wtforms.validators import InputRequired, Length 5 | 6 | ALLOWED_IMAGE_TYPES = ["jpeg", "gif", "png"] 7 | 8 | 9 | class UploadImageForm(FlaskForm): 10 | image = FileField( 11 | "Image", 12 | render_kw={ 13 | "accept": f'{",".join(["image/"+t for t in ALLOWED_IMAGE_TYPES])}', 14 | }, 15 | validators=[FileRequired()], 16 | ) 17 | title = StringField( 18 | "Title", 19 | validators=[InputRequired(), Length(1, 100)], 20 | ) 21 | position = StringField("Position") 22 | description = StringField("Description") 23 | time = StringField("Time") 24 | 25 | 26 | class EditImageForm(FlaskForm): 27 | title = StringField( 28 | "Title", 29 | validators=[InputRequired(), Length(1, 100)], 30 | ) 31 | position = StringField("Position") 32 | description = StringField("Description") 33 | time = StringField("Time") 34 | 35 | 36 | class SettingsForm(FlaskForm): 37 | site_title = StringField( 38 | "Site title", 39 | validators=[InputRequired(), Length(1, 100)], 40 | ) 41 | site_description = TextAreaField( 42 | "Site description", 43 | validators=[InputRequired(), Length(1, 1000)], 44 | ) 45 | no_image_tip = TextAreaField( 46 | "Tips to show when no image", 47 | validators=[InputRequired(), Length(1, 500)], 48 | ) 49 | submit = SubmitField("Submit") 50 | -------------------------------------------------------------------------------- /manager/fw_manager/models.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, date 3 | from typing import Optional, TYPE_CHECKING 4 | 5 | import sqlalchemy.orm as so 6 | from sqlalchemy import func 7 | from flask_sqlalchemy import SQLAlchemy 8 | from flask_sqlalchemy.model import Model 9 | 10 | db = SQLAlchemy() 11 | 12 | if TYPE_CHECKING: 13 | 14 | class BaseModel(Model, so.DeclarativeBase): 15 | """A dummy BaseModel class for type checking""" 16 | 17 | pass 18 | else: 19 | BaseModel = db.Model 20 | 21 | 22 | class CustomizedDumpsEncoder(json.JSONEncoder): 23 | def default(self, obj): 24 | if isinstance(obj, datetime): 25 | return obj.strftime("%Y-%m-%d %H:%M:%S") 26 | elif isinstance(obj, date): 27 | return obj.strftime("%Y-%m-%d") 28 | return super().default(obj) 29 | 30 | 31 | class Base(BaseModel): 32 | __abstract__ = True 33 | 34 | id: so.Mapped[int] = so.mapped_column(primary_key=True) 35 | created_at: so.Mapped[datetime] = so.mapped_column(default=func.now()) 36 | updated_at: so.Mapped[datetime] = so.mapped_column( 37 | default=func.now(), onupdate=func.now() 38 | ) 39 | 40 | def __repr__(self): 41 | attrs = {"id": self.id} 42 | if hasattr(self, "name"): 43 | attrs["name"] = self.name 44 | if hasattr(self, "title"): 45 | attrs["title"] = self.title 46 | return "<{} {}>".format( 47 | self.__class__.__name__, ",".join(f"{k}={v!r}" for k, v in attrs.items()) 48 | ) 49 | 50 | def as_dict(self): 51 | return {c.name: getattr(self, c.name) for c in self.__table__.columns} 52 | 53 | def as_json(self): 54 | return json.dumps( 55 | self.as_dict(), ensure_ascii=False, cls=CustomizedDumpsEncoder 56 | ) 57 | 58 | 59 | class User(Base): 60 | username: so.Mapped[str] = so.mapped_column(unique=True, index=True) 61 | password_hash: so.Mapped[str] 62 | 63 | 64 | class Site(Base): 65 | title: so.Mapped[str] 66 | description: so.Mapped[Optional[str]] 67 | no_image_tip: so.Mapped[Optional[str]] 68 | 69 | 70 | class Image(Base): 71 | uri: so.Mapped[str] = so.mapped_column(unique=True) 72 | thumbnail_uri: so.Mapped[str] 73 | title: so.Mapped[str] 74 | position: so.Mapped[Optional[str]] 75 | time: so.Mapped[Optional[str]] 76 | description: so.Mapped[Optional[str]] 77 | blurhash: so.Mapped[str] 78 | width: so.Mapped[int] 79 | height: so.Mapped[int] 80 | -------------------------------------------------------------------------------- /manager/fw_manager/static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /manager/fw_manager/templates/base.html: -------------------------------------------------------------------------------- 1 | {% from 'bootstrap5/utils.html' import render_icon %} 2 | {% from 'bootstrap5/form.html' import render_form %} 3 | {% from 'bootstrap5/pagination.html' import render_pagination %} 4 | {% from 'bootstrap5/nav.html' import render_nav_item %} 5 | 6 | 7 | 8 | 9 | {% block head %} 10 | 11 | 12 | 13 | 14 | {% block styles %} 15 | 16 | {{ bootstrap.load_css() }} 17 | 35 | {% endblock %} 36 | 37 | {% block title %}FineWeather Manager{% endblock %} 38 | 39 | {% endblock %} 40 | 41 | 42 | 43 |
44 | 58 |
59 | {% block content %} 60 | {% endblock %} 61 | 62 | {% block scripts %} 63 | 64 | {{ bootstrap.load_js() }} 65 | {% endblock %} 66 | 67 | 68 |
70 |
71 |

72 | © {{ year }} CodeKitchen Community 73 |

74 |
75 |
76 | 77 | 78 | -------------------------------------------------------------------------------- /manager/fw_manager/templates/manager.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% from 'bootstrap5/utils.html' import render_icon %} 4 | {% from 'bootstrap5/form.html' import render_form %} 5 | {% from 'bootstrap5/pagination.html' import render_pagination %} 6 | 7 | {% block title %}FineWeather Manager - Images{% endblock %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 | 16 |
17 |
18 |
19 | 21 | 24 |
25 |
26 |
27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {% for image in pagination.items %} 42 | 43 | 44 | 48 | 49 | 50 | 51 | 52 | 62 | 63 | {% else %} 64 | 65 | 69 | 70 | {% endfor %} 71 | 72 |
#ImageTitleDescPositionTimeActions
{{ loop.index }} 45 | 47 | {{ image['title'] }}{{ image['description'] }}{{ image['position'] }}{{ image['time'] }} 53 | 57 | 61 |
67 | No Data 68 |
73 | 76 |
77 |
78 | 79 | 80 | 100 | 101 | 134 | 135 | 155 | 156 | 157 |
158 | 169 |
170 | {% endblock %} 171 | 172 | {% block scripts %} 173 | {{ super() }} 174 | 345 | {% endblock %} 346 | -------------------------------------------------------------------------------- /manager/fw_manager/templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% from 'bootstrap5/form.html' import render_form %} 4 | 5 | {% block title %}FineWeather Manager - Settings{% endblock %} 6 | 7 | {% block content %} 8 |
9 |
Settings
10 |
11 | {{ render_form(form) }} 12 |
13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /manager/fw_manager/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def make_resp(result=None, err_code="", msg="Success"): 5 | return { 6 | "result": result, 7 | "err_code": err_code, 8 | "msg": msg, 9 | "timestamp": int(time.time()), 10 | } 11 | -------------------------------------------------------------------------------- /manager/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Fine Weather 2 | site_description: Fine Weather is a photo album application based on [Vue](https://github.com/vuejs/core) and [BootstrapFlask](https://github.com/helloflask/bootstrap-flask), which is built to collect fine-weather moments of life. 3 | site_url: https://codekitchen-community.github.io/fine-weather/ 4 | docs_dir: docs 5 | theme: 6 | language: en 7 | name: material 8 | palette: 9 | primary: deep-purple 10 | accent: amber 11 | theme: default 12 | features: 13 | - navigation.footer 14 | favicon: _assets/fine-weather-gallery.ico 15 | logo: _assets/fine-weather-gallery.ico 16 | repo_url: https://github.com/codekitchen-community/fine-weather 17 | repo_name: codekitchen-community/fine-weather 18 | copyright: Copyright © 2024 CodeKitchen Community 19 | nav: 20 | - Home: index.md 21 | - Features: features.md 22 | - Changelog: changelog.md 23 | 24 | 25 | plugins: 26 | - search: 27 | lang: 28 | - en 29 | - zh 30 | - i18n: 31 | docs_structure: folder 32 | languages: 33 | - locale: en 34 | default: true 35 | name: English 36 | build: true 37 | - locale: zh 38 | name: 简体中文 39 | build: true 40 | -------------------------------------------------------------------------------- /manager/pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default", "dev", "test", "doc"] 6 | strategy = ["inherit_metadata"] 7 | lock_version = "4.4.1" 8 | content_hash = "sha256:13527d90aea706e1c348f98fb33f4951c71a5dcf258bb137925eedc08c6dd8bc" 9 | 10 | [[package]] 11 | name = "babel" 12 | version = "2.16.0" 13 | requires_python = ">=3.8" 14 | summary = "Internationalization utilities" 15 | groups = ["doc"] 16 | files = [ 17 | {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, 18 | {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, 19 | ] 20 | 21 | [[package]] 22 | name = "blinker" 23 | version = "1.8.2" 24 | requires_python = ">=3.8" 25 | summary = "Fast, simple object-to-object and broadcast signaling" 26 | groups = ["default"] 27 | files = [ 28 | {file = "blinker-1.8.2-py3-none-any.whl", hash = "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01"}, 29 | {file = "blinker-1.8.2.tar.gz", hash = "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83"}, 30 | ] 31 | 32 | [[package]] 33 | name = "blurhash-python" 34 | version = "1.2.2" 35 | summary = "BlurHash encoder implementation for Python" 36 | groups = ["default"] 37 | dependencies = [ 38 | "Pillow", 39 | "cffi", 40 | "six", 41 | ] 42 | files = [ 43 | {file = "blurhash-python-1.2.2.tar.gz", hash = "sha256:f2dbb8a58c5a299c8fca81112e52471a15cff38020ca9a65dae96a777609b8d4"}, 44 | {file = "blurhash_python-1.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c05fc979cec17f10f903c0787398f5c26d970aaac39beeebcdd0480930b583c6"}, 45 | {file = "blurhash_python-1.2.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a494c3f0d6935a329b14c153082ecdea4040a6810b003cd4fff908b3e5654acd"}, 46 | ] 47 | 48 | [[package]] 49 | name = "bootstrap-flask" 50 | version = "2.4.0" 51 | summary = "Bootstrap 4 & 5 helper for your Flask projects." 52 | groups = ["default"] 53 | dependencies = [ 54 | "Flask", 55 | "WTForms", 56 | ] 57 | files = [ 58 | {file = "Bootstrap-Flask-2.4.0.tar.gz", hash = "sha256:3f7aa1b8be31e182fa2eaff2580e9b66aa9fad14f1a35e5c03d486169fb6c1a1"}, 59 | {file = "Bootstrap_Flask-2.4.0-py3-none-any.whl", hash = "sha256:bd5084bcb1557e85db799aa9d70b153cb13a3895ea871603dbf242cd05894b90"}, 60 | ] 61 | 62 | [[package]] 63 | name = "certifi" 64 | version = "2024.8.30" 65 | requires_python = ">=3.6" 66 | summary = "Python package for providing Mozilla's CA Bundle." 67 | groups = ["doc"] 68 | files = [ 69 | {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, 70 | {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, 71 | ] 72 | 73 | [[package]] 74 | name = "cffi" 75 | version = "1.17.1" 76 | requires_python = ">=3.8" 77 | summary = "Foreign Function Interface for Python calling C code." 78 | groups = ["default"] 79 | dependencies = [ 80 | "pycparser", 81 | ] 82 | files = [ 83 | {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, 84 | {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, 85 | ] 86 | 87 | [[package]] 88 | name = "cfgv" 89 | version = "3.4.0" 90 | requires_python = ">=3.8" 91 | summary = "Validate configuration and produce human readable error messages." 92 | groups = ["dev"] 93 | files = [ 94 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 95 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 96 | ] 97 | 98 | [[package]] 99 | name = "charset-normalizer" 100 | version = "3.4.0" 101 | requires_python = ">=3.7.0" 102 | summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 103 | groups = ["doc"] 104 | files = [ 105 | {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, 106 | {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, 107 | {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, 108 | ] 109 | 110 | [[package]] 111 | name = "click" 112 | version = "8.1.7" 113 | requires_python = ">=3.7" 114 | summary = "Composable command line interface toolkit" 115 | groups = ["default", "doc"] 116 | files = [ 117 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 118 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 119 | ] 120 | 121 | [[package]] 122 | name = "colorama" 123 | version = "0.4.6" 124 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 125 | summary = "Cross-platform colored terminal text." 126 | groups = ["doc"] 127 | files = [ 128 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 129 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 130 | ] 131 | 132 | [[package]] 133 | name = "coverage" 134 | version = "7.6.0" 135 | requires_python = ">=3.8" 136 | summary = "Code coverage measurement for Python" 137 | groups = ["test"] 138 | files = [ 139 | {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, 140 | {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, 141 | {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, 142 | {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, 143 | {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, 144 | {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, 145 | {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, 146 | {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, 147 | {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, 148 | {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, 149 | {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, 150 | ] 151 | 152 | [[package]] 153 | name = "coverage" 154 | version = "7.6.0" 155 | extras = ["toml"] 156 | requires_python = ">=3.8" 157 | summary = "Code coverage measurement for Python" 158 | groups = ["test"] 159 | dependencies = [ 160 | "coverage==7.6.0", 161 | ] 162 | files = [ 163 | {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, 164 | {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, 165 | {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, 166 | {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, 167 | {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, 168 | {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, 169 | {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, 170 | {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, 171 | {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, 172 | {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, 173 | {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, 174 | ] 175 | 176 | [[package]] 177 | name = "distlib" 178 | version = "0.3.8" 179 | summary = "Distribution utilities" 180 | groups = ["dev"] 181 | files = [ 182 | {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, 183 | {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, 184 | ] 185 | 186 | [[package]] 187 | name = "filelock" 188 | version = "3.15.4" 189 | requires_python = ">=3.8" 190 | summary = "A platform independent file lock." 191 | groups = ["dev"] 192 | files = [ 193 | {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, 194 | {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, 195 | ] 196 | 197 | [[package]] 198 | name = "flask" 199 | version = "3.0.3" 200 | requires_python = ">=3.8" 201 | summary = "A simple framework for building complex web applications." 202 | groups = ["default"] 203 | dependencies = [ 204 | "Jinja2>=3.1.2", 205 | "Werkzeug>=3.0.0", 206 | "blinker>=1.6.2", 207 | "click>=8.1.3", 208 | "itsdangerous>=2.1.2", 209 | ] 210 | files = [ 211 | {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, 212 | {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"}, 213 | ] 214 | 215 | [[package]] 216 | name = "flask-cors" 217 | version = "4.0.1" 218 | summary = "A Flask extension adding a decorator for CORS support" 219 | groups = ["default"] 220 | dependencies = [ 221 | "Flask>=0.9", 222 | ] 223 | files = [ 224 | {file = "Flask_Cors-4.0.1-py2.py3-none-any.whl", hash = "sha256:f2a704e4458665580c074b714c4627dd5a306b333deb9074d0b1794dfa2fb677"}, 225 | {file = "flask_cors-4.0.1.tar.gz", hash = "sha256:eeb69b342142fdbf4766ad99357a7f3876a2ceb77689dc10ff912aac06c389e4"}, 226 | ] 227 | 228 | [[package]] 229 | name = "flask-httpauth" 230 | version = "4.8.0" 231 | summary = "HTTP authentication for Flask routes" 232 | groups = ["default"] 233 | dependencies = [ 234 | "flask", 235 | ] 236 | files = [ 237 | {file = "Flask-HTTPAuth-4.8.0.tar.gz", hash = "sha256:66568a05bc73942c65f1e2201ae746295816dc009edd84b482c44c758d75097a"}, 238 | {file = "Flask_HTTPAuth-4.8.0-py3-none-any.whl", hash = "sha256:a58fedd09989b9975448eef04806b096a3964a7feeebc0a78831ff55685b62b0"}, 239 | ] 240 | 241 | [[package]] 242 | name = "flask-sqlalchemy" 243 | version = "3.1.1" 244 | requires_python = ">=3.8" 245 | summary = "Add SQLAlchemy support to your Flask application." 246 | groups = ["default"] 247 | dependencies = [ 248 | "flask>=2.2.5", 249 | "sqlalchemy>=2.0.16", 250 | ] 251 | files = [ 252 | {file = "flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0"}, 253 | {file = "flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312"}, 254 | ] 255 | 256 | [[package]] 257 | name = "flask-wtf" 258 | version = "1.2.1" 259 | requires_python = ">=3.8" 260 | summary = "Form rendering, validation, and CSRF protection for Flask with WTForms." 261 | groups = ["default"] 262 | dependencies = [ 263 | "flask", 264 | "itsdangerous", 265 | "wtforms", 266 | ] 267 | files = [ 268 | {file = "flask_wtf-1.2.1-py3-none-any.whl", hash = "sha256:fa6793f2fb7e812e0fe9743b282118e581fb1b6c45d414b8af05e659bd653287"}, 269 | {file = "flask_wtf-1.2.1.tar.gz", hash = "sha256:8bb269eb9bb46b87e7c8233d7e7debdf1f8b74bf90cc1789988c29b37a97b695"}, 270 | ] 271 | 272 | [[package]] 273 | name = "ghp-import" 274 | version = "2.1.0" 275 | summary = "Copy your docs directly to the gh-pages branch." 276 | groups = ["doc"] 277 | dependencies = [ 278 | "python-dateutil>=2.8.1", 279 | ] 280 | files = [ 281 | {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, 282 | {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, 283 | ] 284 | 285 | [[package]] 286 | name = "identify" 287 | version = "2.6.0" 288 | requires_python = ">=3.8" 289 | summary = "File identification library for Python" 290 | groups = ["dev"] 291 | files = [ 292 | {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, 293 | {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, 294 | ] 295 | 296 | [[package]] 297 | name = "idna" 298 | version = "3.10" 299 | requires_python = ">=3.6" 300 | summary = "Internationalized Domain Names in Applications (IDNA)" 301 | groups = ["doc"] 302 | files = [ 303 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 304 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 305 | ] 306 | 307 | [[package]] 308 | name = "iniconfig" 309 | version = "2.0.0" 310 | requires_python = ">=3.7" 311 | summary = "brain-dead simple config-ini parsing" 312 | groups = ["test"] 313 | files = [ 314 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 315 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 316 | ] 317 | 318 | [[package]] 319 | name = "itsdangerous" 320 | version = "2.2.0" 321 | requires_python = ">=3.8" 322 | summary = "Safely pass data to untrusted environments and back." 323 | groups = ["default"] 324 | files = [ 325 | {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, 326 | {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, 327 | ] 328 | 329 | [[package]] 330 | name = "jinja2" 331 | version = "3.1.4" 332 | requires_python = ">=3.7" 333 | summary = "A very fast and expressive template engine." 334 | groups = ["default", "doc"] 335 | dependencies = [ 336 | "MarkupSafe>=2.0", 337 | ] 338 | files = [ 339 | {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, 340 | {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, 341 | ] 342 | 343 | [[package]] 344 | name = "loguru" 345 | version = "0.7.2" 346 | requires_python = ">=3.5" 347 | summary = "Python logging made (stupidly) simple" 348 | groups = ["default"] 349 | files = [ 350 | {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"}, 351 | {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"}, 352 | ] 353 | 354 | [[package]] 355 | name = "markdown" 356 | version = "3.7" 357 | requires_python = ">=3.8" 358 | summary = "Python implementation of John Gruber's Markdown." 359 | groups = ["doc"] 360 | files = [ 361 | {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, 362 | {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, 363 | ] 364 | 365 | [[package]] 366 | name = "markupsafe" 367 | version = "2.1.5" 368 | requires_python = ">=3.7" 369 | summary = "Safely add untrusted strings to HTML/XML markup." 370 | groups = ["default", "doc"] 371 | files = [ 372 | {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, 373 | {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, 374 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, 375 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, 376 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, 377 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, 378 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, 379 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, 380 | {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, 381 | {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, 382 | {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, 383 | ] 384 | 385 | [[package]] 386 | name = "mergedeep" 387 | version = "1.3.4" 388 | requires_python = ">=3.6" 389 | summary = "A deep merge function for 🐍." 390 | groups = ["doc"] 391 | files = [ 392 | {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, 393 | {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, 394 | ] 395 | 396 | [[package]] 397 | name = "mkdocs" 398 | version = "1.6.1" 399 | requires_python = ">=3.8" 400 | summary = "Project documentation with Markdown." 401 | groups = ["doc"] 402 | dependencies = [ 403 | "click>=7.0", 404 | "ghp-import>=1.0", 405 | "jinja2>=2.11.1", 406 | "markdown>=3.3.6", 407 | "markupsafe>=2.0.1", 408 | "mergedeep>=1.3.4", 409 | "mkdocs-get-deps>=0.2.0", 410 | "packaging>=20.5", 411 | "pathspec>=0.11.1", 412 | "pyyaml-env-tag>=0.1", 413 | "pyyaml>=5.1", 414 | "watchdog>=2.0", 415 | ] 416 | files = [ 417 | {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, 418 | {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, 419 | ] 420 | 421 | [[package]] 422 | name = "mkdocs-get-deps" 423 | version = "0.2.0" 424 | requires_python = ">=3.8" 425 | summary = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" 426 | groups = ["doc"] 427 | dependencies = [ 428 | "mergedeep>=1.3.4", 429 | "platformdirs>=2.2.0", 430 | "pyyaml>=5.1", 431 | ] 432 | files = [ 433 | {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, 434 | {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, 435 | ] 436 | 437 | [[package]] 438 | name = "mkdocs-material" 439 | version = "9.5.48" 440 | requires_python = ">=3.8" 441 | summary = "Documentation that simply works" 442 | groups = ["doc"] 443 | dependencies = [ 444 | "babel~=2.10", 445 | "colorama~=0.4", 446 | "jinja2~=3.0", 447 | "markdown~=3.2", 448 | "mkdocs-material-extensions~=1.3", 449 | "mkdocs~=1.6", 450 | "paginate~=0.5", 451 | "pygments~=2.16", 452 | "pymdown-extensions~=10.2", 453 | "regex>=2022.4", 454 | "requests~=2.26", 455 | ] 456 | files = [ 457 | {file = "mkdocs_material-9.5.48-py3-none-any.whl", hash = "sha256:b695c998f4b939ce748adbc0d3bff73fa886a670ece948cf27818fa115dc16f8"}, 458 | {file = "mkdocs_material-9.5.48.tar.gz", hash = "sha256:a582531e8b34f4c7ed38c29d5c44763053832cf2a32f7409567e0c74749a47db"}, 459 | ] 460 | 461 | [[package]] 462 | name = "mkdocs-material-extensions" 463 | version = "1.3.1" 464 | requires_python = ">=3.8" 465 | summary = "Extension pack for Python Markdown and MkDocs Material." 466 | groups = ["doc"] 467 | files = [ 468 | {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, 469 | {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, 470 | ] 471 | 472 | [[package]] 473 | name = "mkdocs-static-i18n" 474 | version = "1.2.3" 475 | requires_python = ">=3.8" 476 | summary = "MkDocs i18n plugin using static translation markdown files" 477 | groups = ["doc"] 478 | dependencies = [ 479 | "mkdocs>=1.5.2", 480 | ] 481 | files = [ 482 | {file = "mkdocs_static_i18n-1.2.3-py3-none-any.whl", hash = "sha256:e6f2f3c53b657d7632015b32dea3c64d0b80a7a8d6cde0334ca8f66f9e33b52f"}, 483 | {file = "mkdocs_static_i18n-1.2.3.tar.gz", hash = "sha256:7ccf4da6dd29570ec49cd863ebff6fef9cb82dbb1cb85249bdf744e8d839c914"}, 484 | ] 485 | 486 | [[package]] 487 | name = "nodeenv" 488 | version = "1.9.1" 489 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 490 | summary = "Node.js virtual environment builder" 491 | groups = ["dev"] 492 | files = [ 493 | {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, 494 | {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, 495 | ] 496 | 497 | [[package]] 498 | name = "packaging" 499 | version = "24.1" 500 | requires_python = ">=3.8" 501 | summary = "Core utilities for Python packages" 502 | groups = ["doc", "test"] 503 | files = [ 504 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 505 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 506 | ] 507 | 508 | [[package]] 509 | name = "paginate" 510 | version = "0.5.7" 511 | summary = "Divides large result sets into pages for easier browsing" 512 | groups = ["doc"] 513 | files = [ 514 | {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, 515 | {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, 516 | ] 517 | 518 | [[package]] 519 | name = "pathspec" 520 | version = "0.12.1" 521 | requires_python = ">=3.8" 522 | summary = "Utility library for gitignore style pattern matching of file paths." 523 | groups = ["doc"] 524 | files = [ 525 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 526 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 527 | ] 528 | 529 | [[package]] 530 | name = "pillow" 531 | version = "10.4.0" 532 | requires_python = ">=3.8" 533 | summary = "Python Imaging Library (Fork)" 534 | groups = ["default"] 535 | files = [ 536 | {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, 537 | {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, 538 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, 539 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, 540 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, 541 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, 542 | {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, 543 | {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, 544 | {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, 545 | {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, 546 | {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, 547 | {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, 548 | {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, 549 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, 550 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, 551 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, 552 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, 553 | {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, 554 | {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, 555 | {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, 556 | {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, 557 | {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, 558 | {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, 559 | ] 560 | 561 | [[package]] 562 | name = "platformdirs" 563 | version = "4.2.2" 564 | requires_python = ">=3.8" 565 | summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 566 | groups = ["dev", "doc"] 567 | files = [ 568 | {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, 569 | {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, 570 | ] 571 | 572 | [[package]] 573 | name = "pluggy" 574 | version = "1.5.0" 575 | requires_python = ">=3.8" 576 | summary = "plugin and hook calling mechanisms for python" 577 | groups = ["test"] 578 | files = [ 579 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 580 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 581 | ] 582 | 583 | [[package]] 584 | name = "pre-commit" 585 | version = "3.8.0" 586 | requires_python = ">=3.9" 587 | summary = "A framework for managing and maintaining multi-language pre-commit hooks." 588 | groups = ["dev"] 589 | dependencies = [ 590 | "cfgv>=2.0.0", 591 | "identify>=1.0.0", 592 | "nodeenv>=0.11.1", 593 | "pyyaml>=5.1", 594 | "virtualenv>=20.10.0", 595 | ] 596 | files = [ 597 | {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, 598 | {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, 599 | ] 600 | 601 | [[package]] 602 | name = "pycparser" 603 | version = "2.22" 604 | requires_python = ">=3.8" 605 | summary = "C parser in Python" 606 | groups = ["default"] 607 | files = [ 608 | {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, 609 | {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, 610 | ] 611 | 612 | [[package]] 613 | name = "pygments" 614 | version = "2.18.0" 615 | requires_python = ">=3.8" 616 | summary = "Pygments is a syntax highlighting package written in Python." 617 | groups = ["doc"] 618 | files = [ 619 | {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, 620 | {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, 621 | ] 622 | 623 | [[package]] 624 | name = "pymdown-extensions" 625 | version = "10.12" 626 | requires_python = ">=3.8" 627 | summary = "Extension pack for Python Markdown." 628 | groups = ["doc"] 629 | dependencies = [ 630 | "markdown>=3.6", 631 | "pyyaml", 632 | ] 633 | files = [ 634 | {file = "pymdown_extensions-10.12-py3-none-any.whl", hash = "sha256:49f81412242d3527b8b4967b990df395c89563043bc51a3d2d7d500e52123b77"}, 635 | {file = "pymdown_extensions-10.12.tar.gz", hash = "sha256:b0ee1e0b2bef1071a47891ab17003bfe5bf824a398e13f49f8ed653b699369a7"}, 636 | ] 637 | 638 | [[package]] 639 | name = "pytest" 640 | version = "8.3.2" 641 | requires_python = ">=3.8" 642 | summary = "pytest: simple powerful testing with Python" 643 | groups = ["test"] 644 | dependencies = [ 645 | "iniconfig", 646 | "packaging", 647 | "pluggy<2,>=1.5", 648 | ] 649 | files = [ 650 | {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, 651 | {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, 652 | ] 653 | 654 | [[package]] 655 | name = "pytest-cov" 656 | version = "5.0.0" 657 | requires_python = ">=3.8" 658 | summary = "Pytest plugin for measuring coverage." 659 | groups = ["test"] 660 | dependencies = [ 661 | "coverage[toml]>=5.2.1", 662 | "pytest>=4.6", 663 | ] 664 | files = [ 665 | {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, 666 | {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, 667 | ] 668 | 669 | [[package]] 670 | name = "python-dateutil" 671 | version = "2.9.0.post0" 672 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 673 | summary = "Extensions to the standard Python datetime module" 674 | groups = ["doc"] 675 | dependencies = [ 676 | "six>=1.5", 677 | ] 678 | files = [ 679 | {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, 680 | {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, 681 | ] 682 | 683 | [[package]] 684 | name = "python-dotenv" 685 | version = "1.0.1" 686 | requires_python = ">=3.8" 687 | summary = "Read key-value pairs from a .env file and set them as environment variables" 688 | groups = ["default"] 689 | files = [ 690 | {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, 691 | {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, 692 | ] 693 | 694 | [[package]] 695 | name = "pyyaml" 696 | version = "6.0.1" 697 | requires_python = ">=3.6" 698 | summary = "YAML parser and emitter for Python" 699 | groups = ["dev", "doc"] 700 | files = [ 701 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, 702 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, 703 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, 704 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, 705 | {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, 706 | {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, 707 | {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, 708 | {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, 709 | ] 710 | 711 | [[package]] 712 | name = "pyyaml-env-tag" 713 | version = "0.1" 714 | requires_python = ">=3.6" 715 | summary = "A custom YAML tag for referencing environment variables in YAML files. " 716 | groups = ["doc"] 717 | dependencies = [ 718 | "pyyaml", 719 | ] 720 | files = [ 721 | {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, 722 | {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, 723 | ] 724 | 725 | [[package]] 726 | name = "regex" 727 | version = "2024.11.6" 728 | requires_python = ">=3.8" 729 | summary = "Alternative regular expression module, to replace re." 730 | groups = ["doc"] 731 | files = [ 732 | {file = "regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b"}, 733 | {file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"}, 734 | ] 735 | 736 | [[package]] 737 | name = "requests" 738 | version = "2.32.3" 739 | requires_python = ">=3.8" 740 | summary = "Python HTTP for Humans." 741 | groups = ["doc"] 742 | dependencies = [ 743 | "certifi>=2017.4.17", 744 | "charset-normalizer<4,>=2", 745 | "idna<4,>=2.5", 746 | "urllib3<3,>=1.21.1", 747 | ] 748 | files = [ 749 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, 750 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, 751 | ] 752 | 753 | [[package]] 754 | name = "six" 755 | version = "1.16.0" 756 | requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 757 | summary = "Python 2 and 3 compatibility utilities" 758 | groups = ["default", "doc"] 759 | files = [ 760 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 761 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 762 | ] 763 | 764 | [[package]] 765 | name = "sqlalchemy" 766 | version = "2.0.31" 767 | requires_python = ">=3.7" 768 | summary = "Database Abstraction Library" 769 | groups = ["default"] 770 | dependencies = [ 771 | "typing-extensions>=4.6.0", 772 | ] 773 | files = [ 774 | {file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3b74570d99126992d4b0f91fb87c586a574a5872651185de8297c6f90055ae42"}, 775 | {file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f77c4f042ad493cb8595e2f503c7a4fe44cd7bd59c7582fd6d78d7e7b8ec52c"}, 776 | {file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd1591329333daf94467e699e11015d9c944f44c94d2091f4ac493ced0119449"}, 777 | {file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74afabeeff415e35525bf7a4ecdab015f00e06456166a2eba7590e49f8db940e"}, 778 | {file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b9c01990d9015df2c6f818aa8f4297d42ee71c9502026bb074e713d496e26b67"}, 779 | {file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66f63278db425838b3c2b1c596654b31939427016ba030e951b292e32b99553e"}, 780 | {file = "SQLAlchemy-2.0.31-cp312-cp312-win32.whl", hash = "sha256:0b0f658414ee4e4b8cbcd4a9bb0fd743c5eeb81fc858ca517217a8013d282c96"}, 781 | {file = "SQLAlchemy-2.0.31-cp312-cp312-win_amd64.whl", hash = "sha256:fa4b1af3e619b5b0b435e333f3967612db06351217c58bfb50cee5f003db2a5a"}, 782 | {file = "SQLAlchemy-2.0.31-py3-none-any.whl", hash = "sha256:69f3e3c08867a8e4856e92d7afb618b95cdee18e0bc1647b77599722c9a28911"}, 783 | {file = "SQLAlchemy-2.0.31.tar.gz", hash = "sha256:b607489dd4a54de56984a0c7656247504bd5523d9d0ba799aef59d4add009484"}, 784 | ] 785 | 786 | [[package]] 787 | name = "typing-extensions" 788 | version = "4.12.2" 789 | requires_python = ">=3.8" 790 | summary = "Backported and Experimental Type Hints for Python 3.8+" 791 | groups = ["default"] 792 | files = [ 793 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 794 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 795 | ] 796 | 797 | [[package]] 798 | name = "urllib3" 799 | version = "2.2.3" 800 | requires_python = ">=3.8" 801 | summary = "HTTP library with thread-safe connection pooling, file post, and more." 802 | groups = ["doc"] 803 | files = [ 804 | {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, 805 | {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, 806 | ] 807 | 808 | [[package]] 809 | name = "virtualenv" 810 | version = "20.26.3" 811 | requires_python = ">=3.7" 812 | summary = "Virtual Python Environment builder" 813 | groups = ["dev"] 814 | dependencies = [ 815 | "distlib<1,>=0.3.7", 816 | "filelock<4,>=3.12.2", 817 | "platformdirs<5,>=3.9.1", 818 | ] 819 | files = [ 820 | {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, 821 | {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, 822 | ] 823 | 824 | [[package]] 825 | name = "watchdog" 826 | version = "6.0.0" 827 | requires_python = ">=3.9" 828 | summary = "Filesystem events monitoring" 829 | groups = ["doc"] 830 | files = [ 831 | {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, 832 | {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, 833 | ] 834 | 835 | [[package]] 836 | name = "werkzeug" 837 | version = "3.0.3" 838 | requires_python = ">=3.8" 839 | summary = "The comprehensive WSGI web application library." 840 | groups = ["default"] 841 | dependencies = [ 842 | "MarkupSafe>=2.1.1", 843 | ] 844 | files = [ 845 | {file = "werkzeug-3.0.3-py3-none-any.whl", hash = "sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8"}, 846 | {file = "werkzeug-3.0.3.tar.gz", hash = "sha256:097e5bfda9f0aba8da6b8545146def481d06aa7d3266e7448e2cccf67dd8bd18"}, 847 | ] 848 | 849 | [[package]] 850 | name = "wtforms" 851 | version = "3.1.2" 852 | requires_python = ">=3.8" 853 | summary = "Form validation and rendering for Python web development." 854 | groups = ["default"] 855 | dependencies = [ 856 | "markupsafe", 857 | ] 858 | files = [ 859 | {file = "wtforms-3.1.2-py3-none-any.whl", hash = "sha256:bf831c042829c8cdbad74c27575098d541d039b1faa74c771545ecac916f2c07"}, 860 | {file = "wtforms-3.1.2.tar.gz", hash = "sha256:f8d76180d7239c94c6322f7990ae1216dae3659b7aa1cee94b6318bdffb474b9"}, 861 | ] 862 | -------------------------------------------------------------------------------- /manager/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fw-manager" 3 | version = "0.0.1" 4 | description = "An album manager." 5 | authors = [ 6 | {name = "CodeKitchen", email = "allen@tkzt.cn"}, 7 | ] 8 | dependencies = [ 9 | "bootstrap-flask>=2.3.3", 10 | "python-dotenv>=1.0.0", 11 | "flask-wtf>=1.2.1", 12 | "flask-sqlalchemy>=3.1.1", 13 | "click>=8.1.7", 14 | "loguru>=0.7.2", 15 | "pillow>=10.2.0", 16 | "blurhash-python>=1.2.1", 17 | "flask-httpauth>=4.8.0", 18 | "flask-cors>=4.0.1" 19 | ] 20 | requires-python = ">=3.12" 21 | readme = "README.md" 22 | license = {text = "MIT"} 23 | 24 | 25 | [project.optional-dependencies] 26 | doc = [ 27 | "mkdocs>=1.6.1", 28 | "mkdocs-material>=9.5.48", 29 | "mkdocs-static-i18n>=1.2.3", 30 | ] 31 | [tool.pdm] 32 | package-type = "application" 33 | 34 | [tool.pdm.scripts] 35 | serve = { cmd = "flask run", help = "Run the development server" } 36 | test = { cmd = "pytest", help = "Run all test cases" } 37 | create-tables = { cmd = "flask create-tables", help = "Create tables" } 38 | drop-tables = { cmd = "flask drop-tables", help = "Drop tables" } 39 | 40 | [tool.pdm.dev-dependencies] 41 | test = [ 42 | "pytest>=7.4.4", 43 | "pytest-cov>=4.1.0", 44 | ] 45 | dev = [ 46 | "pre-commit>=3.6.0", 47 | ] 48 | -------------------------------------------------------------------------------- /manager/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = run 3 | -------------------------------------------------------------------------------- /manager/requirements.txt: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # Please do not edit it manually. 3 | 4 | babel==2.16.0 5 | blinker==1.8.2 6 | blurhash-python==1.2.2 7 | bootstrap-flask==2.4.0 8 | certifi==2024.8.30 9 | cffi==1.17.1 10 | cfgv==3.4.0 11 | charset-normalizer==3.4.0 12 | click==8.1.7 13 | colorama==0.4.6 14 | coverage==7.6.0 15 | distlib==0.3.8 16 | filelock==3.15.4 17 | flask==3.0.3 18 | flask-cors==4.0.1 19 | flask-httpauth==4.8.0 20 | flask-sqlalchemy==3.1.1 21 | flask-wtf==1.2.1 22 | ghp-import==2.1.0 23 | identify==2.6.0 24 | idna==3.10 25 | iniconfig==2.0.0 26 | itsdangerous==2.2.0 27 | jinja2==3.1.4 28 | loguru==0.7.2 29 | markdown==3.7 30 | markupsafe==2.1.5 31 | mergedeep==1.3.4 32 | mkdocs==1.6.1 33 | mkdocs-get-deps==0.2.0 34 | mkdocs-material==9.5.48 35 | mkdocs-material-extensions==1.3.1 36 | mkdocs-static-i18n==1.2.3 37 | nodeenv==1.9.1 38 | packaging==24.1 39 | paginate==0.5.7 40 | pathspec==0.12.1 41 | pillow==10.4.0 42 | platformdirs==4.2.2 43 | pluggy==1.5.0 44 | pre-commit==3.8.0 45 | pycparser==2.22 46 | pygments==2.18.0 47 | pymdown-extensions==10.12 48 | pytest==8.3.2 49 | pytest-cov==5.0.0 50 | python-dateutil==2.9.0.post0 51 | python-dotenv==1.0.1 52 | pyyaml==6.0.1 53 | pyyaml-env-tag==0.1 54 | regex==2024.11.6 55 | requests==2.32.3 56 | six==1.16.0 57 | sqlalchemy==2.0.31 58 | typing-extensions==4.12.2 59 | urllib3==2.2.3 60 | virtualenv==20.26.3 61 | watchdog==6.0.0 62 | werkzeug==3.0.3 63 | wtforms==3.1.2 64 | -------------------------------------------------------------------------------- /manager/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | sys.path.insert(0, str(Path(__file__).parent.parent.absolute())) 8 | global_data = {} 9 | 10 | 11 | @pytest.fixture 12 | def set_global(): 13 | def _set_global(key, value): 14 | global_data[key] = value 15 | 16 | return _set_global 17 | 18 | 19 | @pytest.fixture 20 | def get_global(): 21 | return lambda key: global_data.get(key) 22 | 23 | 24 | @pytest.fixture(autouse=True, scope="session") 25 | def app(): 26 | from fw_manager import create_app 27 | from fw_manager.models import db, Site 28 | 29 | os.environ.update( 30 | { 31 | "FLASK_SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", 32 | "FLASK_SECRET_KEY": "test_secret_key", 33 | "FLASK_WTF_CSRF_ENABLED": "false", 34 | "FLASK_TESTING": "true", 35 | } 36 | ) 37 | app = create_app() 38 | 39 | with app.app_context(): 40 | db.create_all() 41 | 42 | site = Site(title="Fine Weather") 43 | db.session.add(site) 44 | db.session.commit() 45 | 46 | yield app 47 | db.drop_all() 48 | 49 | 50 | @pytest.fixture() 51 | def unauthorized_client(app): 52 | yield app.test_client() 53 | 54 | 55 | @pytest.fixture() 56 | def client(app, monkeypatch): 57 | from fw_manager.blueprints.manager import auth 58 | 59 | authorized_client = app.test_client() 60 | monkeypatch.setattr(auth, "authenticate", lambda *_: True) 61 | yield authorized_client 62 | -------------------------------------------------------------------------------- /manager/tests/resources/picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codekitchen-community/fine-weather/5f4cd4d8c572c72f1e4422bd9e02a2402873c5e5/manager/tests/resources/picture.png -------------------------------------------------------------------------------- /manager/tests/test_manager.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from fw_manager import models, db 6 | 7 | resources = Path(__file__).parent / "resources" 8 | 9 | 10 | def test_auth(unauthorized_client): 11 | resp = unauthorized_client.get("/manager") 12 | assert resp.status_code == 401 13 | 14 | 15 | @pytest.mark.run(order=1) 16 | def test_add_image(client, app, set_global): 17 | resp = client.post( 18 | "/manager/images", 19 | data={ 20 | "title": "Upload Test", 21 | "position": "SH", 22 | "time": "2024", 23 | "description": "Testing uploading...", 24 | "image": ((resources / "picture.png").open("rb"), "picture.png"), 25 | }, 26 | ) 27 | assert resp.status_code == 201 28 | newly_added = resp.json["result"] 29 | assert newly_added and not resp.json["err_code"] 30 | 31 | img = db.session.get(models.Image, newly_added) 32 | root_folder = Path(app.root_path) 33 | img_path = root_folder / img.uri 34 | img_thumbnail_path = root_folder / img.thumbnail_uri 35 | assert img_path.exists() and img_thumbnail_path.exists() 36 | 37 | set_global("newly_added", newly_added) 38 | 39 | 40 | @pytest.mark.run(order=2) 41 | def test_add_repeat(client, set_global): 42 | resp = client.post( 43 | "/manager/images", 44 | data={ 45 | "title": "Upload Test", 46 | "position": "SH", 47 | "time": "2024", 48 | "description": "Testing uploading...", 49 | "image": ((resources / "picture.png").open("rb"), "picture.png"), 50 | }, 51 | ) 52 | assert resp.status_code == 200 53 | assert resp.json["err_code"] == "REPEAT_TITLE" 54 | 55 | 56 | @pytest.mark.run(order=3) 57 | def test_update_image(client, get_global): 58 | img = db.session.get(models.Image, get_global("newly_added")) 59 | assert img 60 | 61 | new_title = "Upload Test 3" 62 | resp = client.put( 63 | f"/manager/images/{img.id}", 64 | data={ 65 | "title": new_title, 66 | "position": "SH", 67 | "time": "2024", 68 | "description": "Testing uploading...", 69 | "image": ((resources / "picture.png").open("rb"), "picture.png"), 70 | }, 71 | ) 72 | assert resp.status_code == 200 73 | 74 | updated_img = db.session.get(models.Image, img.id) 75 | assert updated_img.title == new_title 76 | 77 | 78 | @pytest.mark.run(order=4) 79 | def test_update_repeat(client, get_global, set_global): 80 | # upload another 81 | new_title = "Upload Test 2" 82 | resp = client.post( 83 | "/manager/images", 84 | data={ 85 | "title": new_title, 86 | "position": "SH", 87 | "time": "2024", 88 | "description": "Testing uploading...", 89 | "image": ((resources / "picture.png").open("rb"), "picture.png"), 90 | }, 91 | ) 92 | assert resp.status_code == 201 93 | set_global("another_image", resp.json["result"]) 94 | 95 | img = db.session.get(models.Image, get_global("newly_added")) 96 | assert img 97 | 98 | resp = client.put( 99 | f"/manager/images/{img.id}", 100 | data={ 101 | "title": new_title, 102 | "position": "SH", 103 | "time": "2024", 104 | "description": "Testing uploading...", 105 | "image": ((resources / "picture.png").open("rb"), "picture.png"), 106 | }, 107 | ) 108 | assert resp.status_code == 200 109 | assert resp.json["err_code"] == "REPEAT_TITLE" 110 | 111 | 112 | @pytest.mark.run(order=5) 113 | def test_update_settings(client, set_global): 114 | resp = client.post( 115 | "/manager/settings", 116 | data={ 117 | "site_title": "Test title", 118 | "site_description": "Test description", 119 | "no_image_tip": "Test tip", 120 | }, 121 | follow_redirects=True, 122 | ) 123 | assert resp.status_code == 200 124 | 125 | 126 | @pytest.mark.run(order=6) 127 | def test_retrieve_image(client, get_global): 128 | resp = client.get("/images") 129 | assert resp.status_code == 200 130 | assert isinstance(resp.json["pages"], int) 131 | assert isinstance(resp.json["images"], list) and len(resp.json["images"]) == 2 132 | assert resp.json["site_title"] == "Test title" 133 | assert resp.json["site_description"] == "Test description" 134 | assert resp.json["no_image_tip"] == "Test tip" 135 | 136 | 137 | @pytest.mark.run(order=7) 138 | def test_delete_image(client, app, get_global): 139 | img_id = get_global("newly_added") 140 | img_id_another = get_global("another_image") 141 | 142 | img = db.session.get(models.Image, img_id) 143 | img_another = db.session.get(models.Image, img_id_another) 144 | assert img and img_another 145 | 146 | resp = client.delete(f"/manager/images/{img_id}") 147 | assert resp.status_code == 204 148 | resp = client.delete(f"/manager/images/{img_id_another}") 149 | assert resp.status_code == 204 150 | 151 | img_exist = models.Image.query.count() 152 | assert not img_exist 153 | 154 | root_folder = Path(app.root_path) 155 | img_path = root_folder / img.uri 156 | img_thumbnail_path = root_folder / img.thumbnail_uri 157 | img_path_another = root_folder / img_another.uri 158 | img_thumbnail_path_another = root_folder / img_another.thumbnail_uri 159 | assert not img_path.exists() and not img_thumbnail_path.exists() 160 | assert not img_path_another.exists() and not img_thumbnail_path_another.exists() 161 | --------------------------------------------------------------------------------