From 59e749aa24babc66a69b23c09a7a4902205e1b11 Mon Sep 17 00:00:00 2001 From: "jp.gao" Date: Wed, 9 Apr 2025 20:34:32 +0800 Subject: [PATCH] init --- .dockerignore | 42 +++ .gitignore | 174 +++++++++ Dockerfile | 65 ++++ milk_app/__init__.py | 0 milk_app/app.py | 123 +++++++ milk_app/auth.py | 50 +++ milk_app/consumption.py | 96 +++++ milk_app/database.py | 164 +++++++++ milk_app/devices.py | 79 ++++ milk_app/main_routes.py | 15 + milk_app/milk.py | 115 ++++++ milk_app/overview.py | 67 ++++ milk_app/profile.py | 51 +++ milk_app/static/script.js | 665 ++++++++++++++++++++++++++++++++++ milk_app/static/style.css | 367 +++++++++++++++++++ milk_app/templates/index.html | 183 ++++++++++ milk_app/utils.py | 34 ++ requirements.txt | Bin 0 -> 764 bytes 18 files changed, 2290 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 milk_app/__init__.py create mode 100644 milk_app/app.py create mode 100644 milk_app/auth.py create mode 100644 milk_app/consumption.py create mode 100644 milk_app/database.py create mode 100644 milk_app/devices.py create mode 100644 milk_app/main_routes.py create mode 100644 milk_app/milk.py create mode 100644 milk_app/overview.py create mode 100644 milk_app/profile.py create mode 100644 milk_app/static/script.js create mode 100644 milk_app/static/style.css create mode 100644 milk_app/templates/index.html create mode 100644 milk_app/utils.py create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72d16db --- /dev/null +++ b/.dockerignore @@ -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/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1800114 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8b9dde7 --- /dev/null +++ b/Dockerfile @@ -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()"] \ No newline at end of file diff --git a/milk_app/__init__.py b/milk_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/milk_app/app.py b/milk_app/app.py new file mode 100644 index 0000000..902c7df --- /dev/null +++ b/milk_app/app.py @@ -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 \ No newline at end of file diff --git a/milk_app/auth.py b/milk_app/auth.py new file mode 100644 index 0000000..1ef3f81 --- /dev/null +++ b/milk_app/auth.py @@ -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 \ No newline at end of file diff --git a/milk_app/consumption.py b/milk_app/consumption.py new file mode 100644 index 0000000..544a40b --- /dev/null +++ b/milk_app/consumption.py @@ -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 \ No newline at end of file diff --git a/milk_app/database.py b/milk_app/database.py new file mode 100644 index 0000000..81fa523 --- /dev/null +++ b/milk_app/database.py @@ -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.") \ No newline at end of file diff --git a/milk_app/devices.py b/milk_app/devices.py new file mode 100644 index 0000000..4425613 --- /dev/null +++ b/milk_app/devices.py @@ -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('/', 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 \ No newline at end of file diff --git a/milk_app/main_routes.py b/milk_app/main_routes.py new file mode 100644 index 0000000..e2fcf28 --- /dev/null +++ b/milk_app/main_routes.py @@ -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 \ No newline at end of file diff --git a/milk_app/milk.py b/milk_app/milk.py new file mode 100644 index 0000000..6fbdf20 --- /dev/null +++ b/milk_app/milk.py @@ -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('/', 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 \ No newline at end of file diff --git a/milk_app/overview.py b/milk_app/overview.py new file mode 100644 index 0000000..e048af5 --- /dev/null +++ b/milk_app/overview.py @@ -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 \ No newline at end of file diff --git a/milk_app/profile.py b/milk_app/profile.py new file mode 100644 index 0000000..ebfff9b --- /dev/null +++ b/milk_app/profile.py @@ -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 \ No newline at end of file diff --git a/milk_app/static/script.js b/milk_app/static/script.js new file mode 100644 index 0000000..6ea427f --- /dev/null +++ b/milk_app/static/script.js @@ -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 = ` +
+ ${milk.note || '牛奶'} (${milk.volumePerItem}${milk.volumeUnit}) + ${expiryText} +
+
+ 剩余: ${milk.remainingVolume.toFixed(1)}${milk.volumeUnit} / ${milk.remainingItemsApprox.toFixed(1)} 盒 + +
+ + `; + 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 = ''; // 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 \ No newline at end of file diff --git a/milk_app/static/style.css b/milk_app/static/style.css new file mode 100644 index 0000000..ad24924 --- /dev/null +++ b/milk_app/static/style.css @@ -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 */ + +} \ No newline at end of file diff --git a/milk_app/templates/index.html b/milk_app/templates/index.html new file mode 100644 index 0000000..1ecc628 --- /dev/null +++ b/milk_app/templates/index.html @@ -0,0 +1,183 @@ + + + + + + 牛奶管家 + + + + +
+

🥛 牛奶管家

+
+ +
+
+

概览

+
+
+ 当前总计 + -- L / -- 盒 +
+
+ 最近过期 + -- +
+
+ 预计喝完 + -- +
+
+ + + +

我的牛奶

+
+

请先登录以查看或添加牛奶记录。

+
+
+ + + +
+ + + + + + + +
+

© 牛奶管家

+
未登录
+ +
+ + + + \ No newline at end of file diff --git a/milk_app/utils.py b/milk_app/utils.py new file mode 100644 index 0000000..7cfbcdb --- /dev/null +++ b/milk_app/utils.py @@ -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}") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..30b148076a021c02447a51fcb68437129332fa5e GIT binary patch literal 764 zcmZ`%%Syvg5It+bPf2M~A9dlvg)WMMQlU$UeZ**+ggm7B@#;Ahl z{q>ABauj&O5;ZRQi!s0j&k)Br!rbsVabaWL3m*rpBwA9JV#6cGkmw1|nK5pNr)YwD z6x8m#`o$tvkxOF^r)0?`-gAgB!3>XxaE&cm_FG`V@4{57d45ne#RG0}#cFfBkiWr) zDf(`_PR9C6^IY@W9%OW^n1@~qVmCWQ% zEzj+a_cc@NP+`jdD|#v2zFK10R7A9yl(n?OEcB!-oN>=tJvFISJ>JQ-BHA*OlG@0+ zR;nHEa;DWQ{4EUFNy$mt(Eq-+B%+PHn@(>;$7gNmx81qdo*emaH=dJTlJi=2T7?;( fSf}5=oYlR*9L*hDM!wS-nM(1~rt)<9t&i{nqEv7D literal 0 HcmV?d00001