Initial commit.

This commit is contained in:
Yaro Kasear 2025-06-11 09:10:41 -05:00
commit 189f73b7c2
34 changed files with 1064 additions and 0 deletions

37
__init__.py Normal file
View file

@ -0,0 +1,37 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from urllib.parse import quote_plus
import logging
db = SQLAlchemy()
logger = logging.getLogger('sqlalchemy.engine')
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
logger.addHandler(handler)
def create_app():
app = Flask(__name__)
params = quote_plus(
"DRIVER=ODBC Driver 17 for SQL Server;"
"SERVER=NDVASQLCR01;"
"DATABASE=conradTest;"
"Trusted_Connection=yes;"
)
app.config['SQLALCHEMY_DATABASE_URI'] = f"mssql+pyodbc:///?odbc_connect={params}"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
from .routes import main
app.register_blueprint(main)
@app.route("/")
def index():
return "Hello, you've reached the terrible but functional root of the site."
return app

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

6
app.py Normal file
View file

@ -0,0 +1,6 @@
from . import create_app
app = create_app()
if __name__ == "__main__":
app.run(debug=True)

11
models/__init__.py Normal file
View file

@ -0,0 +1,11 @@
from typing import TYPE_CHECKING
from .. import db # If you're in an actual module
from .areas import Area
from .brands import Brand
from .items import Item
from .inventory import Inventory
from .room_functions import RoomFunction
from .users import User
from .work_log import WorkLog
from .rooms import Room

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

19
models/areas.py Normal file
View file

@ -0,0 +1,19 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .rooms import Room
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class Area(db.Model):
__tablename__ = 'Areas'
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
name: Mapped[Optional[str]] = mapped_column("Area", Unicode(255), nullable=True)
rooms: Mapped[List['Room']] = relationship('Room', back_populates='area')
def __repr__(self):
return f"<Area(id={self.id}, name={repr(self.name)})>"

19
models/brands.py Normal file
View file

@ -0,0 +1,19 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class Brand(db.Model):
__tablename__ = 'Brands'
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
name: Mapped[Optional[str]] = mapped_column("Brand", Unicode(255), nullable=True)
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='brand')
def __repr__(self):
return f"<Brand(id={self.id}, name={repr(self.name)})>"

73
models/inventory.py Normal file
View file

@ -0,0 +1,73 @@
from typing import Any, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .brands import Brand
from .items import Item
from .users import User
from .work_log import WorkLog
from .rooms import Room
from sqlalchemy import Boolean, ForeignKeyConstraint, ForeignKey, Identity, Index, Integer, PrimaryKeyConstraint, String, Unicode, text
from sqlalchemy.dialects.mssql import DATETIME2, MONEY
from sqlalchemy.orm import Mapped, mapped_column, relationship
import datetime
from . import db
class Inventory(db.Model):
__tablename__ = 'Inventory'
__table_args__ = (
Index('Inventory$Bar Code', 'Bar Code'),
)
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
timestamp: Mapped[datetime.datetime] = mapped_column('Date Entered', DATETIME2)
condition: Mapped[str] = mapped_column('Working Condition', Unicode(255))
needed: Mapped[str] = mapped_column("Needed", Unicode(255))
type_id: Mapped[int] = mapped_column('Item Type', Integer, ForeignKey("Items.ID"))
inventory_name: Mapped[Optional[str]] = mapped_column('Inventory #', Unicode(255))
serial: Mapped[Optional[str]] = mapped_column('Serial #', Unicode(255))
model: Mapped[Optional[str]] = mapped_column('Model #', Unicode(255))
notes: Mapped[Optional[str]] = mapped_column('Notes', Unicode(255))
owner_id = mapped_column('Owner', Integer, ForeignKey('Users.ID'))
brand_id: Mapped[Optional[int]] = mapped_column("Brand", Integer, ForeignKey("Brands.ID"))
# Photo: Mapped[Optional[str]] = mapped_column(String(8000)) Will be replacing with something that actually works.
location_id: Mapped[Optional[str]] = mapped_column(ForeignKey("Rooms.ID"))
barcode: Mapped[Optional[str]] = mapped_column('Bar Code', Unicode(255))
shared: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
location: Mapped[Optional['Room']] = relationship('Room', back_populates='inventory')
owner = relationship('User', back_populates='inventory')
brand: Mapped[Optional['Brand']] = relationship('Brand', back_populates='inventory')
item: Mapped['Item'] = relationship('Item', back_populates='inventory')
work_logs: Mapped[List['WorkLog']] = relationship('WorkLog', back_populates='work_item')
def __repr__(self):
parts = [f"id={self.id}"]
if self.inventory_name:
parts.append(f"name={repr(self.inventory_name)}")
if self.item:
parts.append(f"item={repr(self.item.description)}")
if self.notes:
parts.append(f"notes={repr(self.notes)}")
if self.owner:
parts.append(f"owner={repr(self.owner.full_name)}")
if self.location:
parts.append(f"location={repr(self.location.full_name)}")
return f"<Inventory({', '.join(parts)})>"
@property
def identifier(self) -> str:
if self.inventory_name:
return f"Name: {self.inventory_name}"
elif self.barcode:
return f"Bar: {self.barcode}"
elif self.serial:
return f"Serial: {self.serial}"
else:
return f"ID: {self.id}"

