diff --git a/medtrack/.gitignore b/medtrack/.gitignore new file mode 100644 index 0000000..fb485ee --- /dev/null +++ b/medtrack/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.venv/ + +# VSCode/Cursor +.vscode/ +.cursor/ + +# SQLite +*.db +*.sqlite3 +instance/ + +# Env +.env + +# Logs +logs/ +*.log + diff --git a/medtrack/README.md b/medtrack/README.md new file mode 100644 index 0000000..6d29404 --- /dev/null +++ b/medtrack/README.md @@ -0,0 +1,27 @@ +# Medicine Tracking Management System (Flask) + +Simple college project to manage medicines, stock, expiry tracking, and transactions. + +## Features +- Authentication (login/logout) +- Medicines CRUD +- Stock and expiry alerts +- Purchase/Dispense transactions + +## Quickstart +```bash +# 1) Ensure Python 3.10+ and venv support are installed +sudo apt-get update && sudo apt-get install -y python3-venv + +# 2) Setup venv and install deps +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +# 3) Initialize DB and run +export FLASK_APP=medtrack/app.py +flask db-upgrade +flask run --host 0.0.0.0 --port 5000 +``` + +Default admin: admin@example.com / admin123 \ No newline at end of file diff --git a/medtrack/medtrack/__init__.py b/medtrack/medtrack/__init__.py new file mode 100644 index 0000000..f30c50b --- /dev/null +++ b/medtrack/medtrack/__init__.py @@ -0,0 +1 @@ +from .app import create_app, app # noqa: F401 \ No newline at end of file diff --git a/medtrack/medtrack/app.py b/medtrack/medtrack/app.py new file mode 100644 index 0000000..6a87dc7 --- /dev/null +++ b/medtrack/medtrack/app.py @@ -0,0 +1,50 @@ +from flask import Flask +import os + +from .extensions import db, login_manager + + +def create_app() -> Flask: + app = Flask(__name__, instance_relative_config=True) + + app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret") + + os.makedirs(app.instance_path, exist_ok=True) + database_path = os.path.join(app.instance_path, "medtrack.sqlite3") + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{database_path}" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + db.init_app(app) + login_manager.init_app(app) + + from .auth import auth_bp, ensure_default_admin + from .inventory import inventory_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(inventory_bp) + + @app.cli.command("db-upgrade") + def db_upgrade() -> None: + from . import models # noqa: F401 + db.create_all() + ensure_default_admin() + + @app.shell_context_processor + def shell_context(): + from .models import User, Medicine, Transaction + return { + "db": db, + "User": User, + "Medicine": Medicine, + "Transaction": Transaction, + } + + with app.app_context(): + from . import models # noqa: F401 + db.create_all() + ensure_default_admin() + + return app + + +app = create_app() \ No newline at end of file diff --git a/medtrack/medtrack/extensions.py b/medtrack/medtrack/extensions.py new file mode 100644 index 0000000..052c783 --- /dev/null +++ b/medtrack/medtrack/extensions.py @@ -0,0 +1,7 @@ +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy + + +db = SQLAlchemy() +login_manager = LoginManager() +login_manager.login_view = "auth.login" \ No newline at end of file diff --git a/medtrack/medtrack/forms.py b/medtrack/medtrack/forms.py new file mode 100644 index 0000000..8a0d7cc --- /dev/null +++ b/medtrack/medtrack/forms.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import date + +from flask_wtf import FlaskForm +from wtforms import BooleanField, DateField, DecimalField, IntegerField, PasswordField, SelectField, StringField, SubmitField, TextAreaField +from wtforms.validators import DataRequired, Email, Length, NumberRange + + +class LoginForm(FlaskForm): + email = StringField("Email", validators=[DataRequired(), Email(), Length(max=255)]) + password = PasswordField("Password", validators=[DataRequired(), Length(min=4, max=128)]) + remember_me = BooleanField("Remember me") + submit = SubmitField("Login") + + +class MedicineForm(FlaskForm): + name = StringField("Name", validators=[DataRequired(), Length(max=200)]) + manufacturer = StringField("Manufacturer", validators=[Length(max=200)]) + batch_number = StringField("Batch #", validators=[Length(max=100)]) + expiry_date = DateField("Expiry Date", validators=[DataRequired()], format="%Y-%m-%d") + quantity = IntegerField("Quantity", validators=[DataRequired(), NumberRange(min=0)]) + low_stock_threshold = IntegerField("Low Stock Threshold", validators=[DataRequired(), NumberRange(min=0)]) + unit_price = DecimalField("Unit Price", places=2, rounding=None, validators=[DataRequired(), NumberRange(min=0)]) + submit = SubmitField("Save") + + +class TransactionForm(FlaskForm): + type = SelectField("Type", choices=[("PURCHASE", "Purchase"), ("DISPENSE", "Dispense")], validators=[DataRequired()]) + quantity = IntegerField("Quantity", validators=[DataRequired(), NumberRange(min=1)]) + notes = TextAreaField("Notes", validators=[Length(max=255)]) + submit = SubmitField("Record") \ No newline at end of file diff --git a/medtrack/medtrack/inventory.py b/medtrack/medtrack/inventory.py new file mode 100644 index 0000000..14bf020 --- /dev/null +++ b/medtrack/medtrack/inventory.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from datetime import date + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_login import login_required + +from .extensions import db +from .forms import MedicineForm, TransactionForm +from .models import Medicine, Transaction + + +inventory_bp = Blueprint("inventory", __name__) + + +@inventory_bp.route("/") +@login_required +def dashboard(): + medicines = Medicine.query.order_by(Medicine.name.asc()).all() + low_stock = [m for m in medicines if m.is_low_stock] + expiring = [m for m in medicines if m.is_expiring_soon] + return render_template( + "dashboard.html", + medicines=medicines, + low_stock=low_stock, + expiring=expiring, + ) + + +@inventory_bp.route("/medicines/new", methods=["GET", "POST"]) +@login_required +def create_medicine(): + form = MedicineForm() + if form.validate_on_submit(): + medicine = Medicine( + name=form.name.data, + manufacturer=form.manufacturer.data, + batch_number=form.batch_number.data, + expiry_date=form.expiry_date.data, + quantity=form.quantity.data, + low_stock_threshold=form.low_stock_threshold.data, + unit_price=form.unit_price.data, + ) + db.session.add(medicine) + db.session.commit() + flash("Medicine created", "success") + return redirect(url_for("inventory.dashboard")) + return render_template("medicines/form.html", form=form, title="Add Medicine") + + +@inventory_bp.route("/medicines//edit", methods=["GET", "POST"]) +@login_required +def edit_medicine(medicine_id: int): + medicine = Medicine.query.get_or_404(medicine_id) + form = MedicineForm(obj=medicine) + if form.validate_on_submit(): + form.populate_obj(medicine) + db.session.commit() + flash("Medicine updated", "success") + return redirect(url_for("inventory.dashboard")) + return render_template("medicines/form.html", form=form, title="Edit Medicine") + + +@inventory_bp.route("/medicines//delete", methods=["POST"]) +@login_required +def delete_medicine(medicine_id: int): + medicine = Medicine.query.get_or_404(medicine_id) + db.session.delete(medicine) + db.session.commit() + flash("Medicine deleted", "success") + return redirect(url_for("inventory.dashboard")) + + +@inventory_bp.route("/medicines//tx", methods=["GET", "POST"]) +@login_required +def record_transaction(medicine_id: int): + medicine = Medicine.query.get_or_404(medicine_id) + form = TransactionForm() + if form.validate_on_submit(): + quantity_change = form.quantity.data if form.type.data == "PURCHASE" else -form.quantity.data + new_quantity = medicine.quantity + quantity_change + if new_quantity < 0: + flash("Insufficient stock to dispense", "danger") + return render_template("medicines/transaction.html", form=form, medicine=medicine) + medicine.quantity = new_quantity + tx = Transaction(medicine=medicine, type=form.type.data, quantity=form.quantity.data, notes=form.notes.data) + db.session.add(tx) + db.session.commit() + flash("Transaction recorded", "success") + return redirect(url_for("inventory.dashboard")) + return render_template("medicines/transaction.html", form=form, medicine=medicine) \ No newline at end of file diff --git a/medtrack/medtrack/models.py b/medtrack/medtrack/models.py new file mode 100644 index 0000000..9816e51 --- /dev/null +++ b/medtrack/medtrack/models.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import Optional + +from flask_login import UserMixin +from werkzeug.security import check_password_hash, generate_password_hash + +from .extensions import db, login_manager + + +class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.String(32), nullable=False, default="admin") + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + def set_password(self, password: str) -> None: + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password_hash, password) + + +@login_manager.user_loader +def load_user(user_id: str) -> Optional[User]: + return User.query.get(int(user_id)) + + +class Medicine(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False, index=True) + manufacturer = db.Column(db.String(200), nullable=True) + batch_number = db.Column(db.String(100), nullable=True) + expiry_date = db.Column(db.Date, nullable=False) + + quantity = db.Column(db.Integer, nullable=False, default=0) + low_stock_threshold = db.Column(db.Integer, nullable=False, default=10) + + unit_price = db.Column(db.Numeric(10, 2), nullable=False, default=0) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column( + db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + + transactions = db.relationship( + "Transaction", back_populates="medicine", cascade="all, delete-orphan" + ) + + @property + def is_low_stock(self) -> bool: + return self.quantity <= self.low_stock_threshold + + @property + def is_expiring_soon(self) -> bool: + if not self.expiry_date: + return False + days_until_expiry = (self.expiry_date - date.today()).days + return days_until_expiry <= 30 + + +class Transaction(db.Model): + id = db.Column(db.Integer, primary_key=True) + medicine_id = db.Column(db.Integer, db.ForeignKey("medicine.id"), nullable=False) + type = db.Column(db.String(16), nullable=False) # PURCHASE or DISPENSE + quantity = db.Column(db.Integer, nullable=False) + notes = db.Column(db.String(255), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + medicine = db.relationship("Medicine", back_populates="transactions") \ No newline at end of file diff --git a/medtrack/medtrack/templates/base.html b/medtrack/medtrack/templates/base.html new file mode 100644 index 0000000..b347b94 --- /dev/null +++ b/medtrack/medtrack/templates/base.html @@ -0,0 +1,32 @@ + + + + + + MedTrack + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + \ No newline at end of file diff --git a/medtrack/medtrack/templates/dashboard.html b/medtrack/medtrack/templates/dashboard.html new file mode 100644 index 0000000..32d88c8 --- /dev/null +++ b/medtrack/medtrack/templates/dashboard.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block content %} +
+

