From bfebba3e9ed1c91ff07cdeb41ca42e32d42e85fa Mon Sep 17 00:00:00 2001 From: Cole Deck Date: Tue, 3 Sep 2024 16:50:04 -0500 Subject: [PATCH] Publish to git --- .gitignore | 1 + Dockerfile | 12 ++ README.md | 26 +++ db_classes.py | 298 ++++++++++++++++++++++++++++ docker-compose.yml | 36 ++++ inventory/__init__.py | 72 +++++++ inventory/components/__init__.py | 5 + inventory/components/footer.py | 32 +++ inventory/components/item.py | 24 +++ inventory/components/navbar.py | 163 +++++++++++++++ inventory/components/settings.py | 14 ++ inventory/components/testimonial.py | 56 ++++++ inventory/pages/__init__.py | 7 + inventory/pages/about_page.py | 63 ++++++ inventory/pages/add_page.py | 247 +++++++++++++++++++++++ inventory/pages/browse_page.py | 71 +++++++ inventory/pages/item_page.py | 101 ++++++++++ inventory/pages/login_page.py | 49 +++++ inventory/pages/root_page.py | 39 ++++ requirements.txt | 6 + rio.toml | 6 + search.py | 112 +++++++++++ 22 files changed, 1440 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 db_classes.py create mode 100644 docker-compose.yml create mode 100644 inventory/__init__.py create mode 100644 inventory/components/__init__.py create mode 100644 inventory/components/footer.py create mode 100644 inventory/components/item.py create mode 100644 inventory/components/navbar.py create mode 100644 inventory/components/settings.py create mode 100644 inventory/components/testimonial.py create mode 100644 inventory/pages/__init__.py create mode 100644 inventory/pages/about_page.py create mode 100644 inventory/pages/add_page.py create mode 100644 inventory/pages/browse_page.py create mode 100644 inventory/pages/item_page.py create mode 100644 inventory/pages/login_page.py create mode 100644 inventory/pages/root_page.py create mode 100644 requirements.txt create mode 100644 rio.toml create mode 100644 search.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e99e36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8a2c3f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +# Get runtime dependencies +# glx for OpenCV, ghostscript for datasheet PDF rendering, zbar for barcode scanning, git for cloning repos +#RUN apt-get update && apt-get install -y libgl1-mesa-glx ghostscript libzbar0 git && apt-get clean && rm -rf /var/lib/apt/lists +COPY requirements.txt ./ +#COPY config-server.yml config.yml +RUN pip3 install -r requirements.txt +COPY *.py *.txt *.toml ./ +COPY inventory ./inventory +CMD ["rio", "run", "--release", "--public", "--port", "8000"] +EXPOSE 8000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..eed70e1 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# inventory + +This is a placeholder README for your project. Use it to describe what your +project is about, to give new users a quick overview of what they can expect. + +_Inventory_ was created using [Rio](http://rio.dev/), an easy to +use app & website framework for Python._ + +This project is based on the `Multipage Website` template. + +## Multipage Website + +This is a simple website which shows off how to add multiple pages to your Rio +app. The website comes with a custom navbar that allows you to switch between +the different pages. + +The navbar is placed inside a `rio.Overlay` component, which makes it hover +above all other components. It contains buttons to switch between pages, and +also displays the currently active page by highlighting the corresponding +button. + +To avoid placing the app on each page individually, this app makes use of the +app's build method. That's right, build functions aren't just for components! +The app's build creates an instance of `RootPage`, which in turn displays the +navbar and a `rio.PageView`. The currently active page is then always displayed +inside of that page view. diff --git a/db_classes.py b/db_classes.py new file mode 100644 index 0000000..53aff93 --- /dev/null +++ b/db_classes.py @@ -0,0 +1,298 @@ +from peewee import * +from search import InventorySearch as ivs + +db = MySQLDatabase('inventory', thread_safe=True, user='inventory', password='nfrwnfprifbwef', host='database', port=3306) +search = None + +class user(Model): + name = CharField() + username = CharField(unique=True, primary_key=True) + password = CharField() # replace with AD or something!! + changepw = BooleanField(default=False) + + class Meta: + database = db + legacy_table_names = False + +class office(Model): + name = CharField(unique=True) + officeid = AutoField() + + class Meta: + database = db + legacy_table_names = False + +class location(Model): + name = CharField() + locationid = AutoField() + description = CharField(null=True) + + parent = ForeignKeyField('self', null=True, backref="sublocations") + + class Meta: + database = db + legacy_table_names = False + + + +class item(Model): + loc = ForeignKeyField(location, backref="items_here", null=True) + office = ForeignKeyField(office, backref="items_here", null=True) + fullname = CharField(null=True) + description = CharField(null=True) + serial = CharField(null=True) + checkout = BooleanField(default=False) + checkout_user = ForeignKeyField(user, backref="items_held", null=True) + checkout_start = DateTimeField(null=True) + checkout_end = DateTimeField(null=True) + mac = CharField(null=True) + barcode = CharField(unique=True, primary_key=True) + fwver = CharField(null=True) + manufacturer = CharField() + + + last_user = ForeignKeyField(user, null=True) # remove null=True once user auth works + + class Meta: + database = db + legacy_table_names = False + +class component(Model): + owner = ForeignKeyField(item, backref="components") + name = CharField() + description = CharField(null=True) + barcode = CharField(unique=True, primary_key=True) + serial = CharField(null=True) + + class Meta: + database = db + legacy_table_names = False + +def init(): + print("Connecting to database...") + db.connect() + print("Checking & creating tables...") + db.create_tables([location, office, item, component, user]) + print("Database initialized.") + global search + print("Creating cache index...") + search = ivs() + add = item.select().dicts() + #print(add) + #print(type(add)) + for itm in add: + print(itm) + #print(type(itm)) + search.add_document(itm) + print("Cache build complete.") + +def search_item(query, filters: dict={}): + #print(filters) + if len(filters) > 0: + filt = "" + for key, val in filters.items(): + if key == "office": + # convert to integer! + if val != 'all': + if len(office.select().where(office.name == val).dicts()) > 0: + val2 = str(office.select().where(office.name == val).dicts()[0]['officeid']) + #print(val2) + else: + # office does not have any parts + val2 = str(999999) + filt += key + " = " + val2 + " AND " + else: + continue + + else: + filt += key + " = " + val + " AND " + filt = filt[0:-4] # remove extra and + #print(filt) + return search.search(query, filt)["hits"] + else: + return search.search(query, "")["hits"] + +def find_item(barcode): + return search.get_barcode(barcode) + +def create_item(fullname, serial, officename, barcode, location=None, description=None, manufacturer=None, mac=None, fwver=None): + try: + off = office(name=officename) + off.save(force_insert=True) + except IntegrityError: + pass + try: + off = office.select().where(office.name == officename)[0] + itm = item(office=off, barcode=barcode, fullname=fullname, description=description, loc=location, serial=serial, mac=mac, fwver=fwver, manufacturer=manufacturer) + itm.save(force_insert=True) + search.add_document(item.select().where(item.barcode==barcode).dicts()[0]) + print("item: " + itm.fullname) + return itm + except IntegrityError: + print("Duplicate item " + fullname) + return False + +def delete_item(itm): + #item.delete(itm) + itm.delete_instance() + +def item_location_str(itm): + try: + return itm.loc.name + except: + if itm.checkout: + return "Checked out to unknown location" + else: + return "Unknown" + +def create_component(parentitem, name, barcode, serial=None, description=None): + itm = parentitem + try: + cmp = component(owner=itm, name=name, barcode=barcode, description=description, serial=serial) + cmp.save(force_insert=True) + print("component: " + cmp.name) + return cmp + except IntegrityError: + print("Duplicate component " + name) + return False + +def get_item(barcode): + query = item.select().where(item.barcode == barcode) + if len(query) == 1: + return query[0] + + # check if component exists + return get_component(barcode) + + +def get_component(barcode): + query = component.select().where(component.barcode == barcode) + if len(query) == 1: + return query[0] + + return False + +def create_user(name, username, password, changepw=False): + try: + usr = user(username=username, name=name, password=password, changepw=changepw) + usr.save(force_insert=True) + return usr + except IntegrityError: + print("User " + username + " already exists.") + return False + +def change_password(username, password): + usr = get_user(username) + if usr: + usr.password = password + user.changepw = False + usr.save(force_insert=True) + return True + else: + return False + +def checkout(user, barcode, loc=None): + itm = get_item(barcode) + if itm: + itm.checkout = True + itm.checkout_user = user + itm.loc = loc + itm.save() + return itm + else: + return False + +def checkin(user, barcode, loc=None): + itm = get_item(barcode) + if itm: + itm.checkout = False + itm.last_user = user + itm.loc = loc + itm.save() + return itm + else: + return False + + +def create_location(name, parent=None): + if parent is not None: + loc = location(name=name, parent=parent) + loc.save() + return loc + else: + return False + +def _find_parent(loc, parent): + if hasattr(loc, 'parent'): + if loc.parent.locationid == parent.locationid: + return True + else: + return _find_parent(loc.parent, parent) + else: + return False + +def get_location(name, parent=None): + try: + query = location.select().where(location.name == name) + if parent is not None: + for loc in query: + if _find_parent(loc, parent): + return loc + return False + + else: + if len(query) == 1: + return query[0] + else: + return False + except: + return False + +def get_user(name): + query = user.select().where(user.username == name) + if len(query) == 1: + return query[0] + + query = user.select().where(user.name == name) + if len(query) == 1: + return query[0] + + return False + +def user_login(username, password): + user = get_user(username) + if user: + return user.password == password + else: + return False + +def test(): + costa = create_user("Costa Aralis", "caralpwtwfpis", "12345") + costa.username = "caralis" + costa.save(force_insert=True) + + office = location(name="Chicago CIC") + office.save() + shelf2a = location(name="Shelf 2A", parent=office) + shelf2a.save() + + create_item("BRS50", "BRS50-00242Q2Q-STCY99HHSESXX.X.XX", "12345678", location=shelf2a) + + create_item("BRS50", "BRS50-00242W2W-STCY99HHSESXX.X.XX", "123456789", location=office) + + #brs50 = part(name="BRS50", description="it's a frickin BRS dude", quantity=1) + #brs50.save() + #mybrs = item(owner=brs50, fullname="BRS50-00242Q2Q-STCY99HHSESXX.X.XX", description="This one has 6 dead ports. RIP", loc=shelf2a, barcode="tlg4276p4dj85697") + #mybrs.save() + print("Querying...") + #query = part.select() + query = item.select().where(item.name == "BRS50") + + for brs in query: + for itm in brs.items: + print(itm.fullname) + + +if __name__ == "__main__": + init() + test() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a177d9f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + meilisearch: + image: "getmeili/meilisearch:v1.10.1" + ports: + - "7700:7700" + environment: + MEILI_MASTER_KEY: fluffybunnyrabbit + MEILI_NO_ANALYTICS: true +# volumes: +# - "meili_data:/meili_data" + database: + image: mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: lrnigwurbuwfbiowfnwrf + MYSQL_USER: inventory + MYSQL_PASSWORD: nfrwnfprifbwef + MYSQL_DATABASE: inventory + ports: + - "3306:3306" + volumes: + - '/root/db:/var/lib/mysql' + + inventory: + build: . + init: true + ports: + - "8000:8000" + environment: + - PYTHONUNBUFFERED=1 + depends_on: + - meilisearch + - database + +volumes: + meili_data: \ No newline at end of file diff --git a/inventory/__init__.py b/inventory/__init__.py new file mode 100644 index 0000000..48bd443 --- /dev/null +++ b/inventory/__init__.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path +from typing import * # type: ignore + +import rio + +from . import pages +from . import components as comps + +from db_classes import * + +# Define a theme for Rio to use. +# +# You can modify the colors here to adapt the appearance of your app or website. +# The most important parameters are listed, but more are available! You can find +# them all in the docs +# +# https://rio.dev/docs/api/theme +theme = rio.Theme.from_colors( + secondary_color=rio.Color.from_hex("004990ff"), + primary_color=rio.Color.from_hex("004990ff"), + neutral_color=rio.Color.from_hex("b1cad8FF"), + light=True, +) + +init() +# Create a dataclass that inherits from rio.UserSettings. This indicates to +# Rio that these are settings and should be persisted. + +# Create the Rio app +app = rio.App( + name='Inventory', + default_attachments=[ + comps.Settings(), + ], + pages=[ + rio.Page( + name="Home", + page_url='', + build=pages.BrowsePage, + ), + + rio.Page( + name="AboutPage", + page_url='about-page', + build=pages.AboutPage, + ), + + rio.Page( + name="AddPage", + page_url='add', + build=pages.AddPage, + ), + rio.Page( + name="ItemPage", + page_url='item', + build=pages.ItemPage, + ), + ], + # You can optionally provide a root component for the app. By default, + # a simple `rio.PageView` is used. By providing your own component, you + # can create components which stay put while the user navigates between + # pages, such as a navigation bar or footer. + # + # When you do this, make sure your component contains a `rio.PageView` + # so the currently active page is still visible. + build=pages.RootPage, + theme=theme, + assets_dir=Path(__file__).parent / "assets", +) + diff --git a/inventory/components/__init__.py b/inventory/components/__init__.py new file mode 100644 index 0000000..d8c407f --- /dev/null +++ b/inventory/components/__init__.py @@ -0,0 +1,5 @@ +from .footer import Footer +from .navbar import Navbar +from .testimonial import Testimonial +from .item import Item +from .settings import Settings diff --git a/inventory/components/footer.py b/inventory/components/footer.py new file mode 100644 index 0000000..09648e8 --- /dev/null +++ b/inventory/components/footer.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio + +from .. import components as comps + +class Footer(rio.Component): + """ + A simple, static component which displays a footer with the company name and + website name. + """ + + def build(self) -> rio.Component: + return rio.Card( + content=rio.Column( + rio.Icon("rio/logo:fill", width=5, height=5), + rio.Text("Buzzwordz Inc."), + rio.Text( + "Hyper Dyper Website", + style="dim", + ), + spacing=1, + margin=2, + align_x=0.5, + ), + color="hud", + corner_radius=0, + ) + diff --git a/inventory/components/item.py b/inventory/components/item.py new file mode 100644 index 0000000..c4f0a9e --- /dev/null +++ b/inventory/components/item.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio + +from .. import components as comps + +from db_classes import * + +class Item(rio.Component): + + itemcode: str = "" + def build(self) -> rio.Component: + if 'current_item' in self.session: + self.itemcode = self.session['current_item'] + return rio.Card( + rio.Markdown( + self.markdown, + margin=2, + ) + ) + diff --git a/inventory/components/navbar.py b/inventory/components/navbar.py new file mode 100644 index 0000000..6dbe6e3 --- /dev/null +++ b/inventory/components/navbar.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio + +from .. import components as comps + + +class Navbar(rio.Component): + """ + A navbar with a fixed position and responsive width. + """ + + # Make sure the navbar will be rebuilt when the app navigates to a different + # page. While Rio automatically detects state changes and rebuilds + # components as needed, navigating to other pages is not considered a state + # change, since it's not stored in the component. + # + # Instead, we can use Rio's `on_page_change` event to trigger a rebuild of + # the navbar when the page changes. + @rio.event.on_page_change + async def on_page_change(self) -> None: + # Rio comes with a function specifically for this. Whenever Rio is + # unable to detect a change automatically, use this function to force a + # refresh. + self.office = self.session[comps.Settings].office + + + #print(self.office) + await self.force_refresh() + + + checkpage: str = "in" + office: str = "" + def sub_page(self, event: rio.DropdownChangeEvent): + page = event.value + self.session.navigate_to("/" + page) + self.checkpage = "n/a" + + def set_office(self, event: rio.DropdownChangeEvent): + settings = self.session[comps.Settings] + settings.office = event.value + self.session.attach(self.session[comps.Settings]) + #print(settings.office) + self.office = event.value + + @rio.event.on_populate + def set_office_init(self): + self.office = self.session[comps.Settings].office + print(self.office) + + def build(self) -> rio.Component: + + # Which page is currently active? This will be used to highlight the + # correct navigation button. + # + # `active_page_instances` contains the same `rio.Page` instances that + # you've passed the app during creation. Since multiple pages can be + # active at a time (e.g. /foo/bar/baz), this is a list. + active_page = self.session.active_page_instances[0] + active_page_url_segment = active_page.page_url + + # The navbar should appear above all other components. This is easily + # done by using a `rio.Overlay` component. + return rio.Overlay( + rio.Row( + rio.Spacer(), + # Use a card for visual separation + rio.Rectangle( + content=rio.Row( + # Links can be used to navigate to other pages and + # external URLs. You can pass either a simple string, or + # another component as their content. + rio.Link( + rio.Button( + "Browse", + icon="material/info", + style=( + "major" + if active_page_url_segment == "" + else "plain" + ), + ), + "/", + ), + # This spacer will take up any superfluous space, + # effectively pushing the subsequent buttons to the + # right. + rio.Spacer(), + # By sticking buttons into a `rio.Link`, we can easily + # make the buttons navigate to other pages, without + # having to write an event handler. Notice how there is + # no Python function called when the button is clicked. + rio.Dropdown( + options={ + "ALL": "all", + "US-CHI": "us-chi", + "US-SC": "us-sc", + "DE-NT": "de-nt", + "CN-SHA": "cn-sha", + "IN-BAN": "in-ban" + }, + on_change=self.set_office, + selected_value=self.bind().office, + ), + rio.Dropdown( + options={ + "Scan...": "n/a", + "Check in": "in", + "Check out": "out" + }, + on_change=self.sub_page, + selected_value=self.bind().checkpage, + ), + rio.Link( + rio.Button( + "Add", + icon="material/news", + style=( + "major" + if active_page_url_segment == "add" + else "plain" + ), + ), + "/add", + ), + # Same game, different button + rio.Link( + rio.Button( + "About", + icon="material/info", + style=( + "major" + if active_page_url_segment == "about-page" + else "plain" + ), + ), + "/about-page", + ), + spacing=1, + margin=1, + ), + fill=self.session.theme.neutral_color, + corner_radius=self.session.theme.corner_radius_medium, + shadow_radius=0.8, + shadow_color=self.session.theme.shadow_color, + shadow_offset_y=0.2, + ), + rio.Spacer(), + # Proportions are an easy way to make the navbar's size relative + # to the screen. This assigns 5% to the first spacer, 90% to the + # navbar, and 5% to the second spacer. + proportions=(0.5, 9, 0.5), + # Overlay assigns the entire screen to its child component. + # Since the navbar isn't supposed to take up all space, assign + # an alignment. + align_y=0, + margin=2, + ) + ) + diff --git a/inventory/components/settings.py b/inventory/components/settings.py new file mode 100644 index 0000000..e2be00f --- /dev/null +++ b/inventory/components/settings.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio + +from .. import components as comps + + +class Settings(rio.UserSettings): + language: str = "en" + office: str = "us-chi" + selected_item: str = "" diff --git a/inventory/components/testimonial.py b/inventory/components/testimonial.py new file mode 100644 index 0000000..11b7d4b --- /dev/null +++ b/inventory/components/testimonial.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio + +from .. import components as comps + +class Testimonial(rio.Component): + """ + Displays 100% legitimate testimonials from real, totally not made-up people. + """ + + # The quote somebody has definitely said about this company. + quote: str + + # Who said the quote, probably Mark Twain. + name: str + + # The company the person is from. + company: str + + def build(self) -> rio.Component: + # Wrap everything in a card to make it stand out from the background. + return rio.Card( + # A second card, but this one is offset a bit. This allows the outer + # card to pop out a bit, displaying a nice colorful border at the + # bottom. + rio.Card( + # Combine the quote, name, and company into a column. + rio.Column( + rio.Markdown(self.quote), + rio.Text( + f"— {self.name}", + justify="left", + ), + rio.Text( + f"{self.company}", + # Dim text and icons are used for less important + # information and make the app more visually appealing. + style="dim", + justify="left", + ), + spacing=0.4, + margin=2, + align_y=0.5, + ), + margin_bottom=0.2, + ), + # Important colors such as primary, secondary, neutral and + # background are available as string constants for easy access. + color="primary", + width=20, + ) + diff --git a/inventory/pages/__init__.py b/inventory/pages/__init__.py new file mode 100644 index 0000000..978a679 --- /dev/null +++ b/inventory/pages/__init__.py @@ -0,0 +1,7 @@ +from .root_page import RootPage +from .about_page import AboutPage +from .add_page import AddPage +from .browse_page import BrowsePage +from .login_page import LoginPage +from .item_page import ItemPage + diff --git a/inventory/pages/about_page.py b/inventory/pages/about_page.py new file mode 100644 index 0000000..d4396ec --- /dev/null +++ b/inventory/pages/about_page.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio + +from .. import components as comps + +class AboutPage(rio.Component): + """ + A sample page, which displays a humorous description of the company. + """ + + def build(self) -> rio.Component: + return rio.Markdown( + """ +# About Us + +Welcome to Buzzwordz Inc.! Unleashing Synergistic Paradigms for Unprecedented +Excellence since the day after yesterday. + +## About Our Company + +At buzzwordz, we are all talk and no action. Our mission is to be the vanguards +of industry-leading solutions, leveraging bleeding-edge technologies to catapult +your business into the stratosphere of success. Our unparalleled team of ninjas, +gurus, and rockstars is dedicated to disrupting the status quo and actualizing +your wildest business dreams. We live, breathe, and eat operational excellence +and groundbreaking innovation. + +## Synergistic Consulting + +Unlock your business's quantum potential with our bespoke, game-changing +strategies. Our consulting services synergize cross-functional paradigms to +create a holistic ecosystem of perpetual growth and exponential ROI. Did I +mention paradigm-shifts? We've got those too. + +## Agile Hyper-Development + +We turn moonshot ideas into reality with our agile, ninja-level development +techniques. Our team of coding wizards crafts robust, scalable, and future-proof +solutions that redefine industry standards. 24/7 Proactive Hyper-Support + +Experience next-gen support that anticipates your needs before you do. Our +omnipresent customer happiness engineers ensure seamless integration, +frictionless operation, and infinite satisfaction, day and night. + +Embark on a journey of transformational growth and stratospheric success. Don't +delay, give us your money today. + +Phone: (123) 456-7890 + +Email: info@yourwebsite.com + +Address: 123 Main Street, City, Country + """, + width=60, + margin_bottom=4, + align_x=0.5, + align_y=0, + ) + diff --git a/inventory/pages/add_page.py b/inventory/pages/add_page.py new file mode 100644 index 0000000..ecf9f4c --- /dev/null +++ b/inventory/pages/add_page.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio +import datetime +from mac_vendor_lookup import AsyncMacLookup + +from db_classes import * +from .. import components as comps + +class AddPage(rio.Component): + partnum: str = "" + mac: str = "" + serial: str = "" + fwver: str = "" + code: str = "" + popup_message: str = "" + popup_show: bool = False + popup_color: str = 'warning' + date: datetime.date = datetime.date.today() + tz: datetime.tzinfo = datetime.tzinfo() + time: datetime.datetime = datetime.datetime.now() + time_start: str = datetime.datetime.now().strftime(format="%H:%M") + macvendor: str = "" + manu: str = "" + manufield: str = "" + office: str = "" + + @rio.event.periodic(1) + def set_office_init(self): + self.office = self.session[comps.Settings].office + #print("Populated:", self.office) + + async def check_mac(self,mac): + print("Checking", mac) + try: + macvendor = await AsyncMacLookup().lookup(mac) + if self.manufield == "" or self.manufield == self.macvendor: # blank or set by MAC already + self.manu = macvendor + self.manufield = macvendor + self.macvendor = macvendor + #print(macvendor) + except: + pass + # not valid MAC? + + async def check_all(self): + await self.check_mac(self.mac) + # check part number + # lookup in PL_Export_rel + + async def add_part(self): + await self.check_all() + if self.code == "": + # FAIL + self.popup_message = "\n Missing barcode! \n\n" + self.popup_show = True + self.popup_color = 'danger' + else: + # OK, add part + if create_item(self.partnum, self.serial, self.office, self.code, location=None, description=None, manufacturer=self.manu, mac=self.mac, fwver=self.fwver) == False: + self.popup_message = "\n Duplicate barcode! \n\n" + self.popup_show = True + self.popup_color = 'warning' + else: + self.popup_message = "\n Part added! \n\n" + self.popup_show = True + self.popup_color = 'success' + self.name: str = "" + self.partnum: str = "" + self.mac: str = "" + self.serial: str = "" + self.fwver: str = "" + self.code: str = "" + self.macvendor: str = "" + self.manu: str = "" + self.manufield: str = "" + + + async def _add_part_enter(self, event: rio.TextInputConfirmEvent): + await self.add_part() + + async def _add_part_button(self): + await self.add_part() + + @rio.event.periodic(1) + def update_time_view(self): + self.time_start = self.session.timezone.fromutc(datetime.datetime.now()).now().strftime(format="%m/%d/%Y %H:%M:%S") + self.time = datetime.datetime.now() + + def _set_time(self, event: rio.TextInputChangeEvent): + time_str = event.text + try: + time_obj = datetime.datetime.strptime(time_str, "%H:%M").time() + dt_with_time = datetime.datetime.combine(self.date, time_obj) + dt_with_tz = dt_with_time.replace(tzinfo=self.session.timezone) + self.time = dt_with_tz + #event.text = + #print(self.time) + except ValueError: + pass + + async def _update_mac(self, event: rio.TextInputChangeEvent): + await self.check_mac(event.text) + + def _update_partnum(self, event: rio.TextInputChangeEvent): + def __find_hm_header(txt): + searchlist = ["RSPS", "RSPE", "RSP", "RSB", "LRS", "RS", "OS", "RED", "MSP", "MSM", "MS", "MM", "EESX", "EES", "OZD", "OBR"] + for header in searchlist: + if txt.find(header) >= 0: + return txt.find(header) + len(header) + 2 + + def __find_header_general(txt): + acount = 0 + ncount = 0 + dash = False + for char in txt: + if char == '-': + dash = True + break + if char.isdigit(): + ncount += 1 + if char.isalpha(): + acount += 1 + + if dash and acount <= 5 and ncount <= 5: + return acount+ncount + return -1 + + def __find_hm_fwver(txt): + a = txt.find(".") + if a > 5: + return a-2 + return -1 + + def __find_hm_fwmode(txt): + a = __find_hm_fwver(txt) + #print(a) + if txt.find("BRS") == 0: + a -= 1 + if txt[a] in ['S', 'A']: + return txt[a] + a = __find_hm_fwver(txt) + if txt.find("GRS") == 0: + a -= 4 + if txt[a:a+2] in ['2S', '2A', '3S', '3A']: # 1040 + return txt[a:a+2] + elif txt[a+2:a+4] in ['2S', '2A', '3S', '3A']: # 1020/30 + return txt[a+2:a+4] + a = __find_hm_fwver(txt) + if txt.find("RSP") == 0 or txt.find("OS") == 0 or txt.find("MSP") == 0 or txt.find("EESX") == 0 or txt.find("RED"): + a -= 2 + if txt[a:a+2] in ['2S', '2A', '3S', '3A']: + return txt[a:a+2] + a = __find_hm_fwver(txt) + if txt.find("RS") == 0 or txt.find("MS") == 0: + a -= 3 + print(txt,txt[a]) + + if txt[a] in ['P', 'E']: + return txt[a] + a = __find_hm_fwver(txt) + if txt.find("EAGLE") == 0: + a -= 2 + if txt[a:a+2] in ['3F', 'MB', 'IN', 'UN', 'OP', '01', 'SU', 'NF']: + return txt[a:a+2] + print("Failed to match software level", repr(txt)) + return "" + + pn = event.text + self.name = "" + if __find_header_general(pn) >= 0: + # hirschmann switch detected + self.name = pn[0:__find_header_general(pn)] + if __find_hm_fwver(pn) >= 0: + self.fwver = pn[__find_hm_fwver(pn):] + if len(__find_hm_fwmode(pn)) > 0: + self.fwver += " SWL-" + __find_hm_fwmode(pn) + + def build(self) -> rio.Component: + return rio.Column( + rio.Popup( + anchor=rio.Text( + text="Add a part below:", + style='heading1', + align_x = 0.5 + ), + color=self.bind().popup_color, + is_open=self.bind().popup_show, + content=rio.Text( + text=self.bind().popup_message, + ), + + ), + rio.TextInput( + label="Barcode", + text=self.bind().code + ), + rio.TextInput( + label="Full part number", + text=self.bind().partnum, + on_change=self._update_partnum + ), + rio.TextInput( + label="Serial", + text=self.bind().serial + ), + rio.TextInput( + label="MAC", + text=self.bind().mac, + on_change=self._update_mac + ), + rio.TextInput( + label="Manufacturer", + text=self.bind().manufield + ), + rio.TextInput( + label="FW Ver", + text=self.bind().fwver, + on_confirm=self._add_part_enter + ), + rio.Row( + # rio.DateInput( + # label="Timestamp", + # value=self.bind().date + # ), + rio.TextInput( + #text= + is_sensitive=False, + text = self.bind().time_start, + #on_change=self._set_time + ) + ), + rio.Button( + content="Add", + on_press=self._add_part_button + ), + + spacing=2, + width=60, + margin_bottom=4, + align_x=0.5, + align_y=0, + ) + diff --git a/inventory/pages/browse_page.py b/inventory/pages/browse_page.py new file mode 100644 index 0000000..fe0cfef --- /dev/null +++ b/inventory/pages/browse_page.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio + +from .. import components as comps + +from db_classes import * +import functools + +class BrowsePage(rio.Component): + searchtext: str = "" + items: dict = {} + office: str = "" + filters: dict = {} + @rio.event.on_populate + async def _search(self, query=searchtext): + self.office = self.session[comps.Settings].office + self.filters['office'] = self.office + + + self.items = search_item(query, self.filters) + await self.force_refresh() + + @rio.event.periodic(1) + async def set_office_init(self): + + if self.office != self.session[comps.Settings].office: + self.office = self.session[comps.Settings].office + #print(self.office) + await self._search() + + async def _search_trigger(self, event: rio.TextInputChangeEvent): + await self._search(event.text) + + def click_item(self, code): + self.session[comps.Settings].selected_item = code + self.session.attach(self.session[comps.Settings]) + self.session.navigate_to("/item") + + def build(self) -> rio.Component: + searchview: rio.ListView = rio.ListView(height='grow') + for item in self.items: + if item["loc"] is not None: + loc = item["loc"]["name"] + else: + loc = "" + if item["checkout"]: + checkout = item["checkout_user"] + " - " + loc + else: + checkout = loc + searchview.add(rio.SimpleListItem(text=item["fullname"],secondary_text=(item["manufacturer"] + " - Serial: " + item["serial"] + "\n" + checkout), on_press=functools.partial( + self.click_item, + code=item["barcode"]))) + return rio.Column( + rio.Row( + rio.TextInput( + text=self.bind().searchtext, + on_change=self._search_trigger, + label="Search" + ) + ), + searchview, + spacing=2, + width=60, + align_x=0.5, + align_y=0, + ) + diff --git a/inventory/pages/item_page.py b/inventory/pages/item_page.py new file mode 100644 index 0000000..debbff7 --- /dev/null +++ b/inventory/pages/item_page.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio + +from .. import components as comps + +from db_classes import * +import functools + +class ItemPage(rio.Component): + itm: dict = {} + barcode: str = "" + + @rio.event.on_populate + async def _get(self): + self.barcode = self.session[comps.Settings].selected_item + self.itm = find_item(self.barcode) + #print(find_item(self.barcode)) + #print(self.itm) + await self.force_refresh() + + def build(self) -> rio.Component: + if 'barcode' in self.itm: + if self.itm["loc"] is not None: + loc = self.itm["loc"]["name"] + else: + loc = "" + if self.itm["checkout"]: + checkout = self.itm["checkout_user"] + " - " + loc + else: + checkout = loc + # searchview.add(rio.SimpleListItem(text=item["fullname"],secondary_text=(item["manufacturer"] + " - Serial: " + item["serial"] + "\n" + checkout), on_press=functools.partial( + # self.click_item, + # file=item["barcode"]))) + return rio.Column( + rio.Text( + text=str(self.itm["fullname"]), + style='heading1', + align_x = 0.5 + ), + rio.Text( + text=str(self.itm["manufacturer"]), + style='heading2', + align_x = 0.5 + ), + rio.Text( + text="Serial: " + str(self.itm["serial"]), + style='heading2', + align_x = 0.5 + ), + rio.Text( + text="MAC: " + str(self.itm["mac"]), + style='heading2', + align_x = 0.5 + ), + rio.Text( + text="FW Version: " + str(self.itm["fwver"]), + style='heading2', + align_x = 0.5 + ), + rio.Text( + text="Location: " + str(checkout), + style='heading2', + align_x = 0.5 + ), + rio.Text( + text="Checked out?: " + str(self.itm["checkout"]), + style='heading2', + align_x = 0.5 + ), + rio.Text( + text="Checkout start/end: " + str(self.itm["checkout_start"]) + " to " + str(self.itm["checkout_end"]), + style='heading2', + align_x = 0.5 + ), + rio.Text( + text="Office: " + str(self.itm["office"]), + style='heading2', + align_x = 0.5 + ), + rio.Text( + text="Barcode: " + str(self.itm["barcode"]), + style='heading2', + align_x = 0.5 + ), + rio.Text( + text="Description: " + str(self.itm["description"]), + style='heading2', + align_x = 0.5, + ), + spacing=2, + width=60, + align_x=0.5, + align_y=0, + ) + else: + return rio.Text("This item does not exist!") + diff --git a/inventory/pages/login_page.py b/inventory/pages/login_page.py new file mode 100644 index 0000000..1fb33fd --- /dev/null +++ b/inventory/pages/login_page.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio +import datetime + + +from .. import components as comps + +class LoginPage(rio.Component): + name: str = "" + + def build(self) -> rio.Component: + return rio.Column( + rio.Popup( + anchor=rio.Text( + text="Login", + style='heading1', + align_x = 0.5 + ), + color=self.bind().popup_color, + is_open=self.bind().popup_show, + content=rio.Text( + text=self.bind().popup_message, + ), + + ), + rio.TextInput( + label="User", + text=self.bind().code + ), + rio.TextInput( + label="Password", + text=self.bind().partnum + ), + rio.Button( + content="Login", + on_press=self._add_part_button + ), + + spacing=2, + width=60, + margin_bottom=4, + align_x=0.5, + align_y=0, + ) + diff --git a/inventory/pages/root_page.py b/inventory/pages/root_page.py new file mode 100644 index 0000000..6cff810 --- /dev/null +++ b/inventory/pages/root_page.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import KW_ONLY, field +from typing import * # type: ignore + +import rio + +from .. import components as comps + +class RootPage(rio.Component): + """ + This page will be used as the root component for the app. This means, that + it will always be visible, regardless of which page is currently active. + + This makes it the perfect place to put components that should be visible on + all pages, such as a navbar or a footer. + + Additionally, the root page will contain a `rio.PageView`. Page views don't + have any appearance on their own, but they are used to display the content + of the currently active page. Thus, we'll always see the navbar and footer, + with the content of the current page in between. + """ + + def build(self) -> rio.Component: + return rio.Column( + # The navbar contains a `rio.Overlay`, so it will always be on top + # of all other components. + comps.Navbar(), + # Add some empty space so the navbar doesn't cover the content. + rio.Spacer(height=10), + # The page view will display the content of the current page. + rio.PageView( + # Make sure the page view takes up all available space. + height="grow", + ), + # The footer is also common to all pages, so place it here. + comps.Footer(), + ) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f402887 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +peewee +pymysql +flask +rio-ui +meilisearch +mac-vendor-lookup \ No newline at end of file diff --git a/rio.toml b/rio.toml new file mode 100644 index 0000000..f20ed13 --- /dev/null +++ b/rio.toml @@ -0,0 +1,6 @@ +# This is the configuration file for Rio, +# an easy to use app & web framework for Python. + +[app] +main-module = "inventory" +app-type = "website" diff --git a/search.py b/search.py new file mode 100644 index 0000000..2a1fac1 --- /dev/null +++ b/search.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 + +"""Interactions with the Meilisearch API for adding and searching cables.""" +from meilisearch import Client +from meilisearch.task import TaskInfo +from meilisearch.errors import MeilisearchApiError +import time + +DEFAULT_URL = "http://meilisearch:7700" +DEFAULT_APIKEY = "fluffybunnyrabbit" # I WOULD RECOMMEND SOMETHING MORE SECURE +DEFAULT_INDEX = "items" +DEFAULT_FILTERABLE_ATTRS = ["office", "loc", "fullname", "serial", "checkout_user", "checkout", "barcode", "manufacturer"] # default filterable attributes + + +class InventorySearch: + """Class for interacting with the Meilisearch API.""" + def __init__(self, + url: str = None, + api_key: str = None, + index: str = None, + filterable_attrs: list = None): + """Connect to Meilisearch and perform first-run tasks as necessary. + + :param url: Address of the Meilisearch server. Defaults to ``http://localhost:7700`` if unspecified. + :param api_key: API key used to authenticate with Meilisearch. It is highly recommended to set this as something + secure if you can access this endpoint publicly, but you can ignore this and set Meilisearch's default API key + to ``fluffybunnyrabbit``. + :param index: The name of the index to configure. Defaults to ``cables`` if unspecified. + :param filterable_attrs: List of all the attributes we want to filter by.""" + # connect to Meilisearch + url = url or DEFAULT_URL + api_key = api_key or DEFAULT_APIKEY + filterable_attrs = filterable_attrs or DEFAULT_FILTERABLE_ATTRS + self.index = index or DEFAULT_INDEX + self.client = Client(url, api_key) + # create the index if it does not exist already + try: + self.client.get_index(self.index) + self.client.delete_index(self.index) + time.sleep(0.05) + self.client.create_index(self.index, {'primaryKey': 'barcode'}) + time.sleep(0.05) + except MeilisearchApiError as _: + self.client.create_index(self.index, {'primaryKey': 'barcode'}) + # make a variable to easily reference the index + self.idxref = self.client.index(self.index) + time.sleep(0.05) + # update filterable attributes if needed + self.idxref.update_distinct_attribute('barcode') + self.update_filterables(filterable_attrs) + time.sleep(0.05) + + def add_document(self, document: dict) -> TaskInfo: + """Add a cable to the Meilisearch index. + + :param document: Dictionary containing all the cable data. + :returns: A TaskInfo object for the addition of the new document.""" + return self.idxref.add_documents(document) + + def add_documents(self, documents: list): + """Add a list of cables to the Meilisearch index. + + :param documents: List of dictionaries containing all the cable data. + :returns: A TaskInfo object for the last new document.""" + taskinfo = None + for i in documents: + taskinfo = self.add_document(i) + return taskinfo + + def update_filterables(self, filterables: list): + """Update filterable attributes and wait for database to fully index. If the filterable attributes matches the + current attributes in the database, don't update (saves reindexing). + + :param filterables: List of all filterable attributes""" + + #existing_filterables = self.idxref.get_filterable_attributes() + #if len(set(existing_filterables).difference(set(filterables))) > 0: + taskref = self.idxref.update_filterable_attributes(filterables) + #self.client.wait_for_task(taskref.index_uid) + + def search(self, query: str, filters: str = None): + """Execute a search query on the Meilisearch index. + + :param query: Seach query + :param filters: A meilisearch compatible filter statement. + :returns: The search results dict. Actual results are in a list under "hits", but there are other nice values that are useful in the root element.""" + if filters: + q = self.idxref.search(query, {"filter": filters}) + else: + q = self.idxref.search(query) + return q + + def _filter_one(self, filter: str): + """Get the first item to match a filter. + + :param filter: A meilisearch compatible filter statement. + :returns: A dict containing the results; If no results found, an empty dict.""" + q = self.search("", filter) + if q["estimatedTotalHits"] != 0: + return q["hits"][0] + else: + return dict() + + def get_barcode(self, barcode: str): + """Get a specific barcode. + + :param uuid: The barcode to search for.""" + return self._filter_one(f"barcode = {barcode}") + +# entrypoint +if __name__ == "__main__": + jbs = InventorySearch()