20
models/items.py Normal file
View file

@ -0,0 +1,20 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class Item(db.Model):
__tablename__ = 'Items'
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
description: Mapped[Optional[str]] = mapped_column("Description", Unicode(255), nullable=True)
category: Mapped[Optional[str]] = mapped_column("Category", Unicode(255), nullable=True)
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='item')
def __repr__(self):
return f"<Item(id={self.id}, description={repr(self.description)}, category={repr(self.category)})>"

19
models/room_functions.py Normal file
View file

@ -0,0 +1,19 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .rooms import Room
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class RoomFunction(db.Model):
__tablename__ = 'Room Functions'
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
description: Mapped[Optional[str]] = mapped_column("Function", Unicode(255), nullable=True)
rooms: Mapped[List['Room']] = relationship('Room', back_populates='room_function')
def __repr__(self):
return f"<RoomFunction(id={self.id}, description={repr(self.description)})>"

34
models/rooms.py Normal file
View file

@ -0,0 +1,34 @@
from typing import Optional, TYPE_CHECKING, List
if TYPE_CHECKING:
from .areas import Area
from .room_functions import RoomFunction
from .inventory import Inventory
from .users import User
from sqlalchemy import ForeignKey, Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class Room(db.Model):
__tablename__ = 'Rooms'
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
name: Mapped[Optional[str]] = mapped_column("Room", Unicode(255), nullable=True)
area_id: Mapped[Optional[int]] = mapped_column("Area", Integer, ForeignKey("Areas.ID"))
function_id: Mapped[Optional[int]] = mapped_column("Function", Integer, ForeignKey("Room Functions.ID"))
area: Mapped[Optional['Area']] = relationship('Area', back_populates='rooms')
room_function: Mapped[Optional['RoomFunction']] = relationship('RoomFunction', back_populates='rooms')
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='location')
users: Mapped[List['User']] = relationship('User', back_populates='location')
def __repr__(self):
return f"<Room(id={self.id}, room={repr(self.name)}, area_id={self.area_id}, function_id={self.function_id})>"
@property
def full_name(self):
name = self.name or ""
func = self.room_function.description if self.room_function else ""
return f"{name} - {func}".strip(" -")

36
models/users.py Normal file
View file

@ -0,0 +1,36 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from .rooms import Room
from .work_log import WorkLog
from sqlalchemy import Boolean, ForeignKey, Identity, Integer, Unicode, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class User(db.Model):
__tablename__ = 'Users'
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
staff: Mapped[Optional[bool]] = mapped_column("Staff", Boolean, server_default=text('((0))'))
active: Mapped[Optional[bool]] = mapped_column("Active", Boolean, server_default=text('((0))'))
last_name: Mapped[Optional[str]] = mapped_column("Last", Unicode(255), nullable=True)
first_name: Mapped[Optional[str]] = mapped_column("First", Unicode(255), nullable=True)
location_id: Mapped[Optional[str]] = mapped_column(ForeignKey("Rooms.ID"))
supervisor_id: Mapped[Optional[int]] = mapped_column("Supervisor", Integer, ForeignKey("Users.ID"))
supervisor: Mapped[Optional['User']] = relationship('User', remote_side='User.id', back_populates='subordinates')
subordinates: Mapped[List['User']] = relationship('User', back_populates='supervisor')
work_logs: Mapped[List['WorkLog']] = relationship('WorkLog', back_populates='contact')
location: Mapped[Optional['Room']] = relationship('Room', back_populates='users')
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='owner')
@property
def full_name(self) -> str:
return f"{self.first_name or ''} {self.last_name or ''}".strip()
def __repr__(self):
return f"<User(id={self.id}, first_name={repr(self.first_name)}, last_name={repr(self.last_name)}, " \
f"location={repr(self.location)}, staff={self.staff}, active={self.active})>"

35
models/work_log.py Normal file
View file