Dashboard

+ Add Medicine +
+ +
+
+
Low Stock
+
    + {% if low_stock %} + {% for m in low_stock %} +
  • + {{ m.name }} ({{ m.quantity }}) + Record Tx +
  • + {% endfor %} + {% else %} +
  • No low stock items
  • + {% endif %} +
+
+
+
Expiring Soon
+
    + {% if expiring %} + {% for m in expiring %} +
  • + {{ m.name }} (expires {{ m.expiry_date }}) + Edit +
  • + {% endfor %} + {% else %} +
  • No upcoming expiries
  • + {% endif %} +
+
+
+ +
All Medicines
+ + + + + + + + + + + + + {% for m in medicines %} + + + + + + + + + {% else %} + + {% endfor %} + +
NameBatchExpiryQtyPrice
{{ m.name }}{{ m.batch_number or '-' }}{{ m.expiry_date }}{{ m.quantity }}{{ m.unit_price }} + Record Tx + Edit +
+ +
+
No medicines yet
+{% endblock %} \ No newline at end of file diff --git a/medtrack/medtrack/templates/login.html b/medtrack/medtrack/templates/login.html new file mode 100644 index 0000000..59e15f0 --- /dev/null +++ b/medtrack/medtrack/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Login

+
+ {{ form.hidden_tag() }} +
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control", placeholder="you@example.com") }} + {% for e in form.email.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} + {% for e in form.password.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.remember_me(class="form-check-input") }} + {{ form.remember_me.label(class="form-check-label") }} +
+ {{ form.submit(class="btn btn-primary") }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/medtrack/medtrack/templates/medicines/form.html b/medtrack/medtrack/templates/medicines/form.html new file mode 100644 index 0000000..d9055d5 --- /dev/null +++ b/medtrack/medtrack/templates/medicines/form.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block content %} +

