init
This commit is contained in:
commit
59e749aa24
42
.dockerignore
Normal file
42
.dockerignore
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# .dockerignore
|
||||||
|
|
||||||
|
# Git files
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Python cache files and virtual environment
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE / Editor folders
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Secrets / Instance data (handle secrets via runtime env vars, instance via volumes)
|
||||||
|
.env
|
||||||
|
instance/
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
# Test files (optional)
|
||||||
|
tests/
|
||||||
|
*.test.py
|
||||||
|
pytest.ini
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Build artifacts (optional)
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
174
.gitignore
vendored
Normal file
174
.gitignore
vendored
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
65
Dockerfile
Normal file
65
Dockerfile
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Dockerfile
|
||||||
|
|
||||||
|
# Use an official Python runtime as a parent image
|
||||||
|
# Choose a specific version for reproducibility, slim variants are smaller
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set environment variables for Python
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE 1 # Prevents python creating .pyc files
|
||||||
|
ENV PYTHONUNBUFFERED 1 # Force stdout/stderr streams to be unbuffered
|
||||||
|
|
||||||
|
# Set the working directory in the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies if needed (e.g., for libraries like Pillow or specific DB drivers)
|
||||||
|
# RUN apt-get update && apt-get install -y --no-install-recommends some-package && rm -rf /var/lib/apt/lists/*
|
||||||
|
# For basic SQLite and common libraries, often no extra system packages are needed.
|
||||||
|
|
||||||
|
# Create a non-root user and group for security
|
||||||
|
RUN addgroup --system --gid 1001 appgroup && \
|
||||||
|
adduser --system --uid 1001 --gid 1001 appuser
|
||||||
|
# RUN chown -R appuser:appgroup /app # Optional: Set ownership early if needed
|
||||||
|
|
||||||
|
# Copy the requirements file first to leverage Docker cache
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
# Upgrade pip and install requirements
|
||||||
|
RUN pip install --upgrade pip && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy the application code into the container under /app
|
||||||
|
# Ensure correct ownership if copying files after creating the user
|
||||||
|
COPY --chown=appuser:appgroup ./milk_app ./milk_app
|
||||||
|
# Copy .env file if you rely on it INSIDE the container (less secure for secrets)
|
||||||
|
# COPY --chown=appuser:appgroup .env .
|
||||||
|
|
||||||
|
# Set Flask environment variables for production
|
||||||
|
# FLASK_APP points to the factory function
|
||||||
|
ENV FLASK_APP=milk_app.app:create_app()
|
||||||
|
# Ensure Debug mode is OFF for production
|
||||||
|
ENV FLASK_DEBUG=0
|
||||||
|
# Set Database URL to use a path inside the container (ideally mounted as a volume)
|
||||||
|
# Using /app/instance ensures it's within our workdir and owned by appuser if volume isn't mounted yet
|
||||||
|
ENV DATABASE_URL=sqlite:////app/instance/milk_tracker.db
|
||||||
|
# IMPORTANT: Set FLASK_SECRET_KEY and JWT_SECRET_KEY via runtime environment variables (-e flags or docker-compose)
|
||||||
|
# DO NOT hardcode secrets here!
|
||||||
|
# Example placeholders (WILL NOT WORK unless overridden at runtime):
|
||||||
|
# ENV FLASK_SECRET_KEY=your_flask_secret_key_at_runtime
|
||||||
|
# ENV JWT_SECRET_KEY=your_jwt_secret_key_at_runtime
|
||||||
|
|
||||||
|
# Ensure the instance folder exists and has correct permissions for the non-root user
|
||||||
|
# Gunicorn/Flask will run as appuser and needs to write the DB here if it doesn't exist
|
||||||
|
# The directory creation is handled in create_app now, but setting ownership is good.
|
||||||
|
RUN mkdir -p /app/instance && chown -R appuser:appgroup /app/instance
|
||||||
|
|
||||||
|
# Switch to the non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose the port Gunicorn will run on
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Define the command to run the application using Gunicorn
|
||||||
|
# Listen on 0.0.0.0 inside the container
|
||||||
|
# Number of workers can be adjusted or passed as runtime ENV var
|
||||||
|
CMD ["gunicorn", "--workers", "3", "--bind", "0.0.0.0:5000", "milk_app.app:create_app()"]
|
||||||
0
milk_app/__init__.py
Normal file
0
milk_app/__init__.py
Normal file
123
milk_app/app.py
Normal file
123
milk_app/app.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import os
|
||||||
|
import re # For path parsing
|
||||||
|
from flask import Flask
|
||||||
|
from flask_jwt_extended import JWTManager
|
||||||
|
from flask_cors import CORS
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Import database components
|
||||||
|
from .database import db # Assuming db = SQLAlchemy() is in database.py
|
||||||
|
|
||||||
|
# Import blueprints
|
||||||
|
from .auth import auth_bp
|
||||||
|
from .milk import milk_bp
|
||||||
|
from .consumption import consumption_bp
|
||||||
|
from .overview import overview_bp
|
||||||
|
from .profile import profile_bp
|
||||||
|
from .devices import device_bp
|
||||||
|
from .main_routes import main_bp # Import main routes
|
||||||
|
|
||||||
|
load_dotenv() # Load environment variables from .env file
|
||||||
|
|
||||||
|
def create_app(database_uri_override=None): # Allow overriding URI for testing etc.
|
||||||
|
# instance_relative_config=True tells Flask instance folder is relative to app root
|
||||||
|
app = Flask(__name__, instance_relative_config=True)
|
||||||
|
|
||||||
|
# --- Ensure instance folder exists ---
|
||||||
|
# Flask uses app.instance_path for this. os.makedirs won't hurt if it exists.
|
||||||
|
try:
|
||||||
|
# app.instance_path is the absolute path to the instance folder
|
||||||
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
print(f"Checked/Created instance folder: {app.instance_path}")
|
||||||
|
except OSError as e:
|
||||||
|
print(f"CRITICAL: Could not create instance folder at {app.instance_path}: {e}")
|
||||||
|
# Depending on the error, you might want to exit or raise
|
||||||
|
raise # Re-raise the error as this is likely fatal
|
||||||
|
|
||||||
|
# --- Configurations ---
|
||||||
|
# Load default config, then potentially override from .env or function arg
|
||||||
|
app.config.from_mapping(
|
||||||
|
SECRET_KEY=os.environ.get('FLASK_SECRET_KEY', 'default-flask-secret-key-change-me'),
|
||||||
|
JWT_SECRET_KEY=os.environ.get('JWT_SECRET_KEY', 'default-jwt-secret-key-change-me'),
|
||||||
|
# Default to using the instance folder, common Flask practice
|
||||||
|
SQLALCHEMY_DATABASE_URI=f"sqlite:///{os.path.join(app.instance_path, 'milk_tracker.db')}",
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||||
|
JSON_SORT_KEYS=False # Keep JSON order as defined in dicts
|
||||||
|
)
|
||||||
|
# Override DB URI if specified in environment or function call
|
||||||
|
env_db_uri = os.environ.get('DATABASE_URL')
|
||||||
|
final_db_uri = database_uri_override or env_db_uri or app.config['SQLALCHEMY_DATABASE_URI']
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = final_db_uri
|
||||||
|
print(f"Using Database URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ensure Database DIRECTORY Exists BEFORE db.init_app ---
|
||||||
|
db_uri = app.config['SQLALCHEMY_DATABASE_URI']
|
||||||
|
if db_uri.startswith('sqlite:///'):
|
||||||
|
# Extract path part after 'sqlite:///'
|
||||||
|
db_path_part = db_uri[len('sqlite:///'):]
|
||||||
|
db_abs_path = None # Initialize
|
||||||
|
|
||||||
|
# Handle Windows drive letters (e.g., C:/...) if present after ///
|
||||||
|
# Use os.path.splitdrive for robustness
|
||||||
|
drive, path_no_drive = os.path.splitdrive(db_path_part)
|
||||||
|
if drive:
|
||||||
|
db_abs_path = os.path.abspath(db_path_part) # It's an absolute Windows path
|
||||||
|
# Handle absolute Unix paths (starting with /)
|
||||||
|
elif os.path.isabs(db_path_part):
|
||||||
|
db_abs_path = db_path_part
|
||||||
|
# Otherwise, treat as relative path (build from instance path)
|
||||||
|
else:
|
||||||
|
db_abs_path = os.path.join(app.instance_path, db_path_part)
|
||||||
|
|
||||||
|
|
||||||
|
# Get the directory containing the potentially non-existent db file
|
||||||
|
db_dir = os.path.dirname(db_abs_path)
|
||||||
|
|
||||||
|
# Create the directory if it doesn't exist
|
||||||
|
if db_dir: # Check if db_dir is not empty (i.e., not just a filename)
|
||||||
|
try:
|
||||||
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
|
print(f"Checked/Created database directory: {db_dir}")
|
||||||
|
except OSError as e:
|
||||||
|
print(f"CRITICAL: Could not create database directory {db_dir}: {e}")
|
||||||
|
raise # Re-raise as this is likely fatal
|
||||||
|
# else: print("Database file is in the root or instance folder, no sub-directory needed.")
|
||||||
|
|
||||||
|
# --- Initialize Extensions ---
|
||||||
|
# Now that the directory should exist, this should succeed
|
||||||
|
try:
|
||||||
|
db.init_app(app)
|
||||||
|
print("SQLAlchemy initialized.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CRITICAL: Failed to initialize SQLAlchemy: {e}")
|
||||||
|
raise # Re-raise
|
||||||
|
|
||||||
|
JWTManager(app)
|
||||||
|
CORS(app) # Allow requests from your frontend domain in production
|
||||||
|
|
||||||
|
# --- Create Database Tables ---
|
||||||
|
# This ensures tables are created *after* successful initialization
|
||||||
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
print(f"Database tables checked/created.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CRITICAL: Failed to create database tables: {e}")
|
||||||
|
raise # Re-raise
|
||||||
|
|
||||||
|
# --- Register Blueprints ---
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(milk_bp)
|
||||||
|
app.register_blueprint(consumption_bp)
|
||||||
|
app.register_blueprint(overview_bp)
|
||||||
|
app.register_blueprint(profile_bp)
|
||||||
|
app.register_blueprint(device_bp)
|
||||||
|
app.register_blueprint(main_bp) # Register the main route for serving HTML
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/health') # Add a simple health check endpoint
|
||||||
|
def health():
|
||||||
|
return "API OK", 200
|
||||||
|
|
||||||
|
return app
|
||||||
50
milk_app/auth.py
Normal file
50
milk_app/auth.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity
|
||||||
|
from .database import db, User
|
||||||
|
|
||||||
|
auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth')
|
||||||
|
|
||||||
|
@auth_bp.route('/register', methods=['POST'])
|
||||||
|
def register():
|
||||||
|
data = request.get_json()
|
||||||
|
username = data.get('username')
|
||||||
|
password = data.get('password')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"error": "Username and password required"}), 400
|
||||||
|
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
return jsonify({"error": "Username already exists"}), 409 # Conflict
|
||||||
|
|
||||||
|
new_user = User(username=username)
|
||||||
|
new_user.set_password(password)
|
||||||
|
db.session.add(new_user)
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "User registered successfully"}), 201
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Error registering user: {e}")
|
||||||
|
return jsonify({"error": "Registration failed"}), 500
|
||||||
|
|
||||||
|
@auth_bp.route('/login', methods=['POST'])
|
||||||
|
def login():
|
||||||
|
data = request.get_json()
|
||||||
|
username = data.get('username')
|
||||||
|
password = data.get('password')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"error": "Username and password required"}), 400
|
||||||
|
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
|
||||||
|
if user and user.check_password(password):
|
||||||
|
# --- SOLUTION: Convert user.id to string ---
|
||||||
|
identity_data = str(user.id)
|
||||||
|
access_token = create_access_token(identity=identity_data)
|
||||||
|
# ------------------------------------------
|
||||||
|
return jsonify(access_token=access_token)
|
||||||
|
else:
|
||||||
|
return jsonify({"error": "Invalid credentials"}), 401
|
||||||
|
# Add /logout, /refresh_token if needed later
|
||||||
96
milk_app/consumption.py
Normal file
96
milk_app/consumption.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import datetime
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from .database import db, ConsumptionRecord, MilkBatch, User
|
||||||
|
from .utils import convert_volume
|
||||||
|
|
||||||
|
consumption_bp = Blueprint('consumption', __name__, url_prefix='/api/consumption')
|
||||||
|
|
||||||
|
@consumption_bp.route('', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def record_consumption():
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
if not data: return jsonify({"error": "Request must be JSON"}), 400
|
||||||
|
|
||||||
|
batch_id = data.get('milkBatchId')
|
||||||
|
amount_str = data.get('amountConsumed')
|
||||||
|
unit = data.get('unitConsumed') # 'ml', 'L', 'items'
|
||||||
|
consumed_at_str = data.get('dateConsumed', datetime.datetime.utcnow().isoformat())
|
||||||
|
|
||||||
|
# --- Validation ---
|
||||||
|
if not batch_id or amount_str is None or not unit:
|
||||||
|
return jsonify({"error": "Missing fields: milkBatchId, amountConsumed, unitConsumed"}), 400
|
||||||
|
if unit not in ['ml', 'L', 'items']:
|
||||||
|
return jsonify({"error": "Invalid unitConsumed."}), 400
|
||||||
|
try:
|
||||||
|
amount = float(amount_str)
|
||||||
|
if amount <= 0: raise ValueError("Amount must be positive.")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({"error": "Invalid number format for amountConsumed."}), 400
|
||||||
|
try:
|
||||||
|
# Attempt to parse date, fallback to now if invalid format
|
||||||
|
consumed_time = datetime.datetime.fromisoformat(consumed_at_str.replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
consumed_time = datetime.datetime.utcnow()
|
||||||
|
|
||||||
|
# --- Find Batch & Check Status ---
|
||||||
|
batch = MilkBatch.query.filter_by(id=batch_id, user_id=current_user_id, is_deleted=False).first()
|
||||||
|
if not batch: return jsonify({"error": "Milk batch not found or invalid."}), 404
|
||||||
|
if batch.remaining_volume < 0.001: return jsonify({"error": "Selected milk batch is empty."}), 400
|
||||||
|
if batch.expiry_date < datetime.date.today():
|
||||||
|
# Optionally warn or prevent consuming expired? For now, allow but maybe log.
|
||||||
|
print(f"Warning: Consuming from expired batch {batch_id}")
|
||||||
|
|
||||||
|
# --- Calculate Consumed Volume in Batch's Unit ---
|
||||||
|
try:
|
||||||
|
consumed_volume_in_batch_unit = 0
|
||||||
|
if unit == 'items':
|
||||||
|
consumed_volume_in_batch_unit = amount * batch.volume_per_item
|
||||||
|
elif unit == batch.volume_unit:
|
||||||
|
consumed_volume_in_batch_unit = amount
|
||||||
|
else: # Convert (e.g., L consumed from ml batch)
|
||||||
|
consumed_volume_in_batch_unit = convert_volume(amount, unit, batch.volume_unit)
|
||||||
|
|
||||||
|
# --- Check Availability & Update ---
|
||||||
|
# Use tolerance for float comparison
|
||||||
|
if consumed_volume_in_batch_unit > (batch.remaining_volume + 0.001):
|
||||||
|
return jsonify({"error": f"Not enough milk. Only {batch.remaining_volume:.2f} {batch.volume_unit} left."}), 400
|
||||||
|
|
||||||
|
batch.remaining_volume -= consumed_volume_in_batch_unit
|
||||||
|
batch.remaining_volume = max(0, batch.remaining_volume) # Prevent going negative
|
||||||
|
|
||||||
|
# --- Record Consumption ---
|
||||||
|
new_record = ConsumptionRecord(
|
||||||
|
user_id=current_user_id,
|
||||||
|
milk_batch_id=batch_id,
|
||||||
|
amount_consumed=amount, # Log the originally entered amount/unit
|
||||||
|
unit_consumed=unit,
|
||||||
|
consumed_at=consumed_time
|
||||||
|
)
|
||||||
|
db.session.add(new_record)
|
||||||
|
db.session.commit()
|
||||||
|
# Return updated batch state
|
||||||
|
return jsonify(batch.to_dict()), 201
|
||||||
|
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"error": f"Invalid input or calculation error: {e}"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Error recording consumption: {e}")
|
||||||
|
return jsonify({"error": "Database error occurred."}), 500
|
||||||
|
|
||||||
|
@consumption_bp.route('', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_consumption_history():
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
# Add pagination later if needed
|
||||||
|
limit = request.args.get('limit', 50, type=int)
|
||||||
|
offset = request.args.get('offset', 0, type=int)
|
||||||
|
|
||||||
|
records = ConsumptionRecord.query.filter_by(user_id=current_user_id)\
|
||||||
|
.order_by(ConsumptionRecord.consumed_at.desc())\
|
||||||
|
.limit(limit).offset(offset).all()
|
||||||
|
|
||||||
|
return jsonify([record.to_dict() for record in records]), 200
|
||||||
164
milk_app/database.py
Normal file
164
milk_app/database.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import datetime
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from dateutil.relativedelta import relativedelta # pip install python-dateutil
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(128), nullable=False)
|
||||||
|
|
||||||
|
# Settings stored with the user
|
||||||
|
expiry_warning_days = db.Column(db.Integer, default=2, nullable=False)
|
||||||
|
low_stock_threshold_value = db.Column(db.Float) # Value can be ml, L, or items
|
||||||
|
low_stock_threshold_unit = db.Column(db.String(10)) # 'ml', 'L', 'items'
|
||||||
|
notify_expiry_warning = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
notify_expired_alert = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
notify_low_stock = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
milk_batches = db.relationship('MilkBatch', backref='user', lazy=True, cascade="all, delete-orphan")
|
||||||
|
consumption_records = db.relationship('ConsumptionRecord', backref='user', lazy=True, cascade="all, delete-orphan")
|
||||||
|
push_devices = db.relationship('PushDevice', backref='user', lazy=True, cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
# Used for returning user profile/settings
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"username": self.username,
|
||||||
|
"expiryWarningDays": self.expiry_warning_days,
|
||||||
|
"lowStockThresholdValue": self.low_stock_threshold_value,
|
||||||
|
"lowStockThresholdUnit": self.low_stock_threshold_unit,
|
||||||
|
"notificationsEnabled": {
|
||||||
|
"expiry": self.notify_expiry_warning,
|
||||||
|
"expired": self.notify_expired_alert, # Changed key slightly for clarity
|
||||||
|
"lowStock": self.notify_low_stock,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MilkBatch(db.Model):
|
||||||
|
__tablename__ = 'milk_batches'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
|
||||||
|
production_date = db.Column(db.Date) # Store as Date object
|
||||||
|
expiry_date = db.Column(db.Date, nullable=False) # Store as Date object
|
||||||
|
|
||||||
|
initial_quantity_items = db.Column(db.Integer, nullable=False) # e.g., 2 (bottles)
|
||||||
|
volume_per_item = db.Column(db.Float, nullable=False) # e.g., 1000
|
||||||
|
volume_unit = db.Column(db.String(5), nullable=False) # 'ml' or 'L'
|
||||||
|
note = db.Column(db.String(200))
|
||||||
|
|
||||||
|
# Store remaining volume always in the primary volume_unit (ml or L)
|
||||||
|
remaining_volume = db.Column(db.Float, nullable=False)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||||
|
is_deleted = db.Column(db.Boolean, default=False, nullable=False) # Use for soft delete
|
||||||
|
|
||||||
|
consumption_records = db.relationship('ConsumptionRecord', backref='milk_batch', lazy=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def days_remaining(self):
|
||||||
|
"""Calculates days remaining until expiry."""
|
||||||
|
if self.expiry_date:
|
||||||
|
return (self.expiry_date - datetime.date.today()).days
|
||||||
|
return None # Should not happen if expiry_date is required
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self):
|
||||||
|
"""Determines status based on expiry date and user settings."""
|
||||||
|
days = self.days_remaining
|
||||||
|
if days is None: return 'normal'
|
||||||
|
warning_days = self.user.expiry_warning_days if self.user else 2
|
||||||
|
if days < 0:
|
||||||
|
return 'expired'
|
||||||
|
elif days <= warning_days:
|
||||||
|
return 'warning'
|
||||||
|
else:
|
||||||
|
return 'normal'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initial_total_volume(self):
|
||||||
|
"""Calculate initial total volume in the batch's unit."""
|
||||||
|
return self.initial_quantity_items * self.volume_per_item
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_items_approx(self):
|
||||||
|
"""Calculate approximate remaining items."""
|
||||||
|
if self.volume_per_item > 0:
|
||||||
|
# Use a small tolerance to handle floating point issues near zero
|
||||||
|
if self.remaining_volume < 0.001:
|
||||||
|
return 0
|
||||||
|
return max(0, round(self.remaining_volume / self.volume_per_item, 2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Serializes the object to a dictionary."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"userId": self.user_id,
|
||||||
|
"productionDate": self.production_date.isoformat() if self.production_date else None,
|
||||||
|
"expiryDate": self.expiry_date.isoformat(),
|
||||||
|
"initialQuantityItems": self.initial_quantity_items,
|
||||||
|
"volumePerItem": self.volume_per_item,
|
||||||
|
"volumeUnit": self.volume_unit,
|
||||||
|
"note": self.note,
|
||||||
|
"remainingVolume": round(self.remaining_volume, 2), # Round for display
|
||||||
|
"initialTotalVolume": round(self.initial_total_volume, 2),
|
||||||
|
"remainingItemsApprox": self.remaining_items_approx,
|
||||||
|
"createdAt": self.created_at.isoformat(),
|
||||||
|
"isDeleted": self.is_deleted,
|
||||||
|
# Calculated properties
|
||||||
|
"daysRemaining": self.days_remaining,
|
||||||
|
"status": self.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConsumptionRecord(db.Model):
|
||||||
|
__tablename__ = 'consumption_records'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
milk_batch_id = db.Column(db.Integer, db.ForeignKey('milk_batches.id'), nullable=False)
|
||||||
|
|
||||||
|
amount_consumed = db.Column(db.Float, nullable=False)
|
||||||
|
# Store the unit as entered by user for history, but calculation uses batch unit
|
||||||
|
unit_consumed = db.Column(db.String(10), nullable=False) # 'ml', 'L', 'items'
|
||||||
|
consumed_at = db.Column(db.DateTime, nullable=False) # Store as DateTime object
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"userId": self.user_id,
|
||||||
|
"milkBatchId": self.milk_batch_id,
|
||||||
|
"amountConsumed": self.amount_consumed,
|
||||||
|
"unitConsumed": self.unit_consumed,
|
||||||
|
"consumedAt": self.consumed_at.isoformat(),
|
||||||
|
"createdAt": self.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
class PushDevice(db.Model):
|
||||||
|
__tablename__ = 'push_devices'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
device_type = db.Column(db.String(10), nullable=False) # 'web', 'ios', 'android'
|
||||||
|
# Token can be very long, especially for web push subscriptions (JSON)
|
||||||
|
token = db.Column(db.Text, unique=True, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||||
|
|
||||||
|
# Helper function to initialize DB (call once when setting up)
|
||||||
|
def init_database(app):
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
print("Database tables created/verified.")
|
||||||
79
milk_app/devices.py
Normal file
79
milk_app/devices.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from .database import db, PushDevice, User
|
||||||
|
|
||||||
|
device_bp = Blueprint('devices', __name__, url_prefix='/api/devices')
|
||||||
|
|
||||||
|
@device_bp.route('', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def register_device():
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
if not data: return jsonify({"error": "Request must be JSON"}), 400
|
||||||
|
|
||||||
|
device_type = data.get('type')
|
||||||
|
token = data.get('token')
|
||||||
|
|
||||||
|
if not device_type or not token:
|
||||||
|
return jsonify({"error": "Missing 'type' or 'token'"}), 400
|
||||||
|
if device_type not in ['web', 'ios', 'android']:
|
||||||
|
return jsonify({"error": "Invalid device type"}), 400
|
||||||
|
|
||||||
|
# Check if token already exists to prevent duplicates
|
||||||
|
existing = PushDevice.query.filter_by(token=token).first()
|
||||||
|
if existing:
|
||||||
|
# Optional: Update user_id if token somehow got registered by another user?
|
||||||
|
# Or just return OK indicating it's known
|
||||||
|
if existing.user_id != current_user_id:
|
||||||
|
# Decide handling: update owner or error? Let's update.
|
||||||
|
existing.user_id = current_user_id
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Warning: Push token {token[:10]}... re-assigned to user {current_user_id}")
|
||||||
|
return jsonify({"message": "Device already registered"}), 200
|
||||||
|
|
||||||
|
new_device = PushDevice(
|
||||||
|
user_id=current_user_id,
|
||||||
|
device_type=device_type,
|
||||||
|
token=token
|
||||||
|
)
|
||||||
|
db.session.add(new_device)
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "Device registered successfully"}), 201
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
# Could be unique constraint violation if race condition, or DB error
|
||||||
|
print(f"Error registering device: {e}")
|
||||||
|
# Check again if it exists now (due to race condition)
|
||||||
|
existing = PushDevice.query.filter_by(token=token).first()
|
||||||
|
if existing: return jsonify({"message": "Device already registered"}), 200
|
||||||
|
return jsonify({"error": "Failed to register device"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@device_bp.route('/<string:token_prefix>', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
def unregister_device(token_prefix):
|
||||||
|
# Note: Deleting by full token in URL can be problematic (length, encoding).
|
||||||
|
# A better approach might be POST /api/devices/unregister { "token": "..." }
|
||||||
|
# Or require the client to store the device ID returned on registration and use that.
|
||||||
|
# For simplicity now, we'll assume we search by token.
|
||||||
|
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
# This is inefficient if tokens are long. A dedicated device ID is better.
|
||||||
|
# Let's simulate finding based on a prefix if needed, but ideally use full token from body
|
||||||
|
# device = PushDevice.query.filter(PushDevice.token.like(f'{token_prefix}%'), PushDevice.user_id==current_user_id).first()
|
||||||
|
|
||||||
|
# --- Alternative using request body ---
|
||||||
|
data = request.get_json()
|
||||||
|
token_to_delete = data.get('token') if data else None
|
||||||
|
if not token_to_delete:
|
||||||
|
return jsonify({"error": "Token required in request body for deletion"}), 400
|
||||||
|
|
||||||
|
device = PushDevice.query.filter_by(token=token_to_delete, user_id=current_user_id).first()
|
||||||
|
|
||||||
|
if not device:
|
||||||
|
return jsonify({"error": "Device not found or not owned by user"}), 404
|
||||||
|
|
||||||
|
db.session.delete(device)
|
||||||
|
db.session.commit()
|
||||||
|
return '', 204
|
||||||
15
milk_app/main_routes.py
Normal file
15
milk_app/main_routes.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
main_bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
@main_bp.route('/')
|
||||||
|
def home():
|
||||||
|
"""Serves the main index.html page."""
|
||||||
|
# No JWT required here, JS handles auth state based on stored token
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
# Add any other top-level non-API routes here if needed later.
|
||||||
|
# For example, an '/about' page:
|
||||||
|
# @main_bp.route('/about')
|
||||||
|
# def about():
|
||||||
|
# return render_template('about.html') # Assuming you create about.html
|
||||||
115
milk_app/milk.py
Normal file
115
milk_app/milk.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import datetime
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from .database import db, MilkBatch, User
|
||||||
|
from .utils import calculate_expiry_date, convert_volume
|
||||||
|
|
||||||
|
milk_bp = Blueprint('milk', __name__, url_prefix='/api/milk')
|
||||||
|
|
||||||
|
@milk_bp.route('', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def add_milk():
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
data = request.get_json()
|
||||||
|
if not data: return jsonify({"error": "Request must be JSON"}), 400
|
||||||
|
|
||||||
|
expiry_date_str = data.get('expiryDate')
|
||||||
|
production_date_str = data.get('productionDate')
|
||||||
|
shelf_life = data.get('shelfLife')
|
||||||
|
shelf_unit = data.get('shelfUnit', 'days')
|
||||||
|
initial_quantity_items = data.get('quantity')
|
||||||
|
volume_per_item = data.get('volumePerItem')
|
||||||
|
volume_unit = data.get('unit') # 'ml' or 'L'
|
||||||
|
note = data.get('note')
|
||||||
|
|
||||||
|
# --- Validation ---
|
||||||
|
if not initial_quantity_items or volume_per_item is None or not volume_unit:
|
||||||
|
return jsonify({"error": "Missing required fields: quantity, volumePerItem, unit"}), 400
|
||||||
|
if volume_unit not in ['ml', 'L']:
|
||||||
|
return jsonify({"error": "Invalid volume unit. Use 'ml' or 'L'."}), 400
|
||||||
|
try:
|
||||||
|
initial_quantity_items = int(initial_quantity_items)
|
||||||
|
volume_per_item = float(volume_per_item)
|
||||||
|
if initial_quantity_items <= 0 or volume_per_item <= 0:
|
||||||
|
raise ValueError("Quantity and volume must be positive.")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({"error": "Invalid number format for quantity or volumePerItem."}), 400
|
||||||
|
|
||||||
|
# --- Determine Expiry Date ---
|
||||||
|
expiry_date = None
|
||||||
|
if expiry_date_str:
|
||||||
|
try:
|
||||||
|
expiry_date = datetime.date.fromisoformat(expiry_date_str)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "Invalid expiryDate format. Use YYYY-MM-DD."}), 400
|
||||||
|
elif production_date_str and shelf_life:
|
||||||
|
expiry_date = calculate_expiry_date(production_date_str, shelf_life, shelf_unit)
|
||||||
|
if not expiry_date:
|
||||||
|
return jsonify({"error": "Invalid productionDate or shelfLife for expiry calculation."}), 400
|
||||||
|
else:
|
||||||
|
return jsonify({"error": "Either expiryDate or (productionDate and shelfLife) is required."}), 400
|
||||||
|
|
||||||
|
# --- Production Date (Optional) ---
|
||||||
|
prod_date = None
|
||||||
|
if production_date_str:
|
||||||
|
try:
|
||||||
|
prod_date = datetime.date.fromisoformat(production_date_str)
|
||||||
|
except ValueError:
|
||||||
|
# Allow if only expiry date was provided, otherwise error
|
||||||
|
if not expiry_date_str:
|
||||||
|
return jsonify({"error": "Invalid productionDate format. Use YYYY-MM-DD."}), 400
|
||||||
|
|
||||||
|
# --- Create Batch ---
|
||||||
|
initial_remaining_volume = initial_quantity_items * volume_per_item
|
||||||
|
new_batch = MilkBatch(
|
||||||
|
user_id=current_user_id,
|
||||||
|
production_date=prod_date,
|
||||||
|
expiry_date=expiry_date,
|
||||||
|
initial_quantity_items=initial_quantity_items,
|
||||||
|
volume_per_item=volume_per_item,
|
||||||
|
volume_unit=volume_unit,
|
||||||
|
note=note,
|
||||||
|
remaining_volume=initial_remaining_volume,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
db.session.add(new_batch)
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(new_batch.to_dict()), 201
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Error adding milk batch: {e}")
|
||||||
|
return jsonify({"error": "Database error occurred."}), 500
|
||||||
|
|
||||||
|
@milk_bp.route('', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_milk_batches():
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
include_deleted = request.args.get('includeDeleted', 'false').lower() == 'true'
|
||||||
|
only_active = request.args.get('onlyActive', 'true').lower() == 'true' # Only not empty and not expired
|
||||||
|
|
||||||
|
query = MilkBatch.query.filter_by(user_id=current_user_id)
|
||||||
|
|
||||||
|
if not include_deleted:
|
||||||
|
query = query.filter_by(is_deleted=False)
|
||||||
|
|
||||||
|
if only_active:
|
||||||
|
query = query.filter(MilkBatch.remaining_volume > 0.001) # Check remaining volume with tolerance
|
||||||
|
query = query.filter(MilkBatch.expiry_date >= datetime.date.today())
|
||||||
|
|
||||||
|
|
||||||
|
# Sort by expiry date (soonest first)
|
||||||
|
batches = query.order_by(MilkBatch.expiry_date.asc()).all()
|
||||||
|
return jsonify([batch.to_dict() for batch in batches]), 200
|
||||||
|
|
||||||
|
@milk_bp.route('/<int:batch_id>', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
def delete_milk_batch(batch_id):
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
batch = MilkBatch.query.filter_by(id=batch_id, user_id=current_user_id).first()
|
||||||
|
if not batch: return jsonify({"error": "Milk batch not found or access denied."}), 404
|
||||||
|
if batch.is_deleted: return jsonify({"message": "Batch already deleted."}), 200
|
||||||
|
|
||||||
|
batch.is_deleted = True # Soft delete
|
||||||
|
db.session.commit()
|
||||||
|
return '', 204
|
||||||
67
milk_app/overview.py
Normal file
67
milk_app/overview.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import datetime
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from .database import db, MilkBatch, User
|
||||||
|
from sqlalchemy import func # For sum
|
||||||
|
|
||||||
|
overview_bp = Blueprint('overview', __name__, url_prefix='/api/overview')
|
||||||
|
|
||||||
|
@overview_bp.route('', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_overview():
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
user = User.query.get(current_user_id)
|
||||||
|
if not user: return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
# Find active (not deleted, not empty, not expired) batches
|
||||||
|
active_batches = MilkBatch.query.filter(
|
||||||
|
MilkBatch.user_id == current_user_id,
|
||||||
|
MilkBatch.is_deleted == False,
|
||||||
|
MilkBatch.remaining_volume > 0.001,
|
||||||
|
MilkBatch.expiry_date >= datetime.date.today()
|
||||||
|
).order_by(MilkBatch.expiry_date.asc()).all()
|
||||||
|
|
||||||
|
total_volume_ml = 0
|
||||||
|
total_items_approx = 0
|
||||||
|
nearest_expiry_info = None
|
||||||
|
|
||||||
|
for batch in active_batches:
|
||||||
|
# Sum total volume in ml
|
||||||
|
if batch.volume_unit == 'L':
|
||||||
|
total_volume_ml += batch.remaining_volume * 1000
|
||||||
|
else: # Assumed ml
|
||||||
|
total_volume_ml += batch.remaining_volume
|
||||||
|
|
||||||
|
total_items_approx += batch.remaining_items_approx
|
||||||
|
|
||||||
|
# Capture the first one (soonest expiry)
|
||||||
|
if not nearest_expiry_info:
|
||||||
|
nearest_expiry_info = batch.to_dict()
|
||||||
|
|
||||||
|
total_quantity_liters = total_volume_ml / 1000.0
|
||||||
|
|
||||||
|
# Basic low stock check (can be enhanced in push notification logic)
|
||||||
|
is_low_stock = False
|
||||||
|
if user.low_stock_threshold_value is not None and user.low_stock_threshold_unit:
|
||||||
|
threshold_value = user.low_stock_threshold_value
|
||||||
|
threshold_unit = user.low_stock_threshold_unit
|
||||||
|
try:
|
||||||
|
if threshold_unit == 'items':
|
||||||
|
if total_items_approx <= threshold_value: is_low_stock = True
|
||||||
|
else: # ml or L
|
||||||
|
# Convert threshold to ml if needed
|
||||||
|
threshold_ml = convert_volume(threshold_value, threshold_unit, 'ml')
|
||||||
|
if total_volume_ml <= threshold_ml: is_low_stock = True
|
||||||
|
except ValueError:
|
||||||
|
print(f"Warning: Invalid low stock threshold units for user {current_user_id}")
|
||||||
|
|
||||||
|
|
||||||
|
overview_data = {
|
||||||
|
"totalQuantityLiters": round(total_quantity_liters, 2),
|
||||||
|
"totalQuantityItemsApprox": round(total_items_approx),
|
||||||
|
"nearestExpiry": nearest_expiry_info, # Full batch dict or null
|
||||||
|
"isLowStock": is_low_stock, # Simple flag based on settings
|
||||||
|
# "estimatedFinishDate": "N/A" # Requires historical analysis - skip for now
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(overview_data), 200
|
||||||
51
milk_app/profile.py
Normal file
51
milk_app/profile.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from .database import db, User
|
||||||
|
|
||||||
|
profile_bp = Blueprint('profile', __name__, url_prefix='/api/user/profile')
|
||||||
|
|
||||||
|
@profile_bp.route('', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_user_profile():
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
user = User.query.get(current_user_id)
|
||||||
|
if not user: return jsonify({"error": "User not found"}), 404
|
||||||
|
return jsonify(user.to_dict()), 200
|
||||||
|
|
||||||
|
@profile_bp.route('', methods=['PUT'])
|
||||||
|
@jwt_required()
|
||||||
|
def update_user_profile():
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
user = User.query.get(current_user_id)
|
||||||
|
if not user: return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data: return jsonify({"error": "Request must be JSON"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update settings if provided in the request body
|
||||||
|
if 'expiryWarningDays' in data:
|
||||||
|
user.expiry_warning_days = int(data['expiryWarningDays'])
|
||||||
|
if 'lowStockThresholdValue' in data: # Allow setting to null/None
|
||||||
|
user.low_stock_threshold_value = float(data['lowStockThresholdValue']) if data['lowStockThresholdValue'] is not None else None
|
||||||
|
if 'lowStockThresholdUnit' in data:
|
||||||
|
unit = data['lowStockThresholdUnit']
|
||||||
|
if unit in ['ml', 'L', 'items', None]: # Allow None
|
||||||
|
user.low_stock_threshold_unit = unit
|
||||||
|
else: raise ValueError("Invalid lowStockThresholdUnit")
|
||||||
|
|
||||||
|
if 'notificationsEnabled' in data:
|
||||||
|
notif_settings = data['notificationsEnabled']
|
||||||
|
user.notify_expiry_warning = bool(notif_settings.get('expiry', user.notify_expiry_warning))
|
||||||
|
user.notify_expired_alert = bool(notif_settings.get('expired', user.notify_expired_alert))
|
||||||
|
user.notify_low_stock = bool(notif_settings.get('lowStock', user.notify_low_stock))
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(user.to_dict()), 200
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"error": f"Invalid data format: {e}"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Error updating profile: {e}")
|
||||||
|
return jsonify({"error": "Database error occurred."}), 500
|
||||||
665
milk_app/static/script.js
Normal file
665
milk_app/static/script.js
Normal file
@ -0,0 +1,665 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
|
// --- Global Variables & State ---
|
||||||
|
let jwtToken = localStorage.getItem('jwtToken'); // Load token from storage
|
||||||
|
let currentUserId = null; // Will be set after login or token validation
|
||||||
|
let milkDataCache = []; // Cache loaded milk data
|
||||||
|
|
||||||
|
// --- DOM Elements ---
|
||||||
|
const addMilkBtn = document.getElementById('add-milk-btn');
|
||||||
|
const recordConsumptionBtn = document.getElementById('record-consumption-btn');
|
||||||
|
const settingsBtn = document.getElementById('settings-btn');
|
||||||
|
const actionsContainer = document.querySelector('.actions');
|
||||||
|
const backToDashboardBtn = document.getElementById('back-to-dashboard-btn');
|
||||||
|
const logoutBtn = document.getElementById('logout-btn');
|
||||||
|
|
||||||
|
const addMilkModal = document.getElementById('add-milk-modal');
|
||||||
|
const recordConsumptionModal = document.getElementById('record-consumption-modal');
|
||||||
|
const authModal = document.getElementById('auth-modal'); // Auth modal
|
||||||
|
const closeAddModalBtn = document.getElementById('close-add-modal');
|
||||||
|
const closeRecordModalBtn = document.getElementById('close-record-modal');
|
||||||
|
const closeAuthModalBtn = document.getElementById('close-auth-modal'); // Auth close
|
||||||
|
|
||||||
|
const dashboardPage = document.getElementById('dashboard');
|
||||||
|
const settingsPage = document.getElementById('settings-page');
|
||||||
|
|
||||||
|
const addMilkForm = document.getElementById('add-milk-form');
|
||||||
|
const recordConsumptionForm = document.getElementById('record-consumption-form');
|
||||||
|
const settingsForm = document.getElementById('settings-form');
|
||||||
|
const authForm = document.getElementById('auth-form'); // Auth form
|
||||||
|
const loginBtn = document.getElementById('login-btn'); // Login button
|
||||||
|
const registerBtn = document.getElementById('register-btn'); // Register button
|
||||||
|
|
||||||
|
const milkListContainer = document.getElementById('milk-list');
|
||||||
|
const noMilkMessage = document.getElementById('no-milk-message');
|
||||||
|
const consumeDateInput = document.getElementById('consume-date');
|
||||||
|
const authStatusDiv = document.getElementById('auth-status'); // Auth status display
|
||||||
|
const authErrorP = document.getElementById('auth-error'); // Auth error display
|
||||||
|
|
||||||
|
// --- API Helper ---
|
||||||
|
async function fetchApi(endpoint, options = {}) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers, // Allow overriding headers
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add JWT token if available
|
||||||
|
if (jwtToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${jwtToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...options,
|
||||||
|
headers: headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct full API URL (assuming API is served from the same origin)
|
||||||
|
const url = `/api${endpoint}`; // Prepend /api
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
|
||||||
|
// Handle unauthorized errors (e.g., expired token)
|
||||||
|
if (response.status === 401 || response.status === 422) { // 422 Unprocessable Entity (often used by flask-jwt for missing/bad token)
|
||||||
|
console.error('Authentication error:', response.status);
|
||||||
|
handleLogout(true); // Force logout and show login modal
|
||||||
|
throw new Error('Authentication Required'); // Stop further processing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get response body (if any)
|
||||||
|
const responseData = response.headers.get('Content-Type')?.includes('application/json')
|
||||||
|
? await response.json() // Parse JSON if applicable
|
||||||
|
: null; // Handle non-JSON responses if necessary
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Try to get error message from response body
|
||||||
|
const errorMessage = responseData?.error || responseData?.message || `HTTP error ${response.status}`;
|
||||||
|
console.error(`API Error (${response.status}) on ${endpoint}:`, errorMessage, responseData);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`API Success on ${endpoint}: Status ${response.status}`);
|
||||||
|
return responseData; // Return JSON data or null
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Workspace failed for ${endpoint}:`, error);
|
||||||
|
// Display error to user? (e.g., a general banner)
|
||||||
|
displayGlobalError(`请求失败: ${error.message}`);
|
||||||
|
throw error; // Re-throw error so calling function knows it failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Authentication Functions ---
|
||||||
|
function showLoginModal() {
|
||||||
|
clearAuthForm();
|
||||||
|
authErrorP.style.display = 'none';
|
||||||
|
openModal(authModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthForm() {
|
||||||
|
const usernameInput = document.getElementById('auth-username');
|
||||||
|
const passwordInput = document.getElementById('auth-password');
|
||||||
|
if (usernameInput) usernameInput.value = '';
|
||||||
|
if (passwordInput) passwordInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(event) {
|
||||||
|
event?.preventDefault(); // Prevent default if called from button click
|
||||||
|
const username = document.getElementById('auth-username').value;
|
||||||
|
const password = document.getElementById('auth-password').value;
|
||||||
|
authErrorP.style.display = 'none';
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
displayAuthError('请输入用户名和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchApi('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (data && data.access_token) {
|
||||||
|
jwtToken = data.access_token;
|
||||||
|
localStorage.setItem('jwtToken', jwtToken);
|
||||||
|
console.log('Login successful');
|
||||||
|
closeModal(authModal);
|
||||||
|
initializeApp(); // Reload data and update UI for logged-in user
|
||||||
|
} else {
|
||||||
|
displayAuthError('登录失败,请检查凭证');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Error display handled by fetchApi or display specific message
|
||||||
|
displayAuthError(`登录失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister(event) {
|
||||||
|
event?.preventDefault();
|
||||||
|
const username = document.getElementById('auth-username').value;
|
||||||
|
const password = document.getElementById('auth-password').value;
|
||||||
|
authErrorP.style.display = 'none';
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
displayAuthError('请输入用户名和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 4) { // Example basic validation
|
||||||
|
displayAuthError('密码长度至少需要4位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchApi('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
// Registration successful, now attempt login
|
||||||
|
alert('注册成功!请现在登录。');
|
||||||
|
// Maybe prefill username?
|
||||||
|
document.getElementById('auth-password').value = ''; // Clear password
|
||||||
|
// Or automatically log in: await handleLogin();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
displayAuthError(`注册失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout(showLogin = false) {
|
||||||
|
jwtToken = null;
|
||||||
|
currentUserId = null;
|
||||||
|
localStorage.removeItem('jwtToken');
|
||||||
|
console.log('Logged out');
|
||||||
|
// Reset UI
|
||||||
|
milkListContainer.innerHTML = '';
|
||||||
|
noMilkMessage.textContent = '请先登录以查看或添加牛奶记录。';
|
||||||
|
noMilkMessage.style.display = 'block';
|
||||||
|
updateOverview('-- L / -- 盒', '--', '--');
|
||||||
|
updateAuthStatus();
|
||||||
|
document.getElementById('settings-form').reset(); // Clear settings form
|
||||||
|
hideMainContent(); // Hide main buttons/content if logged out
|
||||||
|
|
||||||
|
if (showLogin) {
|
||||||
|
showLoginModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAuthStatus() {
|
||||||
|
if (jwtToken) {
|
||||||
|
// Try to get username if needed (decode token client-side - simple way)
|
||||||
|
// Or make a quick /api/user/profile call, but might fail if token JUST expired
|
||||||
|
let username = '已登录'; // Placeholder
|
||||||
|
try {
|
||||||
|
// Basic decoding (assumes standard JWT structure) - NOT FOR VERIFICATION
|
||||||
|
const payload = JSON.parse(atob(jwtToken.split('.')[1]));
|
||||||
|
currentUserId = payload.sub; // 'sub' usually holds the user ID/identity
|
||||||
|
// Fetch username from profile API if needed for display
|
||||||
|
username = `用户 ${currentUserId}`; // Placeholder until profile loaded
|
||||||
|
fetchApi('/user/profile').then(profile => {
|
||||||
|
if(profile && profile.username){
|
||||||
|
authStatusDiv.textContent = `已登录: ${profile.username}`;
|
||||||
|
}
|
||||||
|
}).catch(() => {}); // Ignore errors here, keep placeholder
|
||||||
|
} catch (e) { console.error("Error decoding token:", e); }
|
||||||
|
|
||||||
|
authStatusDiv.textContent = username;
|
||||||
|
logoutBtn.style.display = 'inline-block';
|
||||||
|
showMainContent();
|
||||||
|
} else {
|
||||||
|
authStatusDiv.textContent = '未登录';
|
||||||
|
logoutBtn.style.display = 'none';
|
||||||
|
hideMainContent();
|
||||||
|
// Optionally show login modal immediately if no token on load
|
||||||
|
// showLoginModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMainContent(){
|
||||||
|
// Hide elements that require login
|
||||||
|
if(actionsContainer) {
|
||||||
|
actionsContainer.style.display = 'none'; // <-- 修改这里,隐藏容器
|
||||||
|
}
|
||||||
|
// (下面几行也可以删掉)
|
||||||
|
// if(addMilkBtn) addMilkBtn.style.display = 'none';
|
||||||
|
// if(recordConsumptionBtn) recordConsumptionBtn.style.display = 'none';
|
||||||
|
// if(settingsBtn) settingsBtn.style.display = 'none';
|
||||||
|
// Could also hide the summary container etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMainContent(){
|
||||||
|
// Show elements after login
|
||||||
|
if(actionsContainer) {
|
||||||
|
actionsContainer.style.display = 'flex'; // <-- 修改这里,显示容器 (用 flex 因为 CSS 里是 flex 布局)
|
||||||
|
}
|
||||||
|
// if(addMilkBtn) addMilkBtn.style.display = 'inline-block';
|
||||||
|
// if(recordConsumptionBtn) recordConsumptionBtn.style.display = 'inline-block';
|
||||||
|
// if(settingsBtn) settingsBtn.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayAuthError(message) {
|
||||||
|
authErrorP.textContent = message;
|
||||||
|
authErrorP.style.display = 'block';
|
||||||
|
}
|
||||||
|
function displayGlobalError(message){
|
||||||
|
// Implement a more robust global error display if needed
|
||||||
|
console.error("Global Error:", message);
|
||||||
|
alert(`操作失败: ${message}`); // Simple alert for now
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Core Data Loading & Rendering ---
|
||||||
|
async function loadMilkData() {
|
||||||
|
console.log("Fetching milk data and overview...");
|
||||||
|
if (!jwtToken) {
|
||||||
|
console.log("No token found, skipping data load.");
|
||||||
|
noMilkMessage.textContent = '请先登录以查看或添加牛奶记录。';
|
||||||
|
noMilkMessage.style.display = 'block';
|
||||||
|
milkListContainer.innerHTML = '';
|
||||||
|
updateOverview('-- L / -- 盒', '--', '--');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch overview and milk list in parallel
|
||||||
|
const [overviewData, milkBatches] = await Promise.all([
|
||||||
|
fetchApi('/overview'),
|
||||||
|
fetchApi('/milk?onlyActive=false') // Get all non-deleted batches initially
|
||||||
|
]);
|
||||||
|
|
||||||
|
milkDataCache = milkBatches || []; // Store fetched data
|
||||||
|
|
||||||
|
// Update Overview Section
|
||||||
|
if (overviewData) {
|
||||||
|
const nearest = overviewData.nearestExpiry;
|
||||||
|
const nearestStr = nearest
|
||||||
|
? `${nearest.note || '牛奶'} - ${nearest.expiryDate} (${nearest.daysRemaining}天)`
|
||||||
|
: '无临期牛奶';
|
||||||
|
updateOverview(
|
||||||
|
`${overviewData.totalQuantityLiters} L / ${overviewData.totalQuantityItemsApprox} 盒`,
|
||||||
|
nearestStr,
|
||||||
|
overviewData.estimatedFinishDate || '--'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updateOverview('-- L / -- 盒', '--', '--');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Milk List
|
||||||
|
milkListContainer.innerHTML = ''; // Clear existing list
|
||||||
|
if (milkBatches && milkBatches.length > 0) {
|
||||||
|
noMilkMessage.style.display = 'none';
|
||||||
|
milkBatches.sort((a, b) => new Date(a.expiryDate) - new Date(b.expiryDate)); // Ensure sorted
|
||||||
|
milkBatches.forEach(renderMilkCard);
|
||||||
|
populateConsumeDropdown(milkBatches.filter(b => b.remainingVolume > 0.001 && b.status !== 'expired')); // Populate with active, non-expired
|
||||||
|
} else {
|
||||||
|
noMilkMessage.textContent = '还没有添加牛奶记录哦。';
|
||||||
|
noMilkMessage.style.display = 'block';
|
||||||
|
populateConsumeDropdown([]); // Clear dropdown
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load initial data:", error);
|
||||||
|
noMilkMessage.textContent = '加载牛奶数据失败,请稍后重试。';
|
||||||
|
noMilkMessage.style.display = 'block';
|
||||||
|
milkListContainer.innerHTML = '';
|
||||||
|
updateOverview('错误', '错误', '错误');
|
||||||
|
// Error should have been handled by fetchApi, maybe logout occurred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMilkCard(milk) {
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = `milk-card ${milk.status || 'normal'}`; // 'normal', 'warning', 'expired'
|
||||||
|
card.dataset.id = milk.id;
|
||||||
|
|
||||||
|
const progressValue = milk.initialTotalVolume > 0 ? (milk.remainingVolume / milk.initialTotalVolume) * 100 : 0;
|
||||||
|
let expiryText;
|
||||||
|
if (milk.daysRemaining === undefined || milk.daysRemaining === null){
|
||||||
|
expiryText = `过期: ${milk.expiryDate}`;
|
||||||
|
} else if (milk.daysRemaining < 0){
|
||||||
|
expiryText = `已过期 ${Math.abs(milk.daysRemaining)} 天 (${milk.expiryDate})`;
|
||||||
|
} else if (milk.daysRemaining === 0){
|
||||||
|
expiryText = `今天过期 (${milk.expiryDate})`;
|
||||||
|
} else {
|
||||||
|
expiryText = `剩 ${milk.daysRemaining} 天 (${milk.expiryDate})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="milk-note">${milk.note || '牛奶'} (${milk.volumePerItem}${milk.volumeUnit})</span>
|
||||||
|
<span class="milk-expiry">${expiryText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="milk-quantity">剩余: ${milk.remainingVolume.toFixed(1)}${milk.volumeUnit} / ${milk.remainingItemsApprox.toFixed(1)} 盒</span>
|
||||||
|
<progress value="${progressValue}" max="100" title="${progressValue.toFixed(0)}%"></progress>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span>生产: ${milk.productionDate || '--'}</span>
|
||||||
|
<div>
|
||||||
|
<button class="card-action-btn consume-from-card-btn" data-id="${milk.id}" title="记录消耗"${milk.status === 'expired' || milk.remainingVolume < 0.001 ? ' disabled' : ''}>消耗</button>
|
||||||
|
<button class="card-action-btn delete-milk-btn" data-id="${milk.id}" title="删除记录">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
milkListContainer.appendChild(card);
|
||||||
|
|
||||||
|
// Add event listeners for buttons on this card
|
||||||
|
const consumeBtn = card.querySelector('.consume-from-card-btn');
|
||||||
|
if(consumeBtn){
|
||||||
|
consumeBtn.addEventListener('click', (e) => {
|
||||||
|
if(!e.target.disabled) {
|
||||||
|
openRecordConsumptionModal(milk.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const deleteBtn = card.querySelector('.delete-milk-btn');
|
||||||
|
if(deleteBtn){
|
||||||
|
deleteBtn.addEventListener('click', () => deleteMilkBatch(milk.id, milk.note));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateConsumeDropdown(activeBatches) {
|
||||||
|
const selectMilk = document.getElementById('select-milk');
|
||||||
|
selectMilk.innerHTML = '<option value="" disabled selected>请选择...</option>'; // Clear existing
|
||||||
|
|
||||||
|
if (activeBatches && activeBatches.length > 0) {
|
||||||
|
// Sort by expiry, soonest first
|
||||||
|
activeBatches.sort((a, b) => new Date(a.expiryDate) - new Date(b.expiryDate));
|
||||||
|
activeBatches.forEach(milk => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = milk.id;
|
||||||
|
option.textContent = `${milk.note || '牛奶'} (${milk.volumePerItem}${milk.volumeUnit}) - Exp: ${milk.expiryDate} (剩 ${milk.remainingVolume.toFixed(1)}${milk.volumeUnit})`;
|
||||||
|
selectMilk.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOverview(totalQtyStr, nearestExpStr, estimatedFinishStr) {
|
||||||
|
document.getElementById('total-quantity').textContent = totalQtyStr;
|
||||||
|
document.getElementById('nearest-expiry').textContent = nearestExpStr;
|
||||||
|
document.getElementById('estimated-finish').textContent = estimatedFinishStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Form Handling Functions ---
|
||||||
|
|
||||||
|
async function handleAddMilkSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(addMilkForm);
|
||||||
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
|
// Basic validation & data prep
|
||||||
|
const payload = {
|
||||||
|
productionDate: data.productionDate || null,
|
||||||
|
shelfLife: data.shelfLife || null,
|
||||||
|
shelfUnit: data.shelfUnit || null,
|
||||||
|
expiryDate: data.expiryDate || null,
|
||||||
|
quantity: parseInt(data.quantity, 10),
|
||||||
|
volumePerItem: parseFloat(data.volumePerItem),
|
||||||
|
unit: data.unit,
|
||||||
|
note: data.note || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.quantity || !payload.volumePerItem || !payload.unit) {
|
||||||
|
alert("请填写数量、单件容量和单位。"); return;
|
||||||
|
}
|
||||||
|
if (!payload.expiryDate && !(payload.productionDate && payload.shelfLife)) {
|
||||||
|
alert("请提供过期日期,或同时提供生产日期和保质期。"); return;
|
||||||
|
}
|
||||||
|
if (payload.expiryDate && payload.productionDate && payload.shelfLife) {
|
||||||
|
// If all 3 provided, maybe prefer expiryDate or warn? Let API handle/prefer expiry.
|
||||||
|
payload.productionDate = null; // Example: Clear others if expiryDate is set
|
||||||
|
payload.shelfLife = null;
|
||||||
|
payload.shelfUnit = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
console.log("Submitting Add Milk:", payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchApi('/milk', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
alert('牛奶入库成功!');
|
||||||
|
closeModal(addMilkModal);
|
||||||
|
loadMilkData(); // Refresh list and overview
|
||||||
|
} catch (error) {
|
||||||
|
// Error already logged by fetchApi
|
||||||
|
alert(`入库失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRecordConsumptionSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(recordConsumptionForm);
|
||||||
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
milkBatchId: parseInt(data.milkBatchId, 10),
|
||||||
|
amountConsumed: parseFloat(data.amountConsumed),
|
||||||
|
unitConsumed: data.unitConsumed,
|
||||||
|
dateConsumed: data.dateConsumed ? new Date(data.dateConsumed).toISOString() : new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.milkBatchId || isNaN(payload.milkBatchId) || !payload.amountConsumed || !payload.unitConsumed) {
|
||||||
|
alert("请选择牛奶、填写消耗量和单位。"); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Submitting Record Consumption:", payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchApi('/consumption', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
alert('消耗记录成功!');
|
||||||
|
closeModal(recordConsumptionModal);
|
||||||
|
loadMilkData(); // Refresh list and overview
|
||||||
|
} catch (error) {
|
||||||
|
alert(`记录失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
console.log("Loading user settings...");
|
||||||
|
if (!jwtToken) return; // Should not happen if settings button is shown only when logged in
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await fetchApi('/user/profile');
|
||||||
|
if (profile) {
|
||||||
|
document.getElementById('expiry-warning-days').value = profile.expiryWarningDays ?? 2;
|
||||||
|
document.getElementById('low-stock-threshold').value = profile.lowStockThresholdValue ?? '';
|
||||||
|
document.getElementById('low-stock-unit').value = profile.lowStockThresholdUnit ?? 'ml';
|
||||||
|
|
||||||
|
const notifications = profile.notificationsEnabled || {};
|
||||||
|
document.querySelector('input[name="enableExpiryWarning"]').checked = notifications.expiry ?? true;
|
||||||
|
document.querySelector('input[name="enableExpiredAlert"]').checked = notifications.expired ?? true;
|
||||||
|
document.querySelector('input[name="enableLowStock"]').checked = notifications.lowStock ?? true;
|
||||||
|
|
||||||
|
// Update auth status with correct username if not already done
|
||||||
|
if (profile.username && authStatusDiv.textContent.startsWith('用户')) {
|
||||||
|
authStatusDiv.textContent = `已登录: ${profile.username}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load settings:", error);
|
||||||
|
alert('加载设置失败。');
|
||||||
|
// Don't switch page if load fails
|
||||||
|
showPage('dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function handleSettingsSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(settingsForm);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
expiryWarningDays: parseInt(formData.get('expiryWarningDays'), 10) || 2,
|
||||||
|
lowStockThresholdValue: formData.get('lowStockThresholdValue') ? parseFloat(formData.get('lowStockThresholdValue')) : null,
|
||||||
|
lowStockThresholdUnit: formData.get('lowStockThresholdValue') ? formData.get('lowStockThresholdUnit') : null, // Only set unit if value exists
|
||||||
|
notificationsEnabled: {
|
||||||
|
expiry: formData.has('enableExpiryWarning'),
|
||||||
|
expired: formData.has('enableExpiredAlert'), // Match key used in User.to_dict
|
||||||
|
lowStock: formData.has('enableLowStock'),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Basic validation for threshold
|
||||||
|
if(payload.lowStockThresholdValue !== null && payload.lowStockThresholdValue < 0){
|
||||||
|
alert("低库存阈值不能为负数。"); return;
|
||||||
|
}
|
||||||
|
if(payload.lowStockThresholdValue !== null && !payload.lowStockThresholdUnit){
|
||||||
|
alert("设置低库存阈值时请选择单位。"); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
console.log("Saving Settings:", payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchApi('/user/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
alert('设置已保存!');
|
||||||
|
showPage('dashboard'); // Go back to dashboard
|
||||||
|
} catch (error) {
|
||||||
|
alert(`保存设置失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMilkBatch(batchId, milkNote) {
|
||||||
|
const note = milkNote || `ID ${batchId}`;
|
||||||
|
if (confirm(`确定要删除这条牛奶记录吗?\n(${note})`)) {
|
||||||
|
console.log("Deleting milk batch:", batchId);
|
||||||
|
try {
|
||||||
|
await fetchApi(`/milk/${batchId}`, { method: 'DELETE' });
|
||||||
|
alert('记录已删除。');
|
||||||
|
loadMilkData(); // Refresh the list
|
||||||
|
} catch (error) {
|
||||||
|
alert(`删除失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Modal Control Functions ---
|
||||||
|
function openModal(modalElement) {
|
||||||
|
if (modalElement) modalElement.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modalElement) {
|
||||||
|
if (modalElement) modalElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRecordConsumptionModal(preselectedMilkId = null) {
|
||||||
|
// Refresh dropdown with currently active batches from cache
|
||||||
|
const activeBatches = milkDataCache.filter(b => !b.isDeleted && b.remainingVolume > 0.001 && b.status !== 'expired');
|
||||||
|
populateConsumeDropdown(activeBatches);
|
||||||
|
|
||||||
|
const selectMilk = document.getElementById('select-milk');
|
||||||
|
if (preselectedMilkId && selectMilk) {
|
||||||
|
selectMilk.value = preselectedMilkId; // Try to pre-select
|
||||||
|
} else if (selectMilk && selectMilk.options.length > 1) {
|
||||||
|
selectMilk.selectedIndex = 1; // Select the first available milk (soonest expiry) if none preselected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form fields
|
||||||
|
recordConsumptionForm.reset();
|
||||||
|
consumeDateInput.value = new Date().toISOString().split('T')[0]; // Reset date to today
|
||||||
|
openModal(recordConsumptionModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Page Navigation ---
|
||||||
|
function showPage(pageId) {
|
||||||
|
document.querySelectorAll('.page').forEach(page => page.style.display = 'none');
|
||||||
|
dashboardPage.style.display = 'none';
|
||||||
|
|
||||||
|
const pageToShow = document.getElementById(pageId);
|
||||||
|
if (pageToShow) {
|
||||||
|
// If settings page, load settings first
|
||||||
|
if (pageId === 'settings-page') {
|
||||||
|
loadSettings(); // Load settings when showing the page
|
||||||
|
}
|
||||||
|
pageToShow.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
dashboardPage.style.display = 'block'; // Default to dashboard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Event Listeners ---
|
||||||
|
if (addMilkBtn) {
|
||||||
|
addMilkBtn.addEventListener('click', () => {
|
||||||
|
addMilkForm.reset(); // Clear form
|
||||||
|
openModal(addMilkModal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (recordConsumptionBtn) {
|
||||||
|
recordConsumptionBtn.addEventListener('click', () => openRecordConsumptionModal());
|
||||||
|
}
|
||||||
|
if (settingsBtn) {
|
||||||
|
settingsBtn.addEventListener('click', () => showPage('settings-page'));
|
||||||
|
}
|
||||||
|
if (backToDashboardBtn) {
|
||||||
|
backToDashboardBtn.addEventListener('click', () => showPage('dashboard'));
|
||||||
|
}
|
||||||
|
if (logoutBtn) {
|
||||||
|
logoutBtn.addEventListener('click', () => handleLogout(true)); // Show login after logout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth Modal Buttons
|
||||||
|
if (loginBtn) loginBtn.addEventListener('click', handleLogin);
|
||||||
|
if (registerBtn) registerBtn.addEventListener('click', handleRegister);
|
||||||
|
|
||||||
|
|
||||||
|
// Close Modal Buttons
|
||||||
|
if (closeAddModalBtn) closeAddModalBtn.addEventListener('click', () => closeModal(addMilkModal));
|
||||||
|
if (closeRecordModalBtn) closeRecordModalBtn.addEventListener('click', () => closeModal(recordConsumptionModal));
|
||||||
|
if (closeAuthModalBtn) closeAuthModalBtn.addEventListener('click', () => closeModal(authModal));
|
||||||
|
|
||||||
|
|
||||||
|
// Close Modal on outside click
|
||||||
|
window.addEventListener('click', (event) => {
|
||||||
|
if (event.target == addMilkModal) closeModal(addMilkModal);
|
||||||
|
if (event.target == recordConsumptionModal) closeModal(recordConsumptionModal);
|
||||||
|
if (event.target == authModal) closeModal(authModal);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form Submissions
|
||||||
|
if (addMilkForm) addMilkForm.addEventListener('submit', handleAddMilkSubmit);
|
||||||
|
if (recordConsumptionForm) recordConsumptionForm.addEventListener('submit', handleRecordConsumptionSubmit);
|
||||||
|
if (settingsForm) settingsForm.addEventListener('submit', handleSettingsSubmit);
|
||||||
|
|
||||||
|
|
||||||
|
// Quick Consume Buttons Logic
|
||||||
|
document.querySelectorAll('.consume-preset').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const amount = this.dataset.amount;
|
||||||
|
const unit = this.dataset.unit;
|
||||||
|
document.getElementById('consume-amount').value = amount;
|
||||||
|
document.getElementById('consume-unit').value = unit;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Initial App Load ---
|
||||||
|
function initializeApp(){
|
||||||
|
console.log("Initializing app...");
|
||||||
|
updateAuthStatus();
|
||||||
|
if(jwtToken){
|
||||||
|
showPage('dashboard'); // Show dashboard if logged in
|
||||||
|
loadMilkData(); // Load data if logged in
|
||||||
|
} else {
|
||||||
|
// Optionally show login immediately on load if not logged in
|
||||||
|
showLoginModal();
|
||||||
|
hideMainContent();
|
||||||
|
noMilkMessage.textContent = '请先登录以查看或添加牛奶记录。';
|
||||||
|
noMilkMessage.style.display = 'block';
|
||||||
|
}
|
||||||
|
// Set current year in footer
|
||||||
|
document.getElementById('current-year').textContent = new Date().getFullYear();
|
||||||
|
// Set default consume date
|
||||||
|
consumeDateInput.value = new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeApp(); // Start the app
|
||||||
|
|
||||||
|
}); // End DOMContentLoaded
|
||||||
367
milk_app/static/style.css
Normal file
367
milk_app/static/style.css
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
/* Basic Reset & Defaults */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding-bottom: 60px; /* Space for footer */
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
background-color: #4a90e2; /* Blue theme */
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-shrink: 0; /* Prevent header from shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content Area */
|
||||||
|
main {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
flex-grow: 1; /* Allow main content to grow */
|
||||||
|
width: 100%; /* Ensure it takes width for centering */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
color: #4a90e2;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary Section */
|
||||||
|
.summary-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item span {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item strong {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.actions {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex; /* Changed back from display: none */
|
||||||
|
gap: 0.8rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.button {
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background-color 0.2s ease, opacity 0.2s ease;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2; /* Adjust for better vertical alignment */
|
||||||
|
}
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.button.primary { background-color: #4a90e2; }
|
||||||
|
.button.primary:hover:not(:disabled) { background-color: #357abd; }
|
||||||
|
|
||||||
|
.button.secondary { background-color: #5cb85c; }
|
||||||
|
.button.secondary:hover:not(:disabled) { background-color: #4cae4c; }
|
||||||
|
|
||||||
|
.button.tertiary { background-color: #f0ad4e; }
|
||||||
|
.button.tertiary:hover:not(:disabled) { background-color: #ec971f; }
|
||||||
|
|
||||||
|
/* Milk List & Cards */
|
||||||
|
.milk-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
/* Responsive grid columns */
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
#no-milk-message {
|
||||||
|
grid-column: 1 / -1; /* Span across all columns if grid is active */
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.milk-card {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
border-left: 5px solid #4a90e2; /* Default border color - normal */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* Stack elements vertically */
|
||||||
|
}
|
||||||
|
|
||||||
|
.milk-card.warning { border-left-color: #f0ad4e; } /* Yellow for warning */
|
||||||
|
.milk-card.expired { border-left-color: #d9534f; opacity: 0.7; } /* Red for expired */
|
||||||
|
|
||||||
|
.card-header, .card-body, .card-footer {
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
.card-footer { margin-bottom: 0; margin-top: auto; } /* Push footer to bottom */
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem; /* Space between note and expiry */
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.milk-note{
|
||||||
|
word-break: break-word; /* Prevent long notes from breaking layout */
|
||||||
|
}
|
||||||
|
.milk-expiry {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #555;
|
||||||
|
flex-shrink: 0; /* Prevent expiry date from shrinking too much */
|
||||||
|
white-space: nowrap; /* Prevent expiry date text wrap */
|
||||||
|
}
|
||||||
|
.milk-card.warning .milk-expiry { color: #c77c0e; }
|
||||||
|
.milk-card.expired .milk-expiry { color: #d9534f; font-weight: bold;}
|
||||||
|
|
||||||
|
.milk-quantity { display: block; margin-bottom: 0.3rem;}
|
||||||
|
progress { width: 100%; height: 8px; border-radius: 4px; overflow: hidden;}
|
||||||
|
/* Styling progress bar for better appearance */
|
||||||
|
progress::-webkit-progress-bar { background-color: #eee; border-radius: 4px;}
|
||||||
|
progress::-webkit-progress-value { background-color: #4a90e2; border-radius: 4px;}
|
||||||
|
progress::-moz-progress-bar { background-color: #4a90e2; border-radius: 4px;}
|
||||||
|
.milk-card.warning progress::-webkit-progress-value { background-color: #f0ad4e; }
|
||||||
|
.milk-card.warning progress::-moz-progress-bar { background-color: #f0ad4e; }
|
||||||
|
.milk-card.expired progress::-webkit-progress-value { background-color: #d9534f; }
|
||||||
|
.milk-card.expired progress::-moz-progress-bar { background-color: #d9534f; }
|
||||||
|
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.card-footer > div { /* Container for buttons */
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action-btn {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background-color: #eee;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.card-action-btn:hover:not(:disabled){ background-color: #ddd;}
|
||||||
|
.card-action-btn.delete-milk-btn { background-color: #f8d7da; border-color: #f5c6cb; color: #721c24;}
|
||||||
|
.card-action-btn.delete-milk-btn:hover:not(:disabled) { background-color: #f5c6cb;}
|
||||||
|
.card-action-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.modal {
|
||||||
|
display: none; /* Hidden by default */
|
||||||
|
position: fixed; /* Stay in place */
|
||||||
|
z-index: 1000; /* Sit on top */
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto; /* Enable scroll if needed */
|
||||||
|
background-color: rgba(0,0,0,0.5); /* Black w/ opacity */
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #fefefe;
|
||||||
|
margin: 10vh auto; /* Vertical margin using viewport height */
|
||||||
|
padding: 25px;
|
||||||
|
border: 1px solid #888;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%; /* Responsive width */
|
||||||
|
max-width: 500px; /* Max width */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
color: #aaa;
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1; /* Ensure consistent positioning */
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover,
|
||||||
|
.close-btn:focus {
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label, fieldset legend {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="file"],
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: #fff; /* Ensure background color */
|
||||||
|
}
|
||||||
|
input[type="file"] { padding: 0.3rem;}
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4a90e2;
|
||||||
|
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
display: block; /* Ensure it takes its own line */
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr { margin: 1rem 0; border: 0; border-top: 1px solid #eee; }
|
||||||
|
|
||||||
|
.quick-consume-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.quick-consume-buttons .button { padding: 0.5rem 0.8rem; font-size: 0.9rem;}
|
||||||
|
|
||||||
|
/* Auth Modal Specifics */
|
||||||
|
.auth-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#auth-error {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Settings Page */
|
||||||
|
.page {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
fieldset legend { font-size: 1rem; padding: 0 0.3rem; font-weight: bold; color: #444; }
|
||||||
|
fieldset label { font-weight: normal; margin-bottom: 0.2rem; display: inline-block; margin-right: 1rem;}
|
||||||
|
fieldset input[type="checkbox"] { margin-right: 0.5rem; vertical-align: middle; }
|
||||||
|
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #777;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
flex-shrink: 0; /* Prevent footer from shrinking */
|
||||||
|
width: 100%;
|
||||||
|
/* Removed position: fixed */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Adjustments */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
margin: 5vh auto; /* Adjust vertical margin for smaller screens */
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
flex-direction: column; /* Stack action buttons */
|
||||||
|
align-items: stretch; /* Make buttons full width */
|
||||||
|
}
|
||||||
|
.summary-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.card-header { flex-direction: column; align-items: flex-start;} /* Stack note and expiry on small screens */
|
||||||
|
.card-footer { flex-direction: column; align-items: flex-start;}
|
||||||
|
.card-footer > div { width: 100%; justify-content: space-around; margin-top: 0.5rem;} /* Space out buttons */
|
||||||
|
.auth-actions { flex-direction: column; } /* Stack login/register buttons */
|
||||||
|
|
||||||
|
}
|
||||||
183
milk_app/templates/index.html
Normal file
183
milk_app/templates/index.html
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>牛奶管家</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>🥛 牛奶管家</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="dashboard">
|
||||||
|
<h2>概览</h2>
|
||||||
|
<div class="summary-container">
|
||||||
|
<div class="summary-item">
|
||||||
|
<span>当前总计</span>
|
||||||
|
<strong id="total-quantity">-- L / -- 盒</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span>最近过期</span>
|
||||||
|
<strong id="nearest-expiry">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span>预计喝完</span>
|
||||||
|
<strong id="estimated-finish">--</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions" style="display: none;"> <button id="add-milk-btn" class="button primary">➕ 添加入库</button>
|
||||||
|
<button id="record-consumption-btn" class="button secondary">➖ 记录消耗</button>
|
||||||
|
<button id="settings-btn" class="button tertiary">⚙️ 设置</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>我的牛奶</h2>
|
||||||
|
<div id="milk-list" class="milk-list">
|
||||||
|
<p id="no-milk-message">请先登录以查看或添加牛奶记录。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="settings-page" class="page" style="display: none;">
|
||||||
|
<h2>设置</h2>
|
||||||
|
<form id="settings-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="expiry-warning-days">临期提醒提前天数:</label>
|
||||||
|
<input type="number" id="expiry-warning-days" name="expiryWarningDays" value="2" min="1"> 天
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="low-stock-threshold">低库存提醒阈值:</label>
|
||||||
|
<input type="number" id="low-stock-threshold" name="lowStockThresholdValue" value="500" min="0" placeholder="留空则不提醒">
|
||||||
|
<select id="low-stock-unit" name="lowStockThresholdUnit">
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
<option value="L">L</option>
|
||||||
|
<option value="items">盒/瓶</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<fieldset>
|
||||||
|
<legend>开启推送通知 (未来功能):</legend>
|
||||||
|
<label><input type="checkbox" name="enableExpiryWarning" checked> 临期提醒</label><br>
|
||||||
|
<label><input type="checkbox" name="enableExpiredAlert" checked> 过期提醒</label><br>
|
||||||
|
<label><input type="checkbox" name="enableLowStock" checked> 低库存提醒</label><br>
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit" class="button primary">保存设置</button>
|
||||||
|
<button type="button" id="back-to-dashboard-btn" class="button secondary">返回看板</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="add-milk-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close-btn" id="close-add-modal">×</span>
|
||||||
|
<h2>添加入库</h2>
|
||||||
|
<form id="add-milk-form">
|
||||||
|
<p>请填写信息:</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="production-date">生产日期 (可选):</label>
|
||||||
|
<input type="date" id="production-date" name="productionDate">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="shelf-life">保质期 (与生产日期配合):</label>
|
||||||
|
<input type="number" id="shelf-life" name="shelfLife" min="1" placeholder="例如: 7">
|
||||||
|
<select name="shelfUnit">
|
||||||
|
<option value="days" selected>天</option>
|
||||||
|
<option value="months">月</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="expiry-date">或 直接输入过期日期:</label>
|
||||||
|
<input type="date" id="expiry-date" name="expiryDate">
|
||||||
|
<small>如果填写此项,则忽略生产日期和保质期</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="quantity">数量:</label>
|
||||||
|
<input type="number" id="quantity" name="quantity" value="1" min="1" required> 盒/瓶
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="volume">单件容量:</label>
|
||||||
|
<input type="number" id="volume" name="volumePerItem" required min="1" placeholder="例如: 250">
|
||||||
|
<select name="unit" required>
|
||||||
|
<option value="ml" selected>ml</option>
|
||||||
|
<option value="L">L</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="note">备注 (品牌等, 可选):</label>
|
||||||
|
<input type="text" id="note" name="note" placeholder="例如: 光明、楼下超市">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button primary">确认入库</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="record-consumption-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close-btn" id="close-record-modal">×</span>
|
||||||
|
<h2>记录消耗</h2>
|
||||||
|
<form id="record-consumption-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="select-milk">选择消耗的牛奶:</label>
|
||||||
|
<select id="select-milk" name="milkBatchId" required>
|
||||||
|
<option value="" disabled selected>请选择...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>消耗量:</label>
|
||||||
|
<div class="quick-consume-buttons">
|
||||||
|
<button type="button" class="button consume-preset" data-amount="250" data-unit="ml">一杯(250ml)</button>
|
||||||
|
<button type="button" class="button consume-preset" data-amount="0.5" data-unit="items">半盒</button>
|
||||||
|
<button type="button" class="button consume-preset" data-amount="1" data-unit="items">整盒</button>
|
||||||
|
</div>
|
||||||
|
<label for="consume-amount">或手动输入:</label>
|
||||||
|
<input type="number" id="consume-amount" name="amountConsumed" min="0.1" step="any" required>
|
||||||
|
<select id="consume-unit" name="unitConsumed" required>
|
||||||
|
<option value="ml" selected>ml</option>
|
||||||
|
<option value="L">L</option>
|
||||||
|
<option value="items">盒/瓶</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="consume-date">消耗日期:</label>
|
||||||
|
<input type="date" id="consume-date" name="dateConsumed" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button primary">确认消耗</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="auth-modal" class="modal" style="display: none;"> <div class="modal-content">
|
||||||
|
<span class="close-btn" id="close-auth-modal">×</span>
|
||||||
|
<div id="auth-section">
|
||||||
|
<h2>登录 / 注册</h2>
|
||||||
|
<form id="auth-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="auth-username">用户名:</label>
|
||||||
|
<input type="text" id="auth-username" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="auth-password">密码:</label>
|
||||||
|
<input type="password" id="auth-password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group auth-actions">
|
||||||
|
<button type="button" id="login-btn" class="button primary">登录</button>
|
||||||
|
<button type="button" id="register-btn" class="button secondary">注册</button>
|
||||||
|
</div>
|
||||||
|
<p id="auth-error" style="color: red; display: none;"></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© <span id="current-year"></span> 牛奶管家</p>
|
||||||
|
<div id="auth-status" style="font-size: 0.8em; margin-top: 5px;">未登录</div>
|
||||||
|
<button id="logout-btn" class="button tertiary" style="display: none; padding: 0.3em 0.6em; font-size: 0.8em;">登出</button>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
milk_app/utils.py
Normal file
34
milk_app/utils.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
def calculate_expiry_date(production_date_str, shelf_life, shelf_unit):
|
||||||
|
"""Calculates expiry date from production date and shelf life."""
|
||||||
|
if not production_date_str or not shelf_life or not shelf_unit:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
production_date = datetime.date.fromisoformat(production_date_str)
|
||||||
|
life = int(shelf_life)
|
||||||
|
if life <= 0: return None # Shelf life must be positive
|
||||||
|
|
||||||
|
if shelf_unit == 'days':
|
||||||
|
return production_date + datetime.timedelta(days=life)
|
||||||
|
elif shelf_unit == 'months':
|
||||||
|
return production_date + relativedelta(months=life)
|
||||||
|
else:
|
||||||
|
return None # Invalid unit
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None # Invalid date format or shelf life
|
||||||
|
|
||||||
|
def convert_volume(amount, from_unit, to_unit):
|
||||||
|
"""Converts volume between ml and L."""
|
||||||
|
if from_unit == to_unit:
|
||||||
|
return float(amount)
|
||||||
|
try:
|
||||||
|
amount = float(amount)
|
||||||
|
if from_unit == 'ml' and to_unit == 'L':
|
||||||
|
return amount / 1000.0
|
||||||
|
if from_unit == 'L' and to_unit == 'ml':
|
||||||
|
return amount * 1000.0
|
||||||
|
raise ValueError("Invalid unit conversion")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise ValueError(f"Invalid amount or units for conversion: {amount}, {from_unit}, {to_unit}")
|
||||||
BIN
requirements.txt
Normal file
BIN
requirements.txt
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user