@ -0,0 +1,35 @@
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from .inventory import User
from sqlalchemy import Boolean, ForeignKeyConstraint, Identity, Integer, ForeignKey, Unicode, text
from sqlalchemy.dialects.mssql import DATETIME2
from sqlalchemy.orm import Mapped, mapped_column, relationship
import datetime
from . import db
class WorkLog(db.Model):
__tablename__ = 'Work Log'
__table_args__ = (
ForeignKeyConstraint(['Work Item'], ['Inventory.ID'], name='Work Log$InventoryWork Log'),
)
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
start_time: Mapped[Optional[datetime.datetime]] = mapped_column('Start Timestamp', DATETIME2)
end_time: Mapped[Optional[datetime.datetime]] = mapped_column('End Timestamp', DATETIME2)
notes: Mapped[Optional[str]] = mapped_column('Description & Notes', Unicode())
complete: Mapped[Optional[bool]] = mapped_column("Complete", Boolean, server_default=text('((0))'))
followup: Mapped[Optional[bool]] = mapped_column('Needs Follow-Up', Boolean, server_default=text('((0))'))
contact_id: Mapped[Optional[int]] = mapped_column('Point of Contact', Integer, ForeignKey("Users.ID"))
analysis: Mapped[Optional[bool]] = mapped_column('Needs Quick Analysis', Boolean, server_default=text('((0))'))
work_item_id: Mapped[Optional[int]] = mapped_column("Work Item", Integer, ForeignKey("Inventory.ID"))
work_item: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='work_logs')
contact: Mapped[Optional['User']] = relationship('User', back_populates='work_logs')
def __repr__(self):
return f"<WorkLog(id={self.id}, start_time={self.start_time}, end_time={self.end_time}, " \
f"notes={repr(self.notes)}, complete={self.complete}, followup={self.followup}, " \
f"contact_id={self.contact_id}, analysis={self.analysis}, work_item_id={self.work_item_id})>"

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
flask
flask_sqlalchemy
pyodbc

236
routes.py Normal file
View file

