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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions medtrack/.gitignore
Original file line number Diff line number Diff line change
@@ -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

27 changes: 27 additions & 0 deletions medtrack/README.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions medtrack/medtrack/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .app import create_app, app # noqa: F401
50 changes: 50 additions & 0 deletions medtrack/medtrack/app.py
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 7 additions & 0 deletions medtrack/medtrack/extensions.py
Original file line number Diff line number Diff line change
@@ -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"
32 changes: 32 additions & 0 deletions medtrack/medtrack/forms.py
Original file line number Diff line number Diff line change
@@ -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")
91 changes: 91 additions & 0 deletions medtrack/medtrack/inventory.py
Original file line number Diff line number Diff line change
@@ -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/<int:medicine_id>/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/<int:medicine_id>/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/<int:medicine_id>/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)
73 changes: 73 additions & 0 deletions medtrack/medtrack/models.py
Original file line number Diff line number Diff line change
@@ -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")
32 changes: 32 additions & 0 deletions medtrack/medtrack/templates/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MedTrack</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">MedTrack</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="/logout">Logout</a></li>
</ul>
</div>
</div>
</nav>
<main class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
Loading