{{ title }}

+
+ {{ form.hidden_tag() }} +
+
+ {{ form.name.label(class="form-label") }} + {{ form.name(class="form-control") }} + {% for e in form.name.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.manufacturer.label(class="form-label") }} + {{ form.manufacturer(class="form-control") }} +
+
+ {{ form.batch_number.label(class="form-label") }} + {{ form.batch_number(class="form-control") }} +
+
+ {{ form.expiry_date.label(class="form-label") }} + {{ form.expiry_date(class="form-control", type="date") }} + {% for e in form.expiry_date.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.unit_price.label(class="form-label") }} + {{ form.unit_price(class="form-control", step="0.01") }} +
+
+ {{ form.quantity.label(class="form-label") }} + {{ form.quantity(class="form-control") }} +
+
+ {{ form.low_stock_threshold.label(class="form-label") }} + {{ form.low_stock_threshold(class="form-control") }} +
+
+
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+{% endblock %} \ No newline at end of file diff --git a/medtrack/medtrack/templates/medicines/transaction.html b/medtrack/medtrack/templates/medicines/transaction.html new file mode 100644 index 0000000..f7b76f5 --- /dev/null +++ b/medtrack/medtrack/templates/medicines/transaction.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block content %} +

Record Transaction - {{ medicine.name }}

+
+ {{ form.hidden_tag() }} +
+
+ {{ form.type.label(class="form-label") }} + {{ form.type(class="form-select") }} +
+
+ {{ form.quantity.label(class="form-label") }} + {{ form.quantity(class="form-control") }} + {% for e in form.quantity.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.notes.label(class="form-label") }} + {{ form.notes(class="form-control", rows=3) }} +
+
+
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+{% endblock %} \ No newline at end of file diff --git a/medtrack/requirements.txt b/medtrack/requirements.txt new file mode 100644 index 0000000..85ac4b3 --- /dev/null +++ b/medtrack/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.0.3 +Flask-Login==0.6.3 +Flask-WTF==1.2.1 +Flask-SQLAlchemy==3.1.1 +WTForms==3.1.2 +SQLAlchemy==2.0.34 +email-validator==2.2.0 +python-dotenv==1.0.1 \ No newline at end of file