@ -0,0 +1,236 @@
from flask import Blueprint, render_template, url_for, request
from .models import Area, Brand, Item, Inventory, RoomFunction, User, WorkLog, Room
import html
from sqlalchemy.orm import joinedload
from typing import Callable, Any, List
from . import db
from .utils import eager_load_user_relationships, eager_load_inventory_relationships, eager_load_room_relationships, eager_load_worklog_relationships
main = Blueprint('main', __name__)
checked_box = '''
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check2" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0"/>
</svg>
'''
unchecked_box = ''
ACTIVE_STATUSES = [
"Working",
"Deployed",
"Partially Inoperable",
"Unverified"
]
INACTIVE_STATUSES = [
"Inoperable",
"Removed",
"Disposed"
]
inventory_headers = {
"Date Entered": lambda i: {"text": i.timestamp.strftime("%Y-%m-%d") if i.timestamp else None},
"Identifier": lambda i: {"text": i.identifier},
"Inventory #": lambda i: {"text": i.inventory_name},
"Serial #": lambda i: {"text": i.serial},
"Bar Code #": lambda i: {"text": i.barcode},
"Brand": lambda i: {"text": i.brand.name} if i.brand else {"text": None},
"Model": lambda i: {"text": i.model},
"Item Type": lambda i: {"text": i.item.description} if i.item else {"text": None},
"Shared?": lambda i: {"text": i.shared, "type": "bool", "html": checked_box if i.shared else unchecked_box},
"Owner": lambda i: {"text": i.owner.full_name, "url": url_for("main.user", id=i.owner.id)} if i.owner else {"text": None},
"Location": lambda i: {"text": i.location.full_name} if i.location else {"Text": None},
"Condition": lambda i: {"text": i.condition},
# "Notes": lambda i: {"text": i.notes}
}
user_headers = {
"Last Name": lambda i: {"text": i.last_name},
"First Name": lambda i: {"text": i.first_name},
"Supervisor": lambda i: {"text": i.supervisor.full_name, "url": url_for("main.user", id=i.supervisor.id)} if i.supervisor else {"text": None},
"Location": lambda i: {"text": i.location.full_name} if i.location else {"text": None},
"Staff?": lambda i: {"text": i.staff, "type": "bool", "html": checked_box if i.staff else unchecked_box},
"Active?": lambda i: {"text": i.active, "type": "bool", "html": checked_box if i.active else unchecked_box}
}
worklog_headers = {
"Contact": lambda i: {"text": i.contact.full_name, "url": url_for("main.user", id=i.contact.id)} if i.contact else {"Text": None},
"Work Item": lambda i: {"text": i.work_item.identifier, "url": url_for('main.inventory_item',id=i.work_item.id)} if i.work_item else {"text": None},
"Start Time": lambda i: {"text": i.start_time.strftime("%Y-%m-%d")},
"End Time": lambda i: {"text": i.end_time.strftime("%Y-%m-%d")} if i.end_time else {"text": None},
"Complete?": lambda i: {"text": i.complete, "type": "bool", "html": checked_box if i.complete else unchecked_box},
"Follow Up?": lambda i: {"text": i.followup, "type": "bool", "html": checked_box if i.followup else unchecked_box},
"Quick Analysis?": lambda i: {"text": i.analysis, "type": "bool", "html": checked_box if i.analysis else unchecked_box},
}
def make_paginated_data(
query,
page: int,
per_page=15
):
model = query.column_descriptions[0]['entity']
items = (
query.order_by(model.id)
.limit(per_page)
.offset((page - 1) * per_page)
.all()
)
has_next = len(items) == per_page
has_prev = page > 1
total_items = query.count()
total_pages = (total_items + per_page - 1) // per_page
return {
"items": items,
"has_next": has_next,
"has_prev": has_prev,
"total_pages": total_pages,
"page": page
}
def render_paginated_table(
query,
page: int,
title: str,
headers: dict,
entry_route: str,
row_fn: Callable[[Any], List[dict]],
endpoint: str,
per_page=15
):
data = make_paginated_data(query, page, per_page)
return render_template(
"table.html",
header=headers.keys(),
rows=[{"id": item.id, "cells": row_fn(item)} for item in data['items']],
title=title,
has_next=data['has_next'],
has_prev=data['has_prev'],
page=page,
endpoint=endpoint,
total_pages=data['total_pages'],
headers=headers,
entry_route=entry_route
)
@main.route("/")
def index():
return render_template("index.html", title="Inventory Manager")
def link(text, endpoint, **values):
return {"text": text, "url": url_for(endpoint, **values)}
@main.route("/inventory")
def list_inventory():
page = request.args.get('page', default=1, type=int)
query = eager_load_inventory_relationships(db.session.query(Inventory)).order_by(Inventory.inventory_name, Inventory.barcode, Inventory.serial)
return render_paginated_table(
query=query,
page=page,
title="Inventory",
headers=inventory_headers,
row_fn=lambda i: [fn(i) for fn in inventory_headers.values()],
endpoint="main.list_inventory",
entry_route="inventory_item"
)
@main.route("/inventory_item/<int:id>")
def inventory_item(id):
worklog_page = request.args.get("worklog_page", default=1, type=int)
inventory_query = db.session.query(Inventory)
item = eager_load_inventory_relationships(inventory_query).filter(Inventory.id == id).first()
brands = db.session.query(Brand).all()
users = eager_load_user_relationships(db.session.query(User)).all()
rooms = eager_load_room_relationships(db.session.query(Room)).all()
worklog_query = db.session.query(WorkLog).filter(WorkLog.work_item_id == id)
worklog_pagination = make_paginated_data(worklog_query, worklog_page, 5)
filtered_worklog_headers = {k: v for k, v in worklog_headers.items() if k not in ['Work Item', 'Contact', 'Follow Up?', 'Quick Analysis?']}
worklog = worklog_pagination['items']
if item:
title = f"Inventory Record - {item.identifier}"
else:
title = "Inventory Record - Not Found"
return render_template("inventory.html", title=title, item=item,
brands=brands, users=users, rooms=rooms,
worklog=worklog,
worklog_pagination=worklog_pagination,
worklog_page=worklog_page,
worklog_headers=filtered_worklog_headers,
worklog_rows=[{"id": log.id, "cells": [fn(log) for fn in filtered_worklog_headers.values()]} for log in worklog],
)
@main.route("/users")
def list_users():
page = request.args.get('page', default=1, type=int)
query = eager_load_user_relationships(db.session.query(User))
return render_paginated_table(
query=query,
page=page,
title="Users",
headers=user_headers,
row_fn=lambda i: [fn(i) for fn in user_headers.values()],
endpoint="main.list_users",
entry_route="user"
)
@main.route("/user/<int:id>")
def user(id):
asset_page = request.args.get("asset_page", default=1, type=int)
worklog_page = request.args.get("worklog_page", default=1, type=int)
users_query = db.session.query(User)
users = eager_load_user_relationships(users_query).all()
user = next((u for u in users if u.id == id), None)
rooms_query = db.session.query(Room)
rooms = eager_load_room_relationships(rooms_query).all()
inventory_query = (
eager_load_inventory_relationships(db.session.query(Inventory))
.filter(Inventory.owner_id == id)
.filter(Inventory.condition.in_(ACTIVE_STATUSES))
)
inventory_pagination = make_paginated_data(inventory_query, asset_page, 10)
inventory = inventory_pagination['items']
filtered_inventory_headers = {k: v for k, v in inventory_headers.items() if k not in ['Date Entered', 'Inventory #', 'Serial #',
'Bar Code #', 'Condition', 'Owner', 'Notes',
'Brand', 'Model', 'Shared?', 'Location']}
worklog_query = eager_load_worklog_relationships(db.session.query(WorkLog)).filter(WorkLog.contact_id == id)
worklog_pagination = make_paginated_data(worklog_query, worklog_page, 10)
worklog = worklog_pagination['items']
filtered_worklog_headers = {k: v for k, v in worklog_headers.items() if k not in ['Contact', 'Follow Up?', 'Quick Analysis?']}
return render_template(
"user.html",
title=(f"User Record - {user.full_name}" if user.active else f"User Record - {user.full_name} (Inactive)") if user else "User Record - Record Not Found",
user=user, users=users, rooms=rooms, assets=inventory,
inventory_headers=filtered_inventory_headers,
inventory_rows=[{"id": item.id, "cells": [fn(item) for fn in filtered_inventory_headers.values()]} for item in inventory],
inventory_pagination=inventory_pagination,
asset_page=asset_page,
worklog=worklog,
worklog_headers=filtered_worklog_headers,
worklog_rows=[{"id": log.id, "cells": [fn(log) for fn in filtered_worklog_headers.values()]} for log in worklog],
worklog_pagination=worklog_pagination,
worklog_page=worklog_page
)
@main.route("/worklog")
def list_worklog(page=1):
page = request.args.get('page', default=1, type=int)
query = eager_load_worklog_relationships(db.session.query(WorkLog))
return render_paginated_table(
query=query,
page=page,
title="Work Log",
headers=worklog_headers,
row_fn=lambda i: [fn(i) for fn in worklog_headers.values()],
endpoint="main.list_worklog",
entry_route="worklog_entry"
)
@main.route("/worklog/<int:id>")
def worklog_entry(id):
log = eager_load_worklog_relationships(db.session.query(WorkLog)).filter(WorkLog.id == id).first()
user_query = db.session.query(User)
users = eager_load_user_relationships(user_query).all()
item_query = db.session.query(Inventory)
items = eager_load_inventory_relationships(item_query).all()
return render_template("worklog.html", title=f"Work Log #{id}", log=log, users=users, items=items)

