Complete and total rework ahead.
This commit is contained in:
parent
559fd56f33
commit
e420110fb3
95 changed files with 394 additions and 6351 deletions
|
|
@ -1,57 +0,0 @@
|
|||
from flask import Flask, current_app
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import logging
|
||||
import os
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
logger = logging.getLogger('sqlalchemy.engine')
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
|
||||
logger.addHandler(handler)
|
||||
|
||||
def is_in_memory_sqlite():
|
||||
uri = current_app.config.get("SQLALCHEMY_DATABASE_URI")
|
||||
if not uri:
|
||||
return False
|
||||
url = make_url(uri)
|
||||
return url.get_backend_name() == "sqlite" and url.database == ":memory:"
|
||||
|
||||
def create_app():
|
||||
from config import Config
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.getenv('SECRET_KEY', 'dev-secret-key-unsafe')
|
||||
app.config.from_object(Config)
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
from . import models
|
||||
if is_in_memory_sqlite():
|
||||
db.create_all()
|
||||
|
||||
# ✅ db.engine is only safe to touch inside an app context
|
||||
SessionLocal = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
|
||||
from .models import registry
|
||||
from .routes import main
|
||||
from .routes.images import image_bp
|
||||
from .ui.blueprint import bp as ui_bp
|
||||
from crudkit.blueprint import make_blueprint as make_json_bp
|
||||
from crudkit.html import make_fragments_blueprint as make_html_bp
|
||||
|
||||
app.register_blueprint(main)
|
||||
app.register_blueprint(image_bp)
|
||||
app.register_blueprint(ui_bp)
|
||||
app.register_blueprint(make_json_bp(SessionLocal, registry), url_prefix="/api")
|
||||
app.register_blueprint(make_html_bp(SessionLocal, registry), url_prefix="/ui")
|
||||
|
||||
from .routes.helpers import generate_breadcrumbs
|
||||
@app.context_processor
|
||||
def inject_breadcrumbs():
|
||||
return {'breadcrumbs': generate_breadcrumbs()}
|
||||
|
||||
return app
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
from . import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import os
|
||||
import urllib.parse
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
def quote(value: str) -> str:
|
||||
return urllib.parse.quote_plus(value or '')
|
||||
|
||||
class Config:
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
DEBUG = os.getenv('DEBUG', 'false').strip().lower() in ['true', '1', 'yes']
|
||||
TESTING = False
|
||||
|
||||
DB_BACKEND = os.getenv('DB_BACKEND', 'sqlite').lower()
|
||||
DB_WINDOWS_AUTH = os.getenv('DB_WINDOWS_AUTH', 'false').strip().lower() in ['true', '1', 'yes']
|
||||
|
||||
DB_USER = os.getenv('DB_USER', '')
|
||||
DB_PASSWORD = os.getenv('DB_PASSWORD', '')
|
||||
DB_HOST = os.getenv('DB_HOST', 'localhost')
|
||||
DB_PORT = os.getenv('DB_PORT', '')
|
||||
DB_NAME = os.getenv('DB_NAME', 'app.db') # default SQLite filename
|
||||
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = None # This will definitely be set below
|
||||
|
||||
if DB_BACKEND == 'mssql':
|
||||
driver = os.getenv('DB_DRIVER', 'ODBC Driver 17 for SQL Server')
|
||||
quoted_driver = quote(driver)
|
||||
|
||||
if DB_WINDOWS_AUTH:
|
||||
SQLALCHEMY_DATABASE_URI = (
|
||||
f"mssql+pyodbc://@{DB_HOST}/{DB_NAME}?driver={quoted_driver}&Trusted_Connection=yes"
|
||||
)
|
||||
else:
|
||||
SQLALCHEMY_DATABASE_URI = (
|
||||
f"mssql+pyodbc://{quote(DB_USER)}:{quote(DB_PASSWORD)}@{DB_HOST}:{DB_PORT or '1433'}/{DB_NAME}"
|
||||
f"?driver={quoted_driver}"
|
||||
)
|
||||
|
||||
elif DB_BACKEND == 'postgres':
|
||||
SQLALCHEMY_DATABASE_URI = (
|
||||
f"postgresql://{quote(DB_USER)}:{quote(DB_PASSWORD)}@{DB_HOST}:{DB_PORT or '5432'}/{DB_NAME}"
|
||||
)
|
||||
|
||||
elif DB_BACKEND in ['mariadb', 'mysql']:
|
||||
SQLALCHEMY_DATABASE_URI = (
|
||||
f"mysql+pymysql://{quote(DB_USER)}:{quote(DB_PASSWORD)}@{DB_HOST}:{DB_PORT or '3306'}/{DB_NAME}"
|
||||
)
|
||||
|
||||
elif DB_BACKEND == 'sqlite':
|
||||
if DB_NAME == ':memory:':
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
else:
|
||||
full_path = os.path.join(BASE_DIR, DB_NAME)
|
||||
SQLALCHEMY_DATABASE_URI = f"sqlite:///{full_path}"
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported DB_BACKEND: {DB_BACKEND}. "
|
||||
"Supported backends: mssql, postgres, mariadb, mysql, sqlite."
|
||||
)
|
||||
|
||||
# Optional: confirm config during development
|
||||
print(f"Using database URI: {SQLALCHEMY_DATABASE_URI}")
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
# inventory/models/__init__.py
|
||||
from inventory import db # your single SQLAlchemy() instance
|
||||
|
||||
# Import *modules* so all model classes are defined & registered
|
||||
from . import image
|
||||
from . import room_functions
|
||||
from . import rooms
|
||||
from . import areas
|
||||
from . import brands
|
||||
from . import items
|
||||
from . import inventory
|
||||
from . import work_log
|
||||
from . import work_note
|
||||
from . import users
|
||||
from . import image_links
|
||||
|
||||
# If you want convenient symbols, export them AFTER modules are imported
|
||||
Image = image.Image
|
||||
ImageAttachable = image.ImageAttachable
|
||||
RoomFunction = room_functions.RoomFunction
|
||||
Room = rooms.Room
|
||||
Area = areas.Area
|
||||
Brand = brands.Brand
|
||||
Item = items.Item
|
||||
Inventory = inventory.Inventory
|
||||
WorkLog = work_log.WorkLog
|
||||
WorkNote = work_note.WorkNote
|
||||
worklog_images = image_links.worklog_images
|
||||
User = users.User
|
||||
|
||||
# Now it’s safe to configure mappers and set global eagerloads
|
||||
from sqlalchemy.orm import configure_mappers, joinedload, selectinload
|
||||
configure_mappers()
|
||||
|
||||
User.ui_eagerload = (
|
||||
joinedload(User.supervisor),
|
||||
joinedload(User.location).joinedload(Room.room_function),
|
||||
)
|
||||
|
||||
Room.ui_eagerload = (
|
||||
joinedload(Room.area),
|
||||
joinedload(Room.room_function),
|
||||
selectinload(Room.inventory),
|
||||
selectinload(Room.users)
|
||||
)
|
||||
|
||||
|
||||
registry = {
|
||||
"area": Area,
|
||||
"brand": Brand,
|
||||
"image": Image,
|
||||
"inventory": Inventory,
|
||||
"item": Item,
|
||||
"room_function": RoomFunction,
|
||||
"room": Room,
|
||||
"user": User,
|
||||
"work_log": WorkLog,
|
||||
"work_note": WorkNote
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"db",
|
||||
"Image", "ImageAttachable",
|
||||
"RoomFunction", "Room",
|
||||
"Area", "Brand", "Item", "Inventory",
|
||||
"WorkLog", "WorkNote", "worklog_images",
|
||||
"User", "registry"
|
||||
]
|
||||
8
inventory/models/area.py
Normal file
8
inventory/models/area.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from sqlalchemy import Column, Unicode
|
||||
|
||||
from crudkit.core.base import Base, CRUDMixin
|
||||
|
||||
class Area(Base, CRUDMixin):
|
||||
__tablename__ = "area"
|
||||
|
||||
name = Column(Unicode(255), nullable=True)
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
from typing import List, Optional, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .rooms import Room
|
||||
|
||||
from crudkit import CrudMixin
|
||||
from sqlalchemy import Identity, Integer, Unicode
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from . import db
|
||||
|
||||
class Area(db.Model, CrudMixin):
|
||||
__tablename__ = 'area'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
|
||||
name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
|
||||
|
||||
rooms: Mapped[List['Room']] = relationship('Room', back_populates='area')
|
||||
|
||||
def __init__(self, name: Optional[str] = None):
|
||||
self.name = name
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Area(id={self.id}, name={repr(self.name)})>"
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
from typing import List, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .inventory import Inventory
|
||||
|
||||
from crudkit import CrudMixin
|
||||
from sqlalchemy import Identity, Integer, Unicode
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from . import db
|
||||
|
||||
class Brand(db.Model, CrudMixin):
|
||||
__tablename__ = 'brand'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Unicode(255), nullable=False)
|
||||
|
||||
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='brand')
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Brand(id={self.id}, name={repr(self.name)})>"
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return self.name if self.name else f"ID: {self.id}"
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
from typing import Optional, List, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .inventory import Inventory
|
||||
from .users import User
|
||||
from .work_log import WorkLog
|
||||
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import Integer, Unicode, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from . import db
|
||||
from .image_links import worklog_images
|
||||
|
||||
from crudkit import CrudMixin
|
||||
|
||||
class Image(db.Model, CrudMixin):
|
||||
__tablename__ = 'images'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
filename: Mapped[str] = mapped_column(Unicode(512))
|
||||
caption: Mapped[str] = mapped_column(Unicode(255), default="")
|
||||
timestamp: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.now(), server_default=func.now())
|
||||
|
||||
inventory: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='image')
|
||||
user: Mapped[Optional['User']] = relationship('User', back_populates='image')
|
||||
worklogs: Mapped[List['WorkLog']] = relationship('WorkLog', secondary=worklog_images, back_populates='images')
|
||||
|
||||
def __init__(self, filename: str, caption: Optional[str] = None):
|
||||
self.filename = filename
|
||||
self.caption = caption or ""
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Image(id={self.id}, filename={self.filename})>"
|
||||
|
||||
class ImageAttachable:
|
||||
def attach_image(self, image: 'Image') -> None:
|
||||
raise NotImplementedError("This model doesn't know how to attach images.")
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
from .. import db
|
||||
|
||||
worklog_images = db.Table(
|
||||
'worklog_images',
|
||||
db.Column('worklog_id', db.Integer, db.ForeignKey('work_log.id'), primary_key=True),
|
||||
db.Column('image_id', db.Integer, db.ForeignKey('images.id', ondelete='CASCADE'), primary_key=True),
|
||||
)
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
from typing import Any, List, Optional, TYPE_CHECKING
|
||||
|
||||
from .image import Image
|
||||
if TYPE_CHECKING:
|
||||
from .brands import Brand
|
||||
from .items import Item
|
||||
from .work_log import WorkLog
|
||||
from .rooms import Room
|
||||
from .image import Image
|
||||
from .users import User
|
||||
|
||||
from crudkit import CrudMixin
|
||||
from sqlalchemy import Boolean, ForeignKey, Identity, Index, Integer, Unicode, DateTime, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import datetime
|
||||
|
||||
from . import db
|
||||
from .brands import Brand
|
||||
from .image import ImageAttachable
|
||||
from .users import User
|
||||
|
||||
class Inventory(db.Model, ImageAttachable, CrudMixin):
|
||||
__tablename__ = 'inventory'
|
||||
__table_args__ = (
|
||||
Index('Inventory$Barcode', 'barcode'),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
|
||||
timestamp: Mapped[datetime.datetime] = mapped_column(DateTime)
|
||||
condition: Mapped[str] = mapped_column(Unicode(255))
|
||||
type_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("item.id"), nullable=True, index=True)
|
||||
name: Mapped[Optional[str]] = mapped_column(Unicode(255))
|
||||
serial: Mapped[Optional[str]] = mapped_column(Unicode(255))
|
||||
model: Mapped[Optional[str]] = mapped_column(Unicode(255))
|
||||
notes: Mapped[Optional[str]] = mapped_column(Unicode(255))
|
||||
owner_id = mapped_column(Integer, ForeignKey('users.id'), nullable=True, index=True)
|
||||
brand_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("brand.id"), nullable=True, index=True)
|
||||
location_id: Mapped[Optional[str]] = mapped_column(ForeignKey("rooms.id"), nullable=True, index=True)
|
||||
barcode: Mapped[Optional[str]] = mapped_column(Unicode(255))
|
||||
shared: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
|
||||
image_id: Mapped[Optional[int]] = mapped_column(ForeignKey('images.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
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')
|
||||
image: Mapped[Optional['Image']] = relationship('Image', back_populates='inventory', passive_deletes=True)
|
||||
device_type: Mapped[Optional['Item']] = relationship('Item', back_populates='inventory')
|
||||
|
||||
def __init__(self, timestamp: datetime.datetime, condition: str, type_id: Optional[int] = None,
|
||||
name: Optional[str] = None, serial: Optional[str] = None,
|
||||
model: Optional[str] = None, notes: Optional[str] = None, owner_id: Optional[int] = None,
|
||||
brand_id: Optional[int] = None, location_id: Optional[str] = None, barcode: Optional[str] = None,
|
||||
shared: bool = False):
|
||||
self.timestamp = timestamp
|
||||
self.condition = condition
|
||||
self.type_id = type_id
|
||||
self.name = name
|
||||
self.serial = serial
|
||||
self.model = model
|
||||
self.notes = notes
|
||||
self.owner_id = owner_id
|
||||
self.brand_id = brand_id
|
||||
self.location_id = location_id
|
||||
self.barcode = barcode
|
||||
self.shared = shared
|
||||
|
||||
def __repr__(self):
|
||||
parts = [f"id={self.id}"]
|
||||
|
||||
if self.name:
|
||||
parts.append(f"name={repr(self.name)}")
|
||||
|
||||
if self.device_type:
|
||||
parts.append(f"item={repr(self.device_type.description)}")
|
||||
|
||||
if self.notes:
|
||||
parts.append(f"notes={repr(self.notes)}")
|
||||
|
||||
if self.owner:
|
||||
parts.append(f"owner={repr(self.owner.identifier)}")
|
||||
|
||||
if self.location:
|
||||
parts.append(f"location={repr(self.location.identifier)}")
|
||||
|
||||
return f"<Inventory({', '.join(parts)})>"
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
if self.name:
|
||||
return f"Name: {self.name}"
|
||||
elif self.barcode:
|
||||
return f"Bar: {self.barcode}"
|
||||
elif self.serial:
|
||||
return f"Serial: {self.serial}"
|
||||
else:
|
||||
return f"ID: {self.id}"
|
||||
|
||||
def serialize(self) -> dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
||||
'condition': self.condition,
|
||||
'type_id': self.type_id,
|
||||
'name': self.name,
|
||||
'serial': self.serial,
|
||||
'model': self.model,
|
||||
'notes': self.notes,
|
||||
'owner_id': self.owner_id,
|
||||
'brand_id': self.brand_id,
|
||||
'location_id': self.location_id,
|
||||
'barcode': self.barcode,
|
||||
'shared': self.shared
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Inventory":
|
||||
timestamp_str = data.get("timestamp")
|
||||
|
||||
return cls(
|
||||
timestamp = datetime.datetime.fromisoformat(str(timestamp_str)) if timestamp_str else datetime.datetime.now(),
|
||||
condition=data.get("condition", "Unverified"),
|
||||
type_id=data["type_id"],
|
||||
name=data.get("name"),
|
||||
serial=data.get("serial"),
|
||||
model=data.get("model"),
|
||||
notes=data.get("notes"),
|
||||
owner_id=data.get("owner_id"),
|
||||
brand_id=data.get("brand_id"),
|
||||
location_id=data.get("location_id"),
|
||||
barcode=data.get("barcode"),
|
||||
shared=bool(data.get("shared", False))
|
||||
)
|
||||
|
||||
def attach_image(self, image: Image) -> None:
|
||||
self.image = image
|
||||
|
||||
@staticmethod
|
||||
def ui_search(stmt, text: str):
|
||||
t = f"%{text}%"
|
||||
return stmt.where(
|
||||
Inventory.name.ilike(t) |
|
||||
Inventory.serial.ilike(t) |
|
||||
Inventory.model.ilike(t) |
|
||||
Inventory.notes.ilike(t) |
|
||||
Inventory.barcode.ilike(t) |
|
||||
Inventory.owner.has(User.first_name.ilike(t)) |
|
||||
Inventory.owner.has(User.last_name.ilike(t)) |
|
||||
Inventory.brand.has(Brand.name.ilike(t))
|
||||
)
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
from typing import List, Optional, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .inventory import Inventory
|
||||
|
||||
from crudkit import CrudMixin
|
||||
from sqlalchemy import Identity, Integer, Unicode
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from . import db
|
||||
|
||||
class Item(db.Model, CrudMixin):
|
||||
__tablename__ = 'item'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
|
||||
|
||||
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='device_type')
|
||||
|
||||
def __init__(self, description: Optional[str] = None):
|
||||
self.description = description
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Item(id={self.id}, description={repr(self.description)})>"
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self.description if self.description else f"Item {self.id}"
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
from typing import List, Optional, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .rooms import Room
|
||||
|
||||
from crudkit import CrudMixin
|
||||
from sqlalchemy import Identity, Integer, Unicode
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from . import db
|
||||
|
||||
class RoomFunction(db.Model, CrudMixin):
|
||||
__tablename__ = 'room_function'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
|
||||
|
||||
rooms: Mapped[List['Room']] = relationship('Room', back_populates='room_function')
|
||||
|
||||
def __init__(self, description: Optional[str] = None):
|
||||
self.description = description
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RoomFunction(id={self.id}, description={repr(self.description)})>"
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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 crudkit import CrudMixin
|
||||
from sqlalchemy import ForeignKey, Identity, Integer, Unicode
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from . import db
|
||||
|
||||
class Room(db.Model, CrudMixin):
|
||||
__tablename__ = 'rooms'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
|
||||
name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
|
||||
area_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("area.id"), nullable=True, index=True)
|
||||
function_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("room_function.id"), nullable=True, index=True)
|
||||
|
||||
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')
|
||||
|
||||
ui_eagerload = tuple()
|
||||
ui_extra_attrs = ('area_id', 'function_id')
|
||||
|
||||
@classmethod
|
||||
def ui_update(cls, session, id_, payload):
|
||||
print(payload)
|
||||
obj = session.get(cls, id_)
|
||||
if not obj:
|
||||
return None
|
||||
obj.name = payload.get("name", obj.name)
|
||||
obj.area_id = payload.get("area_id", obj.area_id)
|
||||
obj.function_id = payload.get("function_id", obj.function_id)
|
||||
session.commit()
|
||||
return obj
|
||||
|
||||
def __init__(self, name: Optional[str] = None, area_id: Optional[int] = None, function_id: Optional[int] = None):
|
||||
self.name = name
|
||||
self.area_id = area_id
|
||||
self.function_id = function_id
|
||||
|
||||
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 identifier(self):
|
||||
name = self.name or ""
|
||||
func = self.room_function.description if self.room_function else ""
|
||||
return f"{name} - {func}".strip(" -")
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
from typing import Any, List, Optional, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .inventory import Inventory
|
||||
from .rooms import Room
|
||||
from .work_log import WorkLog
|
||||
from .image import Image
|
||||
|
||||
from crudkit import CrudMixin
|
||||
from sqlalchemy import Boolean, ForeignKey, Identity, Integer, Unicode, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from . import db
|
||||
from .image import ImageAttachable
|
||||
|
||||
class User(db.Model, ImageAttachable, CrudMixin):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
|
||||
staff: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
|
||||
active: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
|
||||
last_name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
|
||||
first_name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
|
||||
title: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True, default=None)
|
||||
location_id: Mapped[Optional[int]] = mapped_column(ForeignKey("rooms.id"), nullable=True, index=True)
|
||||
supervisor_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
image_id: Mapped[Optional[int]] = mapped_column(ForeignKey('images.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
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')
|
||||
image: Mapped[Optional['Image']] = relationship('Image', back_populates='user', passive_deletes=True)
|
||||
|
||||
ui_eagerload = tuple()
|
||||
ui_order_cols = ('first_name', 'last_name',)
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"{self.first_name or ''} {self.last_name or ''}{', ' + (''.join(word[0].upper() for word in self.title.split())) if self.title else ''}".strip()
|
||||
|
||||
def __init__(self, first_name: Optional[str] = None, last_name: Optional[str] = None,
|
||||
title: Optional[str] = None,location_id: Optional[int] = None,
|
||||
supervisor_id: Optional[int] = None, staff: Optional[bool] = False,
|
||||
active: Optional[bool] = False):
|
||||
self.first_name = first_name
|
||||
self.last_name = last_name
|
||||
self.title = title
|
||||
self.location_id = location_id
|
||||
self.supervisor_id = supervisor_id
|
||||
self.staff = staff
|
||||
self.active = active
|
||||
|
||||
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})>"
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'first_name': self.first_name,
|
||||
'last_name': self.last_name,
|
||||
'title': self.title,
|
||||
'location_id': self.location_id,
|
||||
'supervisor_id': self.supervisor_id,
|
||||
'staff': self.staff,
|
||||
'active': self.active
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "User":
|
||||
return cls(
|
||||
staff=bool(data.get("staff", False)),
|
||||
active=bool(data.get("active", False)),
|
||||
last_name=data.get("last_name"),
|
||||
first_name=data.get("first_name"),
|
||||
title=data.get("title"),
|
||||
location_id=data.get("location_id"),
|
||||
supervisor_id=data.get("supervisor_id")
|
||||
)
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
from typing import Optional, Any, List, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .inventory import Inventory
|
||||
from .image import Image
|
||||
from .users import User
|
||||
from .work_note import WorkNote
|
||||
|
||||
from crudkit import CrudMixin
|
||||
from sqlalchemy import Boolean, Identity, Integer, ForeignKey, Unicode, DateTime, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import datetime
|
||||
|
||||
from . import db
|
||||
from .image import ImageAttachable
|
||||
from .image_links import worklog_images
|
||||
from .work_note import WorkNote
|
||||
|
||||
class WorkLog(db.Model, ImageAttachable, CrudMixin):
|
||||
__tablename__ = 'work_log'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
|
||||
start_time: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime)
|
||||
end_time: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime)
|
||||
notes: Mapped[Optional[str]] = mapped_column(Unicode())
|
||||
complete: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
|
||||
followup: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
|
||||
contact_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
analysis: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
|
||||
work_item_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("inventory.id"), nullable=True, index=True)
|
||||
|
||||
work_item: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='work_logs')
|
||||
contact: Mapped[Optional['User']] = relationship('User', back_populates='work_logs')
|
||||
updates: Mapped[List['WorkNote']] = relationship(
|
||||
'WorkNote',
|
||||
back_populates='work_log',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='WorkNote.timestamp'
|
||||
)
|
||||
images: Mapped[List['Image']] = relationship('Image', secondary=worklog_images, back_populates='worklogs', passive_deletes=True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
start_time: Optional[datetime.datetime] = None,
|
||||
end_time: Optional[datetime.datetime] = None,
|
||||
notes: Optional[str] = None,
|
||||
complete: Optional[bool] = False,
|
||||
followup: Optional[bool] = False,
|
||||
contact_id: Optional[int] = None,
|
||||
analysis: Optional[bool] = False,
|
||||
work_item_id: Optional[int] = None,
|
||||
updates: Optional[List[WorkNote]] = None
|
||||
) -> None:
|
||||
self.start_time = start_time
|
||||
self.end_time = end_time
|
||||
self.notes = notes
|
||||
self.complete = complete
|
||||
self.followup = followup
|
||||
self.contact_id = contact_id
|
||||
self.analysis = analysis
|
||||
self.work_item_id = work_item_id
|
||||
self.updates = updates or []
|
||||
|
||||
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})>"
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'start_time': self.start_time.isoformat() if self.start_time else None,
|
||||
'end_time': self.end_time.isoformat() if self.end_time else None,
|
||||
'notes': self.notes,
|
||||
'updates': [note.serialize() for note in self.updates or []],
|
||||
'complete': self.complete,
|
||||
'followup': self.followup,
|
||||
'contact_id': self.contact_id,
|
||||
'analysis': self.analysis,
|
||||
'work_item_id': self.work_item_id
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "WorkLog":
|
||||
start_time_str = data.get("start_time")
|
||||
end_time_str = data.get("end_time")
|
||||
|
||||
updates_raw = data.get("updates", [])
|
||||
updates: list[WorkNote] = []
|
||||
|
||||
for u in updates_raw:
|
||||
if isinstance(u, dict):
|
||||
content = u.get("content", "").strip()
|
||||
else:
|
||||
content = str(u).strip()
|
||||
|
||||
if content:
|
||||
updates.append(WorkNote(content=content))
|
||||
|
||||
return cls(
|
||||
start_time=datetime.datetime.fromisoformat(str(start_time_str)) if start_time_str else datetime.datetime.now(),
|
||||
end_time=datetime.datetime.fromisoformat(str(end_time_str)) if end_time_str else None,
|
||||
notes=data.get("notes"), # Soon to be removed and sent to a farm upstate
|
||||
complete=bool(data.get("complete", False)),
|
||||
followup=bool(data.get("followup", False)),
|
||||
analysis=bool(data.get("analysis", False)),
|
||||
contact_id=data.get("contact_id"),
|
||||
work_item_id=data.get("work_item_id"),
|
||||
updates=updates
|
||||
)
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import datetime
|
||||
|
||||
from crudkit import CrudMixin
|
||||
from sqlalchemy import ForeignKey, DateTime, UnicodeText, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from . import db
|
||||
|
||||
class WorkNote(db.Model, CrudMixin):
|
||||
__tablename__ = 'work_note'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
work_log_id: Mapped[int] = mapped_column(ForeignKey('work_log.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
timestamp: Mapped[datetime.datetime] = mapped_column(DateTime, default=func.now(), server_default=func.now())
|
||||
content: Mapped[str] = mapped_column(UnicodeText, nullable=False)
|
||||
|
||||
work_log = relationship('WorkLog', back_populates='updates')
|
||||
|
||||
def __init__(self, content: str, timestamp: datetime.datetime | None = None) -> None:
|
||||
self.content = content
|
||||
self.timestamp = timestamp or datetime.datetime.now()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
preview = self.content[:30].replace("\n", " ") + "..." if len(self.content) > 30 else self.content
|
||||
return f"<WorkNote(id={self.id}), log_id={self.work_log_id}, ts={self.timestamp}, content={preview!r}>"
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
'id': self.id,
|
||||
'work_log_id': self.work_log_id,
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'content': self.content
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
import logging
|
||||
|
||||
from flask import Blueprint, g
|
||||
from sqlalchemy.engine import ScalarResult
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql import Select
|
||||
from typing import Iterable, Any, cast
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from . import inventory, user, worklog, settings, index, search, hooks
|
||||
from .. import db
|
||||
from ..ui.blueprint import get_model_class, call
|
||||
from ..ui.defaults import default_query
|
||||
|
||||
def _eager_from_fields(Model, fields: Iterable[str]):
|
||||
rels = {f.split(".", 1)[0] for f in fields if "." in f}
|
||||
opts = []
|
||||
for r in rels:
|
||||
rel_attr = getattr(Model, r, None)
|
||||
if getattr(rel_attr, "property", None) is not None:
|
||||
opts.append(joinedload(rel_attr))
|
||||
return opts
|
||||
|
||||
def _cell_cache():
|
||||
if not hasattr(g, '_cell_cache'):
|
||||
g._cell_cache = {}
|
||||
return g._cell_cache
|
||||
|
||||
def _tmpl_cache(name: str):
|
||||
if not hasattr(g, "_tmpl_caches"):
|
||||
g._tmpl_caches = {}
|
||||
return g._tmpl_caches.setdefault(name, {})
|
||||
|
||||
def _project_row(obj: Any, fields: Iterable[str]) -> dict[str, Any]:
|
||||
out = {"id": obj.id}
|
||||
Model = type(obj)
|
||||
allow = getattr(Model, "ui_value_allow", None)
|
||||
for f in fields:
|
||||
if allow and f not in allow:
|
||||
out[f] = None
|
||||
continue
|
||||
if "." in f:
|
||||
rel, attr = f.split(".", 1)
|
||||
relobj = getattr(obj, rel, None)
|
||||
out[f] = getattr(relobj, attr, None) if relobj else None
|
||||
else:
|
||||
out[f] = getattr(obj, f, None)
|
||||
return out
|
||||
|
||||
@main.app_template_global()
|
||||
def cell(model_name: str, id_: int, field: str, default: str = ""):
|
||||
from ..ui.blueprint import get_model_class, call
|
||||
from ..ui.defaults import default_value
|
||||
key = (model_name, int(id_), field)
|
||||
cache = _cell_cache()
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
|
||||
try:
|
||||
Model = get_model_class(model_name)
|
||||
val = call(Model, 'ui_value', db.session, id_=id_, field=field)
|
||||
if val is None:
|
||||
val = default_value(db.session, Model, id_=id_, field=field)
|
||||
except Exception as e:
|
||||
log.warning(f"cell() error for {model_name} {id_} {field}: {e}")
|
||||
val = default
|
||||
if val is None:
|
||||
val = default
|
||||
cache[key] = val
|
||||
return val
|
||||
|
||||
@main.app_template_global()
|
||||
def cells(model_name: str, id_: int, *fields: str):
|
||||
from ..ui.blueprint import get_model_class, call
|
||||
from ..ui.defaults import default_values
|
||||
fields = [f for f in fields if f]
|
||||
key = (model_name, int(id_), tuple(fields))
|
||||
cache = _cell_cache()
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
|
||||
try:
|
||||
Model = get_model_class(model_name)
|
||||
data = call(Model, 'ui_values', db.session, id_=int(id_), fields=fields)
|
||||
if data is None:
|
||||
data = default_values(db.session, Model, id_=int(id_), fields=fields)
|
||||
except Exception:
|
||||
data = {f: None for f in fields}
|
||||
cache[key] = data
|
||||
return data
|
||||
|
||||
@main.app_template_global()
|
||||
def row(model_name: str, id_: int, *fields: str):
|
||||
"""
|
||||
One row, many fields. Returns a dict like {'id': 1, 'a':..., 'rel.b': ...}
|
||||
"""
|
||||
fields = [f for f in fields if f]
|
||||
key = (model_name, int(id_), tuple(fields))
|
||||
cache = _tmpl_cache("row")
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
|
||||
Model = get_model_class(model_name)
|
||||
obj = db.session.get(Model, int(id_))
|
||||
data = _project_row(obj, fields) if obj else {"id": int(id_), **{f: None for f in fields}}
|
||||
cache[key] = data
|
||||
return data
|
||||
|
||||
@main.app_template_global()
|
||||
def table(model_name: str, fields: Iterable[str], *,
|
||||
q: str = None, sort: str = None, direction: str = "asc",
|
||||
limit: int = 100, offset: int = 0):
|
||||
"""
|
||||
Many rows, many fields — mirrors /list behavior, but returns only the requested columns.
|
||||
Uses ui_query(Model, session, **qkwargs) if present else default_query. Cached per request.
|
||||
"""
|
||||
fields = [f.strip() for f in (fields or []) if f and f.strip()]
|
||||
key = (model_name, tuple(fields), q or "", sort or "", direction or "asc", int(limit), int(offset))
|
||||
cache = _tmpl_cache("table")
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
|
||||
Model = get_model_class(model_name)
|
||||
qkwargs = dict(text=(q or None), limit=int(limit), offset=int(offset),
|
||||
sort=(sort or None), direction=(direction or "asc").lower())
|
||||
extra_opts = _eager_from_fields(Model, fields)
|
||||
if extra_opts:
|
||||
if isinstance(rows_any, Select):
|
||||
rows_any = rows_any.options(*extra_opts)
|
||||
elif rows_any is None:
|
||||
original = getattr(Model, 'ui_eagerload', ())
|
||||
def dyn_opts():
|
||||
base = original() if callable(original) else original
|
||||
return tuple(base) + tuple(extra_opts)
|
||||
setattr(Model, 'ui_eagerload', dyn_opts)
|
||||
rows_any: Any = call(Model, "ui_query", db.session, **qkwargs)
|
||||
|
||||
if rows_any is None:
|
||||
objs = default_query(db.session, Model, **qkwargs)
|
||||
elif isinstance(rows_any, list):
|
||||
objs = rows_any
|
||||
elif isinstance(rows_any, Select):
|
||||
objs = list(cast(ScalarResult[Any], db.session.execute(rows_any).scalars()))
|
||||
else:
|
||||
scalars = getattr(rows_any, "scalars", None)
|
||||
if callable(scalars):
|
||||
objs = list(cast(ScalarResult[Any], scalars()))
|
||||
else:
|
||||
objs = list(rows_any)
|
||||
|
||||
data = [_project_row(o, fields) for o in objs]
|
||||
cache[key] = data
|
||||
return data
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import base64
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
|
||||
from flask import url_for, jsonify, request
|
||||
from flask import current_app as app
|
||||
|
||||
from ..models import User, Inventory, WorkLog
|
||||
|
||||
from ..models.image import ImageAttachable
|
||||
|
||||
ROUTE_BREADCRUMBS = {
|
||||
'main.user': {
|
||||
'trail': [('Users', 'main.list_users')],
|
||||
'model': User,
|
||||
'arg': 'id',
|
||||
'label_attr': 'identifier',
|
||||
'url_func': lambda i: url_for('main.user', id=i.id)
|
||||
},
|
||||
'main.inventory_item': {
|
||||
'trail': [('Inventory', 'main.list_inventory')],
|
||||
'model': Inventory,
|
||||
'arg': 'id',
|
||||
'label_attr': 'identifier',
|
||||
'url_func': lambda i: url_for('main.inventory_item', id=i.id)
|
||||
},
|
||||
'main.worklog': {
|
||||
'trail': [('Work Log', 'main.list_worklog')],
|
||||
'model': WorkLog,
|
||||
'arg': 'id',
|
||||
'label_attr': 'identifier',
|
||||
'url_func': lambda i: url_for('main.worklog', id=i.id)
|
||||
}
|
||||
}
|
||||
|
||||
inventory_headers = {
|
||||
"Date Entered": lambda i: {"field": "timestamp", "text": i.timestamp.strftime("%Y-%m-%d") if i.timestamp else None},
|
||||
"Identifier": lambda i: {"field": "identifier", "text": i.identifier},
|
||||
"Name": lambda i: {"field": "name", "text": i.name},
|
||||
"Serial Number": lambda i: {"field": "serial", "text": i.serial},
|
||||
"Bar Code": lambda i: {"field": "barcode", "text": i.barcode},
|
||||
"Brand": lambda i: {"field": "brand.name", "text": i.brand.name} if i.brand else {"text": None},
|
||||
"Model": lambda i: {"field": "model", "text": i.model},
|
||||
"Item Type": lambda i: {"field": "device_type.description", "text": i.device_type.description} if i.device_type else {"text": None},
|
||||
"Shared?": lambda i: {"field": "shared", "text": i.shared, "type": "bool", "html": checked_box if i.shared else unchecked_box},
|
||||
"Owner": lambda i: {"field": "owner.identifier", "text": i.owner.identifier, "url": url_for("main.user_item", id=i.owner.id)} if i.owner else {"text": None},
|
||||
"Location": lambda i: {"field": "location.identifier", "text": i.location.identifier} if i.location else {"Text": None},
|
||||
"Condition": lambda i: {"field": "condition", "text": i.condition}
|
||||
}
|
||||
|
||||
checked_box = '''
|
||||
<i class="bi bi-check2"></i>
|
||||
'''
|
||||
unchecked_box = ''
|
||||
|
||||
ACTIVE_STATUSES = [
|
||||
"Working",
|
||||
"Deployed",
|
||||
"Partially Inoperable",
|
||||
"Unverified"
|
||||
]
|
||||
|
||||
INACTIVE_STATUSES = [
|
||||
"Inoperable",
|
||||
"Removed",
|
||||
"Disposed"
|
||||
]
|
||||
|
||||
FILTER_MAP = {
|
||||
'user': Inventory.owner_id,
|
||||
'location': Inventory.location_id,
|
||||
'type': Inventory.type_id,
|
||||
}
|
||||
|
||||
user_headers = {
|
||||
"Last Name": lambda i: {"field": "last_name","text": i.last_name},
|
||||
"First Name": lambda i: {"field": "first_name","text": i.first_name},
|
||||
"Title": lambda i: {"field": "title","text": i.title},
|
||||
"Supervisor": lambda i: {"field": "supervisor,identifier","text": i.supervisor.identifier, "url": url_for("main.user_item", id=i.supervisor.id)} if i.supervisor else {"text": None},
|
||||
"Location": lambda i: {"field": "location,identifier","text": i.location.identifier} if i.location else {"text": None},
|
||||
"Staff?": lambda i: {"field": "staff","text": i.staff, "type": "bool", "html": checked_box if i.staff else unchecked_box},
|
||||
"Active?": lambda i: {"field": "active","text": i.active, "type": "bool", "html": checked_box if i.active else unchecked_box}
|
||||
}
|
||||
|
||||
worklog_headers = {
|
||||
"Contact": lambda i: {"text": i.contact.identifier, "url": url_for("main.user_item", 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, "highlight": i.followup},
|
||||
"Quick Analysis?": lambda i: {"text": i.analysis, "type": "bool", "html": checked_box if i.analysis else unchecked_box},
|
||||
}
|
||||
|
||||
def link(text, endpoint, **values):
|
||||
return {"text": text, "url": url_for(endpoint, **values)}
|
||||
|
||||
|
||||
def generate_hashed_filename(file) -> str:
|
||||
content = file.read()
|
||||
file.seek(0) # Reset after reading
|
||||
|
||||
hash = hashlib.sha256(content).hexdigest()
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
return f"{hash}_{file.filename}"
|
||||
|
||||
def get_image_attachable_class_by_name(name: str):
|
||||
for cls in ImageAttachable.__subclasses__():
|
||||
if getattr(cls, '__tablename__', None) == name:
|
||||
return cls
|
||||
return None
|
||||
|
||||
def make_csv(export_func, columns, rows):
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow(columns)
|
||||
|
||||
for row in rows:
|
||||
writer.writerow([export_func(row, col) for col in columns])
|
||||
|
||||
csv_string = output.getvalue()
|
||||
output.close()
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"csv": base64.b64encode(csv_string.encode()).decode(),
|
||||
"count": len(rows)
|
||||
})
|
||||
|
||||
def generate_breadcrumbs():
|
||||
crumbs = []
|
||||
|
||||
endpoint = request.endpoint
|
||||
view_args = request.view_args or {}
|
||||
|
||||
if endpoint in ROUTE_BREADCRUMBS:
|
||||
print(endpoint, view_args)
|
||||
config = ROUTE_BREADCRUMBS[endpoint]
|
||||
|
||||
for label, ep in config.get('trail', []):
|
||||
crumbs.append({'label': label, 'url': url_for(ep)})
|
||||
|
||||
obj_id = view_args.get(config['arg'])
|
||||
if obj_id:
|
||||
obj = config['model'].query.get(obj_id)
|
||||
if obj:
|
||||
crumbs.append({
|
||||
'label': getattr(obj, config['label_attr'], str(obj)),
|
||||
'url': config['url_func'](obj)
|
||||
})
|
||||
else:
|
||||
# fallback to ugly slashes
|
||||
path = request.path.strip('/').split('/')
|
||||
accumulated = ''
|
||||
for segment in path:
|
||||
accumulated += '/' + segment
|
||||
crumbs.append({'label': segment.title(), 'url': accumulated})
|
||||
|
||||
return crumbs
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
from bs4 import BeautifulSoup
|
||||
from flask import current_app as app
|
||||
|
||||
from . import main
|
||||
|
||||
@main.after_request
|
||||
def prettify_or_minify_html_response(response):
|
||||
if response.content_type.startswith("text/html"):
|
||||
try:
|
||||
html = response.get_data(as_text=True)
|
||||
soup = BeautifulSoup(html, 'html5lib')
|
||||
|
||||
if app.debug:
|
||||
pretty_html = soup.prettify()
|
||||
response.set_data(pretty_html.encode("utf-8")) # type: ignore
|
||||
#else:
|
||||
# # Minify by stripping extra whitespace between tags and inside text
|
||||
# minified_html = re.sub(r">\s+<", "><", str(soup)) # collapse whitespace between tags
|
||||
# minified_html = re.sub(r"\s{2,}", " ", minified_html) # collapse multi-spaces to one
|
||||
# minified_html = re.sub(r"\n+", "", minified_html) # remove newlines
|
||||
|
||||
# response.set_data(minified_html.encode("utf-8")) # type: ignore
|
||||
|
||||
response.headers['Content-Type'] = 'text/html; charset=utf-8'
|
||||
except Exception as e:
|
||||
print(f"⚠️ Prettifying/Minifying failed: {e}")
|
||||
|
||||
return response
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import os
|
||||
import posixpath
|
||||
|
||||
from flask import Blueprint, current_app, request, jsonify
|
||||
|
||||
from .helpers import generate_hashed_filename, get_image_attachable_class_by_name
|
||||
from .. import db
|
||||
from ..models import Image
|
||||
|
||||
image_bp = Blueprint("image_api", __name__)
|
||||
|
||||
def save_image(file, model: str) -> str:
|
||||
assert current_app.static_folder
|
||||
|
||||
filename = generate_hashed_filename(file)
|
||||
rel_path = posixpath.join("uploads", "images", model, filename)
|
||||
abs_path = os.path.join(current_app.static_folder, rel_path)
|
||||
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
|
||||
file.save(abs_path)
|
||||
return rel_path
|
||||
|
||||
@image_bp.route("/api/images", methods=["POST"])
|
||||
def upload_image():
|
||||
file = request.files.get("file")
|
||||
model = request.form.get("target_model")
|
||||
model_id = request.form.get("model_id")
|
||||
caption = request.form.get("caption", "")
|
||||
|
||||
if not file or not model or not model_id:
|
||||
return jsonify({"success": False, "error": "Missing file, model, or model_id"}), 400
|
||||
|
||||
ModelClass = get_image_attachable_class_by_name(model)
|
||||
if not ModelClass:
|
||||
return jsonify({"success": False, "error": f"Model '{model}' does not support image attachments."}), 400
|
||||
|
||||
try:
|
||||
model_id = int(model_id)
|
||||
except ValueError:
|
||||
return jsonify({"success": False, "error": "model_id must be an integer"}), 400
|
||||
|
||||
# Save file
|
||||
rel_path = save_image(file, model)
|
||||
|
||||
# Create Image row
|
||||
image = Image(filename=rel_path, caption=caption)
|
||||
db.session.add(image)
|
||||
|
||||
# Attach image to model
|
||||
target = db.session.get(ModelClass, model_id)
|
||||
if not target:
|
||||
return jsonify({"success": False, "error": f"No {model} found with ID {model_id}"}), 404
|
||||
|
||||
target.attach_image(image)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({"success": True, "id": image.id}), 201
|
||||
|
||||
@image_bp.route("/api/images/<int:image_id>", methods=["GET"])
|
||||
def get_image(image_id: int):
|
||||
image = db.session.get(Image, image_id)
|
||||
if not image:
|
||||
return jsonify({"success": False, "error": f"No image found with ID {image_id}"}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"id": image.id,
|
||||
"filename": image.filename,
|
||||
"caption": image.caption,
|
||||
"timestamp": image.timestamp.isoformat() if image.timestamp else None,
|
||||
"url": f"/static/{image.filename}"
|
||||
})
|
||||
|
||||
@image_bp.route("/api/images/<int:image_id>", methods=["DELETE"])
|
||||
def delete_image(image_id):
|
||||
image = db.session.get(Image, image_id)
|
||||
if not image:
|
||||
return jsonify({"success": False, "error": "Image not found"})
|
||||
|
||||
rel_path = posixpath.normpath(str(image.filename))
|
||||
static_dir = str(current_app.static_folder) # appease the gods
|
||||
abs_path = os.path.join(static_dir, rel_path)
|
||||
|
||||
if os.path.exists(abs_path):
|
||||
os.remove(abs_path)
|
||||
|
||||
db.session.delete(image)
|
||||
db.session.commit()
|
||||
return jsonify({"success": True})
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
from flask import render_template, request
|
||||
import pandas as pd
|
||||
import random
|
||||
|
||||
from . import main
|
||||
from .helpers import worklog_headers
|
||||
from .. import db
|
||||
from ..models import WorkLog, Inventory
|
||||
from ..utils.load import eager_load_worklog_relationships, eager_load_inventory_relationships
|
||||
|
||||
def generate_solvable_matrix(level, seed_clicks=None):
|
||||
size = level + 3
|
||||
matrix = [[True for _ in range(size)] for _ in range(size)]
|
||||
presses = []
|
||||
|
||||
def press(x, y):
|
||||
# record the press (once)
|
||||
presses.append((x, y))
|
||||
# apply its effect
|
||||
for dx, dy in [(0,0),(-1,0),(1,0),(0,-1),(0,1)]:
|
||||
nx, ny = x + dx, y + dy
|
||||
if 0 <= nx < size and 0 <= ny < size:
|
||||
matrix[nx][ny] = not matrix[nx][ny]
|
||||
|
||||
num_clicks = seed_clicks if seed_clicks is not None else random.randint(size, size * 2)
|
||||
for _ in range(num_clicks):
|
||||
x = random.randint(0, size - 1)
|
||||
y = random.randint(0, size - 1)
|
||||
press(x, y)
|
||||
|
||||
return matrix, presses # return the PRESS LIST as the “solution”
|
||||
|
||||
@main.route("/12648243")
|
||||
def coffee():
|
||||
level = request.args.get('level', 0, int)
|
||||
score = request.args.get('score', 0, int)
|
||||
matrix, clicked = generate_solvable_matrix(level)
|
||||
return render_template("coffee.html", matrix=matrix, level=level, clicked=clicked, score=score)
|
||||
|
||||
@main.route("/playground")
|
||||
def playground():
|
||||
return render_template("playground.html")
|
||||
|
||||
@main.route("/")
|
||||
def index():
|
||||
worklog_query = eager_load_worklog_relationships(
|
||||
db.session.query(WorkLog)
|
||||
).all()
|
||||
|
||||
active_worklogs = [log for log in worklog_query if not log.complete]
|
||||
|
||||
active_count = len(active_worklogs)
|
||||
active_worklog_headers = {
|
||||
k: v for k, v in worklog_headers.items()
|
||||
if k not in ['End Time', 'Quick Analysis?', 'Complete?', 'Follow Up?']
|
||||
}
|
||||
|
||||
inventory_query = eager_load_inventory_relationships(
|
||||
db.session.query(Inventory)
|
||||
)
|
||||
|
||||
results = inventory_query.all()
|
||||
|
||||
data = [{
|
||||
'id': item.id,
|
||||
'condition': item.condition
|
||||
} for item in results]
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Count items per condition
|
||||
expected_conditions = [
|
||||
'Deployed','Inoperable', 'Partially Inoperable',
|
||||
'Unverified', 'Working'
|
||||
]
|
||||
if 'condition' in df.columns:
|
||||
pivot = df['condition'].value_counts().reindex(expected_conditions, fill_value=0)
|
||||
else:
|
||||
pivot = pd.Series([0] * len(expected_conditions), index=expected_conditions)
|
||||
|
||||
# Convert pandas/numpy int64s to plain old Python ints
|
||||
pivot = pivot.astype(int)
|
||||
labels = list(pivot.index)
|
||||
data = [int(x) for x in pivot.values]
|
||||
|
||||
datasets = {}
|
||||
|
||||
datasets['summary'] = [{
|
||||
'type': 'pie',
|
||||
'labels': labels,
|
||||
'values': data,
|
||||
'name': 'Inventory Conditions'
|
||||
}]
|
||||
|
||||
users = set([log.contact for log in worklog_query if log.contact])
|
||||
work_summary = {}
|
||||
|
||||
for user in sorted(users, key=lambda u: u.identifier):
|
||||
work_summary[user.identifier] = {}
|
||||
work_summary[user.identifier]['active_count'] = len([log for log in worklog_query if log.contact == user and not log.complete])
|
||||
work_summary[user.identifier]['complete_count'] = len([log for log in worklog_query if log.contact == user and log.complete])
|
||||
|
||||
datasets['work_summary'] = [{
|
||||
'type': 'bar',
|
||||
'x': list(work_summary.keys()),
|
||||
'y': [work_summary[user]['active_count'] for user in work_summary],
|
||||
'name': 'Active Worklogs',
|
||||
'marker': {'color': 'red'}
|
||||
}, {
|
||||
'type': 'bar',
|
||||
'x': list(work_summary.keys()),
|
||||
'y': [work_summary[user]['complete_count'] for user in work_summary],
|
||||
'name': 'Completed Worklogs',
|
||||
'marker': {'color': 'green'}
|
||||
}]
|
||||
|
||||
active_worklog_rows = []
|
||||
for log in active_worklogs:
|
||||
# Create a dictionary of {column name: cell dict}
|
||||
cells_by_key = {k: fn(log) for k, fn in worklog_headers.items()}
|
||||
|
||||
# Use original, full header set for logic
|
||||
highlight = cells_by_key.get("Follow Up?", {}).get("highlight", False)
|
||||
|
||||
# Use only filtered headers — and in exact order
|
||||
cells = [cells_by_key[k] for k in active_worklog_headers]
|
||||
|
||||
active_worklog_rows.append({
|
||||
"id": log.id,
|
||||
"cells": cells,
|
||||
"highlight": highlight
|
||||
})
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
active_count=active_count,
|
||||
active_worklog_headers=active_worklog_headers,
|
||||
active_worklog_rows=active_worklog_rows,
|
||||
labels=labels,
|
||||
datasets=datasets
|
||||
)
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
import datetime
|
||||
from flask import request, render_template, jsonify
|
||||
|
||||
from . import main
|
||||
from .helpers import FILTER_MAP, inventory_headers, worklog_headers, make_csv
|
||||
|
||||
from .. import db
|
||||
from ..models import Inventory, User, Room, Item, RoomFunction, Brand, WorkLog
|
||||
from ..utils.load import eager_load_inventory_relationships, eager_load_user_relationships, eager_load_worklog_relationships, eager_load_room_relationships, chunk_list
|
||||
|
||||
@main.route("/inventory")
|
||||
def list_inventory():
|
||||
filter_by = request.args.get('filter_by', type=str)
|
||||
id = request.args.get('id', type=int)
|
||||
|
||||
filter_name = None
|
||||
|
||||
query = db.session.query(Inventory)
|
||||
query = eager_load_inventory_relationships(query)
|
||||
# query = query.order_by(Inventory.name, Inventory.barcode, Inventory.serial)
|
||||
|
||||
if filter_by and id:
|
||||
column = FILTER_MAP.get(filter_by)
|
||||
if column is not None:
|
||||
filter_name = None
|
||||
if filter_by == 'user':
|
||||
if not (user := db.session.query(User).filter(User.id == id).first()):
|
||||
return "Invalid User ID", 400
|
||||
filter_name = user.identifier
|
||||
elif filter_by == 'location':
|
||||
if not (room := db.session.query(Room).filter(Room.id == id).first()):
|
||||
return "Invalid Location ID", 400
|
||||
filter_name = room.identifier
|
||||
else:
|
||||
if not (item := db.session.query(Item).filter(Item.id == id).first()):
|
||||
return "Invalid Type ID", 400
|
||||
filter_name = item.description
|
||||
|
||||
query = query.filter(column == id)
|
||||
else:
|
||||
return "Invalid filter_by parameter", 400
|
||||
|
||||
inventory = query.all()
|
||||
inventory = sorted(inventory, key=lambda i: i.identifier)
|
||||
|
||||
rows=[{"id": item.id, "cells": [row_fn(item) for row_fn in inventory_headers.values()]} for item in inventory]
|
||||
fields = [d['field'] for d in rows[0]['cells']]
|
||||
|
||||
return render_template(
|
||||
'table.html',
|
||||
title=f"Inventory Listing ({filter_name})" if filter_by else "Inventory Listing",
|
||||
header=inventory_headers,
|
||||
fields=fields,
|
||||
rows=rows,
|
||||
entry_route = 'inventory_item',
|
||||
csv_route = 'inventory',
|
||||
model_name = 'inventory'
|
||||
)
|
||||
|
||||
@main.route("/inventory/index")
|
||||
def inventory_index():
|
||||
category = request.args.get('category')
|
||||
listing = None
|
||||
|
||||
if category == 'user':
|
||||
users = db.session.query(User.id, User.first_name, User.last_name).order_by(User.first_name, User.last_name).all()
|
||||
listing = chunk_list([(user.id, f"{user.first_name or ''} {user.last_name or ''}".strip()) for user in users], 12)
|
||||
elif category == 'location':
|
||||
rooms = (
|
||||
db.session.query(Room.id, Room.name, RoomFunction.description)
|
||||
.join(RoomFunction, Room.function_id == RoomFunction.id)
|
||||
.order_by(Room.name, RoomFunction.description)
|
||||
.all()
|
||||
)
|
||||
listing = chunk_list([(room.id, f"{room.name or ''} - {room.description or ''}".strip()) for room in rooms], 12)
|
||||
elif category == 'type':
|
||||
types = db.session.query(Item.id, Item.description).order_by(Item.description).all()
|
||||
listing = chunk_list(types, 12)
|
||||
elif category:
|
||||
return f"Dude, why {category}?"
|
||||
|
||||
return render_template(
|
||||
'inventory_index.html',
|
||||
title=f"Inventory ({category.capitalize()} Index)" if category else "Inventory",
|
||||
category=category,
|
||||
listing=listing
|
||||
)
|
||||
|
||||
@main.route("/inventory_item/<id>", methods=['GET', 'POST'])
|
||||
def inventory_item(id):
|
||||
try:
|
||||
id = int(id)
|
||||
except ValueError:
|
||||
return render_template('error.html', title="Bad ID", message="ID must be an integer", endpoint='inventory_item', endpoint_args={'id': -1})
|
||||
|
||||
inventory_query = db.session.query(Inventory)
|
||||
item = eager_load_inventory_relationships(inventory_query).filter(Inventory.id == id).first()
|
||||
brands = db.session.query(Brand).order_by(Brand.name).all()
|
||||
users = eager_load_user_relationships(db.session.query(User).filter(User.active == True).order_by(User.first_name, User.last_name)).all()
|
||||
rooms = eager_load_room_relationships(db.session.query(Room).order_by(Room.name)).all()
|
||||
worklog_query = db.session.query(WorkLog).filter(WorkLog.work_item_id == id)
|
||||
worklog = eager_load_worklog_relationships(worklog_query).all()
|
||||
notes = [note for log in worklog for note in log.updates]
|
||||
types = db.session.query(Item).order_by(Item.description).all()
|
||||
filtered_worklog_headers = {k: v for k, v in worklog_headers.items() if k not in ['Work Item', 'Contact', 'Follow Up?', 'Quick Analysis?']}
|
||||
|
||||
if item:
|
||||
title = f"Inventory Record - {item.identifier}"
|
||||
else:
|
||||
title = "Inventory Record - Not Found"
|
||||
return render_template('error.html',
|
||||
title=title,
|
||||
message=f'Inventory item with id {id} not found!',
|
||||
endpoint='inventory_item',
|
||||
endpoint_args={'id': -1})
|
||||
|
||||
return render_template("inventory.html", title=title, item=item,
|
||||
brands=brands, users=users, rooms=rooms,
|
||||
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],
|
||||
types=types,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
@main.route("/inventory_item/new", methods=["GET"])
|
||||
def new_inventory_item():
|
||||
brands = db.session.query(Brand).order_by(Brand.name).all()
|
||||
users = eager_load_user_relationships(db.session.query(User).filter(User.active == True).order_by(User.first_name, User.last_name)).all()
|
||||
rooms = eager_load_room_relationships(db.session.query(Room).order_by(Room.name)).all()
|
||||
types = db.session.query(Item).order_by(Item.description).all()
|
||||
|
||||
item = Inventory(
|
||||
timestamp=datetime.datetime.now(),
|
||||
condition="Unverified",
|
||||
type_id=None,
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"inventory.html",
|
||||
item=item,
|
||||
brands=brands,
|
||||
users=users,
|
||||
rooms=rooms,
|
||||
types=types,
|
||||
worklog=[],
|
||||
worklog_headers={},
|
||||
worklog_rows=[]
|
||||
)
|
||||
|
||||
@main.route("/api/inventory", methods=["POST"])
|
||||
def create_inventory_item():
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
|
||||
new_item = Inventory.from_dict(data)
|
||||
|
||||
db.session.add(new_item)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True, "id": new_item.id}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
@main.route("/api/inventory/<int:id>", methods=["PUT"])
|
||||
def update_inventory_item(id):
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
item = db.session.query(Inventory).get(id)
|
||||
|
||||
if not item:
|
||||
return jsonify({"success": False, "error": f"Inventory item with ID {id} not found."}), 404
|
||||
|
||||
item.timestamp = datetime.datetime.fromisoformat(data.get("timestamp")) if data.get("timestamp") else item.timestamp
|
||||
item.condition = data.get("condition", item.condition)
|
||||
item.type_id = data.get("type_id", item.type_id)
|
||||
item.name = data.get("name", item.name)
|
||||
item.serial = data.get("serial", item.serial)
|
||||
item.model = data.get("model", item.model)
|
||||
item.notes = data.get("notes", item.notes)
|
||||
item.owner_id = data.get("owner_id", item.owner_id)
|
||||
item.brand_id = data.get("brand_id", item.brand_id)
|
||||
item.location_id = data.get("location_id", item.location_id)
|
||||
item.barcode = data.get("barcode", item.barcode)
|
||||
item.shared = bool(data.get("shared", item.shared))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True, "id": item.id}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
@main.route("/api/inventory/<int:id>", methods=["DELETE"])
|
||||
def delete_inventory_item(id):
|
||||
try:
|
||||
item = db.session.query(Inventory).get(id)
|
||||
|
||||
if not item:
|
||||
return jsonify({"success": False, "error": f"Item with ID {id} not found"}), 404
|
||||
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
@main.route("/api/inventory/export", methods=["POST"])
|
||||
def get_inventory_csv():
|
||||
def export_value(item, col):
|
||||
try:
|
||||
match col:
|
||||
case "brand":
|
||||
return item.brand.name
|
||||
case "location":
|
||||
return item.location.identifier
|
||||
case "owner":
|
||||
return item.owner.identifier
|
||||
case "type":
|
||||
return item.device_type.description
|
||||
case _:
|
||||
return getattr(item, col, "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
data = request.get_json()
|
||||
ids = data.get('ids', [])
|
||||
|
||||
if not ids:
|
||||
return jsonify({"success": False, "error": "No IDs provided"}), 400
|
||||
|
||||
rows = eager_load_inventory_relationships(db.session.query(Inventory).filter(Inventory.id.in_(ids))).all()
|
||||
|
||||
columns = [
|
||||
"id",
|
||||
"timestamp",
|
||||
"condition",
|
||||
"type",
|
||||
"name",
|
||||
"serial",
|
||||
"model",
|
||||
"notes",
|
||||
"owner",
|
||||
"brand",
|
||||
"location",
|
||||
"barcode",
|
||||
"shared"
|
||||
]
|
||||
|
||||
return make_csv(export_value, columns, rows)
|
||||
|
||||
@main.route("/inventory_available")
|
||||
def inventory_available():
|
||||
query = eager_load_inventory_relationships(db.session.query(Inventory).filter(Inventory.condition == "Working"))
|
||||
|
||||
inventory = query.all()
|
||||
inventory = sorted(inventory, key=lambda i: i.identifier)
|
||||
|
||||
return render_template(
|
||||
"table.html",
|
||||
title = "Available Inventory",
|
||||
header=inventory_headers,
|
||||
rows=[{"id": item.id, "cells": [row_fn(item) for row_fn in inventory_headers.values()]} for item in inventory],
|
||||
entry_route = 'inventory_item'
|
||||
)
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
from flask import request, redirect, url_for, render_template
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from . import main
|
||||
from .helpers import inventory_headers, user_headers, worklog_headers
|
||||
from .. import db
|
||||
from ..models import Inventory, User, WorkLog
|
||||
from ..utils.load import eager_load_inventory_relationships, eager_load_user_relationships, eager_load_worklog_relationships
|
||||
|
||||
@main.route("/search")
|
||||
def search():
|
||||
query = request.args.get('q', '').strip()
|
||||
|
||||
if not query:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
InventoryAlias = aliased(Inventory)
|
||||
UserAlias = aliased(User)
|
||||
|
||||
inventory_query = eager_load_inventory_relationships(db.session.query(Inventory).join(UserAlias, Inventory.owner)).filter(
|
||||
or_(
|
||||
Inventory.name.ilike(f"%{query}%"),
|
||||
Inventory.serial.ilike(f"%{query}%"),
|
||||
Inventory.barcode.ilike(f"%{query}%"),
|
||||
Inventory.notes.ilike(f"%{query}%"),
|
||||
UserAlias.first_name.ilike(f"%{query}%"),
|
||||
UserAlias.last_name.ilike(f"%{query}%"),
|
||||
UserAlias.title.ilike(f"%{query}%")
|
||||
))
|
||||
inventory_results = inventory_query.all()
|
||||
user_query = eager_load_user_relationships(db.session.query(User).outerjoin(UserAlias, User.supervisor)).filter(
|
||||
or_(
|
||||
User.first_name.ilike(f"%{query}%"),
|
||||
User.last_name.ilike(f"%{query}%"),
|
||||
User.title.ilike(f"%{query}%"),
|
||||
UserAlias.first_name.ilike(f"%{query}%"),
|
||||
UserAlias.last_name.ilike(f"%{query}%"),
|
||||
UserAlias.title.ilike(f"%{query}%")
|
||||
))
|
||||
user_results = user_query.all()
|
||||
worklog_query = eager_load_worklog_relationships(db.session.query(WorkLog).join(UserAlias, WorkLog.contact).join(InventoryAlias, WorkLog.work_item)).filter(
|
||||
or_(
|
||||
WorkLog.notes.ilike(f"%{query}%"),
|
||||
UserAlias.first_name.ilike(f"%{query}%"),
|
||||
UserAlias.last_name.ilike(f"%{query}%"),
|
||||
UserAlias.title.ilike(f"%{query}%"),
|
||||
InventoryAlias.name.ilike(f"%{query}%"),
|
||||
InventoryAlias.serial.ilike(f"%{query}%"),
|
||||
InventoryAlias.barcode.ilike(f"%{query}%")
|
||||
))
|
||||
worklog_results = worklog_query.all()
|
||||
|
||||
results = {
|
||||
'inventory': {
|
||||
'results': inventory_query,
|
||||
'headers': inventory_headers,
|
||||
'rows': [{"id": item.id, "cells": [fn(item) for fn in inventory_headers.values()]} for item in inventory_results]
|
||||
},
|
||||
'users': {
|
||||
'results': user_query,
|
||||
'headers': user_headers,
|
||||
'rows': [{"id": user.id, "cells": [fn(user) for fn in user_headers.values()]} for user in user_results]
|
||||
},
|
||||
'worklog': {
|
||||
'results': worklog_query,
|
||||
'headers': worklog_headers,
|
||||
'rows': [{"id": log.id, "cells": [fn(log) for fn in worklog_headers.values()]} for log in worklog_results]
|
||||
}
|
||||
}
|
||||
|
||||
return render_template('search.html', title=f"Database Search ({query})" if query else "Database Search", results=results, query=query)
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
from flask import render_template
|
||||
|
||||
from . import main
|
||||
from .. import db
|
||||
from ..models import Image
|
||||
from ..utils.load import chunk_list
|
||||
|
||||
@main.route('/settings')
|
||||
def settings():
|
||||
images = chunk_list(db.session.query(Image).order_by(Image.timestamp).all(), 6)
|
||||
|
||||
return render_template('settings.html',
|
||||
title="Settings",
|
||||
image_list=images
|
||||
)
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
import base64
|
||||
import csv
|
||||
import io
|
||||
|
||||
from flask import render_template, request, jsonify
|
||||
|
||||
from . import main
|
||||
from .helpers import ACTIVE_STATUSES, user_headers, inventory_headers, worklog_headers, make_csv
|
||||
from .. import db
|
||||
from ..utils.load import eager_load_user_relationships, eager_load_room_relationships, eager_load_inventory_relationships, eager_load_worklog_relationships
|
||||
from ..models import User, Room, Inventory, WorkLog
|
||||
|
||||
@main.route("/users")
|
||||
def list_users():
|
||||
return render_template(
|
||||
'table.html',
|
||||
header = user_headers,
|
||||
model_name = 'user',
|
||||
title = "Users",
|
||||
entry_route = 'user_item',
|
||||
csv_route = 'user',
|
||||
fields = ['last_name', 'first_name', 'title', 'supervisor.identifier', 'location.identifier', 'staff', 'active'],
|
||||
)
|
||||
|
||||
@main.route("/user/<id>")
|
||||
def user_item(id):
|
||||
try:
|
||||
id = int(id)
|
||||
except ValueError:
|
||||
return render_template('error.html', title='Bad ID', message='ID must be an integer.', endpoint='user_item', endpoint_args={'id': -1})
|
||||
|
||||
users_query = db.session.query(User).order_by(User.first_name, User.last_name)
|
||||
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) # type: ignore
|
||||
.filter(Inventory.condition.in_(ACTIVE_STATUSES))
|
||||
)
|
||||
|
||||
inventory = inventory_query.all()
|
||||
filtered_inventory_headers = {k: v for k, v in inventory_headers.items() if k not in ['Date Entered', 'Name', 'Serial Number',
|
||||
'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 = worklog_query.order_by(WorkLog.start_time.desc()).all()
|
||||
filtered_worklog_headers = {k: v for k, v in worklog_headers.items() if k not in ['Contact', 'Follow Up?', 'Quick Analysis?']}
|
||||
|
||||
if user:
|
||||
title = f"User Record - {user.identifier}" if user.active else f"User Record - {user.identifier} (Inactive)"
|
||||
else:
|
||||
title = f"User Record - User Not Found"
|
||||
return render_template(
|
||||
'error.html',
|
||||
title=title,
|
||||
message=f"User with id {id} not found!"
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"user.html",
|
||||
title=title,
|
||||
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],
|
||||
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]
|
||||
)
|
||||
|
||||
@main.route("/user/<id>/org")
|
||||
def user_org(id):
|
||||
user = eager_load_user_relationships(db.session.query(User).filter(User.id == id).order_by(User.first_name, User.last_name)).first()
|
||||
if not user:
|
||||
return render_template('error.html', title='User Not Found', message=f'User with ID {id} not found.')
|
||||
|
||||
current_user = user
|
||||
org_chart = []
|
||||
while current_user:
|
||||
subordinates = (
|
||||
eager_load_user_relationships(
|
||||
db.session.query(User).filter(User.supervisor_id == current_user.id).order_by(User.first_name, User.last_name)
|
||||
).all()
|
||||
)
|
||||
org_chart.insert(0, {
|
||||
"user": current_user,
|
||||
"subordinates": [subordinate for subordinate in subordinates if subordinate.active and subordinate.staff]
|
||||
})
|
||||
current_user = current_user.supervisor
|
||||
|
||||
return render_template(
|
||||
"user_org.html",
|
||||
user=user,
|
||||
org_chart=org_chart
|
||||
)
|
||||
|
||||
@main.route("/user/new", methods=["GET"])
|
||||
def new_user():
|
||||
rooms = eager_load_room_relationships(db.session.query(Room)).all()
|
||||
users = eager_load_user_relationships(db.session.query(User)).all()
|
||||
|
||||
user = User(
|
||||
active=True
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"user.html",
|
||||
title="New User",
|
||||
user=user,
|
||||
users=users,
|
||||
rooms=rooms
|
||||
)
|
||||
|
||||
@main.route("/api/user", methods=["POST"])
|
||||
def create_user():
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
|
||||
new_user = User.from_dict(data)
|
||||
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True, "id": new_user.id}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
@main.route("/api/user/<int:id>", methods=["PUT"])
|
||||
def update_user(id):
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
user = db.session.query(User).get(id)
|
||||
|
||||
if not user:
|
||||
return jsonify({"success": False, "error": f"User with ID {id} not found."}), 404
|
||||
|
||||
user.staff = bool(data.get("staff", user.staff))
|
||||
user.active = bool(data.get("active", user.active))
|
||||
user.last_name = data.get("last_name", user.last_name)
|
||||
user.first_name = data.get("first_name", user.first_name)
|
||||
user.title = data.get("title", user.title)
|
||||
user.location_id = data.get("location_id", user.location_id)
|
||||
user.supervisor_id = data.get("supervisor_id", user.supervisor_id)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True, "id": user.id}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
@main.route("/api/user/export", methods=["POST"])
|
||||
def get_user_csv():
|
||||
def export_value(user, col):
|
||||
try:
|
||||
match col:
|
||||
case "location":
|
||||
return user.location.identifier
|
||||
case "supervisor":
|
||||
return user.supervisor.identifier
|
||||
case _:
|
||||
return getattr(user, col, "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
data = request.get_json()
|
||||
ids = data.get('ids', [])
|
||||
|
||||
if not ids:
|
||||
return jsonify({"success": False, "error": "No IDs provided"}), 400
|
||||
|
||||
rows = eager_load_user_relationships(db.session.query(User).filter(User.id.in_(ids))).all()
|
||||
|
||||
columns = [
|
||||
"id",
|
||||
"staff",
|
||||
"active",
|
||||
"last_name",
|
||||
"first_name",
|
||||
"title",
|
||||
"location",
|
||||
"supervisor"
|
||||
]
|
||||
|
||||
return make_csv(export_value, columns, rows)
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import base64
|
||||
import csv
|
||||
import datetime
|
||||
import io
|
||||
|
||||
from flask import request, render_template, jsonify
|
||||
|
||||
from . import main
|
||||
from .helpers import worklog_headers, make_csv
|
||||
from .. import db
|
||||
from ..models import WorkLog, User, Inventory, WorkNote
|
||||
from ..utils.load import eager_load_worklog_relationships, eager_load_user_relationships, eager_load_inventory_relationships
|
||||
|
||||
@main.route("/worklog")
|
||||
def list_worklog():
|
||||
query = eager_load_worklog_relationships(db.session.query(WorkLog))
|
||||
return render_template(
|
||||
'table.html',
|
||||
header=worklog_headers,
|
||||
model_name='worklog',
|
||||
title="Work Log",
|
||||
fields = ['contact.identifier', 'work_item.identifier', 'start_time', 'end_time', 'complete', 'followup', 'analysis'],
|
||||
entry_route='worklog_item',
|
||||
csv_route='worklog'
|
||||
)
|
||||
|
||||
@main.route("/worklog/<id>")
|
||||
def worklog_item(id):
|
||||
try:
|
||||
id = int(id)
|
||||
except ValueError:
|
||||
return render_template('error.html', title='Bad ID', message='ID must be an integer.', endpoint='worklog_item', endpoint_args={'id': -1})
|
||||
|
||||
log = eager_load_worklog_relationships(db.session.query(WorkLog)).get(id)
|
||||
user_query = db.session.query(User).order_by(User.first_name)
|
||||
users = eager_load_user_relationships(user_query).all()
|
||||
item_query = db.session.query(Inventory)
|
||||
items = eager_load_inventory_relationships(item_query).all()
|
||||
items = sorted(items, key=lambda i: i.identifier)
|
||||
|
||||
if log:
|
||||
title = f'Work Log - Entry #{id}'
|
||||
else:
|
||||
title = "Work Log - Entry Not Found"
|
||||
return render_template(
|
||||
'error.html',
|
||||
title=title,
|
||||
message=f"The work log with ID {id} is not found!"
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"worklog.html",
|
||||
title=title,
|
||||
log=log,
|
||||
users=users,
|
||||
items=items
|
||||
)
|
||||
|
||||
@main.route("/worklog_item/new", methods=["GET"])
|
||||
def new_worklog():
|
||||
items = eager_load_inventory_relationships(db.session.query(Inventory)).all()
|
||||
users = eager_load_user_relationships(db.session.query(User).order_by(User.first_name)).all()
|
||||
|
||||
items = sorted(items, key=lambda i: i.identifier)
|
||||
|
||||
log = WorkLog(
|
||||
start_time=datetime.datetime.now(),
|
||||
followup=True
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"worklog.html",
|
||||
title="New Entry",
|
||||
log=log,
|
||||
users=users,
|
||||
items=items
|
||||
)
|
||||
|
||||
@main.route("/api/worklog", methods=["POST"])
|
||||
def create_worklog():
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
|
||||
new_worklog = WorkLog.from_dict(data)
|
||||
|
||||
db.session.add(new_worklog)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True, "id": new_worklog.id}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
@main.route("/api/worklog/<int:id>", methods=["PUT"])
|
||||
def update_worklog(id):
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
log = eager_load_worklog_relationships(db.session.query(WorkLog)).get(id)
|
||||
|
||||
if not log:
|
||||
return jsonify({"success": False, "error": f"Work Log with ID {id} not found."}), 404
|
||||
|
||||
log.start_time = datetime.datetime.fromisoformat(data.get("start_time")) if data.get("start_time") else log.start_time
|
||||
log.end_time = datetime.datetime.fromisoformat(data.get("end_time")) if data.get("end_time") else log.end_time
|
||||
log.complete = bool(data.get("complete", log.complete))
|
||||
log.followup = bool(data.get("followup", log.followup))
|
||||
log.analysis = bool(data.get("analysis", log.analysis))
|
||||
log.contact_id = data.get("contact_id", log.contact_id)
|
||||
log.work_item_id = data.get("work_item_id", log.work_item_id)
|
||||
existing = {str(note.id): note for note in log.updates}
|
||||
incoming = data.get("updates", [])
|
||||
new_updates = []
|
||||
|
||||
for note_data in incoming:
|
||||
if isinstance(note_data, dict):
|
||||
if "id" in note_data and str(note_data["id"]) in existing:
|
||||
note = existing[str(note_data["id"])]
|
||||
note.content = note_data.get("content", note.content)
|
||||
new_updates.append(note)
|
||||
elif "content" in note_data:
|
||||
new_updates.append(WorkNote(content=note_data["content"]))
|
||||
|
||||
log.updates[:] = new_updates # This replaces in-place
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True, "id": log.id}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
@main.route("/api/worklog/<int:id>", methods=["DELETE"])
|
||||
def delete_worklog(id):
|
||||
try:
|
||||
log = db.session.query(WorkLog).get(id)
|
||||
|
||||
if not log:
|
||||
return jsonify({"success": False, "errpr": f"Item with ID {id} not found!"}), 404
|
||||
|
||||
db.session.delete(log)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
@main.route("/api/worklog/export", methods=["POST"])
|
||||
def get_worklog_csv():
|
||||
def export_value(log, col):
|
||||
try:
|
||||
match col:
|
||||
case "contact":
|
||||
return log.contact.identifier
|
||||
case "work_item":
|
||||
return log.work_item.identifier
|
||||
case "latest_update":
|
||||
if log.updates:
|
||||
return log.updates[-1].content
|
||||
return ""
|
||||
case _:
|
||||
return getattr(log, col, "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
data = request.get_json()
|
||||
ids = data.get('ids', [])
|
||||
|
||||
if not ids:
|
||||
return jsonify({"success": False, "error": "No IDs provided"}), 400
|
||||
|
||||
rows = eager_load_worklog_relationships(db.session.query(WorkLog).filter(WorkLog.id.in_(ids))).all()
|
||||
|
||||
columns = [
|
||||
"id",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"complete",
|
||||
"followup",
|
||||
"contact",
|
||||
"work_item",
|
||||
"analysis",
|
||||
"latest_update"
|
||||
]
|
||||
|
||||
return make_csv(export_value, columns, rows)
|
||||
|
||||
# return jsonify({
|
||||
# "success": True,
|
||||
# "csv": base64.b64encode(csv_string.encode()).decode(),
|
||||
# "count": len(rows)
|
||||
# })
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
.combo-box-widget .form-control:focus,
|
||||
.combo-box-widget .form-select:focus,
|
||||
.combo-box-widget .btn:focus {
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
border-color: #ced4da !important; /* Bootstrap’s default neutral border */
|
||||
background-color: inherit; /* Or explicitly #fff if needed */
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.combo-box-widget .btn-primary:focus,
|
||||
.combo-box-widget .btn-danger:focus {
|
||||
background-color: inherit; /* Keep button from darkening */
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.combo-box-widget:focus-within {
|
||||
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
.dropdown-search-input:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
function ComboBox(cfg) {
|
||||
return {
|
||||
id: cfg.id,
|
||||
createUrl: cfg.createUrl,
|
||||
editUrl: cfg.editUrl,
|
||||
deleteUrl: cfg.deleteUrl,
|
||||
refreshUrl: cfg.refreshUrl,
|
||||
|
||||
query: '',
|
||||
isEditing: false,
|
||||
editingOption: null,
|
||||
selectedIds: [],
|
||||
|
||||
get hasSelection() { return this.selectedIds.length > 0 },
|
||||
|
||||
onListChange() {
|
||||
const sel = Array.from(this.$refs.list.selectedOptions);
|
||||
this.selectedIds = sel.map(o => o.value);
|
||||
|
||||
if (sel.length === 1) {
|
||||
this.query = sel[0].textContent.trim();
|
||||
this.isEditing = true;
|
||||
this.editingOption = sel[0];
|
||||
} else {
|
||||
this.cancelEdit();
|
||||
}
|
||||
},
|
||||
|
||||
cancelEdit() { this.isEditing = false; this.editingOption = null; },
|
||||
|
||||
async submitAddOrEdit() {
|
||||
const name = (this.query || '').trim();
|
||||
if (!name) return;
|
||||
|
||||
if (this.isEditing && this.editingOption && this.editUrl) {
|
||||
const id = this.editingOption.value;
|
||||
const ok = await this._post(this.editUrl, { id, name });
|
||||
if (ok) this.editingOption.textContent = name;
|
||||
|
||||
this.$dispatch('combobox:item-edited', { id, name, ...this.editingOption.dataset });
|
||||
} else if (this.createUrl) {
|
||||
const data = await this._post(this.createUrl, { name }, true);
|
||||
const id = (data && data.id) ? data.id : ('temp-' + Math.random().toString(36).slice(2));
|
||||
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id; opt.textContent = data?.name || name;
|
||||
this.$refs.list.appendChild(opt);
|
||||
this._sortOptions();
|
||||
|
||||
this.$dispatch('combobox:item-created', { id, name: data?.name || name });
|
||||
}
|
||||
|
||||
this.query = '';
|
||||
this.cancelEdit();
|
||||
this._maybeRefresh();
|
||||
},
|
||||
|
||||
async removeSelected() {
|
||||
const ids = [...this.selectedIds];
|
||||
if (!ids.length) return;
|
||||
if (!confirm(`Delete ${ids.length} item(s)?`)) return;
|
||||
|
||||
let ok = true;
|
||||
if (this.deleteUrl) ok = !!(await this._post(this.deleteUrl, { ids }));
|
||||
if (!ok) return;
|
||||
|
||||
// Remove matching options from DOM
|
||||
const all = Array.from(this.$refs.list.options);
|
||||
all.forEach(o => { if (ids.includes(o.value)) o.remove(); });
|
||||
|
||||
// Clear selection reactively
|
||||
this.selectedIds = [];
|
||||
this.query = '';
|
||||
this.cancelEdit();
|
||||
this._maybeRefresh();
|
||||
},
|
||||
|
||||
_sortOptions() {
|
||||
const list = this.$refs.list;
|
||||
const sorted = Array.from(list.options).sort((a, b) => a.text.localeCompare(b.text));
|
||||
list.innerHTML = ''; sorted.forEach(o => list.appendChild(o));
|
||||
},
|
||||
|
||||
_maybeRefresh() { if (this.refreshUrl) this.$dispatch('combobox:refresh'); },
|
||||
|
||||
async _post(url, payload, expectJson = false) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => 'Error');
|
||||
alert(msg);
|
||||
return false;
|
||||
}
|
||||
if (expectJson) {
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) return await res.json();
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
alert('Network error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
async function export_csv(ids, csv_route, filename=`${csv_route}_export.csv`) {
|
||||
const payload = ids;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/${csv_route}/export`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const decodedCsv = atob(result.csv);
|
||||
const blob = new Blob([decodedCsv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
Toast.renderToast({ message: `Export failed: ${result.error}`, type: 'danger' });
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.renderToast({ message: `Export failed: ${err}`, type: 'danger' });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
function DropDown(cfg) {
|
||||
return {
|
||||
id: cfg.id,
|
||||
refreshUrl: cfg.refreshUrl,
|
||||
selectUrl: cfg.selectUrl,
|
||||
recordId: cfg.recordId, // NEW
|
||||
field: cfg.field, // NEW
|
||||
|
||||
selectedId: null,
|
||||
selectedLabel: '',
|
||||
|
||||
init() {
|
||||
const v = this.$refs.hidden?.value || '';
|
||||
if (v) {
|
||||
this.selectedId = v;
|
||||
this.$refs.clear?.classList.remove('d-none');
|
||||
}
|
||||
|
||||
this.$refs.button.addEventListener('shown.bs.dropdown', (e) => this.onShown(e));
|
||||
},
|
||||
|
||||
itemSelect(e) {
|
||||
const a = e.currentTarget;
|
||||
const id = a.dataset.invValue || a.getAttribute('data-inv-value');
|
||||
const label = a.textContent.trim();
|
||||
|
||||
const hidden = this.$refs.hidden;
|
||||
const button = this.$refs.button;
|
||||
const clear = this.$refs.clear;
|
||||
|
||||
this.selectedId = id;
|
||||
this.selectedLabel = label;
|
||||
if (hidden) hidden.value = id;
|
||||
|
||||
if (button) {
|
||||
button.textContent = label || '-';
|
||||
button.dataset.invValue = id;
|
||||
button.classList.add("rounded-end-0", "border-end-0");
|
||||
button.classList.remove("rounded-end");
|
||||
}
|
||||
|
||||
clear?.classList.toggle('d-none', !id);
|
||||
|
||||
if (this.selectUrl && this.recordId && this.field) {
|
||||
const payload = { id: this.recordId };
|
||||
payload[this.field] = id ? parseInt(id) : null;
|
||||
|
||||
fetch(this.selectUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => { });
|
||||
}
|
||||
},
|
||||
|
||||
clearSelection() {
|
||||
const hidden = this.$refs.hidden;
|
||||
const button = this.$refs.button;
|
||||
const clear = this.$refs.clear;
|
||||
|
||||
this.selectedId = '';
|
||||
this.selectedLabel = '';
|
||||
|
||||
if (hidden) hidden.value = '';
|
||||
if (button) {
|
||||
button.textContent = '-';
|
||||
button.removeAttribute('data-inv-value');
|
||||
button.classList.remove("rounded-end-0", "border-end-0");
|
||||
button.classList.add("rounded-end");
|
||||
}
|
||||
clear?.classList.add('d-none');
|
||||
|
||||
if (this.selectUrl && this.recordId && this.field) {
|
||||
const payload = { id: this.recordId };
|
||||
payload[this.field] = null;
|
||||
fetch(this.selectUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => { });
|
||||
}
|
||||
|
||||
this.$dispatch('dropdown:cleared', {});
|
||||
},
|
||||
|
||||
onShown() {
|
||||
const { menu, search, content } = this.$refs || {};
|
||||
if (!menu || !search || !content) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const viewportH = window.innerHeight;
|
||||
const menuTop = menu.getBoundingClientRect().top;
|
||||
|
||||
const capByViewport = viewportH * 0.40;
|
||||
const spaceBelow = viewportH - menuTop - 12;
|
||||
const menuCap = Math.max(0, Math.min(capByViewport, spaceBelow));
|
||||
|
||||
const inputH = search.offsetHeight || 0;
|
||||
const contentMax = Math.max(0, menuCap - inputH);
|
||||
|
||||
content.style.maxHeight = `${contentMax - 2}px`;
|
||||
|
||||
requestAnimationFrame(() => search.focus());
|
||||
});
|
||||
},
|
||||
|
||||
filterItems() {
|
||||
const search = this.$refs.search;
|
||||
const dropdown = this.$refs.dropdown;
|
||||
const filter = search.value.toLowerCase().trim();
|
||||
const items = dropdown.querySelectorAll('.dropdown-item');
|
||||
|
||||
items.forEach(item => {
|
||||
const text = item.textContent.toLowerCase();
|
||||
if (text.includes(filter)) {
|
||||
item.classList.remove('d-none');
|
||||
} else {
|
||||
item.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
function Editor(cfg) {
|
||||
return {
|
||||
id: cfg.id,
|
||||
refreshUrl: cfg.refreshUrl,
|
||||
updateUrl: cfg.updateUrl,
|
||||
createUrl: cfg.createUrl,
|
||||
deleteUrl: cfg.deleteUrl,
|
||||
fieldName: cfg.fieldName,
|
||||
recordId: cfg.recordId,
|
||||
|
||||
init() {
|
||||
this.renderViewer();
|
||||
if (this.refreshUrl) this.refresh();
|
||||
},
|
||||
|
||||
buildRefreshUrl() {
|
||||
if (!this.refreshUrl) return null;
|
||||
const u = new URL(this.refreshUrl, window.location.origin);
|
||||
u.search = new URLSearchParams({ field: this.fieldName, id: this.recordId }).toString();
|
||||
return u.toString();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
const url = this.buildRefreshUrl();
|
||||
if (!url) return;
|
||||
const res = await fetch(url, { headers: { 'HX-Request': 'true' } });
|
||||
const text = await res.text();
|
||||
if (this.$refs.editor) {
|
||||
this.$refs.editor.value = text;
|
||||
this.resizeEditor();
|
||||
this.renderViewer();
|
||||
}
|
||||
},
|
||||
|
||||
triggerRefresh() {
|
||||
this.$refs.container?.dispatchEvent(new CustomEvent('editor:refresh', { bubbles: true }));
|
||||
},
|
||||
|
||||
openEditTab() {
|
||||
this.$nextTick(() => { this.resizeEditor(); this.renderViewer(); });
|
||||
},
|
||||
|
||||
resizeEditor() {
|
||||
const ta = this.$refs.editor;
|
||||
if (!ta) return;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = `${ta.scrollHeight + 2}px`;
|
||||
},
|
||||
|
||||
renderViewer() {
|
||||
const ta = this.$refs.editor, viewer = this.$refs.viewer;
|
||||
if (!viewer || !ta) return;
|
||||
const raw = ta.value || '';
|
||||
viewer.innerHTML = (window.marked ? marked.parse(raw) : raw);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let tempIdCounter = 1;
|
||||
|
||||
function createEditorWidget(template, id, timestamp, content = '') {
|
||||
let html = template.innerHTML
|
||||
.replace(/__ID__/g, id)
|
||||
.replace(/__TIMESTAMP__/g, timestamp)
|
||||
.replace(/__CONTENT__/g, content);
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html;
|
||||
|
||||
return wrapper.firstElementChild;
|
||||
}
|
||||
|
||||
function createTempId(prefix = "temp") {
|
||||
return `${prefix}-${tempIdCounter++}`;
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
const ImageWidget = (() => {
|
||||
function submitImageUpload(id) {
|
||||
const form = document.getElementById(`image-upload-form-${id}`);
|
||||
const formData = new FormData(form);
|
||||
|
||||
fetch("/api/images", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
}).then(async response => {
|
||||
if (!response.ok) {
|
||||
// Try to parse JSON, fallback to text
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
let errorDetails;
|
||||
if (contentType.includes("application/json")) {
|
||||
errorDetails = await response.json();
|
||||
} else {
|
||||
errorDetails = { error: await response.text() };
|
||||
}
|
||||
throw errorDetails;
|
||||
}
|
||||
return response.json();
|
||||
}).then(data => {
|
||||
Toast.renderToast({ message: `Image uploaded.`, type: "success" });
|
||||
location.reload();
|
||||
}).catch(err => {
|
||||
const msg = typeof err === "object" && err.error ? err.error : err.toString();
|
||||
Toast.renderToast({ message: `Upload failed: ${msg}`, type: "danger" });
|
||||
});
|
||||
}
|
||||
|
||||
function deleteImage(inventoryId, imageId) {
|
||||
if (!confirm("Are you sure you want to delete this image?")) return;
|
||||
|
||||
fetch(`/api/images/${imageId}`, {
|
||||
method: "DELETE"
|
||||
}).then(response => response.json()).then(data => {
|
||||
if (data.success) {
|
||||
Toast.renderToast({ message: "Image deleted.", type: "success" });
|
||||
location.reload(); // Update view
|
||||
} else {
|
||||
Toast.renderToast({ message: `Failed to delete: ${data.error}`, type: "danger" });
|
||||
}
|
||||
}).catch(err => {
|
||||
Toast.renderToast({ message: `Error deleting image: ${err}`, type: "danger" });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
submitImageUpload,
|
||||
deleteImage
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
function Label(cfg) {
|
||||
return {
|
||||
id: cfg.id,
|
||||
refreshUrl: cfg.refreshUrl,
|
||||
fieldName: cfg.fieldName,
|
||||
recordId: cfg.recordId,
|
||||
|
||||
init() {
|
||||
if (this.refreshUrl) this.refresh();
|
||||
},
|
||||
|
||||
buildRefreshUrl() {
|
||||
if (!this.refreshUrl) return null;
|
||||
const u = new URL(this.refreshUrl, window.location.origin);
|
||||
u.search = new URLSearchParams({ field: this.fieldName, id: this.recordId }).toString();
|
||||
return u.toString();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
const url = this.buildRefreshUrl();
|
||||
if (!url) return;
|
||||
const res = await fetch(url, { headers: { 'HX-Request': 'true' } });
|
||||
const text = await res.text();
|
||||
if (this.$refs.label) {
|
||||
this.$refs.label.innerHTML = text;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
function Table(cfg) {
|
||||
return {
|
||||
id: cfg.id,
|
||||
refreshUrl: cfg.refreshUrl,
|
||||
headers: cfg.headers || [],
|
||||
fields: cfg.fields || [],
|
||||
// external API
|
||||
perPage: cfg.perPage || 10,
|
||||
offset: cfg.offset || 0,
|
||||
|
||||
// derived + server-fed state
|
||||
page: Math.floor((cfg.offset || 0) / (cfg.perPage || 10)) + 1,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
|
||||
init() {
|
||||
if (this.refreshUrl) this.refresh();
|
||||
},
|
||||
|
||||
buildRefreshUrl() {
|
||||
if (!this.refreshUrl) return null;
|
||||
const u = new URL(this.refreshUrl, window.location.origin);
|
||||
|
||||
// We want server-side pagination with page/per_page
|
||||
u.searchParams.set('view', 'table');
|
||||
u.searchParams.set('page', this.page);
|
||||
u.searchParams.set('per_page', this.perPage);
|
||||
|
||||
// Send requested fields in the way your backend expects
|
||||
// If your route supports &field=... repeaters, do this:
|
||||
this.fields.forEach(f => u.searchParams.append('field', f));
|
||||
// If your route only supports "fields=a,b,c", then use:
|
||||
// if (this.fields.length) u.searchParams.set('fields', this.fields.join(','));
|
||||
|
||||
return u.toString();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
const url = this.buildRefreshUrl();
|
||||
if (!url) return;
|
||||
const res = await fetch(url, { headers: { 'X-Requested-With': 'fetch' } });
|
||||
const html = await res.text();
|
||||
|
||||
// Dump the server-rendered <tr> rows into the tbody
|
||||
if (this.$refs.body) this.$refs.body.innerHTML = html;
|
||||
|
||||
// Read pagination metadata from headers
|
||||
const toInt = (v, d=0) => {
|
||||
const n = parseInt(v ?? '', 10);
|
||||
return Number.isFinite(n) ? n : d;
|
||||
};
|
||||
|
||||
const total = toInt(res.headers.get('X-Total'));
|
||||
const pages = toInt(res.headers.get('X-Pages'));
|
||||
const page = toInt(res.headers.get('X-Page'), this.page);
|
||||
const per = toInt(res.headers.get('X-Per-Page'), this.perPage);
|
||||
|
||||
// Update local state
|
||||
this.total = total;
|
||||
this.pages = pages;
|
||||
this.page = page;
|
||||
this.perPage = per;
|
||||
this.offset = (this.page - 1) * this.perPage;
|
||||
|
||||
// Update pager UI (if you put <ul x-ref="pagination"> in your caption)
|
||||
this.buildPager();
|
||||
// Caption numbers are bound via x-text so they auto-update.
|
||||
},
|
||||
|
||||
buildPager() {
|
||||
const ul = this.$refs.pagination;
|
||||
if (!ul) return;
|
||||
ul.innerHTML = '';
|
||||
|
||||
const mk = (label, page, disabled=false, active=false) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = `page-item${disabled ? ' disabled' : ''}${active ? ' active' : ''}`;
|
||||
const a = document.createElement('a');
|
||||
a.className = 'page-link';
|
||||
a.href = '#';
|
||||
a.textContent = label;
|
||||
a.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
if (disabled || active) return;
|
||||
this.page = page;
|
||||
this.refresh();
|
||||
};
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
};
|
||||
|
||||
// Prev
|
||||
ul.appendChild(mk('«', Math.max(1, this.page - 1), this.page <= 1));
|
||||
|
||||
// Windowed page buttons
|
||||
const maxButtons = 7;
|
||||
let start = Math.max(1, this.page - Math.floor(maxButtons/2));
|
||||
let end = Math.min(this.pages || 1, start + maxButtons - 1);
|
||||
start = Math.max(1, Math.min(start, Math.max(1, end - maxButtons + 1)));
|
||||
|
||||
if (start > 1) ul.appendChild(mk('1', 1));
|
||||
if (start > 2) ul.appendChild(mk('…', this.page, true));
|
||||
|
||||
for (let p = start; p <= end; p++) {
|
||||
ul.appendChild(mk(String(p), p, false, p === this.page));
|
||||
}
|
||||
|
||||
if (end < (this.pages || 1) - 1) ul.appendChild(mk('…', this.page, true));
|
||||
if (end < (this.pages || 1)) ul.appendChild(mk(String(this.pages), this.pages));
|
||||
|
||||
// Next
|
||||
ul.appendChild(mk('»', Math.min(this.pages || 1, this.page + 1), this.page >= (this.pages || 1)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const toastData = localStorage.getItem("toastMessage");
|
||||
if (toastData) {
|
||||
const { message, type } = JSON.parse(toastData);
|
||||
Toast.renderToast({ message, type });
|
||||
localStorage.removeItem("toastMessage");
|
||||
}
|
||||
});
|
||||
|
||||
const Toast = (() => {
|
||||
const ToastConfig = {
|
||||
containerId: 'toast-container',
|
||||
positionClasses: 'toast-container position-fixed bottom-0 end-0 p-3',
|
||||
defaultType: 'info',
|
||||
defaultTimeout: 3000
|
||||
};
|
||||
|
||||
function updateToastConfig(overrides = {}) {
|
||||
Object.assign(ToastConfig, overrides);
|
||||
}
|
||||
|
||||
function renderToast({ message, type = ToastConfig.defaultType, timeout = ToastConfig.defaultTimeout }) {
|
||||
if (!message) {
|
||||
console.warn('renderToast was called without a message.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-create the toast container if it doesn't exist
|
||||
let container = document.getElementById(ToastConfig.containerId);
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = ToastConfig.containerId;
|
||||
container.className = ToastConfig.positionClasses;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toastId = `toast-${Date.now()}`;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = `
|
||||
<div id="${toastId}" class="toast align-items-center text-bg-${type}" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const toastElement = wrapper.firstElementChild;
|
||||
container.appendChild(toastElement);
|
||||
|
||||
const toast = new bootstrap.Toast(toastElement, { delay: timeout });
|
||||
toast.show();
|
||||
|
||||
toastElement.addEventListener('hidden.bs.toast', () => {
|
||||
toastElement.remove();
|
||||
|
||||
// Clean up container if empty
|
||||
if (container.children.length === 0) {
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
updateToastConfig,
|
||||
renderToast
|
||||
};
|
||||
})();
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
{% extends 'layout.html' %}
|
||||
|
||||
{% block style %}
|
||||
.light {
|
||||
transition: background-color .25s;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block precontent %}
|
||||
{{ toolbars.render_toolbar(
|
||||
id = 'score',
|
||||
left = '<span id="best_score">Loading...</span>' | safe,
|
||||
right= ('<span id="current_score">Score: ' + score|string + '</span>') | safe
|
||||
) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container border border-black bg-success-subtle">
|
||||
{% for x in range(level + 3) %}
|
||||
<div class="row" style="min-height: {{ 80 / (level + 3) }}vh;">
|
||||
{% for y in range(level + 3) %}
|
||||
<div class="col m-0 p-0 align-items-center d-flex justify-content-center outline outline-{% if (x, y) in clicked and false %}danger border-2{% else %}black{% endif %} light" id="light-{{ x }}-{{ y }}">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input d-none" id="checkbox-{{ x }}-{{ y }}"{% if matrix[x][y] %} checked{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
var score = {{ score }};
|
||||
var initialScore = score;
|
||||
const gridSize = {{ level + 3 }};
|
||||
var clickOrder = {};
|
||||
var clickCounter = 0;
|
||||
|
||||
updateLights();
|
||||
|
||||
{{ clicked | tojson }}.forEach(([x, y]) => {
|
||||
clickCounter++;
|
||||
const key = `${x}-${y}`;
|
||||
(clickOrder[key] ??= []).push(clickCounter);
|
||||
});
|
||||
|
||||
let bestScore = Object.values(clickOrder)
|
||||
.reduce((n, arr) => n + (arr.length & 1), 0);
|
||||
|
||||
document.getElementById('best_score').textContent = `Perfect Clicks: ${bestScore}`;
|
||||
|
||||
Object.entries(clickOrder).forEach(([key, value]) => {
|
||||
const light = document.querySelector(`#light-${key}`);
|
||||
// light.innerHTML += value;
|
||||
});
|
||||
|
||||
function updateLights() {
|
||||
document.querySelectorAll('.light').forEach(light => {
|
||||
const [x, y] = light.id.split('-').slice(1).map(Number);
|
||||
const checkbox = document.querySelector(`#checkbox-${x}-${y}`);
|
||||
|
||||
if(checkbox.checked) {
|
||||
light.classList.add('bg-danger-subtle');
|
||||
light.classList.remove('bg-light-subtle');
|
||||
} else {
|
||||
light.classList.remove('bg-danger-subtle');
|
||||
light.classList.add('bg-light-subtle');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.light').forEach(light => {
|
||||
light.addEventListener('click', function() {
|
||||
const [x, y] = this.id.split('-').slice(1).map(Number);
|
||||
const checkbox = document.querySelector(`#checkbox-${x}-${y}`);
|
||||
++score;
|
||||
document.getElementById('current_score').textContent = `Score: ${score}`;
|
||||
|
||||
// Toggle manually
|
||||
checkbox.checked = !checkbox.checked;
|
||||
// Fire a non-bubbling change so it won't climb back to .light
|
||||
checkbox.dispatchEvent(new Event('change'));
|
||||
updateLights();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.form-check-input').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
const [x, y] = this.id.split('-').slice(1).map(Number);
|
||||
const neighbors = [
|
||||
[x - 1, y],
|
||||
[x + 1, y],
|
||||
[x, y - 1],
|
||||
[x, y + 1]
|
||||
];
|
||||
|
||||
neighbors.forEach(([nx, ny]) => {
|
||||
if (nx < 0 || nx >= gridSize || ny < 0 || ny >= gridSize) return; // Skip out of bounds
|
||||
|
||||
const neighborCheckbox = document.querySelector(`#checkbox-${nx}-${ny}`);
|
||||
neighborCheckbox.checked = !neighborCheckbox.checked;
|
||||
});
|
||||
|
||||
// Check if all checkboxes are checked
|
||||
const allChecked = Array.from(document.querySelectorAll('.form-check-input')).every(cb => cb.checked);
|
||||
const allUnchecked = Array.from(document.querySelectorAll('.form-check-input')).every(cb => !cb.checked);
|
||||
if (allChecked && !window.__alreadyNavigated && {{ level }} < 51) {
|
||||
window.__alreadyNavigated = true;
|
||||
if ((score - bestScore) == initialScore) {
|
||||
bestScore *= 2;
|
||||
}
|
||||
location.href = `{{ url_for('main.coffee', level=level + 1) | safe }}&score=${Math.max(score - bestScore, 0)}`;
|
||||
} else if (allUnchecked && !window.__alreadyNavigated && {{ level }} > -2) {
|
||||
window.__alreadyNavigated = true;
|
||||
location.href = `{{ url_for('main.coffee', level=level - 1) | safe }}&score=${score + bestScore}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
{% endblock %}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{% extends 'layout.html' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert-danger text-center">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{% import "fragments/_icon_fragment.html" as icons %}
|
||||
|
||||
{% macro render_breadcrumb(breadcrumbs=[]) %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb m-0">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}" class="link-success link-underline-opacity-0">{{ icons.render_icon('house', 16) }}</a></li>
|
||||
{% for crumb in breadcrumbs %}
|
||||
<li class="breadcrumb-item{% if loop.last %} active{% endif %}" {% if loop.last %}aria-current="page"{% endif %}>
|
||||
{% if not loop.last %}
|
||||
<a href="{{ crumb.url }}" class="link-success link-underline-opacity-0">{{ crumb.label }}</a>
|
||||
{% else %}
|
||||
{{ crumb.label }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{% import "fragments/_icon_fragment.html" as icons %}
|
||||
|
||||
{% macro render_button(id, icon, style='primary', logic = None, label = None, enabled = True) %}
|
||||
<!-- Button Fragment -->
|
||||
<button type="button" class="btn btn-{{ style }}" id="{{ id }}Button"{% if not enabled %} disabled{% endif %}>{{ icons.render_icon(icon, 16) }}{{ label if label
|
||||
else '' }}</button>
|
||||
{% if logic %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("{{ id }}Button").addEventListener("click", async (e) => {
|
||||
{{ logic | safe }}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
{% import "fragments/_icon_fragment.html" as icons %}
|
||||
|
||||
{% macro render_combobox(
|
||||
id, options, label = none, placeholder = none, data_attributes = none,
|
||||
create_url = none, edit_url = none, delete_url = none, refresh_url = none
|
||||
) %}
|
||||
{% if label %}
|
||||
<label for="{{ id }}-input" class="form-label">{{ label }}</label>
|
||||
{% endif %}
|
||||
<div id="{{ id }}-container" x-data='ComboBox({
|
||||
id: {{ id|tojson }},
|
||||
createUrl: {{ create_url|tojson if create_url else "null" }},
|
||||
editUrl: {{ edit_url|tojson if edit_url else "null" }},
|
||||
deleteUrl: {{ delete_url|tojson if delete_url else "null" }},
|
||||
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }}
|
||||
})' hx-preserve class="combo-box-widget">
|
||||
<div class="input-group">
|
||||
<input type="text" id="{{ id }}-input" x-model.trim="query" @keydown.enter.prevent="submitAddOrEdit()"
|
||||
@keydown.escape="cancelEdit()" class="form-control rounded-bottom-0">
|
||||
|
||||
<button id="{{ id }}-add" :disabled="!query" @click="submitAddOrEdit()"
|
||||
class="btn btn-primary rounded-bottom-0">
|
||||
<i class="bi icon-state" :class="isEditing ? 'bi-pencil' : 'bi-plus-lg'"></i>
|
||||
</button>
|
||||
|
||||
<button id="{{ id }}-remove" :disabled="!hasSelection" @click="removeSelected()"
|
||||
class="btn btn-danger rounded-bottom-0">
|
||||
{{ icons.render_icon('dash-lg', 16) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select id="{{ id }}-list" multiple x-ref="list" @change="onListChange"
|
||||
class="form-select border-top-0 rounded-top-0" name="{{ id }}" size="10">
|
||||
{% for option in options if options %}
|
||||
<option value="{{ option.id }}">{{ option.name }}</option>
|
||||
{% endfor %}
|
||||
{% if not options and refresh_url %}
|
||||
<option disabled>Loading...</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
|
||||
{% if refresh_url %}
|
||||
{% set url = refresh_url ~ ('&' if '?' in refresh_url else '?') ~ 'view=option&limit=0&per_page=0' %}
|
||||
<div id="{{ id }}-htmx-refresh" class="d-none" hx-get="{{ url }}"
|
||||
hx-trigger="revealed, combobox:refresh from:#{{ id }}-container" hx-target="#{{ id }}-list" hx-swap="innerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
{% import "fragments/_icon_fragment.html" as icons %}
|
||||
{% import "fragments/_link_fragment.html" as links %}
|
||||
|
||||
{% macro render_dropdown(id, list = none, label = none, current_item = none, entry_link = none, enabled = true, refresh_url = none, select_url = none, record_id = none, field_name = none) %}
|
||||
<label for="{{ id }}" class="form-label">
|
||||
{{ label or '' }}
|
||||
{% if entry_link %}
|
||||
{{ links.entry_link(entry_link, current_item.id) }}
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="dropdown" id="{{ id }}-dropdown" x-data='DropDown({
|
||||
id: {{ id|tojson }},
|
||||
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }},
|
||||
selectUrl: {{ select_url|tojson if select_url else "null" }},
|
||||
recordId: {{ record_id|tojson if record_id else "null" }},
|
||||
field: {{ field_name|tojson if field_name else "null" }}
|
||||
})'
|
||||
hx-preserve x-init="init()" x-ref="dropdown">
|
||||
<div class="btn-group w-100">
|
||||
<button
|
||||
class="btn btn-outline-dark dropdown-toggle overflow-x-hidden w-100 rounded-end{% if current_item and enabled %}-0 border-end-0{% endif %} dropdown-button"
|
||||
type="button" data-bs-toggle="dropdown" data-inv-value="{{ current_item.id if current_item else '' }}"
|
||||
id="{{ id }}Button" {% if not enabled %} disabled{% endif %}
|
||||
style="border-color: rgb(222, 226, 230);{% if not enabled %} background-color: rgb(233, 236, 239); color: rgb(0, 0, 0);{% endif %}"
|
||||
x-ref="button">
|
||||
{{ current_item.identifier if current_item else '-' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-danger rounded-end font-weight-bold border-start-0{% if not current_item or not enabled %} d-none{% endif %}"
|
||||
type="button" id="{{ id }}ClearButton"
|
||||
style="z-index: 9999; border-color: rgb(222, 226, 230);{% if not enabled %} background-color: rgb(233, 236, 239); color: rgb(0, 0, 0);{% endif %}"
|
||||
x-ref="clear"
|
||||
@click="clearSelection">
|
||||
{{ icons.render_icon('x-lg', 16) }}
|
||||
</button>
|
||||
<input type="hidden" name="{{ id }}" id="{{ id }}" value="{{ current_item.id if current_item else '' }}" x-ref="hidden">
|
||||
<ul class="dropdown-menu w-100 pt-0" style="max-height: 40vh; z-index: 9999;" id="menu{{ id }}" x-ref="menu">
|
||||
<input type="text"
|
||||
class="form-control rounded-bottom-0 border-start-0 border-top-0 border-end-0 dropdown-search-input"
|
||||
id="search{{ id }}" placeholder="Search..." x-ref="search" @input="filterItems()">
|
||||
<div class="overflow-auto overflow-x-hidden" style="z-index: 9999;" id="{{ id }}DropdownContent" x-ref="content">
|
||||
{% if list %}
|
||||
{% for item in list %}
|
||||
<li><a class="dropdown-item" data-inv-value="{{ item.id }}">{{
|
||||
item.identifier }}</a></li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
{% if refresh_url %}
|
||||
{% set url = refresh_url ~ ('&' if '?' in refresh_url else '?') ~ 'view=list&limit=0&per_page=0' %}
|
||||
<div id="{{ id }}-htmx-refresh" class="d-none" hx-get="{{ url }}"
|
||||
hx-trigger="revealed, combobox:refresh from:#{{ id }}-dropdown" hx-target="#{{ id }}DropdownContent" hx-swap="innerHTML"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
{% import "fragments/_icon_fragment.html" as icons %}
|
||||
|
||||
{% macro render_editor(id, title, mode='edit', content=none, enabled=true, create_url=none, refresh_url=none,
|
||||
update_url=none, delete_url=none, field_name=none, record_id=none) %}
|
||||
<!-- Editor Fragment -->
|
||||
<div class="row mb-3" id="editor-container-{{ id }}" x-data='Editor({
|
||||
id: "{{ id }}",
|
||||
createUrl: {{ create_url|tojson if create_url else "null" }},
|
||||
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }},
|
||||
deleteUrl: {{ delete_url|tojson if delete_url else "null" }},
|
||||
updateUrl: {{ update_url|tojson if update_url else "null" }},
|
||||
fieldName: {{ field_name|tojson if field_name else "null" }},
|
||||
recordId: {{ record_id|tojson if record_id else "null" }}
|
||||
})' x-ref="container" hx-preserve @editor:refresh.window="refresh()">
|
||||
<div class="col">
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item">
|
||||
<span class="nav-link text-black">{{ title }}</span>
|
||||
</li>
|
||||
{% if enabled %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if mode == 'view' %} active{% endif %}" data-bs-toggle="tab"
|
||||
data-bs-target="#viewer{{ id }}">{{ icons.render_icon('file-earmark-richtext', 16) }}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if mode == 'edit' %} active{% endif %}" data-bs-toggle="tab"
|
||||
data-bs-target="#editor{{ id }}" id="editTab{{ id }}" @shown.bs.tab="openEditTab()">
|
||||
{{ icons.render_icon('pencil', 16) }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="tab-content" id="tabContent{{ id }}">
|
||||
<div class="tab-pane fade{% if mode == 'view' %} show active border border-top-0{% endif %} p-2 markdown-body viewer"
|
||||
id="viewer{{ id }}" x-ref="viewer"></div>
|
||||
<div class="tab-pane fade{% if mode == 'edit' %} show active border border-top-0{% endif %}"
|
||||
id="editor{{ id }}" @change="renderViewer()">
|
||||
<textarea x-ref="editor" id="textEditor{{ id }}" name="editor{{ id }}"
|
||||
class="form-control border-top-0 rounded-top-0{% if not enabled %} disabled{% endif %} editor"
|
||||
data-note-id="{{ id }}" @input="resizeEditor(); renderViewer()">{{ content if content }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
|
||||
{% macro render_icon(icon, size=24, extra_class='') %}
|
||||
<!-- Icon Fragment -->
|
||||
|
||||
<i class="bi bi-{{ icon }} {{ extra_class }}" style="font-size: {{ size }}px;"></i>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
{% import "fragments/_icon_fragment.html" as icons %}
|
||||
|
||||
{% macro render_image(id, image=None, enabled=True) %}
|
||||
<!-- Image fragment -->
|
||||
<div class="image-slot text-center">
|
||||
{% if image %}
|
||||
<img src="{{ url_for('static', filename=image.filename) }}" alt="Image of ID {{ id }}" class="img-thumbnail w-100"
|
||||
style="height: auto;" data-bs-toggle="modal" data-bs-target="#imageModal-{{ id }}">
|
||||
<div class="modal fade" id="imageModal-{{ id }}" tabindex="-1" style="z-index: 9999;">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body text-center">
|
||||
<img src="{{ url_for('static', filename=image.filename) }}" alt="Image of ID {{ id }}"
|
||||
class="img-fluid">
|
||||
</div>
|
||||
{% if enabled %}
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button class="btn btn-danger" onclick="ImageWidget.deleteImage('{{ id }}', '{{ image.id }}')">
|
||||
{{ icons.render_icon('trash') }}
|
||||
</button>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" id="image-upload-form-{{ id }}" class="d-none">
|
||||
<input type="file" id="image-upload-input-{{ id }}" name="file"
|
||||
onchange="ImageWidget.submitImageUpload('{{ id }}')">
|
||||
<input type="hidden" name="target_model" value="inventory">
|
||||
<input type="hidden" name="model_id" value="{{ id }}">
|
||||
<input type="hidden" name="caption" value="Uploaded via UI">
|
||||
</form>
|
||||
|
||||
<label class="btn btn-secondary mb0" for="image-upload-input-{{ id }}">
|
||||
{{ icons.render_icon('upload') }}
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if enabled %}
|
||||
<a href="#" class="link-secondary"
|
||||
onclick="document.getElementById('image-upload-input-{{ id }}').click(); return false;">
|
||||
{{ icons.render_icon('image', 256) }}
|
||||
</a>
|
||||
<form method="POST" enctype="multipart/form-data" id="image-upload-form-{{ id }}" class="d-none">
|
||||
<input type="file" id="image-upload-input-{{ id }}" name="file"
|
||||
onchange="ImageWidget.submitImageUpload('{{ id }}')">
|
||||
<input type="hidden" name="target_model" value="inventory">
|
||||
<input type="hidden" name="model_id" value="{{ id }}">
|
||||
<input type="hidden" name="caption" value="Uploaded via UI">
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{% macro render_label(id, text=none, refresh_url=none, field_name=none, record_id=none) %}
|
||||
<span id="label-{{ id }}" x-data='Label({
|
||||
id: "{{ id }}",
|
||||
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }},
|
||||
fieldName: {{ field_name|tojson if field_name else "null" }},
|
||||
recordId: {{ record_id|tojson if record_id else "null" }}
|
||||
})' x-ref="label" hx-preserve>{{ text if text else '' }}</span>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
{% import "fragments/_icon_fragment.html" as icons %}
|
||||
|
||||
{% macro category_link(endpoint, label, icon_html=none, arguments={}) %}
|
||||
<!-- Category Link Fragment -->
|
||||
|
||||
<div class="col text-center">
|
||||
<a href="{{ url_for('main.' + endpoint, **arguments) }}"
|
||||
class="d-flex flex-column justify-content-center link-success link-underline-opacity-0">
|
||||
{% if icon_html %}
|
||||
{{ icon_html | safe }}
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
</a>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro navigation_link(endpoint, label, icon_html=none, arguments={}, active=false) %}
|
||||
<!-- Navigation Link Fragment -->
|
||||
|
||||
{% if not active %}
|
||||
{% set active = request.endpoint == 'main.' + endpoint %}
|
||||
{% endif %}
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('main.' + endpoint, **arguments) }}" class="nav-link{% if active %} active{% endif %}">
|
||||
{% if icon_html %}
|
||||
{{ icon_html | safe }}
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
</a>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro entry_link(endpoint, id) %}
|
||||
<!-- Entry Link Fragment -->
|
||||
|
||||
<a href="{{ url_for('main.' + endpoint, id=id) }}" class="link-success link-underline-opacity-0">
|
||||
{{ icons.render_icon('link', 12) }}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro export_link(id, endpoint, ids) %}
|
||||
<!-- Export Link Fragment -->
|
||||
|
||||
<a class="link-success link-underline-opacity-0" onclick="export_csv({{ ids }}, '{{ endpoint }}', '{{ id }}_export');">{{ icons.render_icon('box-arrow-up', 12) }}</a>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<!-- List Fragment -->
|
||||
{% for it in options %}
|
||||
<li>
|
||||
<a class="dropdown-item" data-inv-value="{{ it.id }}"
|
||||
{% for k, v in it.items() if k not in ('id','name') and v is not none %}
|
||||
data-{{ k|e }}="{{ v|e }}"
|
||||
{% endfor %}
|
||||
@click.prevent='itemSelect($event)'>{{ it.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{# templates/fragments/_option_fragment.html #}
|
||||
{% for it in options %}
|
||||
<option value="{{ it.id }}"
|
||||
{% for k, v in it.items() if k not in ('id','name') and v is not none %}
|
||||
data-{{ k|e }}="{{ v|e }}"
|
||||
{% endfor %}>{{ it.name }}</option>
|
||||
{% endfor %}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<!-- fragments/_table_data_fragment.html -->
|
||||
{% for r in rows %}
|
||||
<tr style="cursor: pointer;" onclick="window.location='{{ url_for('main.' + model_name + '_item', id=r.id) }}'">
|
||||
{% for key, val in r.items() if not key == 'id' %}
|
||||
<td class="text-nowrap">{{ val if val else '-' }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
{% macro render_table(headers, rows, id, entry_route=None, title=None, per_page=15) %}
|
||||
<!-- Table Fragment -->
|
||||
|
||||
{% if rows %}
|
||||
{% if title %}
|
||||
<label for="datatable-{{ id|default('table')|replace(' ', '-')|lower }}" class="form-label">{{ title }}</label>
|
||||
{% endif %}
|
||||
<div class="table-responsive">
|
||||
<table id="datatable-{{ id|default('table')|replace(' ', '-')|lower }}"
|
||||
class="table table-bordered table-sm table-hover table-striped table-light m-0{% if title %} caption-top{% endif %}">
|
||||
<thead class="sticky-top">
|
||||
<tr>
|
||||
{% for h in headers %}
|
||||
<th class="text-nowrap">{{ 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 %}{% if row['highlight'] %} class="table-info" {% endif %}>
|
||||
{% for cell in row.cells %}
|
||||
<td class="text-nowrap{% if cell.type=='bool' %} text-center{% endif %}">
|
||||
{% if cell.type == 'bool' %}
|
||||
{{ cell.html | safe }}
|
||||
{% elif cell.url %}
|
||||
<a class="link-success link-underline-opacity-0" href="{{ cell.url }}">{{ cell.text }}</a>
|
||||
{% else %}
|
||||
{{ cell.text or '-' }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
new DataTable('#datatable-{{ id|default('table')|replace(' ', ' - ')|lower }}', {
|
||||
pageLength: {{ per_page }},
|
||||
scrollX: true,
|
||||
scrollY: '60vh',
|
||||
scrollCollapse: true,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="container text-center">No data.</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro dynamic_table(id, headers=none, fields=none, entry_route=None, title=None, per_page=15, offset=0,
|
||||
refresh_url=none) %}
|
||||
<!-- Table Fragment -->
|
||||
|
||||
{% if rows or refresh_url %}
|
||||
{% if title %}
|
||||
<label for="datatable-{{ id|default('table')|replace(' ', '-')|lower }}" class="form-label">{{ title }}</label>
|
||||
{% endif %}
|
||||
<div class="table-responsive" id="table-container-{{ id }}" x-data='Table({
|
||||
id: "{{ id }}",
|
||||
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }},
|
||||
headers: {{ headers|tojson if headers else "[]" }},
|
||||
perPage: {{ per_page }},
|
||||
offset: {{ offset if offset else 0 }},
|
||||
fields: {{ fields|tojson if fields else "[]" }}
|
||||
})'>
|
||||
<table id="datatable-{{ id|default('table')|replace(' ', '-')|lower }}"
|
||||
class="table table-bordered table-sm table-hover table-striped table-light m-0 caption-bottom">
|
||||
<caption class="p-0">
|
||||
<nav class="d-flex flex-column align-items-center px-2 py-1">
|
||||
<!-- This is your pagination control -->
|
||||
<ul class="pagination mb-0" x-ref="pagination"></ul>
|
||||
<!-- This is just Alpine text binding -->
|
||||
<div>
|
||||
Page <span x-text="page"></span> of <span x-text="pages"></span>
|
||||
(<span x-text="total"></span> total)
|
||||
</div>
|
||||
</nav>
|
||||
</caption>
|
||||
<thead class="sticky-top">
|
||||
<tr>
|
||||
{% for h in headers %}
|
||||
<th class="text-nowrap">{{ h }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody x-ref="body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="container text-center">No data.</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{% macro render_toolbar(id, left=None, center=None, right=None) %}
|
||||
<nav class="navbar navbar-expand bg-light-subtle border-bottom" id="toolbar-{{ id }}">
|
||||
<div class="d-flex justify-content-between container-fluid">
|
||||
<div>{{ left if left }}</div>
|
||||
<div>{{ center if center }}</div>
|
||||
<div>{{ right if right }}</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<!-- templates/index.html -->
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container text-center">
|
||||
<h1 class="display-4">Welcome to Inventory Manager</h1>
|
||||
<p class="lead">Find out about all of your assets.</p>
|
||||
<div class="row">
|
||||
{% if active_worklog_rows %}
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Active Worklogs</h5>
|
||||
<h6 class="card-subtitle mb-2 text-body-secondary">
|
||||
You have {{ active_count }} active worklogs.
|
||||
{% set ids %}
|
||||
{ids: [{% for row in active_worklog_rows %}{{ row['id'] }}, {% endfor %}]}
|
||||
{% endset %}
|
||||
{{ links.export_link(
|
||||
'active_worklog',
|
||||
'worklog',
|
||||
ids
|
||||
) }}
|
||||
</h6>
|
||||
{{ tables.render_table(
|
||||
headers = active_worklog_headers,
|
||||
rows = active_worklog_rows,
|
||||
id = 'Active Worklog',
|
||||
entry_route = 'worklog_item',
|
||||
per_page = 10
|
||||
)}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if (datasets['summary'][0]['values'] | sum) > 0 %}
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Inventory Summary</h5>
|
||||
<div id="summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
{% if (datasets['summary'][0]['values'] | sum) > 0 %}
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Work Summary</h5>
|
||||
<div id="work_summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{% if datasets['summary'] %}
|
||||
const data = {{ datasets['summary']|tojson }};
|
||||
const layout = { title: 'Summary' };
|
||||
Plotly.newPlot('summary', data, layout)
|
||||
{% endif %}
|
||||
{% if datasets['work_summary'] %}
|
||||
const work_data = {{ datasets['work_summary']|tojson }};
|
||||
const work_layout = { title: 'Work Summary', xaxis: { tickangle: -45 } };
|
||||
Plotly.newPlot('work_summary', work_data, work_layout);
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
<!-- templates/inventory.html -->
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block precontent %}
|
||||
{% set saveLogic %}
|
||||
e.preventDefault();
|
||||
|
||||
const payload = {
|
||||
timestamp: document.querySelector("input[name='timestamp']").value,
|
||||
condition: document.querySelector("select[name='condition']").value,
|
||||
type_id: parseInt(document.querySelector("input[name='type']").value),
|
||||
name: document.querySelector("input[name='name']").value || null,
|
||||
serial: document.querySelector("input[name='serial']").value || null,
|
||||
model: document.querySelector("input[name='model']").value || null,
|
||||
notes: document.querySelector("textarea[name='editornotes']").value || null,
|
||||
owner_id: parseInt(document.querySelector("input[name='owner']").value) || null,
|
||||
brand_id: parseInt(document.querySelector("input[name='brand']").value) || null,
|
||||
location_id: parseInt(document.querySelector("input[name='room']").value) || null,
|
||||
barcode: document.querySelector("input[name='barcode']").value || null,
|
||||
shared: document.querySelector("input[name='shared']").checked
|
||||
};
|
||||
|
||||
try {
|
||||
const id = document.querySelector("#inventoryId").value;
|
||||
const isEdit = id && id !== "None";
|
||||
|
||||
const endpoint = isEdit ? `/api/inventory/${id}` : "/api/inventory";
|
||||
const method = isEdit ? "PUT" : "POST";
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
localStorage.setItem("toastMessage", JSON.stringify({
|
||||
message: isEdit ? "Inventory item updated!" : "Inventory item created!",
|
||||
type: "success"
|
||||
}));
|
||||
|
||||
window.location.href = `/inventory_item/${result.id}`;
|
||||
} else {
|
||||
Toast.renderToast({ message: `Error: ${result.error}`, type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
Toast.renderToast({ message: `Error: ${err}`, type: "danger" });
|
||||
}
|
||||
{% endset %}
|
||||
{% set deleteLogic %}
|
||||
const id = document.querySelector("#inventoryId").value;
|
||||
|
||||
if (!id || id === "None") {
|
||||
Toast.renderToast({ message: "No item ID found to delete.", type: "danger" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("Are you sure you want to delete this inventory item? This action cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/inventory/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
localStorage.setItem("toastMessage", JSON.stringify({
|
||||
message: "Inventory item deleted.",
|
||||
type: "success"
|
||||
}));
|
||||
|
||||
window.location.href = "/inventory";
|
||||
} else {
|
||||
Toast.renderToast({ message: `Error: ${result.error}`, type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
Toast.renderToast({ message: `Error: ${err}`, type: "danger" });
|
||||
}
|
||||
{% endset %}
|
||||
{% set buttonBar %}
|
||||
<div class="btn-group">
|
||||
{% if item.id != None %}
|
||||
{{ buttons.render_button(
|
||||
id='new',
|
||||
icon='plus-lg',
|
||||
style='outline-primary rounded-start',
|
||||
logic="window.location.href = '" + url_for('main.new_inventory_item') + "';"
|
||||
)}}
|
||||
{% endif %}
|
||||
{{ buttons.render_button(
|
||||
id='save',
|
||||
icon='floppy',
|
||||
logic=saveLogic,
|
||||
style="outline-primary" + (' rounded-end' if not item.id else '')
|
||||
) }}
|
||||
{% if item.id != None %}
|
||||
{{ buttons.render_button(
|
||||
id='delete',
|
||||
icon='trash',
|
||||
logic=deleteLogic,
|
||||
style="outline-danger rounded-end"
|
||||
) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endset %}
|
||||
{{ toolbars.render_toolbar(
|
||||
id='inventory',
|
||||
left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs),
|
||||
right=buttonBar
|
||||
) }}
|
||||
{% if item.condition in ["Removed", "Disposed"] %}
|
||||
<div class="alert alert-danger rounded-0">
|
||||
This item is not available and cannot be edited.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<input type="hidden" id="inventoryId" value="{{ item.id }}">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<label for="timestamp" class="form-label">Date Entered</label>
|
||||
<input type="date" class="form-control-plaintext" name="timestamp"
|
||||
value="{{ item.timestamp.date().isoformat() }}" readonly>
|
||||
</div>
|
||||
<div class="col">
|
||||
<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="name" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="name" placeholder="-" value="{{ item.name or '' }}" {%
|
||||
if item.condition in ["Removed", "Disposed" ] %} disabled{% endif %}>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label for="serial" class="form-label">Serial Number</label>
|
||||
<input type="text" class="form-control" name="serial" placeholder="-"
|
||||
value="{{ item.serial if item.serial else '' }}" {% if item.condition in ["Removed", "Disposed"
|
||||
] %} disabled{% endif %}>
|
||||
</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 '' }}" {% if item.condition in
|
||||
["Removed", "Disposed" ] %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
{{ dropdowns.render_dropdown(
|
||||
id='brand',
|
||||
label='Brand',
|
||||
current_item=item.brand,
|
||||
enabled=item.condition not in ["Removed", "Disposed"],
|
||||
refresh_url=url_for('ui.list_items', model_name='brand'),
|
||||
select_url=url_for('ui.update_item', model_name='inventory'),
|
||||
record_id=item.id,
|
||||
field_name='brand_id'
|
||||
) }}
|
||||
</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 if item.model else '' }}" {% if item.condition in ["Removed", "Disposed" ]
|
||||
%} disabled{% endif %}>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{{ dropdowns.render_dropdown(
|
||||
id='type',
|
||||
label='Category',
|
||||
current_item=item.device_type,
|
||||
enabled=item.condition not in ["Removed", "Disposed"],
|
||||
refresh_url=url_for('ui.list_items', model_name='item'),
|
||||
select_url=url_for('ui.update_item', model_name='inventory'),
|
||||
record_id=item.id,
|
||||
field_name='type_id'
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
{{ dropdowns.render_dropdown(
|
||||
id='owner',
|
||||
label='Contact',
|
||||
current_item=item.owner,
|
||||
entry_link='user_item',
|
||||
enabled=item.condition not in ["Removed", "Disposed"],
|
||||
refresh_url=url_for('ui.list_items', model_name='user'),
|
||||
select_url=url_for('ui.update_item', model_name='inventory'),
|
||||
record_id=item.id,
|
||||
field_name='owner_id'
|
||||
) }}
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{{ dropdowns.render_dropdown(
|
||||
id='room',
|
||||
label='Location',
|
||||
current_item=item.location,
|
||||
enabled=item.condition not in ["Removed", "Disposed"],
|
||||
refresh_url=url_for('ui.list_items', model_name='room'),
|
||||
select_url=url_for('ui.update_item', model_name='inventory'),
|
||||
record_id=item.id,
|
||||
field_name='location_id'
|
||||
) }}
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<label for="condition" class="form-label">Condition</label>
|
||||
<select name="condition" id="condition" class="form-select">
|
||||
<option>-</option>
|
||||
{% for condition in ["Working", "Deployed", "Partially Inoperable", "Inoperable", "Unverified",
|
||||
"Removed", "Disposed"] %}
|
||||
<option value="{{ condition }}" {% if item.condition==condition %} selected{% endif %}>{{
|
||||
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 %}{% if item.condition in ["Removed", "Disposed" ] %} disabled{% endif %}>
|
||||
<label for="shared" class="form-check-label">Shared?</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if item.image or item.condition not in ["Removed", "Disposed"] %}
|
||||
<div class="col-4">
|
||||
{{ images.render_image(item.id, item.image, enabled = item.condition not in ["Removed", "Disposed"]) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col p-3">
|
||||
{{ editor.render_editor(
|
||||
id = "notes",
|
||||
title = "Notes & Comments",
|
||||
mode = 'view' if item.id else 'edit',
|
||||
enabled = item.condition not in ["Removed", "Disposed"],
|
||||
refresh_url = url_for('ui.get_value', model_name='inventory'),
|
||||
field_name='notes',
|
||||
record_id=item.id
|
||||
) }}
|
||||
</div>
|
||||
{% if worklog %}
|
||||
<div class="col" id="worklog">
|
||||
<div class="row">
|
||||
<div class="col form-label">
|
||||
Work Log Entries
|
||||
{% set id_list = worklog_rows | map(attribute='id') | list %}
|
||||
{{ links.export_link(
|
||||
id = (item.identifier | replace('Name: ', '')
|
||||
| replace('ID:', '')
|
||||
| replace('Serial: ', '')
|
||||
| replace('Barcode: ', '')
|
||||
| lower) + '_worklog',
|
||||
endpoint = 'worklog',
|
||||
ids = {'ids': id_list}
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row border">
|
||||
<div class="col overflow-auto" style="max-height: 300px;">
|
||||
{% for note in notes %}
|
||||
{% set title %}
|
||||
{{ note.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ links.entry_link('worklog_item', note.work_log_id) }}
|
||||
{% endset %}
|
||||
{{ editor.render_editor(
|
||||
id = 'updates' + (note.id | string),
|
||||
title = title,
|
||||
mode = 'view',
|
||||
enabled = false,
|
||||
refresh_url = url_for('ui.get_value', model_name='work_note'),
|
||||
field_name='content',
|
||||
record_id=note.id
|
||||
) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
<!-- templates/inventory_index.html -->
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block precontent %}
|
||||
{{ toolbars.render_toolbar('index', left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs)) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
{% if not category %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h2 class="display-6 text-center mt-5">Browse</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
{{ links.category_link(endpoint = 'list_inventory', label = "Full Listing", icon_html = icons.render_icon('table', 32)) }}
|
||||
{{ links.category_link(endpoint = 'inventory_index', label = "By User", icon_html = icons.render_icon('person', 32), arguments = {'category': 'user'}) }}
|
||||
{{ links.category_link(endpoint = 'inventory_index', label = 'By Location', icon_html = icons.render_icon('map', 32), arguments = {'category': 'location'}) }}
|
||||
{{ links.category_link(endpoint = 'inventory_index', label = 'By Type', icon_html = icons.render_icon('motherboard', 32), arguments = {'category': 'type'}) }}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h2 class="display-6 text-center mt-5">Reports</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
{{ links.category_link(endpoint = 'inventory_available', label = 'Available', icon_html = icons.render_icon('box-seam', 32)) }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="container">
|
||||
{% for line in listing %}
|
||||
<div class="row my-3">
|
||||
{% for id, name in line %}
|
||||
{{ links.category_link(endpoint = 'list_inventory', label = name, arguments = {'filter_by': category, 'id': id}) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
{% import "fragments/_button_fragment.html" as buttons %}
|
||||
{% import "fragments/_breadcrumb_fragment.html" as breadcrumb_macro %}
|
||||
{% import "fragments/_combobox_fragment.html" as combos %}
|
||||
{% import "fragments/_dropdown_fragment.html" as dropdowns %}
|
||||
{% import "fragments/_editor_fragment.html" as editor %}
|
||||
{% import "fragments/_icon_fragment.html" as icons %}
|
||||
{% import "fragments/_image_fragment.html" as images %}
|
||||
{% import "fragments/_label_fragment.html" as labels %}
|
||||
{% import "fragments/_link_fragment.html" as links %}
|
||||
{% import "fragments/_table_fragment.html" as tables %}
|
||||
{% import "fragments/_toolbar_fragment.html" as toolbars %}
|
||||
|
||||
<!-- templates/layout.html -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Inventory Manager{% if title %} - {% endif %}{% block title %}{% 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">
|
||||
<link
|
||||
href="https://cdn.datatables.net/v/bs5/jq-3.7.0/moment-2.29.4/jszip-3.10.1/dt-2.3.2/b-3.2.4/b-html5-3.2.4/fh-4.0.3/r-3.0.5/datatables.min.css"
|
||||
rel="stylesheet" integrity="sha384-hSj3bXMT805MYGWJ+03fwhNIuAiFbC0OFMeKgQeB0ndGAdPMSutk5qr9WHSXzHU/"
|
||||
crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.8.1/github-markdown.min.css"
|
||||
integrity="sha512-BrOPA520KmDMqieeM7XFe6a3u3Sb3F1JBaQnrIAmWg3EYrciJ+Qqe6ZcKCdfPv26rGcgTrJnZ/IdQEct8h3Zhw=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/combobox.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/dropdown.css') }}">
|
||||
<style>
|
||||
{% block style %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-tertiary text-primary-emphasis">
|
||||
<nav class="navbar navbar-expand bg-body-tertiary border-bottom">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('main.index') }}">
|
||||
Inventory Manager
|
||||
</a>
|
||||
<button class="navbar-toggler">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
{{ links.navigation_link(endpoint = 'inventory_index', label = 'Inventory') }}
|
||||
{{ links.navigation_link(endpoint = 'list_users', label = 'Users') }}
|
||||
{{ links.navigation_link(endpoint = 'list_worklog', label = 'Worklog') }}
|
||||
</ul>
|
||||
<form class="d-flex" method="GET" action="{{ url_for('main.search') }}">
|
||||
<input type="text" class="form-control me-2" placeholder="Search" name="q" id="search" />
|
||||
<button class="btn btn-primary" type="submit" id="searchButton" disabled>Search</button>
|
||||
</form>
|
||||
<ul class="navbar-nav ms-2">
|
||||
{{ links.navigation_link(endpoint='settings', label = '', icon_html = icons.render_icon('gear')) }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% block precontent %}
|
||||
{% endblock %}
|
||||
<main class="container-flex m-5">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script src="{{ url_for('static', filename='js/combobox.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/csv.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dropdown.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/editor.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/image.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/label.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/table.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/toast.js') }}" defer></script>
|
||||
<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 src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/mark.min.js"
|
||||
integrity="sha512-5CYOlHXGh6QpOFA/TeTylKLWfB3ftPsde7AnmhuitiTX4K5SqCLBeKro6sPS8ilsz1Q4NRx3v8Ko2IBiszzdww=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdn.plot.ly/plotly-3.0.1.min.js" charset="utf-8"></script>
|
||||
<script
|
||||
src="https://cdn.datatables.net/v/bs5/jq-3.7.0/moment-2.29.4/dt-2.3.2/b-3.2.4/r-3.0.5/sc-2.4.3/sp-2.3.3/datatables.min.js"
|
||||
integrity="sha384-zqgMe4cx+N3TuuqXt4kWWDluM5g1CiRwqWBm3vpvY0GcDoXTwU8d17inavaLy3p3"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
const searchInput = document.querySelector('#search');
|
||||
const searchButton = document.querySelector('#searchButton');
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
searchButton.disabled = searchInput.value.trim() === '';
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
{% block script %} {% endblock %}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{% extends 'layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
{{ combos.render_combobox(
|
||||
id='combo',
|
||||
label='Breakfast!',
|
||||
refresh_url=url_for('ui.list_items', model_name='brand')
|
||||
) }}
|
||||
|
||||
{{ dropdowns.render_dropdown(
|
||||
id = 'dropdown',
|
||||
refresh_url=url_for('ui.list_items', model_name='user')
|
||||
) }}
|
||||
|
||||
{% set vals = cells('user', 8, 'first_name', 'last_name') %}
|
||||
{{ vals['first_name'] }} {{ vals['last_name'] }}
|
||||
|
||||
{{ table('inventory', ['name', 'barcode', 'serial'], limit=0, q='BH0298') | tojson }}
|
||||
{% endblock %}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<!-- templates/search.html -->
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block precontent %}
|
||||
{{ toolbars.render_toolbar(
|
||||
id='search',
|
||||
left = breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs)
|
||||
) }}
|
||||
{% if not results['inventory']['rows'] and not results['users']['rows'] and not results['worklog']['rows'] %}
|
||||
<div class="alert alert-danger rounded-0">There are no results for "{{ query }}".</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
{% if results['inventory']['rows'] %}
|
||||
<div>
|
||||
{{ tables.render_table(
|
||||
headers = results['inventory']['headers'],
|
||||
rows = results['inventory']['rows'],
|
||||
entry_route = 'inventory_item',
|
||||
title='Inventory Results',
|
||||
id='search-inventory'
|
||||
)}}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if results['users']['rows'] %}
|
||||
<div>
|
||||
{{ tables.render_table(
|
||||
headers = results['users']['headers'],
|
||||
rows = results['users']['rows'],
|
||||
entry_route = 'user_item',
|
||||
title='User Results',
|
||||
id='search-users'
|
||||
)}}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if results['worklog']['rows'] %}
|
||||
<div>
|
||||
{{ tables.render_table(
|
||||
headers = results['worklog']['headers'],
|
||||
rows = results['worklog']['rows'],
|
||||
entry_route = 'worklog_item',
|
||||
title='Worklog Results',
|
||||
id='search-worklog'
|
||||
)}}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{% if query and (results['inventory']['rows'] or results['users']['rows'] or results['worklog']['rows']) %}
|
||||
const query = "{{ query|e }}";
|
||||
if (query) {
|
||||
const instance = new Mark(document.querySelector("main"));
|
||||
instance.mark(query);
|
||||
}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block precontent %}
|
||||
{{ toolbars.render_toolbar(
|
||||
id='settings',
|
||||
left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs)
|
||||
) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active" id="inventory-tab" data-bs-toggle="tab" data-bs-target="#inventory-tab-pane"
|
||||
type="button">Inventory</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" id="location-tab" data-bs-toggle="tab" data-bs-target="#location-tab-pane"
|
||||
type="button">Location</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" id="photo-tab" data-bs-toggle="tab" data-bs-target="#images-tab-pane"
|
||||
type="button">Images</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content" id="tabContent">
|
||||
<div class="tab-pane fade show active border border-top-0 p-3" id="inventory-tab-pane">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{{ combos.render_combobox(
|
||||
id='brand',
|
||||
label='Brands',
|
||||
placeholder='Add a new brand',
|
||||
create_url=url_for('ui.create_item', model_name='brand'),
|
||||
edit_url=url_for('ui.update_item', model_name='brand'),
|
||||
refresh_url=url_for('ui.list_items', model_name='brand'),
|
||||
delete_url=url_for('ui.delete_item', model_name='brand')
|
||||
) }}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{ combos.render_combobox(
|
||||
id='type',
|
||||
label='Inventory Types',
|
||||
placeholder='Add a new type',
|
||||
create_url=url_for('ui.create_item', model_name='item'),
|
||||
edit_url=url_for('ui.update_item', model_name='item'),
|
||||
refresh_url=url_for('ui.list_items', model_name='item'),
|
||||
delete_url=url_for('ui.delete_item', model_name='item')
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade border border-top-0 p-3" id="location-tab-pane">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{{ combos.render_combobox(
|
||||
id='section',
|
||||
label='Sections',
|
||||
placeholder='Add a new section',
|
||||
create_url=url_for('ui.create_item', model_name='area'),
|
||||
edit_url=url_for('ui.update_item', model_name='area'),
|
||||
refresh_url=url_for('ui.list_items', model_name='area'),
|
||||
delete_url=url_for('ui.delete_item', model_name='area')
|
||||
) }}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{ combos.render_combobox(
|
||||
id='function',
|
||||
label='Functions',
|
||||
placeholder='Add a new function',
|
||||
create_url=url_for('ui.create_item', model_name='room_function'),
|
||||
edit_url=url_for('ui.update_item', model_name='room_function'),
|
||||
refresh_url=url_for('ui.list_items', model_name='room_function'),
|
||||
delete_url=url_for('ui.delete_item', model_name='room_function')
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{{ combos.render_combobox(
|
||||
id='room',
|
||||
label='Rooms',
|
||||
placeholder='Add a new room',
|
||||
data_attributes={'area_id': 'section-id', 'function_id': 'function-id'},
|
||||
create_url=url_for('ui.create_item', model_name='room'),
|
||||
edit_url=url_for('ui.update_item', model_name='room'),
|
||||
refresh_url=url_for('ui.list_items', model_name='room'),
|
||||
delete_url=url_for('ui.delete_item', model_name='room')
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade border border-top-0 p-3" id="images-tab-pane">
|
||||
<div class="container border rounded" style="max-height: 60vh; overflow-y: auto;">
|
||||
{% for chunk in image_list %}
|
||||
<div class="row my-3">
|
||||
{% for image in chunk %}
|
||||
<div class="col mx-3">
|
||||
{{ images.render_image(
|
||||
id=image.id,
|
||||
image=image
|
||||
) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="roomEditor" data-bs-backdrop="static" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="roomEditorLabel">Room Editor</h1>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="roomName" class="form-label">Room Name</label>
|
||||
<input type="text" class="form-input" id="roomName" placeholder="Enter room name">
|
||||
<input type="hidden" id="roomId">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="roomSection" class="form-label">Section</label>
|
||||
<select id="roomSection" class="form-select">
|
||||
<option value="">Select a section</option>
|
||||
{% for section in sections %}
|
||||
<option value="{{ section.id }}">{{ section.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">Function</label>
|
||||
<select id="roomFunction" class="form-select">
|
||||
<option value="">Select a function</option>
|
||||
{% for function in functions %}
|
||||
<option value="{{ function.id }}">{{ function.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" id="roomEditorCancelButton">{{
|
||||
icons.render_icon('x-lg', 16) }}</button>
|
||||
{% set editorSaveLogic %}
|
||||
const modalEl = document.getElementById('roomEditor');
|
||||
const idRaw = document.getElementById('roomId').value;
|
||||
const name = document.getElementById('roomName').value.trim();
|
||||
const sectionId = document.getElementById('roomSection').value || null;
|
||||
const functionId = document.getElementById('roomFunction').value || null;
|
||||
|
||||
if (!name) { alert('Please enter a room name.'); return; }
|
||||
if (!idRaw) { alert('Missing room ID.'); return; }
|
||||
|
||||
(async () => {
|
||||
const res = await fetch('{{ url_for("ui.update_item", model_name="room") }}', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ id: parseInt(idRaw, 10), name, area_id: sectionId, function_id: functionId })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(()=> 'Error'); alert(txt); return;
|
||||
}
|
||||
|
||||
htmx.trigger('#room-container', 'combobox:refresh');
|
||||
bootstrap.Modal.getInstance(modalEl).hide();
|
||||
})();
|
||||
{% endset %}
|
||||
{{ buttons.render_button(
|
||||
id='editorSave',
|
||||
icon='floppy',
|
||||
logic=editorSaveLogic
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
const modal = document.getElementById('roomEditor');
|
||||
const cancelButton = document.getElementById('roomEditorCancelButton');
|
||||
const form = document.getElementById('settingsForm');
|
||||
|
||||
modal.addEventListener('roomEditor:prepare', (event) => {
|
||||
const { id, name, sectionId, functionId } = event.detail;
|
||||
document.getElementById('roomId').value = id;
|
||||
document.getElementById('roomName').value = name;
|
||||
|
||||
// Populate dropdowns before assigning selection
|
||||
const modalSections = document.getElementById('roomSection');
|
||||
const modalFunctions = document.getElementById('roomFunction');
|
||||
const pageSections = document.getElementById('section-list');
|
||||
const pageFunctions = document.getElementById('function-list');
|
||||
|
||||
modalSections.innerHTML = '<option value="">Select a section</option>';
|
||||
modalFunctions.innerHTML = '<option value="">Select a function</option>';
|
||||
|
||||
Array.from(pageSections.options).forEach(opt => {
|
||||
const option = new Option(opt.textContent, opt.value);
|
||||
if (opt.value === sectionId) {
|
||||
option.selected = true;
|
||||
}
|
||||
modalSections.appendChild(option);
|
||||
});
|
||||
|
||||
Array.from(pageFunctions.options).forEach(opt => {
|
||||
const option = new Option(opt.textContent, opt.value);
|
||||
if (opt.value === functionId) {
|
||||
option.selected = true;
|
||||
}
|
||||
modalFunctions.appendChild(option);
|
||||
});
|
||||
});
|
||||
|
||||
cancelButton.addEventListener('click', () => {
|
||||
bootstrap.Modal.getInstance(modal).hide();
|
||||
});
|
||||
|
||||
(function () {
|
||||
const container = document.getElementById('room-container');
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('combobox:item-created', (e) => {
|
||||
if (container.id !== 'room-container') return;
|
||||
|
||||
const { id, name } = e.detail || {};
|
||||
const prep = new CustomEvent('roomEditor:prepare', {
|
||||
detail: { id, name, sectionId: '', functionId: '' }
|
||||
});
|
||||
document.getElementById('roomEditor').dispatchEvent(prep);
|
||||
|
||||
const roomEditorModal = new bootstrap.Modal(document.getElementById('roomEditor'));
|
||||
roomEditorModal.show();
|
||||
});
|
||||
|
||||
container.addEventListener('combobox:item-edited', (e) => {
|
||||
if (container.id !== 'room-container') return;
|
||||
|
||||
const { id, name, area_id, function_id } = e.detail;
|
||||
console.log(id, name, area_id, function_id)
|
||||
const prep = new CustomEvent('roomEditor:prepare', {
|
||||
detail: { id, name, sectionId: area_id, functionId: function_id }
|
||||
});
|
||||
document.getElementById('roomEditor').dispatchEvent(prep);
|
||||
|
||||
const roomEditorModal = new bootstrap.Modal(document.getElementById('roomEditor'));
|
||||
roomEditorModal.show();
|
||||
});
|
||||
})();
|
||||
{% endblock %}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<!-- templates/table.html -->
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block precontent %}
|
||||
{% set createButtonLogic %}
|
||||
window.location.href = '/{{ entry_route }}/new';
|
||||
{% endset %}
|
||||
{% set exportButtonLogic %}
|
||||
const payload = {ids: [{% for row in rows %}{{ row['id'] }}, {% endfor %}]}
|
||||
|
||||
export_csv(payload, '{{ csv_route }}');
|
||||
{% endset %}
|
||||
{% set toolbarButtons %}
|
||||
<div class="btn-group">
|
||||
{{ buttons.render_button(id='export', icon='box-arrow-up', style='outline-primary rounded-start', logic=exportButtonLogic) }}
|
||||
{{ buttons.render_button(id='import', icon='box-arrow-in-down', style='outline-primary', logic='alert("Not implemented yet!")') }}
|
||||
{{ buttons.render_button(id='create', icon='plus-lg', logic=createButtonLogic, style='outline-primary rounded-end') }}
|
||||
</div>
|
||||
{% endset %}
|
||||
{{ toolbars.render_toolbar(
|
||||
'table',
|
||||
left = breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs),
|
||||
right = toolbarButtons
|
||||
) }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{{ tables.dynamic_table(
|
||||
id='table',
|
||||
headers=header.keys()|list if header else [],
|
||||
entry_route=entry_route,
|
||||
refresh_url = url_for('ui.list_items', model_name=model_name, view='table'),
|
||||
offset=offset,
|
||||
fields=fields
|
||||
) }}
|
||||
{% endblock %}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
<!-- templates/user.html -->
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block precontent %}
|
||||
{% set saveLogic %}
|
||||
e.preventDefault();
|
||||
|
||||
const payload = {
|
||||
staff: document.querySelector("input[name='staffCheck']").checked,
|
||||
active: document.querySelector("input[name='activeCheck']").checked,
|
||||
last_name: document.querySelector("input[name='lastName']").value,
|
||||
first_name: document.querySelector("input[name='firstName']").value,
|
||||
title: document.querySelector("input[name='title']").value,
|
||||
supervisor_id: parseInt(document.querySelector("input[name='supervisor']").value) || null,
|
||||
location_id: parseInt(document.querySelector("input[name='location']").value) || null
|
||||
};
|
||||
|
||||
try {
|
||||
const id = document.querySelector("#userId").value;
|
||||
const isEdit = id && id !== "None";
|
||||
|
||||
const endpoint = isEdit ? `/api/user/${id}` : "/api/user";
|
||||
const method = isEdit ? "PUT" : "POST";
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
localStorage.setItem("toastMessage", JSON.stringify({
|
||||
message: isEdit ? "User updated!" : "User created!",
|
||||
type: "success"
|
||||
}));
|
||||
|
||||
window.location.href = `/user/${result.id}`;
|
||||
} else {
|
||||
Toast.renderToast({ message: `Error: ${result.error}`, type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
{% endset %}
|
||||
{% set iconBar %}
|
||||
{% if user.id != None %}
|
||||
{{ buttons.render_button(
|
||||
id = 'org_chart',
|
||||
icon = 'diagram-3',
|
||||
logic = "window.location.href = '" + url_for('main.user_org', id=user.id) + "';",
|
||||
style = 'outline-secondary'
|
||||
) }}
|
||||
{% endif %}
|
||||
<div class="btn-group">
|
||||
{% if user.id != None %}
|
||||
{{ buttons.render_button(
|
||||
id = 'new',
|
||||
icon = 'plus-lg',
|
||||
style = 'outline-secondary rounded-start',
|
||||
logic = "window.location.href = '" + url_for('main.new_user') + "';"
|
||||
)}}
|
||||
{% endif %}
|
||||
{{ buttons.render_button(
|
||||
id = 'save',
|
||||
icon = 'floppy',
|
||||
logic = saveLogic,
|
||||
style = 'outline-primary rounded-end'
|
||||
) }}
|
||||
</div>
|
||||
{% endset %}
|
||||
{{ toolbars.render_toolbar(
|
||||
id = 'newUser',
|
||||
left = breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs),
|
||||
right = iconBar
|
||||
) }}
|
||||
{% if not user.active %}
|
||||
<div class="alert alert-danger rounded-0">This user is inactive. You will not be able to make any changes to this record.</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<input type="hidden" id="userId" value="{{ user.id }}">
|
||||
<div class="container">
|
||||
<form action="POST">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="lastName" class="form-label">Last Name</label>
|
||||
<input type="text" class="form-control" id="lastName" name="lastName" placeholder="Doe" value="{{ user.last_name if user.last_name else '' }}"{% if not user.active %} disabled readonly{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<label for="firstName" class="form-label">First Name</label>
|
||||
<input type="text" class="form-control" id="firstName" name="firstName" placeholder="John" value="{{ user.first_name if user.first_name else '' }}"{% if not user.active %} disabled readonly{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<label for="title" class="form-label">Title</label>
|
||||
<input type="text" class="form-control" id="title" name="title" placeholder="President" value="{{ user.title if user.title else '' }}"{% if not user.active %} disabled readonly{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-2">
|
||||
<div class="col-6">
|
||||
{{ dropdowns.render_dropdown(
|
||||
id='supervisor',
|
||||
label='Supervisor',
|
||||
current_item=user.supervisor if user.supervisor else None,
|
||||
entry_link='user_item',
|
||||
enabled=user.active,
|
||||
refresh_url = url_for('ui.list_items', model_name='user'),
|
||||
select_url = url_for('ui.update_item', model_name='user'),
|
||||
record_id = user.id,
|
||||
field_name = 'supervisor_id'
|
||||
) }}
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
{{ dropdowns.render_dropdown(
|
||||
id='location',
|
||||
label='Location',
|
||||
current_item=user.location if user.location else None,
|
||||
enabled=user.active,
|
||||
refresh_url = url_for('ui.list_items', model_name='room'),
|
||||
select_url = url_for('ui.update_item', model_name='user'),
|
||||
record_id = user.id,
|
||||
field_name = 'location_id'
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
<div class="col-6">
|
||||
<input type="checkbox" class="form-check-input" id="activeCheck" name="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" name="staffCheck"{% if user.staff %} checked{% endif %}{% if not user.active %} disabled readonly{% endif %}>
|
||||
<label for="staffCheck" class="form-check-label">Staff</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="row mt-3">
|
||||
{% if inventory_rows %}
|
||||
<div class="col">
|
||||
{% set id_list = inventory_rows | map(attribute='id') | list %}
|
||||
{% set inventory_title %}
|
||||
Assets
|
||||
{{ links.export_link(
|
||||
(user.identifier | lower | replace(' ', '_')) + '_user_inventory',
|
||||
'inventory',
|
||||
{'ids': id_list}
|
||||
) }}
|
||||
{% endset %}
|
||||
<div class="row">
|
||||
{{ tables.render_table(headers=inventory_headers, rows=inventory_rows, id='assets', entry_route='inventory_item', title=inventory_title, per_page=8) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if worklog_rows %}
|
||||
{% set id_list = worklog_rows | map(attribute='id') | list %}
|
||||
{% set worklog_title %}
|
||||
Work Done
|
||||
{{ links.export_link(
|
||||
(user.identifier | lower | replace(' ', '_')) + '_user_worklog',
|
||||
'worklog',
|
||||
{'ids': id_list}
|
||||
) }}
|
||||
{% endset %}
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
{{ tables.render_table(headers=worklog_headers, rows=worklog_rows, id='worklog', entry_route='worklog_item', title=worklog_title, per_page=8) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
{% extends 'layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% for layer in org_chart %}
|
||||
{% set current_index = loop.index0 %}
|
||||
{% set next_user = org_chart[current_index + 1].user if current_index + 1 < org_chart|length else None %}
|
||||
|
||||
{% if loop.first %}
|
||||
<div class="d-flex mb-5 justify-content-center">
|
||||
<div class="card border border-primary border-2" style="width: 15rem;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">
|
||||
{{ layer.user.first_name }} {{ layer.user.last_name }}<br />{{ links.entry_link('user_item', layer.user.id) }}
|
||||
</h5>
|
||||
<div class="card-text text-center">
|
||||
{% if layer.user.title %}
|
||||
({{ layer.user.title }})
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if layer.subordinates %}
|
||||
<div class="mb-5 px-3">
|
||||
<div class="org-row d-grid gap-3" style="grid-auto-flow: column; overflow-x: auto; max-width: 100%;">
|
||||
{% for subordinate in layer.subordinates %}
|
||||
<div class="card {% if next_user and subordinate.id == next_user.id %}border border-primary border-2 highlighted-card{% endif %}" style="min-width: 15rem;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">
|
||||
{% if subordinate == user %}
|
||||
{{ subordinate.first_name }} {{ subordinate.last_name }}
|
||||
{% else %}
|
||||
<a class="link-success link-underline-opacity-0"
|
||||
href="{{ url_for('main.user_org', id=subordinate.id) }}">
|
||||
{{ subordinate.first_name }} {{ subordinate.last_name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<div class="card-text text-center">
|
||||
{% if subordinate.title %}
|
||||
({{ subordinate.title }})<br />
|
||||
{% endif %}
|
||||
{{ links.entry_link('user_item', subordinate.id) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
document.querySelectorAll('.highlighted-card').forEach(card => {
|
||||
card.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
inline: "center"
|
||||
});
|
||||
});
|
||||
{% endblock %}
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
<!-- templates/worklog.html -->
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block precontent %}
|
||||
{% set saveLogic %}
|
||||
e.preventDefault();
|
||||
|
||||
const updateTextareas = Array.from(document.querySelectorAll("textarea[name^='editor']"));
|
||||
const updates = updateTextareas
|
||||
.map(el => {
|
||||
const content = el.value.trim();
|
||||
if (!content) return null;
|
||||
const id = el.dataset.noteId;
|
||||
return id ? { id, content } : { content };
|
||||
})
|
||||
.filter(u => u !== null);
|
||||
|
||||
const payload = {
|
||||
start_time: document.querySelector("input[name='start']").value,
|
||||
end_time: document.querySelector("input[name='end']").value,
|
||||
complete: document.querySelector("input[name='complete']").checked,
|
||||
analysis: document.querySelector("input[name='analysis']").checked,
|
||||
followup: document.querySelector("input[name='followup']").checked,
|
||||
contact_id: parseInt(document.querySelector("input[name='contact']").value) || null,
|
||||
work_item_id: parseInt(document.querySelector("input[name='item']").value) || null,
|
||||
updates: updates
|
||||
};
|
||||
|
||||
try {
|
||||
const id = document.querySelector("#logId").value;
|
||||
const isEdit = id && id !== "None";
|
||||
|
||||
const endpoint = isEdit ? `/api/worklog/${id}` : "/api/worklog";
|
||||
const method = isEdit ? "PUT" : "POST";
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
localStorage.setItem("toastMessage", JSON.stringify({
|
||||
message: isEdit ? "Work Log entry updated!" : "Work Log entry created!",
|
||||
type: "success"
|
||||
}));
|
||||
|
||||
window.location.href = `/worklog/${result.id}`;
|
||||
} else {
|
||||
Toast.renderToast({ message: `Error: ${result.error}`, type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Toast.renderToast({ message: `Error: ${err}`, type: "danger" });
|
||||
}
|
||||
{% endset %}
|
||||
{% set deleteLogic %}
|
||||
const id = document.querySelector("#logId").value;
|
||||
|
||||
if (!id || id === "None") {
|
||||
Toast.renderToast({ message: "No item ID found to delete.", type: "danger" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("Are you sure you want to delete this work log entry? This action cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/worklog/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
localStorage.setItem("toastMessage", JSON.stringify({
|
||||
message: "Work log entry deleted.",
|
||||
type: "success"
|
||||
}));
|
||||
|
||||
window.location.href = "/worklog";
|
||||
} else {
|
||||
Toast.renderToast({ message: `Error: ${result.error}`, type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.renderToast({ message: `Error: ${err}`, type: "danger" });
|
||||
}
|
||||
{% endset %}
|
||||
{% set iconBar %}
|
||||
<div class="btn-group">
|
||||
{% if log.id != None %}
|
||||
{{ buttons.render_button(
|
||||
id='new',
|
||||
icon='plus-lg',
|
||||
style='outline-primary rounded-start',
|
||||
logic="window.location.href = '" + url_for('main.new_worklog') + "';"
|
||||
) }}
|
||||
{% endif %}
|
||||
{{ buttons.render_button(
|
||||
id='save',
|
||||
icon='floppy',
|
||||
logic=saveLogic,
|
||||
style='outline-primary'
|
||||
) }}
|
||||
{% if log.id != None %}
|
||||
{{ buttons.render_button(
|
||||
id='delete',
|
||||
icon='trash',
|
||||
style='outline-danger rounded-end',
|
||||
logic=deleteLogic
|
||||
) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endset %}
|
||||
{{ toolbars.render_toolbar(
|
||||
id='newWorklog',
|
||||
left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs),
|
||||
right=iconBar
|
||||
) }}
|
||||
{% if log.complete %}
|
||||
<div class="alert alert-success rounded-0">
|
||||
This work item is complete. You cannot make any further changes.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<input type="hidden" id="logId" value="{{ log.id }}">
|
||||
<div class="container">
|
||||
<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 }}"{% if log.complete %} disabled{% endif %}>
|
||||
</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 }}"{% if log.complete %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
{{ dropdowns.render_dropdown(
|
||||
id='contact',
|
||||
label='Contact',
|
||||
current_item=log.contact,
|
||||
entry_link='user_item',
|
||||
enabled = not log.complete,
|
||||
refresh_url=url_for('ui.list_items', model_name='user'),
|
||||
select_url=url_for('ui.update_item', model_name='worklog'),
|
||||
record_id=log.id,
|
||||
field_name='contact_id'
|
||||
) }}
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{{ dropdowns.render_dropdown(
|
||||
id='item',
|
||||
label='Work Item',
|
||||
current_item=log.work_item,
|
||||
entry_link='inventory_item',
|
||||
enabled = not log.complete,
|
||||
refresh_url=url_for('ui.list_items', model_name='inventory'),
|
||||
select_url=url_for('ui.update_item', model_name='worklog'),
|
||||
record_id=log.id,
|
||||
field_name='work_item_id'
|
||||
) }}
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<input type="checkbox" id="complete" class="form-check-input" name="complete" {% if log.complete %}
|
||||
checked{% endif %}>
|
||||
<label for="complete" class="form-check-label">
|
||||
Complete?
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<input type="checkbox" id="followup" class="form-check-input" name="followup" {% if log.followup %}
|
||||
checked{% endif %}{% if log.complete %} disabled{% endif %}>
|
||||
<label for="followup" class="form-check-label">
|
||||
Follow Up?
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<input type="checkbox" id="analysis" class="form-check-input" name="analysis" {% if log.analysis %}
|
||||
checked{% endif %}{% if log.complete %} disabled{% endif %}>
|
||||
<label for="analysis" class="form-check-label">
|
||||
Quick Analysis?
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container" id="updates-container">
|
||||
<div class="row">
|
||||
<div class="col-11">
|
||||
<label class="form-label">Updates</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
{% set addUpdateLogic %}
|
||||
function formatDate(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
||||
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
const template = document.getElementById("editor-template");
|
||||
const newEditor = createEditorWidget(template, createTempId("new"), formatDate(new Date()));
|
||||
|
||||
const updatesContainer = document.getElementById("updates-container");
|
||||
updatesContainer.appendChild(newEditor);
|
||||
{% endset %}
|
||||
{{ buttons.render_button(
|
||||
id='addUpdate',
|
||||
icon='plus-lg',
|
||||
logic=addUpdateLogic
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
{% for update in log.updates %}
|
||||
{{ editor.render_editor(
|
||||
id = update.id,
|
||||
title = labels.render_label(
|
||||
id = update.id,
|
||||
refresh_url = url_for('ui.get_value', model_name='work_note'),
|
||||
field_name = 'timestamp',
|
||||
record_id = update.id
|
||||
),
|
||||
mode = 'view',
|
||||
enabled = not log.complete,
|
||||
refresh_url = url_for('ui.get_value', model_name='work_note'),
|
||||
field_name = 'content',
|
||||
record_id = update.id
|
||||
) }}
|
||||
{% endfor %}
|
||||
<template id="editor-template">
|
||||
{{ editor.render_editor('__ID__', '__TIMESTAMP__', 'edit', '') }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,455 +0,0 @@
|
|||
from collections import defaultdict
|
||||
|
||||
from flask import Blueprint, request, render_template, jsonify, abort, make_response
|
||||
from sqlalchemy.engine import ScalarResult
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import class_mapper, load_only, selectinload, joinedload, Load
|
||||
from sqlalchemy.sql import Select
|
||||
from typing import Any, List, cast, Iterable, Tuple, Set, Dict
|
||||
|
||||
from .defaults import (
|
||||
default_query, default_create, default_update, default_delete, default_serialize, default_values, default_value, default_select, ensure_order_by, count_for
|
||||
)
|
||||
|
||||
from .. import db
|
||||
|
||||
bp = Blueprint("ui", __name__, url_prefix="/ui")
|
||||
|
||||
from sqlalchemy.orm import Load
|
||||
|
||||
def _option_targets_rel(opt: Load, Model, rel_name: str) -> bool:
|
||||
"""
|
||||
Return True if this Load option targets Model.rel_name at its root path.
|
||||
Works for joinedload/selectinload/subqueryload options.
|
||||
"""
|
||||
try:
|
||||
# opt.path is a PathRegistry; .path is a tuple of (mapper, prop, mapper, prop, ...)
|
||||
path = tuple(getattr(opt, "path", ()).path) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
return False
|
||||
if not path:
|
||||
return False
|
||||
# We only care about the first hop: (Mapper[Model], RelationshipProperty(rel_name))
|
||||
if len(path) < 2:
|
||||
return False
|
||||
first_mapper, first_prop = path[0], path[1]
|
||||
try:
|
||||
is_model = first_mapper.class_ is Model # type: ignore[attr-defined]
|
||||
is_rel = getattr(first_prop, "key", "") == rel_name
|
||||
return bool(is_model and is_rel)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _has_loader_for(stmt: Select, Model, rel_name: str) -> bool:
|
||||
"""
|
||||
True if stmt already has any loader option configured for Model.rel_name.
|
||||
"""
|
||||
opts = getattr(stmt, "_with_options", ()) # SQLAlchemy stores Load options here
|
||||
for opt in opts:
|
||||
if isinstance(opt, Load) and _option_targets_rel(opt, Model, rel_name):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _strategy_for_rel_attr(rel_attr) -> type[Load] | None:
|
||||
# rel_attr is an InstrumentedAttribute (Model.foo)
|
||||
prop = getattr(rel_attr, "property", None)
|
||||
lazy = getattr(prop, "lazy", None)
|
||||
if lazy in ("joined", "subquery"):
|
||||
return joinedload
|
||||
if lazy == "selectin":
|
||||
return selectinload
|
||||
# default if mapper left it None or something exotic like 'raise'
|
||||
return selectinload
|
||||
|
||||
def apply_model_default_eager(stmt: Select, Model, skip_rels: Set[str]) -> Select:
|
||||
# mapper.relationships yields RelationshipProperty objects
|
||||
mapper = class_mapper(Model)
|
||||
for prop in mapper.relationships:
|
||||
if prop.key in skip_rels:
|
||||
continue
|
||||
lazy = getattr(prop, "lazy", None)
|
||||
if lazy in ("joined", "subquery"):
|
||||
stmt = stmt.options(joinedload(getattr(Model, prop.key)))
|
||||
elif lazy == "selectin":
|
||||
stmt = stmt.options(selectinload(getattr(Model, prop.key)))
|
||||
# else: leave it alone (noload/raise/dynamic/etc.)
|
||||
return stmt
|
||||
|
||||
def split_fields(Model, fields: Iterable[str]) -> Tuple[Set[str], Dict[str, Set[str]]]:
|
||||
"""
|
||||
Split requested fields into base model columns and relation->attr sets.
|
||||
Example: ["name", "brand.name", "owner.identifier"] =>
|
||||
base_cols = {"name"}
|
||||
rel_cols = {"brand": {"name"}, "owner": {"identifier"}}
|
||||
"""
|
||||
base_cols: Set[str] = set()
|
||||
rel_cols: Dict[str, Set[str]] = defaultdict(set)
|
||||
|
||||
for f in fields:
|
||||
f = f.strip()
|
||||
if not f:
|
||||
continue
|
||||
if "." in f:
|
||||
rel, attr = f.split(".", 1)
|
||||
rel_cols[rel].add(attr)
|
||||
else:
|
||||
base_cols.add(f)
|
||||
return base_cols, rel_cols
|
||||
|
||||
def _load_only_existing(Model, names: Set[str]):
|
||||
"""
|
||||
Return a list of mapped column attributes present on Model for load_only(...).
|
||||
Skips relationships and unmapped/hybrid attributes so SQLA doesn’t scream.
|
||||
"""
|
||||
cols = []
|
||||
mapper = class_mapper(Model)
|
||||
mapped_attr_names = set(mapper.attrs.keys())
|
||||
for n in names:
|
||||
if n in mapped_attr_names:
|
||||
attr = getattr(Model, n)
|
||||
prop = getattr(attr, "property", None)
|
||||
if prop is not None and hasattr(prop, "columns"):
|
||||
cols.append(attr)
|
||||
return cols
|
||||
|
||||
def apply_field_loaders(stmt: Select, Model, fields: Iterable[str]) -> Select:
|
||||
base_cols, rel_cols = split_fields(Model, fields)
|
||||
|
||||
base_only = _load_only_existing(Model, base_cols)
|
||||
if base_only:
|
||||
stmt = stmt.options(load_only(*base_only))
|
||||
|
||||
for rel_name, attrs in rel_cols.items():
|
||||
if not hasattr(Model, rel_name):
|
||||
continue
|
||||
|
||||
# If someone already attached a loader for this relation, don't add another
|
||||
if _has_loader_for(stmt, Model, rel_name):
|
||||
# still allow trimming columns on the related entity if we can
|
||||
rel_attr = getattr(Model, rel_name)
|
||||
try:
|
||||
target_cls = rel_attr.property.mapper.class_
|
||||
except Exception:
|
||||
continue
|
||||
rel_only = _load_only_existing(target_cls, attrs)
|
||||
if rel_only:
|
||||
# attach a Load that only applies load_only to that path,
|
||||
# without picking a different strategy
|
||||
# This relies on SQLA merging load_only onto existing Load for the same path.
|
||||
stmt = stmt.options(
|
||||
getattr(Load(Model), rel_name).load_only(*rel_only)
|
||||
)
|
||||
continue
|
||||
|
||||
# Otherwise choose a strategy and add it
|
||||
rel_attr = getattr(Model, rel_name)
|
||||
strategy = _strategy_for_rel_attr(rel_attr)
|
||||
if not strategy:
|
||||
continue
|
||||
opt = strategy(rel_attr)
|
||||
|
||||
# Trim columns on the related entity if requested
|
||||
try:
|
||||
target_cls = rel_attr.property.mapper.class_
|
||||
except Exception:
|
||||
continue
|
||||
rel_only = _load_only_existing(target_cls, attrs)
|
||||
if rel_only:
|
||||
opt = opt.options(load_only(*rel_only))
|
||||
|
||||
stmt = stmt.options(opt)
|
||||
|
||||
return stmt
|
||||
|
||||
def _normalize(s: str) -> str:
|
||||
return s.replace("_", "").replace("-", "").lower()
|
||||
|
||||
def get_model_class(model_name: str) -> type:
|
||||
"""Resolve a model class by name across SA/Flask-SA versions."""
|
||||
target = _normalize(model_name)
|
||||
|
||||
# SA 2.x / Flask-SQLAlchemy 3.x path
|
||||
registry = getattr(db.Model, "registry", None)
|
||||
if registry and getattr(registry, "mappers", None):
|
||||
for mapper in registry.mappers:
|
||||
cls = mapper.class_
|
||||
# match on class name w/ and w/o underscores
|
||||
if _normalize(cls.__name__) == target or cls.__name__.lower() == model_name.lower():
|
||||
return cls
|
||||
|
||||
# Legacy Flask-SQLAlchemy 2.x path (if someone runs old stack)
|
||||
decl = getattr(db.Model, "_decl_class_registry", None)
|
||||
if decl:
|
||||
for cls in decl.values():
|
||||
if isinstance(cls, type) and (
|
||||
_normalize(cls.__name__) == target or cls.__name__.lower() == model_name.lower()
|
||||
):
|
||||
return cls
|
||||
|
||||
abort(404, f"Unknown resource '{model_name}'")
|
||||
|
||||
def call(Model: type, name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
fn = getattr(Model, name, None)
|
||||
return fn(*args, **kwargs) if callable(fn) else None
|
||||
|
||||
from flask import request, jsonify, render_template
|
||||
from sqlalchemy.sql import Select
|
||||
from sqlalchemy.engine import ScalarResult
|
||||
from typing import Any, cast
|
||||
|
||||
@bp.get("/<model_name>/list")
|
||||
def list_items(model_name):
|
||||
Model = get_model_class(model_name)
|
||||
|
||||
text = (request.args.get("q") or "").strip() or None
|
||||
fields_raw = (request.args.get("fields") or "").strip()
|
||||
fields = [f.strip() for f in fields_raw.split(",") if f.strip()]
|
||||
fields.extend(request.args.getlist("field"))
|
||||
|
||||
# legacy params
|
||||
limit_param = request.args.get("limit")
|
||||
if limit_param in (None, "", "0", "-1"):
|
||||
effective_limit = 0
|
||||
else:
|
||||
effective_limit = min(int(limit_param), 500)
|
||||
|
||||
offset = int(request.args.get("offset", 0))
|
||||
|
||||
# new-school params
|
||||
page = request.args.get("page", type=int)
|
||||
per_page = request.args.get("per_page", type=int)
|
||||
|
||||
# map legacy limit/offset to page/per_page if new params not provided
|
||||
if per_page is None:
|
||||
per_page = effective_limit or 20 # default page size if not unlimited
|
||||
if page is None:
|
||||
page = (offset // per_page) + 1 if per_page else 1
|
||||
|
||||
# unlimited: treat as "no pagination"
|
||||
unlimited = (per_page == 0)
|
||||
|
||||
view = (request.args.get("view") or "json").strip()
|
||||
sort = (request.args.get("sort") or "").strip() or None
|
||||
direction = (request.args.get("dir") or request.args.get("direction") or "asc").lower()
|
||||
if direction not in ("asc", "desc"):
|
||||
direction = "asc"
|
||||
|
||||
qkwargs: dict[str, Any] = {
|
||||
"text": text,
|
||||
"limit": 0 if unlimited else per_page,
|
||||
"offset": 0 if unlimited else (page - 1) * per_page if per_page else 0,
|
||||
"sort": sort,
|
||||
"direction": direction,
|
||||
}
|
||||
|
||||
# compute requested relations once
|
||||
base_cols, rel_cols = split_fields(Model, fields)
|
||||
skip_rels = set(rel_cols.keys()) if fields else set()
|
||||
|
||||
# 1) per-model override first
|
||||
rows_any: Any = call(Model, "ui_query", db.session, **qkwargs)
|
||||
|
||||
stmt: Select | None = None
|
||||
total: int
|
||||
|
||||
if rows_any is None:
|
||||
stmt = default_select(Model, text=text, sort=sort, direction=direction, eager=False)
|
||||
|
||||
if not fields:
|
||||
stmt = apply_model_default_eager(stmt, Model, skip_rels=set())
|
||||
else:
|
||||
stmt = apply_field_loaders(stmt, Model, fields)
|
||||
|
||||
stmt = ensure_order_by(stmt, Model, sort=sort, direction=direction)
|
||||
|
||||
elif isinstance(rows_any, Select):
|
||||
# TRUST ui_query; don't add loaders on top
|
||||
stmt = ensure_order_by(rows_any, Model, sort=sort, direction=direction)
|
||||
|
||||
elif isinstance(rows_any, list):
|
||||
# materialized list; paginate in python
|
||||
total = len(rows_any)
|
||||
if unlimited:
|
||||
rows = rows_any
|
||||
else:
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
rows = rows_any[start:end]
|
||||
# serialize and return at the bottom like usual
|
||||
else:
|
||||
# SQLAlchemy Result-like or generic iterable
|
||||
scalars = getattr(rows_any, "scalars", None)
|
||||
if callable(scalars):
|
||||
all_rows = list(cast(ScalarResult[Any], scalars()))
|
||||
total = len(all_rows)
|
||||
rows = all_rows if unlimited else all_rows[(page - 1) * per_page : (page * per_page)]
|
||||
else:
|
||||
try:
|
||||
all_rows = list(rows_any)
|
||||
total = len(all_rows)
|
||||
rows = all_rows if unlimited else all_rows[(page - 1) * per_page : (page * per_page)]
|
||||
except TypeError:
|
||||
total = 1
|
||||
rows = [rows_any]
|
||||
|
||||
# If we have a real Select, run it once (unlimited) or paginate once.
|
||||
if stmt is not None:
|
||||
if unlimited:
|
||||
rows = list(db.session.execute(stmt).scalars())
|
||||
total = count_for(db.session, stmt)
|
||||
else:
|
||||
pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False)
|
||||
rows = pagination.items
|
||||
total = pagination.total
|
||||
|
||||
# Serialize
|
||||
if fields:
|
||||
items = []
|
||||
for r in rows:
|
||||
row = {"id": r.id}
|
||||
for f in fields:
|
||||
if '.' in f:
|
||||
rel, attr = f.split('.', 1)
|
||||
rel_obj = getattr(r, rel, None)
|
||||
row[f] = getattr(rel_obj, attr, None) if rel_obj else None
|
||||
else:
|
||||
row[f] = getattr(r, f, None)
|
||||
items.append(row)
|
||||
else:
|
||||
items = [
|
||||
(call(Model, "ui_serialize", r, view=view) or default_serialize(Model, r, view=view))
|
||||
for r in rows
|
||||
]
|
||||
|
||||
# Views
|
||||
want_option = (request.args.get("view") == "option")
|
||||
want_list = (request.args.get("view") == "list")
|
||||
want_table = (request.args.get("view") == "table")
|
||||
|
||||
if want_option:
|
||||
return render_template("fragments/_option_fragment.html", options=items)
|
||||
if want_list:
|
||||
return render_template("fragments/_list_fragment.html", options=items)
|
||||
if want_table:
|
||||
resp = make_response(render_template("fragments/_table_data_fragment.html",
|
||||
rows=items, model_name=model_name))
|
||||
resp.headers['X-Total'] = str(total)
|
||||
resp.headers['X-Page'] = str(page)
|
||||
resp.headers['X-PAges'] = str((0 if unlimited else ((total + per_page - 1) // per_page)))
|
||||
resp.headers['X-Per-Page'] = str(per_page)
|
||||
return resp
|
||||
|
||||
return jsonify({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (0 if unlimited else ((total + per_page - 1) // per_page))
|
||||
})
|
||||
|
||||
@bp.post("/<model_name>/create")
|
||||
def create_item(model_name):
|
||||
Model = get_model_class(model_name)
|
||||
payload: dict[str, Any] = request.get_json(silent=True) or {}
|
||||
if not payload:
|
||||
return jsonify({"error": "Payload required"}), 422
|
||||
try:
|
||||
obj = call(Model, 'ui_create', db.session, payload=payload) \
|
||||
or default_create(db.session, Model, payload)
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
return jsonify({"error": "Duplicate"}), 409
|
||||
data = call(Model, 'ui_serialize', obj) or default_serialize(Model, obj)
|
||||
want_html = (request.args.get('view') == 'option') or ("HX-Request" in request.headers)
|
||||
if want_html:
|
||||
return "Yo."
|
||||
return jsonify(data), 201
|
||||
|
||||
@bp.post("/<model_name>/update")
|
||||
def update_item(model_name):
|
||||
Model = get_model_class(model_name)
|
||||
payload: dict[str, Any] = request.get_json(silent=True) or {}
|
||||
|
||||
id_raw: Any = payload.get("id")
|
||||
if isinstance(id_raw, bool): # bool is an int subclass; explicitly ban
|
||||
return jsonify({"error": "Invalid id"}), 422
|
||||
try:
|
||||
id_ = int(id_raw) # will raise on None, '', junk
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid id"}), 422
|
||||
|
||||
obj = call(Model, 'ui_update', db.session, id_=id_, payload=payload) \
|
||||
or default_update(db.session, Model, id_, payload)
|
||||
if not obj:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
return ("", 204)
|
||||
|
||||
@bp.post("/<model_name>/delete")
|
||||
def delete_item(model_name):
|
||||
Model = get_model_class(model_name)
|
||||
payload: dict[str, Any] = request.get_json(silent=True) or {}
|
||||
ids_raw = payload.get("ids") or []
|
||||
if not isinstance(ids_raw, list):
|
||||
return jsonify({"error": "Invalid ids"}), 422
|
||||
try:
|
||||
ids: List[int] = [int(x) for x in ids_raw]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid ids"}), 422
|
||||
try:
|
||||
deleted = call(Model, 'ui_delete', db.session, ids=ids) \
|
||||
or default_delete(db.session, Model, ids)
|
||||
except IntegrityError as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"error": "Constraint", "detail": str(e)}), 409
|
||||
return jsonify({"deleted": deleted}), 200
|
||||
|
||||
@bp.get("/<model_name>/value")
|
||||
def get_value(model_name):
|
||||
Model = get_model_class(model_name)
|
||||
|
||||
field = (request.args.get("field") or "").strip()
|
||||
if not field:
|
||||
return jsonify({"error": "field required"}), 422
|
||||
|
||||
id_raw = request.args.get("id")
|
||||
try:
|
||||
id_ = int(id_raw)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid id"}), 422
|
||||
|
||||
# per-model override hook: ui_value(session, id_: int, field: str) -> Any
|
||||
try:
|
||||
val = call(Model, "ui_value", db.session, id_=id_, field=field)
|
||||
if val is None:
|
||||
val = default_value(db.session, Model, id_=id_, field=field)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
# If HTMX hit this, keep the response boring and small
|
||||
if request.headers.get("HX-Request"):
|
||||
# text/plain keeps htmx happy for innerHTML swaps
|
||||
return (str(val) if val is not None else ""), 200, {"Content-Type": "text/plain; charset=utf-8"}
|
||||
|
||||
return jsonify({"id": id_, "field": field, "value": val})
|
||||
|
||||
@bp.get("<model_name>/values")
|
||||
def get_values(model_name):
|
||||
Model = get_model_class(model_name)
|
||||
|
||||
raw = request.args.get("fields") or ""
|
||||
parts = [p for p in raw.split(",") if p.strip()]
|
||||
parts.extend(request.args.getlist("field"))
|
||||
|
||||
id_raw = request.args.get("id")
|
||||
try:
|
||||
id_ = int(id_raw)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid id"}), 422
|
||||
|
||||
try:
|
||||
data = call(Model, "ui_values", db.session, id_=id_, fields=parts) \
|
||||
or default_values(db.session, Model, id_=id_, fields=parts)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
return jsonify({"id": id_, "fields": parts, "values": data})
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
from sqlalchemy import select, asc as sa_asc, desc as sa_desc, or_, func
|
||||
from sqlalchemy.inspection import inspect
|
||||
from sqlalchemy.orm import class_mapper, joinedload, selectinload
|
||||
from sqlalchemy.sql import Select
|
||||
from sqlalchemy.sql.sqltypes import String, Unicode, Text
|
||||
from typing import Any, Optional, cast, Iterable
|
||||
|
||||
PREFERRED_LABELS = ("identifier", "name", "first_name", "last_name", "description")
|
||||
|
||||
def _columns_for_text_search(Model):
|
||||
mapper = inspect(Model)
|
||||
cols = []
|
||||
for c in mapper.columns:
|
||||
if isinstance(c.type, (String, Unicode, Text)):
|
||||
cols.append(getattr(Model, c.key))
|
||||
|
||||
return cols
|
||||
|
||||
def _mapped_column(Model, attr):
|
||||
"""Return the mapped column attr on the class (InstrumentedAttribute) or None"""
|
||||
mapper = inspect(Model)
|
||||
if attr in mapper.columns.keys():
|
||||
return getattr(Model, attr)
|
||||
for prop in mapper.column_attrs:
|
||||
if prop.key == attr:
|
||||
return getattr(Model, prop.key)
|
||||
return None
|
||||
|
||||
def infer_label_attr(Model):
|
||||
explicit = getattr(Model, 'ui_label_attr', None)
|
||||
if explicit:
|
||||
if _mapped_column(Model, explicit) is not None:
|
||||
return explicit
|
||||
raise RuntimeError(f"ui_label_attr '{explicit}' on {Model.__name__} is not a mapped column")
|
||||
|
||||
for a in PREFERRED_LABELS:
|
||||
if _mapped_column(Model, a) is not None:
|
||||
return a
|
||||
raise RuntimeError(f"No label-like mapped column on {Model.__name__} (tried {PREFERRED_LABELS})")
|
||||
|
||||
def count_for(session, stmt: Select) -> int:
|
||||
# strip ORDER BY for efficiency
|
||||
subq = stmt.order_by(None).subquery()
|
||||
count_stmt = select(func.count()).select_from(subq)
|
||||
return session.execute(count_stmt).scalar_one()
|
||||
|
||||
def ensure_order_by(stmt, Model, sort=None, direction="asc"):
|
||||
try:
|
||||
has_order = bool(getattr(stmt, '_order_by_clauses', None))
|
||||
except Exception:
|
||||
has_order = False
|
||||
if has_order:
|
||||
return stmt
|
||||
|
||||
cols = []
|
||||
|
||||
if sort and hasattr(Model, sort):
|
||||
col = getattr(Model, sort)
|
||||
cols.append(col.desc() if direction == "desc" else col.asc())
|
||||
|
||||
if not cols:
|
||||
ui_order_cols = getattr(Model, 'ui_order_cols', ())
|
||||
for name in ui_order_cols or ():
|
||||
c = getattr(Model, name, None)
|
||||
if c is not None:
|
||||
cols.append(c.asc())
|
||||
|
||||
if not cols:
|
||||
for pk_col in inspect(Model).primary_key:
|
||||
cols.append(pk_col.asc())
|
||||
|
||||
return stmt.order_by(*cols)
|
||||
|
||||
def default_select(
|
||||
Model,
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
sort: Optional[str] = None,
|
||||
direction: str = "asc",
|
||||
eager = False,
|
||||
skip_rels=frozenset()
|
||||
) -> Select[Any]:
|
||||
stmt: Select[Any] = select(Model)
|
||||
|
||||
# search
|
||||
ui_search = getattr(Model, "ui_search", None)
|
||||
if callable(ui_search) and text:
|
||||
stmt = cast(Select[Any], ui_search(stmt, text))
|
||||
elif text:
|
||||
# optional generic search fallback if you used this in default_query
|
||||
t = f"%{text}%"
|
||||
text_cols = _columns_for_text_search(Model) # your existing helper
|
||||
if text_cols:
|
||||
stmt = stmt.where(or_(*(col.ilike(t) for col in text_cols)))
|
||||
|
||||
# sorting
|
||||
if sort:
|
||||
ui_sort = getattr(Model, "ui_sort", None)
|
||||
if callable(ui_sort):
|
||||
stmt = cast(Select[Any], ui_sort(stmt, sort, direction))
|
||||
else:
|
||||
col = getattr(Model, sort, None)
|
||||
if col is not None:
|
||||
stmt = stmt.order_by(sa_desc(col) if direction == "desc" else sa_asc(col))
|
||||
else:
|
||||
ui_order_cols = getattr(Model, "ui_order_cols", ())
|
||||
if ui_order_cols:
|
||||
order_cols = []
|
||||
for name in ui_order_cols:
|
||||
col = getattr(Model, name, None)
|
||||
if col is not None:
|
||||
order_cols.append(sa_asc(col))
|
||||
if order_cols:
|
||||
stmt = stmt.order_by(*order_cols)
|
||||
|
||||
# eagerload defaults
|
||||
opts_attr = getattr(Model, "ui_eagerload", ())
|
||||
if callable(opts_attr):
|
||||
opts = cast(Iterable[Any], opts_attr()) # if you prefer, pass Model in
|
||||
else:
|
||||
opts = cast(Iterable[Any], opts_attr)
|
||||
for opt in opts:
|
||||
stmt = stmt.options(opt)
|
||||
|
||||
if eager:
|
||||
for prop in class_mapper(Model).relationships:
|
||||
if prop.key in skip_rels:
|
||||
continue
|
||||
lazy = getattr(prop, "lazy", None)
|
||||
if lazy in ("joined", "subquery"):
|
||||
stmt = stmt.options(joinedload(getattr(Model, prop.key)))
|
||||
elif lazy == "selectin":
|
||||
stmt = stmt.options(selectinload(getattr(Model, prop.key)))
|
||||
return stmt
|
||||
|
||||
def default_query(
|
||||
session,
|
||||
Model,
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
limit: int = 0,
|
||||
offset: int = 0,
|
||||
sort: Optional[str] = None,
|
||||
direction: str = "asc",
|
||||
) -> list[Any]:
|
||||
"""
|
||||
SA 2.x ONLY. Returns list[Model].
|
||||
|
||||
Hooks:
|
||||
- ui_search(stmt: Select, text: str) -> Select
|
||||
- ui_sort(stmt: Select, sort: str, direction: str) -> Select
|
||||
- ui_order_cols: tuple[str, ...] # default ordering columns
|
||||
"""
|
||||
stmt: Select[Any] = select(Model)
|
||||
|
||||
ui_search = getattr(Model, "ui_search", None)
|
||||
if callable(ui_search) and text:
|
||||
stmt = cast(Select[Any], ui_search(stmt, text))
|
||||
elif text:
|
||||
t = f"%{text}%"
|
||||
text_cols = _columns_for_text_search(Model)
|
||||
if text_cols:
|
||||
stmt = stmt.where(or_(*(col.ilike(t) for col in text_cols)))
|
||||
|
||||
if sort:
|
||||
ui_sort = getattr(Model, "ui_sort", None)
|
||||
if callable(ui_sort):
|
||||
stmt = cast(Select[Any], ui_sort(stmt, sort, direction))
|
||||
else:
|
||||
col = getattr(Model, sort, None)
|
||||
if col is not None:
|
||||
stmt = stmt.order_by(sa_desc(col) if direction == "desc" else sa_asc(col))
|
||||
else:
|
||||
order_cols = getattr(Model, "ui_order_cols", ())
|
||||
if order_cols:
|
||||
for colname in order_cols:
|
||||
col = getattr(Model, colname, None)
|
||||
if col is not None:
|
||||
stmt = stmt.order_by(sa_asc(col))
|
||||
|
||||
if offset:
|
||||
stmt = stmt.offset(offset)
|
||||
if limit > 0:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
opts_attr = getattr(Model, "ui_eagerload", ())
|
||||
|
||||
opts: Iterable[Any]
|
||||
if callable(opts_attr):
|
||||
opts = cast(Iterable[Any], opts_attr()) # if you want, pass Model to it: opts_attr(Model)
|
||||
else:
|
||||
opts = cast(Iterable[Any], opts_attr)
|
||||
|
||||
for opt in opts:
|
||||
stmt = stmt.options(opt)
|
||||
|
||||
return list(session.execute(stmt).scalars().all())
|
||||
|
||||
def _resolve_column(Model, path: str):
|
||||
"""Return (selectable, joins:list[tuple[parent, attr]]) for 'col' or 'rel.col'"""
|
||||
if '.' not in path:
|
||||
col = _mapped_column(Model, path)
|
||||
if col is None:
|
||||
raise ValueError(f"Column '{path}' is not a mapped column on {Model.__name__}")
|
||||
return col, []
|
||||
rel_name, rel_field = path.split('.', 1)
|
||||
rel_attr = getattr(Model, rel_name, None)
|
||||
if getattr(rel_attr, 'property', None) is None:
|
||||
raise ValueError(f"Column '{path}' is not a valid relationship on {Model.__name__}")
|
||||
Rel = rel_attr.property.mapper.class_
|
||||
col = _mapped_column(Rel, rel_field)
|
||||
if col is None:
|
||||
raise ValueError(f"Column '{path}' is not a mapped column on {Rel.__name__}")
|
||||
return col, [(Model, rel_name)]
|
||||
|
||||
def default_values(session, Model, *, id_: int, fields: Iterable[str]) -> dict[str, Any]:
|
||||
fields = [f.strip() for f in fields if f.strip()]
|
||||
if not fields:
|
||||
raise ValueError("No fields provided for default_values")
|
||||
|
||||
mapper = inspect(Model)
|
||||
pk = mapper.primary_key[0]
|
||||
|
||||
selects = []
|
||||
joins = []
|
||||
for f in fields:
|
||||
col, j = _resolve_column(Model, f)
|
||||
selects.append(col.label(f.replace('.', '_')))
|
||||
joins.extend(j)
|
||||
|
||||
seen = set()
|
||||
stmt = select(*selects).where(pk == id_)
|
||||
current = Model
|
||||
for parent, attr_name in joins:
|
||||
key = (parent, attr_name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
stmt = stmt.join(getattr(parent, attr_name))
|
||||
|
||||
row = session.execute(stmt).one_or_none()
|
||||
if row is None:
|
||||
return {}
|
||||
|
||||
allow = getattr(Model, "ui_value_allow", None)
|
||||
if allow:
|
||||
for f in fields:
|
||||
if f not in allow:
|
||||
raise ValueError(f"Field '{f}' not allowed")
|
||||
|
||||
data = {}
|
||||
for f in fields:
|
||||
key = f.replace('.', '_')
|
||||
data[f] = getattr(row, key, None)
|
||||
return data
|
||||
|
||||
def default_value(session, Model, *, id_: int, field: str) -> Any:
|
||||
if '.' not in field:
|
||||
col = _mapped_column(Model, field)
|
||||
if col is None:
|
||||
raise ValueError(f"Field '{field}' is not a mapped column on {Model.__name__}")
|
||||
pk = inspect(Model).primary_key[0]
|
||||
return session.scalar(select(col).where(pk == id_))
|
||||
|
||||
rel_name, rel_field = field.split('.', 1)
|
||||
rel_attr = getattr(Model, rel_name, None)
|
||||
if rel_attr is None or not hasattr(rel_attr, 'property'):
|
||||
raise ValueError(f"Field '{field}' is not a valid relationship on {Model.__name__}")
|
||||
|
||||
Rel = rel_attr.property.mapper.class_
|
||||
rel_col = _mapped_column(Rel, rel_field)
|
||||
if rel_col is None:
|
||||
raise ValueError(f"Field '{field}' is not a mapped column on {Rel.__name__}")
|
||||
|
||||
pk = inspect(Model).primary_key[0]
|
||||
stmt = select(rel_col).join(getattr(Model, rel_name)).where(pk == id_).limit(1)
|
||||
return session.scalar(stmt)
|
||||
|
||||
def default_create(session, Model, payload):
|
||||
label = infer_label_attr(Model)
|
||||
obj = Model(**{label: payload.get(label) or payload.get("name")})
|
||||
session.add(obj)
|
||||
session.commit()
|
||||
return obj
|
||||
|
||||
def default_update(session, Model, id_, payload):
|
||||
obj = session.get(Model, id_)
|
||||
if not obj:
|
||||
return None
|
||||
|
||||
editable = getattr(Model, 'ui_editable_cols', None)
|
||||
|
||||
changed = False
|
||||
for k, v in payload.items():
|
||||
if k == 'id':
|
||||
continue
|
||||
|
||||
col = _mapped_column(Model, k)
|
||||
if col is None:
|
||||
continue
|
||||
|
||||
if editable and k not in editable:
|
||||
continue
|
||||
|
||||
if v == '' or v is None:
|
||||
nv = None
|
||||
else:
|
||||
try:
|
||||
nv = int(v) if col.type.python_type is int else v
|
||||
except Exception:
|
||||
nv = v
|
||||
|
||||
setattr(obj, k, nv)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
session.commit()
|
||||
return obj
|
||||
|
||||
def default_delete(session, Model, ids):
|
||||
count = 0
|
||||
for i in ids:
|
||||
obj = session.get(Model, i)
|
||||
if obj:
|
||||
session.delete(obj); count += 1
|
||||
session.commit()
|
||||
return count
|
||||
|
||||
def default_serialize(Model, obj, *, view=None):
|
||||
# 1. Explicit config wins
|
||||
label_attr = getattr(Model, 'ui_label_attr', None)
|
||||
|
||||
# 2. Otherwise, pick the first PREFERRED_LABELS that exists (can be @property or real column)
|
||||
if not label_attr:
|
||||
for candidate in PREFERRED_LABELS:
|
||||
if hasattr(obj, candidate):
|
||||
label_attr = candidate
|
||||
break
|
||||
|
||||
# 3. Fallback to str(obj) if literally nothing found
|
||||
if not label_attr:
|
||||
name_val = str(obj)
|
||||
else:
|
||||
try:
|
||||
name_val = getattr(obj, label_attr)
|
||||
except Exception:
|
||||
name_val = str(obj)
|
||||
|
||||
data = {'id': obj.id, 'name': name_val}
|
||||
|
||||
# Include extra attrs if defined
|
||||
for attr in getattr(Model, 'ui_extra_attrs', ()):
|
||||
if hasattr(obj, attr):
|
||||
data[attr] = getattr(obj, attr)
|
||||
|
||||
return data
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from ..models import User, Room, Inventory, WorkLog
|
||||
from .. import db
|
||||
|
||||
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.device_type),
|
||||
selectinload(Inventory.location).selectinload(Room.room_function)
|
||||
)
|
||||
|
||||
def eager_load_room_relationships(query):
|
||||
return query.options(
|
||||
joinedload(Room.area),
|
||||
joinedload(Room.room_function),
|
||||
selectinload(Room.inventory),
|
||||
selectinload(Room.users)
|
||||
)
|
||||
|
||||
def eager_load_worklog_relationships(query):
|
||||
return query.options(
|
||||
joinedload(WorkLog.contact),
|
||||
joinedload(WorkLog.work_item),
|
||||
joinedload(WorkLog.updates)
|
||||
)
|
||||
|
||||
def chunk_list(lst, chunk_size):
|
||||
return [lst[i:i + chunk_size] for i in range(0, len(lst), chunk_size)]
|
||||
|
||||
def add_named_entities(items: list[str], model, attr: str, mapper: dict | None = None):
|
||||
for name in items:
|
||||
clean = name.strip()
|
||||
if clean:
|
||||
new_obj = model(**{attr: clean})
|
||||
db.session.add(new_obj)
|
||||
if mapper is not None:
|
||||
db.session.flush()
|
||||
mapper[clean] = new_obj.id
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from inventory import create_app
|
||||
|
||||
app = create_app()
|
||||
Loading…
Add table
Add a link
Reference in a new issue