View file

@ -0,0 +1,75 @@
{% macro render_table(headers, rows, entry_route=None, title=None) %}
<div class="table-responsive">
<table
class="table table-bordered table-sm table-hover table-striped table-light m-0{% if title %} caption-top{% endif %}">
{% if title %}
<caption>{{ title }}</caption>
{% endif %}
<thead class="sticky-top">
<tr>
{% for h in headers %}
<th>{{ h }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr {% if entry_route %}onclick="window.location='{{ url_for('main.' + entry_route, id=row.id) }}'"
style="cursor: pointer;" {% endif %}>
{% for cell in row.cells %}
<td {% if cell.type=='bool' %}class="text-center" {% endif %}>
{% if cell.type == 'bool' %}
{{ cell.html | safe }}
{% elif cell.url %}
<a href="{{ cell.url }}">{{ cell.text }}</a>
{% else %}
{{ cell.text or '-' }}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endmacro %}
{% macro render_pagination(endpoint, page, has_prev, has_next, total_pages, page_variable='page', extra_args={}) %}
{% set prev_args = extra_args.copy() %}
{% set next_args = extra_args.copy() %}
{% set first_args = extra_args.copy() %}
{% set last_args = extra_args.copy() %}
{% set _ = prev_args.update({page_variable: page - 1}) %}
{% set _ = next_args.update({page_variable: page + 1}) %}
{% set _ = first_args.update({page_variable: 1}) %}
{% set _ = last_args.update({page_variable: total_pages}) %}
<div class="d-flex justify-content-between pt-3 px-5 align-items-center">
<div>
<a href="{{ url_for(endpoint, **first_args) }}"
class="btn btn-primary{% if not has_prev %} disabled{% endif %}">&laquo; First</a>
<a href="{{ url_for(endpoint, **prev_args) }}"
class="btn btn-primary{% if not has_prev %} disabled{% endif %}">&lt; Prev</a>
</div>
<div class="d-flex flex-column align-items-center text-center pt-3">
<div>Page {{ page }} of {{ total_pages }}</div>
<div>
{% for number in range(page - 2, page + 3) if number > 0 and number <= total_pages %} {% set
args=extra_args.copy() %} {% set _=args.update({page_variable: number}) %} <a
href="{{ url_for(endpoint, **args) }}"
class="btn btn-sm {% if number == page %}btn-primary{% else %}btn-outline-primary{% endif %} mx-1">
{{ number }}
</a>
{% endfor %}
</div>
</div>
<div>
<a href="{{ url_for(endpoint, **next_args) }}"
class="btn btn-primary{% if not has_next %} disabled{% endif %}">Next &gt;</a>
<a href="{{ url_for(endpoint, **last_args) }}"
class="btn btn-primary{% if not has_next %} disabled{% endif %}">Last &raquo;</a>
</div>
</div>
{% endmacro %}

21
templates/index.html Normal file
View file

@ -0,0 +1,21 @@
<!-- templates/index.html -->
{% extends "layout.html" %}
{% import "_table_fragment.html" as tables %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<h1 class="text-center display-3">Table of Contents</h1>
<div class="row text-center m-5">
<div class="col">
<a class="link-success link-underline-opacity-0" href="{{ url_for('main.list_inventory') }}">Inventory</a>
</div>
<div class="col">
<a class="link-success link-underline-opacity-0" href="{{ url_for('main.list_users') }}">Users</a>
</div>
<div class="col">
<a class="link-success link-underline-opacity-0" href="{{ url_for('main.list_worklog') }}">Work Log</a>
</div>
</div>
{% endblock %}

127
templates/inventory.html Normal file
View file

@ -0,0 +1,127 @@
<!-- templates/inventory.html -->
{% extends "layout.html" %}
{% import "_table_fragment.html" as tables %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<nav>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('index') }}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-house" viewBox="0 0 16 16">
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293zM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5z"/>
</svg>
</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('main.list_inventory') }}">Inventory</a></li>
<li class="breadcrumb-item active">{{ item.identifier }}</li>
</ol>
</nav>
<div class="container">
<form action="POST">
<div class="row">
<div class="col-6">
<label for="timestamp" class="form-label">Date Entered</label>
<input type="date" class="form-control" name="timestamp" value="{{ item.timestamp.date().isoformat() }}">
</div>
<div class="col-6">
<label for="identifier" class="form-label">Identifier</label>
<input type="text" class="form-control-plaintext" value="{{ item.identifier }}" readonly>
</div>
</div>
<div class="row">
<div class="col-4">
<label for="inventory_name" class="form-label">Inventory #</label>
<input type="text" class="form-control" name="inventory_name" placeholder="-" value="{{ item.inventory_name if item.inventory_name else '' }}">
</div>
<div class="col-4">
<label for="serial" class="form-label">Serial #</label>
<input type="text" class="form-control" name="serial" placeholder="-" value="{{ item.serial if item.serial else '' }}">
</div>
<div class="col-4">
<label for="barcode" class="form-label">Bar Code #</label>
<input type="text" class="form-control" name="barcode" placeholder="-" value="{{ item.barcode if item.barcode else '' }}">
</div>
</div>
<div class="row">
<div class="col-4">
<label for="brand" class="form-label">Brand</label>
<input list="brandList" id="brand" name="brand" class="form-control" placeholder="-" value="{{ item.brand.name }}">
<input type="hidden" id="brandId">
<datalist id="brandList">
{% for brand in brands %}
<option data-id="{{ brand.id }}" value="{{ brand.name }}"></option>
{% endfor %}
</datalist>
</div>
<div class="col-4">
<label for="model" class="form-label">Model</label>
<input type="text" class="form-control" name="model" placeholder="-" value="{{ item.model }}">
</div>
<div class="col-4">
<label for="category" class="form-label">Category</label>
<input type="text" class="form-control" name="category" placeholder="-" value="{{ item.item.description }}">
</div>
</div>
<div class="row">
<div class="col-4">
<label for="owner" class="form-label">Contact</label>
<input list="userList" id="owner" class="form-control" name="owner" placeholder="-" value="{{ item.owner.full_name }}">
<input type="hidden" id="userId">
<datalist id="userList">
{% for user in users %}
<option data-id="{{ user.id }}" value="{{ user.full_name }}"></option>
{% endfor %}
</datalist>
</div>
<div class="col-4">
<label for="location" class="form-label">Location</label>
<input list="roomList" id="location" class="form-control" name="location" placeholder="-" value="{{ item.location.full_name }}">
<input type="hidden" id="roomId">
<datalist id="roomList">
{% for room in rooms %}
<option data-id="{{ room.id }}" value="{{ room.full_name }}"></option>
{% endfor %}
</datalist>
</div>
<div class="col-2">
<label for="condition" class="form-label">Condition</label>
<select name="condition" id="" class="form-select" value="{{ item.condition }}">
{% for condition in ["Working", "Deployed", "Partially Inoperable", "Inoperable", "Unverified", "Removed", "Disposed"] %}
<option value="{{ condition }}">{{ condition }}</option>
{% endfor %}
</select>
</div>
<div class="col-2 d-flex align-items-center justify-content-center" style="margin-top: 1.9rem;">
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input" id="shared" name="shared" {% if item.shared %}checked{% endif %}>
<label for="shared" class="form-check-label">Shared?</label>
</div>
</div>
</div>
<div class="row">
<div class="col-{% if worklog %}6{% else %}12{% endif %}">
<label for="notes" class="form-label">Notes &amp; Comments</label>
<textarea name="notes" id="notes" class="form-control" rows="10">{{ item.notes if item.notes else '' }}</textarea>
</div>
{% if worklog %}
<div class="col-6">
{{ tables.render_table(worklog_headers, worklog_rows, 'worklog_entry', 'Work Log') }}
{% if worklog_pagination['total_pages'] > 1 %}
{{ tables.render_pagination(
page=worklog_pagination['page'],
has_prev=worklog_pagination['has_prev'],
has_next=worklog_pagination['has_next'],
total_pages=worklog_pagination['total_pages'],
endpoint='main.inventory_item',
page_variable='worklog_page',
extra_args={'id': item.id, 'worklog_page': worklog_page}
) }}
{% endif %}
</div>
{% endif %}
</div>
</form>
</div>
{% endblock %}

53
templates/layout.html Normal file
View file

@ -0,0 +1,53 @@
<!-- templates/layout.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Inventory{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4Q6Gf2aSP4eDXB8Miphtr37CMZZQ5oXLH2yaXMJ2w8e2ZtHTl7GptT4jmndRuHDT" crossorigin="anonymous">
<style>
{% block style %}
.sticky-top {
position: sticky;
top: 0;
z-index: 1020;
}
table td,
th {
white-space: nowrap;
}
input[type="checkbox"][disabled] {
pointer-events: none;
opacity: 1;
filter: none;
appearance: auto;
-webkit-appearance: checkbox;
background-color: white !important;
color: black !important;
}
input[type="checkbox"][disabled]::-moz-checkbox {
background-color: white;
}
{% endblock %}
</style>
</head>
<body class="bg-tertiary text-primary-emphasis">
<header class="bg-success text-light p-2">
<h1>{{ title }}</h1>
</header>
<main class="container-flex m-5">{% block content %}{% endblock %}</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"
integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO"
crossorigin="anonymous"></script>
<script>{% block script %}{% endblock %}</script>
</body>
</html>

20
templates/table.html Normal file
View file

@ -0,0 +1,20 @@
<!-- templates/table.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('index') }}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-house" viewBox="0 0 16 16">
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293zM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5z"/>
</svg>
</a></li>
<li class="breadcrumb-item active">{{ title }}</li>
</ol>
{% import "_table_fragment.html" as tables %}
{{ tables.render_table(header, rows, entry_route) }}
{{ tables.render_pagination(endpoint, page, has_prev, has_next, total_pages) }}
{% endblock %}

130
templates/user.html Normal file
View file

@ -0,0 +1,130 @@
<!-- templates/user.html -->
{% extends "layout.html" %}
{% import "_table_fragment.html" as tables %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<nav>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('index') }}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-house" viewBox="0 0 16 16">
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293zM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5z"/>
</svg>
</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('main.list_users') }}">Users</a></li>
<li class="breadcrumb-item active">{{ user.full_name }}</li>
</ol>
</nav>
<div class="container">
<form action="POST">
<div class="row">
<div class="col-6">
<label for="lastName" class="form-label">Last Name</label>
<input type="text" class="form-control" id="lastName" placeholder="Doe" value="{{ user.last_name }}">
</div>
<div class="col-6">
<label for="firstName" class="form-label">First Name</label>
<input type="text" class="form-control" id="firstName" placeholder="John" value="{{ user.first_name }}">
</div>
</div>
<div class="row mt-2">
<div class="col-6">
<label for="supervisor" class="form-label">Supervisor</label>
<input list="supervisorList" id="supervisor" name="supervisorName" class="form-control"
value="{{ user.supervisor.full_name }}">
<input type="hidden" id="supervisorId">
<datalist id="supervisorList">
{% for supervisor in users %}
<option data-id="{{ supervisor.id }}" value="{{ supervisor.full_name }}"></option>
{% endfor %}
</datalist>
</div>
<div class="col-6">
<label for="location" class="form-label">Location</label>
<input list="locationList" id="location" name="locationName" class="form-control"
value="{{ user.location.full_name }}">
<input type="hidden" id="locationId">
<datalist id="locationList">
{% for location in rooms %}
<option data-id="{{ location.id }}" value="{{ location.full_name }}"></option>
{% endfor %}
</datalist>
</div>
</div>
<div class="row mt-4">
<div class="col-6">
<input type="checkbox" class="form-check-input" id="activeCheck" {% if user.active %}checked{% endif %}>
<label for="activeCheck" class="form-check-label">Active</label>
</div>
<div class="col-6">
<input type="checkbox" class="form-check-input" id="staffCheck" {% if user.staff %}checked{% endif %}>
<label for="staffCheck" class="form-check-label">Staff</label>
</div>
</div>
</form>
<div class="row mt-3">
{% if inventory_rows %}
<div class="col-6">
<div class="row">
{{ tables.render_table(inventory_headers, inventory_rows, 'inventory_item', title='Assets') }}
</div>
</div>
{% endif %}
{% if worklog_rows %}
<div class="col-6">
<div class="row">
{{ tables.render_table(worklog_headers, worklog_rows, 'worklog_entry', title='Work Done') }}
</div>
</div>
{% endif %}
</div>
<div class="row">
{% if inventory_pagination['total_pages'] > 1 %}
<div class="col-6">
{{ tables.render_pagination(
page=inventory_pagination['page'],
has_prev=inventory_pagination['has_prev'],
has_next=inventory_pagination['has_next'],
total_pages=inventory_pagination['total_pages'],
endpoint='main.user',
page_variable='asset_page',
extra_args={'id': user.id, 'worklog_page': worklog_page}
) }}
</div>
{% endif %}
{% if worklog_pagination['total_pages'] > 1 %}
<div class="col-6">
{{ tables.render_pagination(
page=worklog_pagination['page'],
has_prev=worklog_pagination['has_prev'],
has_next=worklog_pagination['has_next'],
total_pages=worklog_pagination['total_pages'],
endpoint='main.user',
page_variable='worklog_page',
extra_args={'id': user.id, 'worklog_page': worklog_page}
) }}
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block script %}
document.getElementById('supervisor').addEventListener('input', function() {
const input = this.value;
const options = document.querySelectorAll('#supervisorList option');
let foundId = '';
options.forEach(option => {
if (option.value === input) {
foundId = option.dataset.id;
}
})
document.getElementById('supervisorId').value = foundId;
});
{% endblock %}

60
templates/worklog.html Normal file
View file

@ -0,0 +1,60 @@
<!-- templates/worklog.html -->
{% extends "layout.html" %}
{% import "_table_fragment.html" as tables %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<nav>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('index') }}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-house" viewBox="0 0 16 16">
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293zM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5z"/>
</svg>
</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('main.list_worklog') }}">Work Log</a></li>
<li class="breadcrumb-item active">{{ title }}</li>
</ol>
<div class="container">
<form action="POST">
<div class="row">
<div class="col-6">
<label for="start" class="form-label">Start Timestamp</label>
<input type="date" class="form-control" name="start" placeholder="-" value="{{ log.start_time.date().isoformat() if log.start_time }}">
</div>
<div class="col-6">
<label for="end" class="form-label">End Timestamp</label>
<input type="date" class="form-control" name="end" placeholder="-" value="{{ log.end_time.date().isoformat() if log.end_time }}">
</div>
</div>
<div class="row">
<div class="col-4">
<label for="contact" class="form-label">Contact</label>
<input list="contactList" class="form-control" id="contact" value="{{ log.contact.full_name }}">
<input type="hidden" id="contactId">
<datalist id="contactList">
{% for contact in users %}
<option data-id="{{ contact.id }}" value="{{ contact.full_name }}">{{ contact.full_name }}</option>
{% endfor %}
</datalist>
</div>
<div class="col-4">
<label for="item" class="form-label">Work Item</label>
<input list="itemList" class="form-control" id="item" placeholder="-" value="{{ log.work_item.identifier }}">
<input type="hidden" id="itemId">
<datalist id="itemList">
{% for item in items %}
<option data-id="{{ item.id }}" value="{{ item.identifier }}">{{ item.identifier }}</option>
{% endfor %}
</datalist>
</div>
<div class="col-4">
</div>
</div>
</form>
</div>
</nav>
{% endblock %}

30
utils.py Normal file
View file

@ -0,0 +1,30 @@
from sqlalchemy.orm import joinedload
from .models import User, Room, Inventory, WorkLog
def eager_load_user_relationships(query):
return query.options(
joinedload(User.supervisor),
joinedload(User.location).joinedload(Room.room_function)
)
def eager_load_inventory_relationships(query):
return query.options(
joinedload(Inventory.owner),
joinedload(Inventory.brand),
joinedload(Inventory.item),
joinedload(Inventory.location).joinedload(Room.room_function)
)
def eager_load_room_relationships(query):
return query.options(
joinedload(Room.area),
joinedload(Room.room_function),
joinedload(Room.inventory),
joinedload(Room.users)
)
def eager_load_worklog_relationships(query):
return query.options(
joinedload(WorkLog.contact),
joinedload(WorkLog.work_item)
)