commit 1196de6e88baa8db9487371d888d596c0645cca4 Author: cproudlock Date: Tue Jan 13 16:07:34 2026 -0500 Initial commit: Shop Database Flask Application Flask backend with Vue 3 frontend for shop floor machine management. Includes database schema export for MySQL shopdb_flask database. Co-Authored-By: Claude Opus 4.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0dabc15 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Flask configuration +FLASK_APP=wsgi.py +FLASK_ENV=development +SECRET_KEY=change-this-to-a-secure-random-string + +# Database +DATABASE_URL=mysql+pymysql://user:password@localhost:3306/shopdb_flask + +# JWT +JWT_SECRET_KEY=change-this-to-another-secure-random-string +JWT_ACCESS_TOKEN_EXPIRES=3600 +JWT_REFRESH_TOKEN_EXPIRES=2592000 + +# Logging +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efe75be --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Flask +instance/ +.env +*.db +*.sqlite + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ + +# Logs +logs/ +*.log + +# OS +.DS_Store +Thumbs.db + +# Node/Frontend +node_modules/ +frontend/dist/ +*.local diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..525c07e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,106 @@ +# Contributing to ShopDB Flask + +## Coding Standards + +### Database Naming Convention + +**IMPORTANT: No underscores in database identifiers** + +All database table names and column names must use lowercase concatenated words (no underscores). + +| Pattern | Good | Bad | +|---------|------|-----| +| Table names | `printerdata` | `printer_data` | +| Column names | `passwordhash` | `password_hash` | +| Foreign keys | `machinetypeid` | `machine_type_id` | +| Index names | `idx_printer_zabbix` | Allowed for indexes | + +### Intuitive Naming for Non-Technical Users + +Database tables and columns should use **simple, intuitive names** that non-technical users can understand when viewing data or reports. + +| Avoid | Prefer | Why | +|-------|--------|-----| +| `printerextensions` | `printerdata` | "data" is clearer than "extensions" | +| `machinerelationships` | `machinelinks` | "links" is simpler (consider) | +| `comtypeid` | `connectiontypeid` | Spell it out when unclear | + +**Guiding principle:** If someone unfamiliar with the system looked at a table name, would they understand what's in it? + +Examples: + +```python +# Good +class PrinterExtension(db.Model): + __tablename__ = 'printerextensions' + + machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid')) + lastzabbixsync = db.Column(db.DateTime) + isnetworkprinter = db.Column(db.Boolean) + +# Bad +class PrinterExtension(db.Model): + __tablename__ = 'printer_extensions' + + machine_id = db.Column(db.Integer, db.ForeignKey('machines.machine_id')) + last_zabbix_sync = db.Column(db.DateTime) + is_network_printer = db.Column(db.Boolean) +``` + +### API Response Keys + +API JSON responses should also use lowercase concatenated keys to match database columns: + +```json +{ + "machineid": 1, + "machinenumber": "M001", + "lastzabbixsync": "2026-01-12T10:00:00Z", + "isnetworkprinter": true +} +``` + +### Python Code + +Python variable and function names follow standard Python conventions (snake_case for variables/functions, PascalCase for classes): + +```python +# Variables and functions use snake_case +machine_type = get_machine_type() +is_valid = validate_input(data) + +# Classes use PascalCase +class PrinterExtension: + pass +``` + +### File Structure + +- Models: `shopdb/core/models/` or `plugins//models/` +- API routes: `shopdb/core/api/` or `plugins//api/` +- Services: `shopdb/core/services/` or `plugins//services/` + +### Plugin Development + +When creating a new plugin: + +1. Create directory structure in `plugins//` +2. Include `manifest.json` with metadata +3. Extend `BasePlugin` class +4. Follow naming conventions above +5. Run `flask plugin install ` to install + +## Testing + +Run tests with: + +```bash +pytest tests/ +``` + +## Code Review Checklist + +- [ ] No underscores in table/column names +- [ ] API responses use consistent key naming +- [ ] Plugin follows BasePlugin interface +- [ ] Tests included for new functionality diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..23ff098 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,608 @@ +-- MySQL dump 10.13 Distrib 5.6.51, for Linux (x86_64) +-- +-- Host: localhost Database: shopdb_flask +-- ------------------------------------------------------ +-- Server version 5.6.51 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `applications` +-- + +DROP TABLE IF EXISTS `applications`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `applications` ( + `appid` int(11) NOT NULL AUTO_INCREMENT, + `appname` varchar(100) NOT NULL, + `appdescription` varchar(255) DEFAULT NULL, + `supportteamid` int(11) DEFAULT NULL, + `isinstallable` tinyint(1) DEFAULT NULL, + `applicationnotes` text, + `installpath` varchar(255) DEFAULT NULL, + `applicationlink` varchar(512) DEFAULT NULL, + `documentationpath` varchar(512) DEFAULT NULL, + `ishidden` tinyint(1) DEFAULT NULL, + `isprinter` tinyint(1) DEFAULT NULL, + `islicenced` tinyint(1) DEFAULT NULL, + `image` varchar(255) DEFAULT NULL, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`appid`), + UNIQUE KEY `appname` (`appname`), + KEY `supportteamid` (`supportteamid`), + CONSTRAINT `applications_ibfk_1` FOREIGN KEY (`supportteamid`) REFERENCES `supportteams` (`supportteamid`) +) ENGINE=InnoDB AUTO_INCREMENT=82 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `appowners` +-- + +DROP TABLE IF EXISTS `appowners`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `appowners` ( + `appownerid` int(11) NOT NULL AUTO_INCREMENT, + `appowner` varchar(100) NOT NULL, + `sso` varchar(50) DEFAULT NULL, + `email` varchar(100) DEFAULT NULL, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`appownerid`) +) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `appversions` +-- + +DROP TABLE IF EXISTS `appversions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `appversions` ( + `appversionid` int(11) NOT NULL AUTO_INCREMENT, + `appid` int(11) NOT NULL, + `version` varchar(50) NOT NULL, + `releasedate` date DEFAULT NULL, + `notes` varchar(255) DEFAULT NULL, + `dateadded` datetime DEFAULT NULL, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`appversionid`), + UNIQUE KEY `uq_app_version` (`appid`,`version`), + CONSTRAINT `appversions_ibfk_1` FOREIGN KEY (`appid`) REFERENCES `applications` (`appid`) +) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `businessunits` +-- + +DROP TABLE IF EXISTS `businessunits`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `businessunits` ( + `businessunitid` int(11) NOT NULL AUTO_INCREMENT, + `businessunit` varchar(100) NOT NULL, + `code` varchar(20) DEFAULT NULL COMMENT 'Short code', + `description` text, + `parentid` int(11) DEFAULT NULL, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`businessunitid`), + UNIQUE KEY `businessunit` (`businessunit`), + UNIQUE KEY `code` (`code`), + KEY `parentid` (`parentid`), + CONSTRAINT `businessunits_ibfk_1` FOREIGN KEY (`parentid`) REFERENCES `businessunits` (`businessunitid`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `communications` +-- + +DROP TABLE IF EXISTS `communications`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `communications` ( + `communicationid` int(11) NOT NULL AUTO_INCREMENT, + `machineid` int(11) NOT NULL, + `comtypeid` int(11) NOT NULL, + `ipaddress` varchar(50) DEFAULT NULL, + `subnetmask` varchar(50) DEFAULT NULL, + `gateway` varchar(50) DEFAULT NULL, + `dns1` varchar(50) DEFAULT NULL, + `dns2` varchar(50) DEFAULT NULL, + `macaddress` varchar(50) DEFAULT NULL, + `isdhcp` tinyint(1) DEFAULT NULL, + `comport` varchar(20) DEFAULT NULL, + `baudrate` int(11) DEFAULT NULL, + `databits` int(11) DEFAULT NULL, + `stopbits` varchar(10) DEFAULT NULL, + `parity` varchar(20) DEFAULT NULL, + `flowcontrol` varchar(20) DEFAULT NULL, + `port` int(11) DEFAULT NULL, + `username` varchar(100) DEFAULT NULL, + `pathname` varchar(255) DEFAULT NULL, + `pathname2` varchar(255) DEFAULT NULL COMMENT 'Secondary path for dualpath', + `isprimary` tinyint(1) DEFAULT NULL COMMENT 'Primary communication method', + `ismachinenetwork` tinyint(1) DEFAULT NULL COMMENT 'On machine network vs office network', + `notes` text, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`communicationid`), + KEY `comtypeid` (`comtypeid`), + KEY `idx_comm_ip` (`ipaddress`), + KEY `idx_comm_machine` (`machineid`), + CONSTRAINT `communications_ibfk_1` FOREIGN KEY (`machineid`) REFERENCES `machines` (`machineid`), + CONSTRAINT `communications_ibfk_2` FOREIGN KEY (`comtypeid`) REFERENCES `communicationtypes` (`comtypeid`) +) ENGINE=InnoDB AUTO_INCREMENT=117 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `communicationtypes` +-- + +DROP TABLE IF EXISTS `communicationtypes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `communicationtypes` ( + `comtypeid` int(11) NOT NULL AUTO_INCREMENT, + `comtype` varchar(50) NOT NULL, + `description` text, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`comtypeid`), + UNIQUE KEY `comtype` (`comtype`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `installedapps` +-- + +DROP TABLE IF EXISTS `installedapps`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `installedapps` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `machineid` int(11) NOT NULL, + `appid` int(11) NOT NULL, + `appversionid` int(11) DEFAULT NULL, + `isactive` tinyint(1) NOT NULL, + `installeddate` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_machine_app` (`machineid`,`appid`), + KEY `appid` (`appid`), + KEY `appversionid` (`appversionid`), + CONSTRAINT `installedapps_ibfk_1` FOREIGN KEY (`machineid`) REFERENCES `machines` (`machineid`), + CONSTRAINT `installedapps_ibfk_2` FOREIGN KEY (`appid`) REFERENCES `applications` (`appid`), + CONSTRAINT `installedapps_ibfk_3` FOREIGN KEY (`appversionid`) REFERENCES `appversions` (`appversionid`) +) ENGINE=InnoDB AUTO_INCREMENT=2392 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `knowledgebase` +-- + +DROP TABLE IF EXISTS `knowledgebase`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `knowledgebase` ( + `linkid` int(11) NOT NULL AUTO_INCREMENT, + `appid` int(11) DEFAULT NULL, + `shortdescription` varchar(500) NOT NULL, + `linkurl` varchar(2000) DEFAULT NULL, + `keywords` varchar(500) DEFAULT NULL, + `clicks` int(11) DEFAULT NULL, + `lastupdated` datetime DEFAULT NULL, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`linkid`), + KEY `appid` (`appid`), + CONSTRAINT `knowledgebase_ibfk_1` FOREIGN KEY (`appid`) REFERENCES `applications` (`appid`) +) ENGINE=InnoDB AUTO_INCREMENT=254 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `locations` +-- + +DROP TABLE IF EXISTS `locations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `locations` ( + `locationid` int(11) NOT NULL AUTO_INCREMENT, + `locationname` varchar(100) NOT NULL, + `building` varchar(100) DEFAULT NULL, + `floor` varchar(50) DEFAULT NULL, + `room` varchar(50) DEFAULT NULL, + `description` text, + `mapimage` varchar(500) DEFAULT NULL COMMENT 'Path to floor map image', + `mapwidth` int(11) DEFAULT NULL, + `mapheight` int(11) DEFAULT NULL, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`locationid`), + UNIQUE KEY `locationname` (`locationname`) +) ENGINE=InnoDB DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `machinerelationships` +-- + +DROP TABLE IF EXISTS `machinerelationships`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `machinerelationships` ( + `relationshipid` int(11) NOT NULL AUTO_INCREMENT, + `parentmachineid` int(11) NOT NULL, + `childmachineid` int(11) NOT NULL, + `relationshiptypeid` int(11) NOT NULL, + `notes` text, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`relationshipid`), + UNIQUE KEY `uq_machine_relationship` (`parentmachineid`,`childmachineid`,`relationshiptypeid`), + KEY `childmachineid` (`childmachineid`), + KEY `relationshiptypeid` (`relationshiptypeid`), + CONSTRAINT `machinerelationships_ibfk_1` FOREIGN KEY (`parentmachineid`) REFERENCES `machines` (`machineid`), + CONSTRAINT `machinerelationships_ibfk_2` FOREIGN KEY (`childmachineid`) REFERENCES `machines` (`machineid`), + CONSTRAINT `machinerelationships_ibfk_3` FOREIGN KEY (`relationshiptypeid`) REFERENCES `relationshiptypes` (`relationshiptypeid`) +) ENGINE=InnoDB AUTO_INCREMENT=208 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `machines` +-- + +DROP TABLE IF EXISTS `machines`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `machines` ( + `machineid` int(11) NOT NULL AUTO_INCREMENT, + `machinenumber` varchar(50) NOT NULL COMMENT 'Business identifier (e.g., CMM01, G5QX1GT3ESF)', + `alias` varchar(100) DEFAULT NULL COMMENT 'Friendly name', + `hostname` varchar(100) DEFAULT NULL COMMENT 'Network hostname (for PCs)', + `serialnumber` varchar(100) DEFAULT NULL COMMENT 'Hardware serial number', + `machinetypeid` int(11) NOT NULL, + `pctypeid` int(11) DEFAULT NULL COMMENT 'Set for PCs, NULL for equipment', + `businessunitid` int(11) DEFAULT NULL, + `modelnumberid` int(11) DEFAULT NULL, + `vendorid` int(11) DEFAULT NULL, + `statusid` int(11) DEFAULT NULL COMMENT 'In Use, Spare, Retired, etc.', + `locationid` int(11) DEFAULT NULL, + `mapleft` int(11) DEFAULT NULL COMMENT 'X coordinate on floor map', + `maptop` int(11) DEFAULT NULL COMMENT 'Y coordinate on floor map', + `islocationonly` tinyint(1) DEFAULT NULL COMMENT 'Virtual location marker (not actual machine)', + `osid` int(11) DEFAULT NULL, + `loggedinuser` varchar(100) DEFAULT NULL, + `lastreporteddate` datetime DEFAULT NULL, + `lastboottime` datetime DEFAULT NULL, + `isvnc` tinyint(1) DEFAULT NULL COMMENT 'VNC remote access enabled', + `iswinrm` tinyint(1) DEFAULT NULL COMMENT 'WinRM enabled', + `isshopfloor` tinyint(1) DEFAULT NULL COMMENT 'Shopfloor PC', + `requiresmanualconfig` tinyint(1) DEFAULT NULL COMMENT 'Multi-PC machine needs manual configuration', + `notes` text, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + `deleteddate` datetime DEFAULT NULL, + `deletedby` varchar(100) DEFAULT NULL, + `createdby` varchar(100) DEFAULT NULL, + `modifiedby` varchar(100) DEFAULT NULL, + PRIMARY KEY (`machineid`), + UNIQUE KEY `ix_machines_machinenumber` (`machinenumber`), + KEY `pctypeid` (`pctypeid`), + KEY `businessunitid` (`businessunitid`), + KEY `modelnumberid` (`modelnumberid`), + KEY `vendorid` (`vendorid`), + KEY `statusid` (`statusid`), + KEY `osid` (`osid`), + KEY `idx_machine_active` (`isactive`), + KEY `idx_machine_hostname` (`hostname`), + KEY `idx_machine_type_bu` (`machinetypeid`,`businessunitid`), + KEY `ix_machines_serialnumber` (`serialnumber`), + KEY `ix_machines_hostname` (`hostname`), + KEY `idx_machine_location` (`locationid`), + CONSTRAINT `machines_ibfk_1` FOREIGN KEY (`machinetypeid`) REFERENCES `machinetypes` (`machinetypeid`), + CONSTRAINT `machines_ibfk_2` FOREIGN KEY (`pctypeid`) REFERENCES `pctypes` (`pctypeid`), + CONSTRAINT `machines_ibfk_3` FOREIGN KEY (`businessunitid`) REFERENCES `businessunits` (`businessunitid`), + CONSTRAINT `machines_ibfk_4` FOREIGN KEY (`modelnumberid`) REFERENCES `models` (`modelnumberid`), + CONSTRAINT `machines_ibfk_5` FOREIGN KEY (`vendorid`) REFERENCES `vendors` (`vendorid`), + CONSTRAINT `machines_ibfk_6` FOREIGN KEY (`statusid`) REFERENCES `machinestatuses` (`statusid`), + CONSTRAINT `machines_ibfk_7` FOREIGN KEY (`locationid`) REFERENCES `locations` (`locationid`), + CONSTRAINT `machines_ibfk_8` FOREIGN KEY (`osid`) REFERENCES `operatingsystems` (`osid`) +) ENGINE=InnoDB AUTO_INCREMENT=639 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `machinestatuses` +-- + +DROP TABLE IF EXISTS `machinestatuses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `machinestatuses` ( + `statusid` int(11) NOT NULL AUTO_INCREMENT, + `status` varchar(50) NOT NULL, + `description` text, + `color` varchar(20) DEFAULT NULL COMMENT 'CSS color for UI', + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`statusid`), + UNIQUE KEY `status` (`status`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `machinetypes` +-- + +DROP TABLE IF EXISTS `machinetypes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `machinetypes` ( + `machinetypeid` int(11) NOT NULL AUTO_INCREMENT, + `machinetype` varchar(100) NOT NULL, + `category` varchar(50) NOT NULL COMMENT 'Equipment, PC, Network, or Printer', + `description` text, + `icon` varchar(50) DEFAULT NULL COMMENT 'Icon name for UI', + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`machinetypeid`), + UNIQUE KEY `machinetype` (`machinetype`) +) ENGINE=InnoDB AUTO_INCREMENT=29 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `models` +-- + +DROP TABLE IF EXISTS `models`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `models` ( + `modelnumberid` int(11) NOT NULL AUTO_INCREMENT, + `modelnumber` varchar(100) NOT NULL, + `machinetypeid` int(11) DEFAULT NULL, + `vendorid` int(11) DEFAULT NULL, + `description` text, + `imageurl` varchar(500) DEFAULT NULL COMMENT 'URL to product image', + `documentationurl` varchar(500) DEFAULT NULL COMMENT 'URL to documentation', + `notes` text, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`modelnumberid`), + UNIQUE KEY `uq_model_vendor` (`modelnumber`,`vendorid`), + KEY `machinetypeid` (`machinetypeid`), + KEY `vendorid` (`vendorid`), + CONSTRAINT `models_ibfk_1` FOREIGN KEY (`machinetypeid`) REFERENCES `machinetypes` (`machinetypeid`), + CONSTRAINT `models_ibfk_2` FOREIGN KEY (`vendorid`) REFERENCES `vendors` (`vendorid`) +) ENGINE=InnoDB AUTO_INCREMENT=105 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `operatingsystems` +-- + +DROP TABLE IF EXISTS `operatingsystems`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `operatingsystems` ( + `osid` int(11) NOT NULL AUTO_INCREMENT, + `osname` varchar(100) NOT NULL, + `osversion` varchar(50) DEFAULT NULL, + `architecture` varchar(20) DEFAULT NULL COMMENT 'x86, x64, ARM', + `endoflife` date DEFAULT NULL COMMENT 'End of support date', + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`osid`), + UNIQUE KEY `uq_os_name_version` (`osname`,`osversion`) +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `pctypes` +-- + +DROP TABLE IF EXISTS `pctypes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `pctypes` ( + `pctypeid` int(11) NOT NULL AUTO_INCREMENT, + `pctype` varchar(100) NOT NULL, + `description` text, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`pctypeid`), + UNIQUE KEY `pctype` (`pctype`) +) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `printerdata` +-- + +DROP TABLE IF EXISTS `printerdata`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `printerdata` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `machineid` int(11) NOT NULL, + `windowsname` varchar(255) DEFAULT NULL COMMENT 'Windows printer name (e.g., \\\\server\\printer)', + `sharename` varchar(100) DEFAULT NULL COMMENT 'CSF/share name', + `iscsf` tinyint(1) DEFAULT NULL COMMENT 'Is CSF printer', + `installpath` varchar(255) DEFAULT NULL COMMENT 'Driver install path', + `pin` varchar(20) DEFAULT NULL, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `ix_printerdata_machineid` (`machineid`), + KEY `idx_printer_windowsname` (`windowsname`), + CONSTRAINT `printerdata_ibfk_1` FOREIGN KEY (`machineid`) REFERENCES `machines` (`machineid`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `relationshiptypes` +-- + +DROP TABLE IF EXISTS `relationshiptypes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `relationshiptypes` ( + `relationshiptypeid` int(11) NOT NULL AUTO_INCREMENT, + `relationshiptype` varchar(50) NOT NULL, + `description` text, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`relationshiptypeid`), + UNIQUE KEY `relationshiptype` (`relationshiptype`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `roles` +-- + +DROP TABLE IF EXISTS `roles`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `roles` ( + `roleid` int(11) NOT NULL AUTO_INCREMENT, + `rolename` varchar(50) NOT NULL, + `description` text, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`roleid`), + UNIQUE KEY `rolename` (`rolename`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `supportteams` +-- + +DROP TABLE IF EXISTS `supportteams`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `supportteams` ( + `supportteamid` int(11) NOT NULL AUTO_INCREMENT, + `teamname` varchar(100) NOT NULL, + `teamurl` varchar(255) DEFAULT NULL, + `appownerid` int(11) DEFAULT NULL, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`supportteamid`), + KEY `appownerid` (`appownerid`), + CONSTRAINT `supportteams_ibfk_1` FOREIGN KEY (`appownerid`) REFERENCES `appowners` (`appownerid`) +) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `userroles` +-- + +DROP TABLE IF EXISTS `userroles`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `userroles` ( + `userid` int(11) NOT NULL, + `roleid` int(11) NOT NULL, + PRIMARY KEY (`userid`,`roleid`), + KEY `roleid` (`roleid`), + CONSTRAINT `userroles_ibfk_1` FOREIGN KEY (`userid`) REFERENCES `users` (`userid`), + CONSTRAINT `userroles_ibfk_2` FOREIGN KEY (`roleid`) REFERENCES `roles` (`roleid`) +) ENGINE=InnoDB DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `users` +-- + +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `users` ( + `userid` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(100) NOT NULL, + `email` varchar(255) NOT NULL, + `passwordhash` varchar(255) NOT NULL, + `firstname` varchar(100) DEFAULT NULL, + `lastname` varchar(100) DEFAULT NULL, + `lastlogindate` datetime DEFAULT NULL, + `failedlogins` int(11) DEFAULT NULL, + `lockeduntil` datetime DEFAULT NULL, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`userid`), + UNIQUE KEY `email` (`email`), + UNIQUE KEY `ix_users_username` (`username`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `vendors` +-- + +DROP TABLE IF EXISTS `vendors`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `vendors` ( + `vendorid` int(11) NOT NULL AUTO_INCREMENT, + `vendor` varchar(100) NOT NULL, + `description` text, + `website` varchar(255) DEFAULT NULL, + `supportphone` varchar(50) DEFAULT NULL, + `supportemail` varchar(100) DEFAULT NULL, + `notes` text, + `createddate` datetime NOT NULL, + `modifieddate` datetime NOT NULL, + `isactive` tinyint(1) NOT NULL, + PRIMARY KEY (`vendorid`), + UNIQUE KEY `vendor` (`vendor`) +) ENGINE=InnoDB AUTO_INCREMENT=44 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2026-01-13 21:07:15 diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..dbe3ced --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,104 @@ +# Frontend Development Standards + +## CSS Styling Standards + +### Use CSS Variables for ALL Colors + +**NEVER hardcode colors in component styles.** Always use CSS variables defined in `src/assets/style.css`. + +Available variables: +```css +--primary /* Primary brand color */ +--primary-dark /* Darker primary for hover states */ +--secondary /* Secondary/muted color */ +--success /* Success states (green) */ +--warning /* Warning states (orange) */ +--danger /* Error/danger states (red) */ +--bg /* Page background */ +--bg-card /* Card/panel background */ +--text /* Primary text color */ +--text-light /* Secondary/muted text */ +--border /* Border color */ +--link /* Link color (bright blue in dark mode) */ +``` + +**Bad:** +```css +.my-card { + background: white; + color: #1a1a1a; +} +``` + +**Good:** +```css +.my-card { + background: var(--bg-card); + color: var(--text); +} +``` + +### Detail Pages - Use Global Styles + +All detail pages (MachineDetail, PCDetail, PrinterDetail, ApplicationDetail) should use the **unified global styles** from `style.css`: + +- `.detail-page` - Container wrapper +- `.hero-card` - Main hero section with image and info +- `.hero-image`, `.hero-content`, `.hero-title`, `.hero-meta`, `.hero-details` +- `.section-card` - Info sections +- `.section-title` - Section headers +- `.info-list`, `.info-row`, `.info-label`, `.info-value` +- `.content-grid`, `.content-column` - Two-column layout +- `.audit-footer` - Created/modified timestamps + +**Only add scoped styles for page-specific elements** (e.g., supplies grid for printers, version list for applications). + +### PrinterDetail.vue is the Master Template for Detail Pages + +Use `PrinterDetail.vue` as the reference for new detail pages. Follow its structure and styling patterns. + +### List Pages - Use Global Styles + +All list pages should use the **unified global styles** from `style.css`: + +- `.page-header` - Header with title and action button +- `.filters` - Search and filter controls +- `.card` - Main content container +- `.table-container` - Scrollable table wrapper +- `table`, `th`, `td` - Table styling +- `.pagination` - Page navigation +- `.badge`, `.badge-success`, etc. - Status badges +- `.actions` - Action button column + +**PrintersList.vue is the Master Template for List Pages** + +Use `PrintersList.vue` as the reference for new list pages. It has NO scoped styles - everything uses global CSS. + +**Only add scoped styles for page-specific elements** (e.g., icon cells for applications, stats badge for knowledge base). + +### Dark Mode Support + +Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page. + +## Component Organization + +- **Global styles**: `src/assets/style.css` +- **Page-specific styles**: Scoped ` + + diff --git a/frontend/src/components/Modal.vue b/frontend/src/components/Modal.vue new file mode 100644 index 0000000..dac2495 --- /dev/null +++ b/frontend/src/components/Modal.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/src/components/ShopFloorMap.vue b/frontend/src/components/ShopFloorMap.vue new file mode 100644 index 0000000..927bfcd --- /dev/null +++ b/frontend/src/components/ShopFloorMap.vue @@ -0,0 +1,589 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..ef5fb9d --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,11 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..0a1c6ca --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,249 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +// Views +import Login from '../views/Login.vue' +import AppLayout from '../views/AppLayout.vue' +import Dashboard from '../views/Dashboard.vue' +import MachinesList from '../views/machines/MachinesList.vue' +import MachineDetail from '../views/machines/MachineDetail.vue' +import MachineForm from '../views/machines/MachineForm.vue' +import PrintersList from '../views/printers/PrintersList.vue' +import PrinterDetail from '../views/printers/PrinterDetail.vue' +import PrinterForm from '../views/printers/PrinterForm.vue' +import PCsList from '../views/pcs/PCsList.vue' +import PCDetail from '../views/pcs/PCDetail.vue' +import PCForm from '../views/pcs/PCForm.vue' +import VendorsList from '../views/vendors/VendorsList.vue' +import ApplicationsList from '../views/applications/ApplicationsList.vue' +import ApplicationDetail from '../views/applications/ApplicationDetail.vue' +import ApplicationForm from '../views/applications/ApplicationForm.vue' +import KnowledgeBaseList from '../views/knowledgebase/KnowledgeBaseList.vue' +import KnowledgeBaseDetail from '../views/knowledgebase/KnowledgeBaseDetail.vue' +import KnowledgeBaseForm from '../views/knowledgebase/KnowledgeBaseForm.vue' +import SearchResults from '../views/SearchResults.vue' +import SettingsIndex from '../views/settings/SettingsIndex.vue' +import MachineTypesList from '../views/settings/MachineTypesList.vue' +import LocationsList from '../views/settings/LocationsList.vue' +import StatusesList from '../views/settings/StatusesList.vue' +import ModelsList from '../views/settings/ModelsList.vue' +import PCTypesList from '../views/settings/PCTypesList.vue' +import OperatingSystemsList from '../views/settings/OperatingSystemsList.vue' +import BusinessUnitsList from '../views/settings/BusinessUnitsList.vue' +import MapView from '../views/MapView.vue' + +const routes = [ + { + path: '/login', + name: 'login', + component: Login, + meta: { guest: true } + }, + { + path: '/', + component: AppLayout, + children: [ + { + path: '', + name: 'dashboard', + component: Dashboard + }, + { + path: 'search', + name: 'search', + component: SearchResults + }, + { + path: 'machines', + name: 'machines', + component: MachinesList + }, + { + path: 'machines/new', + name: 'machine-new', + component: MachineForm, + meta: { requiresAuth: true } + }, + { + path: 'machines/:id', + name: 'machine-detail', + component: MachineDetail + }, + { + path: 'machines/:id/edit', + name: 'machine-edit', + component: MachineForm, + meta: { requiresAuth: true } + }, + { + path: 'printers', + name: 'printers', + component: PrintersList + }, + { + path: 'printers/new', + name: 'printer-new', + component: PrinterForm, + meta: { requiresAuth: true } + }, + { + path: 'printers/:id', + name: 'printer-detail', + component: PrinterDetail + }, + { + path: 'printers/:id/edit', + name: 'printer-edit', + component: PrinterForm, + meta: { requiresAuth: true } + }, + { + path: 'pcs', + name: 'pcs', + component: PCsList + }, + { + path: 'pcs/new', + name: 'pc-new', + component: PCForm, + meta: { requiresAuth: true } + }, + { + path: 'pcs/:id', + name: 'pc-detail', + component: PCDetail + }, + { + path: 'pcs/:id/edit', + name: 'pc-edit', + component: PCForm, + meta: { requiresAuth: true } + }, + { + path: 'map', + name: 'map', + component: MapView + }, + { + path: 'applications', + name: 'applications', + component: ApplicationsList + }, + { + path: 'applications/new', + name: 'application-new', + component: ApplicationForm, + meta: { requiresAuth: true } + }, + { + path: 'applications/:id', + name: 'application-detail', + component: ApplicationDetail + }, + { + path: 'applications/:id/edit', + name: 'application-edit', + component: ApplicationForm, + meta: { requiresAuth: true } + }, + { + path: 'knowledgebase', + name: 'knowledgebase', + component: KnowledgeBaseList + }, + { + path: 'knowledgebase/new', + name: 'knowledgebase-new', + component: KnowledgeBaseForm, + meta: { requiresAuth: true } + }, + { + path: 'knowledgebase/:id', + name: 'knowledgebase-detail', + component: KnowledgeBaseDetail + }, + { + path: 'knowledgebase/:id/edit', + name: 'knowledgebase-edit', + component: KnowledgeBaseForm, + meta: { requiresAuth: true } + }, + { + path: 'settings', + name: 'settings', + component: SettingsIndex, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/vendors', + name: 'vendors', + component: VendorsList, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/machinetypes', + name: 'machinetypes', + component: MachineTypesList, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/locations', + name: 'locations', + component: LocationsList, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/statuses', + name: 'statuses', + component: StatusesList, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/models', + name: 'models', + component: ModelsList, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/pctypes', + name: 'pctypes', + component: PCTypesList, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/operatingsystems', + name: 'operatingsystems', + component: OperatingSystemsList, + meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: 'settings/businessunits', + name: 'businessunits', + component: BusinessUnitsList, + meta: { requiresAuth: true, requiresAdmin: true } + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// Navigation guard +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + next('/login') + } else if (to.meta.requiresAdmin && !authStore.isAdmin) { + next('/') + } else if (to.meta.guest && authStore.isAuthenticated) { + next('/') + } else { + next() + } +}) + +export default router diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..a419b6b --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,63 @@ +import { defineStore } from 'pinia' +import { authApi } from '../api' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + user: JSON.parse(localStorage.getItem('user') || 'null'), + token: localStorage.getItem('token') || null + }), + + getters: { + isAuthenticated: (state) => !!state.token, + username: (state) => state.user?.username || '', + roles: (state) => state.user?.roles || [], + hasRole: (state) => (role) => state.user?.roles?.includes(role) || false, + isAdmin: (state) => state.user?.roles?.includes('admin') || false + }, + + actions: { + async login(username, password) { + try { + const response = await authApi.login(username, password) + const { access_token, refresh_token, user } = response.data.data + + this.token = access_token + this.user = user + + localStorage.setItem('token', access_token) + localStorage.setItem('refreshToken', refresh_token) + localStorage.setItem('user', JSON.stringify(user)) + + return { success: true } + } catch (error) { + const message = error.response?.data?.message || 'Login failed' + return { success: false, message } + } + }, + + async logout() { + try { + await authApi.logout() + } catch (e) { + // Ignore logout errors + } + + this.token = null + this.user = null + + localStorage.removeItem('token') + localStorage.removeItem('refreshToken') + localStorage.removeItem('user') + }, + + async fetchUser() { + try { + const response = await authApi.me() + this.user = response.data.data + localStorage.setItem('user', JSON.stringify(this.user)) + } catch (error) { + this.logout() + } + } + } +}) diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue new file mode 100644 index 0000000..6ac6bbc --- /dev/null +++ b/frontend/src/views/AppLayout.vue @@ -0,0 +1,65 @@ + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..7ed9064 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,133 @@ + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..d653a48 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue new file mode 100644 index 0000000..81ef704 --- /dev/null +++ b/frontend/src/views/MapView.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue new file mode 100644 index 0000000..6383701 --- /dev/null +++ b/frontend/src/views/SearchResults.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/frontend/src/views/applications/ApplicationDetail.vue b/frontend/src/views/applications/ApplicationDetail.vue new file mode 100644 index 0000000..ae15a0b --- /dev/null +++ b/frontend/src/views/applications/ApplicationDetail.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/frontend/src/views/applications/ApplicationForm.vue b/frontend/src/views/applications/ApplicationForm.vue new file mode 100644 index 0000000..af8e8cb --- /dev/null +++ b/frontend/src/views/applications/ApplicationForm.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/frontend/src/views/applications/ApplicationsList.vue b/frontend/src/views/applications/ApplicationsList.vue new file mode 100644 index 0000000..d5e90a5 --- /dev/null +++ b/frontend/src/views/applications/ApplicationsList.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue b/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue new file mode 100644 index 0000000..ed87346 --- /dev/null +++ b/frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/frontend/src/views/knowledgebase/KnowledgeBaseForm.vue b/frontend/src/views/knowledgebase/KnowledgeBaseForm.vue new file mode 100644 index 0000000..f83a5c6 --- /dev/null +++ b/frontend/src/views/knowledgebase/KnowledgeBaseForm.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/frontend/src/views/knowledgebase/KnowledgeBaseList.vue b/frontend/src/views/knowledgebase/KnowledgeBaseList.vue new file mode 100644 index 0000000..6b4fd2e --- /dev/null +++ b/frontend/src/views/knowledgebase/KnowledgeBaseList.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/frontend/src/views/machines/MachineDetail.vue b/frontend/src/views/machines/MachineDetail.vue new file mode 100644 index 0000000..5da08bb --- /dev/null +++ b/frontend/src/views/machines/MachineDetail.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/frontend/src/views/machines/MachineForm.vue b/frontend/src/views/machines/MachineForm.vue new file mode 100644 index 0000000..5804470 --- /dev/null +++ b/frontend/src/views/machines/MachineForm.vue @@ -0,0 +1,523 @@ + + + + + diff --git a/frontend/src/views/machines/MachinesList.vue b/frontend/src/views/machines/MachinesList.vue new file mode 100644 index 0000000..b6a2f90 --- /dev/null +++ b/frontend/src/views/machines/MachinesList.vue @@ -0,0 +1,140 @@ + + + diff --git a/frontend/src/views/pcs/PCDetail.vue b/frontend/src/views/pcs/PCDetail.vue new file mode 100644 index 0000000..d011631 --- /dev/null +++ b/frontend/src/views/pcs/PCDetail.vue @@ -0,0 +1,372 @@ + + + + + diff --git a/frontend/src/views/pcs/PCForm.vue b/frontend/src/views/pcs/PCForm.vue new file mode 100644 index 0000000..90454f6 --- /dev/null +++ b/frontend/src/views/pcs/PCForm.vue @@ -0,0 +1,484 @@ + + + + + diff --git a/frontend/src/views/pcs/PCsList.vue b/frontend/src/views/pcs/PCsList.vue new file mode 100644 index 0000000..aaa6bec --- /dev/null +++ b/frontend/src/views/pcs/PCsList.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/frontend/src/views/printers/PrinterDetail.vue b/frontend/src/views/printers/PrinterDetail.vue new file mode 100644 index 0000000..12d015f --- /dev/null +++ b/frontend/src/views/printers/PrinterDetail.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/frontend/src/views/printers/PrinterForm.vue b/frontend/src/views/printers/PrinterForm.vue new file mode 100644 index 0000000..06ee53e --- /dev/null +++ b/frontend/src/views/printers/PrinterForm.vue @@ -0,0 +1,599 @@ + + + + + diff --git a/frontend/src/views/printers/PrintersList.vue b/frontend/src/views/printers/PrintersList.vue new file mode 100644 index 0000000..c6bce49 --- /dev/null +++ b/frontend/src/views/printers/PrintersList.vue @@ -0,0 +1,136 @@ + + + diff --git a/frontend/src/views/settings/BusinessUnitsList.vue b/frontend/src/views/settings/BusinessUnitsList.vue new file mode 100644 index 0000000..71417f2 --- /dev/null +++ b/frontend/src/views/settings/BusinessUnitsList.vue @@ -0,0 +1,176 @@ + + + diff --git a/frontend/src/views/settings/LocationsList.vue b/frontend/src/views/settings/LocationsList.vue new file mode 100644 index 0000000..99db775 --- /dev/null +++ b/frontend/src/views/settings/LocationsList.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/frontend/src/views/settings/MachineTypesList.vue b/frontend/src/views/settings/MachineTypesList.vue new file mode 100644 index 0000000..925cd8f --- /dev/null +++ b/frontend/src/views/settings/MachineTypesList.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/frontend/src/views/settings/ModelsList.vue b/frontend/src/views/settings/ModelsList.vue new file mode 100644 index 0000000..29c8fe0 --- /dev/null +++ b/frontend/src/views/settings/ModelsList.vue @@ -0,0 +1,362 @@ + + + + + diff --git a/frontend/src/views/settings/OperatingSystemsList.vue b/frontend/src/views/settings/OperatingSystemsList.vue new file mode 100644 index 0000000..568b388 --- /dev/null +++ b/frontend/src/views/settings/OperatingSystemsList.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/frontend/src/views/settings/PCTypesList.vue b/frontend/src/views/settings/PCTypesList.vue new file mode 100644 index 0000000..9ba1ecf --- /dev/null +++ b/frontend/src/views/settings/PCTypesList.vue @@ -0,0 +1,166 @@ + + + diff --git a/frontend/src/views/settings/SettingsIndex.vue b/frontend/src/views/settings/SettingsIndex.vue new file mode 100644 index 0000000..723333f --- /dev/null +++ b/frontend/src/views/settings/SettingsIndex.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/src/views/settings/StatusesList.vue b/frontend/src/views/settings/StatusesList.vue new file mode 100644 index 0000000..84d506e --- /dev/null +++ b/frontend/src/views/settings/StatusesList.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/frontend/src/views/vendors/VendorsList.vue b/frontend/src/views/vendors/VendorsList.vue new file mode 100644 index 0000000..ea18a29 --- /dev/null +++ b/frontend/src/views/vendors/VendorsList.vue @@ -0,0 +1,322 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..5b40f18 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:5050', + changeOrigin: true + } + } + } +}) diff --git a/import_apps.py b/import_apps.py new file mode 100644 index 0000000..82f213c --- /dev/null +++ b/import_apps.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""Import applications data from original ShopDB MySQL database.""" + +import os +import sys +import pymysql +from dotenv import load_dotenv + +# Add project to path +sys.path.insert(0, '/home/camp/projects/shopdb-flask') + +# Load environment variables +load_dotenv('/home/camp/projects/shopdb-flask/.env') + +# Source MySQL connection (original shopdb) +MYSQL_CONFIG = { + 'host': '127.0.0.1', + 'port': 3306, + 'user': 'root', + 'password': 'rootpassword', + 'database': 'shopdb', + 'charset': 'utf8mb4', + 'cursorclass': pymysql.cursors.DictCursor +} + + +def get_mysql_connection(): + """Get MySQL connection to source database.""" + return pymysql.connect(**MYSQL_CONFIG) + + +def to_bool(val): + """Convert MySQL bit field to Python bool.""" + if val is None: + return False + if isinstance(val, bytes): + return val != b'\x00' + return bool(val) + + +def import_appowners(mysql_conn, db, AppOwner): + """Import app owners from MySQL.""" + print("Importing app owners...") + cursor = mysql_conn.cursor() + cursor.execute("SELECT * FROM appowners WHERE isactive = 1") + owners = cursor.fetchall() + + count = 0 + for o in owners: + existing = AppOwner.query.filter_by(appownerid=o['appownerid']).first() + if not existing: + owner = AppOwner( + appownerid=o['appownerid'], + appowner=o['appowner'], + sso=o.get('sso'), + isactive=True + ) + db.session.add(owner) + count += 1 + + db.session.commit() + print(f" Imported {count} app owners") + return count + + +def import_supportteams(mysql_conn, db, SupportTeam): + """Import support teams from MySQL.""" + print("Importing support teams...") + cursor = mysql_conn.cursor() + # Note: source table has typo 'supporteamid' instead of 'supportteamid' + cursor.execute("SELECT supporteamid as supportteamid, teamname, teamurl, appownerid, isactive FROM supportteams WHERE isactive = 1") + teams = cursor.fetchall() + + count = 0 + for t in teams: + existing = SupportTeam.query.filter_by(supportteamid=t['supportteamid']).first() + if not existing: + team = SupportTeam( + supportteamid=t['supportteamid'], + teamname=t['teamname'], + teamurl=t.get('teamurl'), + appownerid=t.get('appownerid'), + isactive=True + ) + db.session.add(team) + count += 1 + + db.session.commit() + print(f" Imported {count} support teams") + return count + + +def import_applications(mysql_conn, db, Application): + """Import applications from MySQL.""" + print("Importing applications...") + cursor = mysql_conn.cursor() + cursor.execute(""" + SELECT a.*, st.supporteamid as supportteamid + FROM applications a + LEFT JOIN supportteams st ON a.supportteamid = st.supporteamid + WHERE a.isactive = 1 + """) + apps = cursor.fetchall() + + count = 0 + skipped_dupes = 0 + seen_names = set() + for a in apps: + # Skip duplicate appnames (source has some) + if a['appname'] in seen_names: + skipped_dupes += 1 + continue + seen_names.add(a['appname']) + + existing = Application.query.filter_by(appname=a['appname']).first() + if not existing: + app = Application( + appid=a['appid'], + appname=a['appname'], + appdescription=a.get('appdescription'), + supportteamid=a.get('supportteamid'), + isinstallable=to_bool(a.get('isinstallable')), + applicationnotes=a.get('applicationnotes'), + installpath=a.get('installpath'), + applicationlink=a.get('applicationlink'), + documentationpath=a.get('documentationpath'), + ishidden=to_bool(a.get('ishidden')), + isprinter=to_bool(a.get('isprinter')), + islicenced=to_bool(a.get('islicenced')), + image=a.get('image'), + isactive=True + ) + db.session.add(app) + count += 1 + + db.session.commit() + print(f" Imported {count} applications (skipped {skipped_dupes} duplicates)") + return count + + +def import_appversions(mysql_conn, db, AppVersion, Application): + """Import application versions from MySQL.""" + print("Importing app versions...") + cursor = mysql_conn.cursor() + cursor.execute(""" + SELECT av.*, a.appname + FROM appversions av + JOIN applications a ON av.appid = a.appid + WHERE av.isactive = 1 + """) + versions = cursor.fetchall() + + # Build app lookup by name (since we may have different IDs) + app_map = {a.appname: a.appid for a in Application.query.all()} + + count = 0 + skipped = 0 + for v in versions: + # Look up app by name + new_appid = app_map.get(v['appname']) + if not new_appid: + skipped += 1 + continue + + existing = AppVersion.query.filter_by( + appid=new_appid, + version=v['version'] + ).first() + + if not existing: + version = AppVersion( + appversionid=v['appversionid'], + appid=new_appid, + version=v['version'], + releasedate=v.get('releasedate'), + notes=v.get('notes'), + isactive=True + ) + db.session.add(version) + count += 1 + + db.session.commit() + print(f" Imported {count} app versions (skipped {skipped})") + return count + + +def import_installedapps(mysql_conn, db, InstalledApp, Machine, Application, AppVersion): + """Import installed apps (PC-application relationships) from MySQL.""" + print("Importing installed apps...") + cursor = mysql_conn.cursor() + cursor.execute(""" + SELECT ia.*, m.machinenumber, a.appname, av.version as version_str + FROM installedapps ia + JOIN machines m ON ia.machineid = m.machineid + JOIN applications a ON ia.appid = a.appid + LEFT JOIN appversions av ON ia.appversionid = av.appversionid + WHERE ia.isactive = 1 + """) + installed = cursor.fetchall() + + # Build lookup for machines by machinenumber (since IDs may differ) + machine_map = {m.machinenumber: m.machineid for m in Machine.query.all()} + # Build app lookup by name + app_map = {a.appname: a.appid for a in Application.query.all()} + # Build version lookup by (appid, version) + version_map = {(v.appid, v.version): v.appversionid for v in AppVersion.query.all()} + + count = 0 + skipped = 0 + for i in installed: + # Look up machine by machinenumber + new_machineid = machine_map.get(i['machinenumber']) + if not new_machineid: + skipped += 1 + continue + + # Look up app by name + new_appid = app_map.get(i['appname']) + if not new_appid: + skipped += 1 + continue + + # Look up version if exists + new_versionid = None + if i.get('version_str'): + new_versionid = version_map.get((new_appid, i['version_str'])) + + # Check if already exists + existing = InstalledApp.query.filter_by( + machineid=new_machineid, + appid=new_appid + ).first() + + if not existing: + installed_app = InstalledApp( + machineid=new_machineid, + appid=new_appid, + appversionid=new_versionid, + isactive=True + ) + db.session.add(installed_app) + count += 1 + elif new_versionid and not existing.appversionid: + # Update existing with version if we now have it + existing.appversionid = new_versionid + count += 1 + + db.session.commit() + print(f" Imported {count} installed apps (skipped {skipped})") + return count + + +def import_knowledgebase(mysql_conn, db, KnowledgeBase, Application): + """Import knowledge base articles from MySQL.""" + print("Importing knowledge base articles...") + cursor = mysql_conn.cursor() + cursor.execute(""" + SELECT kb.*, a.appname + FROM knowledgebase kb + LEFT JOIN applications a ON kb.appid = a.appid + WHERE kb.isactive = 1 + """) + articles = cursor.fetchall() + + # Build app lookup by name + app_map = {a.appname: a.appid for a in Application.query.all()} + + count = 0 + skipped = 0 + for article in articles: + existing = KnowledgeBase.query.filter_by(linkid=article['linkid']).first() + if not existing: + # Look up app by name if exists + new_appid = None + if article.get('appname'): + new_appid = app_map.get(article['appname']) + + kb = KnowledgeBase( + linkid=article['linkid'], + appid=new_appid, + shortdescription=article.get('shortdescription'), + linkurl=article.get('linkurl'), + keywords=article.get('keywords'), + clicks=article.get('clicks') or 0, + isactive=True + ) + db.session.add(kb) + count += 1 + else: + skipped += 1 + + db.session.commit() + print(f" Imported {count} knowledge base articles (skipped {skipped} existing)") + return count + + +def main(): + """Main import function.""" + print("=" * 60) + print("ShopDB Applications Import") + print("=" * 60) + + from shopdb import create_app + from shopdb.extensions import db + from shopdb.core.models import Application, AppOwner, SupportTeam, AppVersion, InstalledApp, Machine, KnowledgeBase + + app = create_app() + + with app.app_context(): + # Create any missing tables + print("\nCreating tables if needed...") + db.create_all() + + print("\nConnecting to MySQL...") + mysql_conn = get_mysql_connection() + print(" Connected!") + + try: + print("\n--- Application Data ---") + import_appowners(mysql_conn, db, AppOwner) + import_supportteams(mysql_conn, db, SupportTeam) + import_applications(mysql_conn, db, Application) + import_appversions(mysql_conn, db, AppVersion, Application) + + print("\n--- PC-Application Relationships ---") + import_installedapps(mysql_conn, db, InstalledApp, Machine, Application, AppVersion) + + print("\n--- Knowledge Base ---") + import_knowledgebase(mysql_conn, db, KnowledgeBase, Application) + + print("\n" + "=" * 60) + print("Import complete!") + print("=" * 60) + + finally: + mysql_conn.close() + + +if __name__ == '__main__': + main() diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..0c42fb2 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1 @@ +"""ShopDB plugins package.""" diff --git a/plugins/printers/__init__.py b/plugins/printers/__init__.py new file mode 100644 index 0000000..bc7220d --- /dev/null +++ b/plugins/printers/__init__.py @@ -0,0 +1,5 @@ +"""Printers plugin - extends machines with printer-specific functionality.""" + +from .plugin import PrintersPlugin + +__all__ = ['PrintersPlugin'] diff --git a/plugins/printers/api/__init__.py b/plugins/printers/api/__init__.py new file mode 100644 index 0000000..1bc1279 --- /dev/null +++ b/plugins/printers/api/__init__.py @@ -0,0 +1,5 @@ +"""Printers plugin API.""" + +from .routes import printers_bp + +__all__ = ['printers_bp'] diff --git a/plugins/printers/api/routes.py b/plugins/printers/api/routes.py new file mode 100644 index 0000000..320a8e6 --- /dev/null +++ b/plugins/printers/api/routes.py @@ -0,0 +1,243 @@ +"""Printers API routes.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.utils.responses import success_response, error_response, paginated_response, ErrorCodes +from shopdb.utils.pagination import get_pagination_params, paginate_query +from shopdb.core.models.machine import Machine, MachineType +from shopdb.core.models.communication import Communication, CommunicationType + +from ..models import PrinterData +from ..services import ZabbixService + +printers_bp = Blueprint('printers', __name__) + + +@printers_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def list_printers(): + """List all printers.""" + page, per_page = get_pagination_params(request) + + # Get printer machine types + printer_types = MachineType.query.filter_by(category='Printer').all() + printer_type_ids = [pt.machinetypeid for pt in printer_types] + + query = Machine.query.filter( + Machine.machinetypeid.in_(printer_type_ids), + Machine.isactive == True + ) + + # Filters + if location_id := request.args.get('location', type=int): + query = query.filter(Machine.locationid == location_id) + + if search := request.args.get('search'): + query = query.filter( + db.or_( + Machine.machinenumber.ilike(f'%{search}%'), + Machine.hostname.ilike(f'%{search}%'), + Machine.alias.ilike(f'%{search}%') + ) + ) + + query = query.order_by(Machine.machinenumber) + items, total = paginate_query(query, page, per_page) + + printers = [] + for machine in items: + printer_data = { + 'machineid': machine.machineid, + 'machinenumber': machine.machinenumber, + 'hostname': machine.hostname, + 'alias': machine.alias, + 'serialnumber': machine.serialnumber, + 'location': machine.location.locationname if machine.location else None, + 'vendor': machine.vendor.vendor if machine.vendor else None, + 'model': machine.model.modelnumber if machine.model else None, + 'status': machine.status.status if machine.status else None, + } + + # Add printer-specific data + if machine.printerdata: + pd = machine.printerdata + printer_data['printerdata'] = { + 'windowsname': pd.windowsname, + 'sharename': pd.sharename, + 'iscsf': pd.iscsf, + 'pin': pd.pin, + } + + # Get IP from communications + primary_comm = next((c for c in machine.communications if c.isprimary), None) + if not primary_comm and machine.communications: + primary_comm = machine.communications[0] + printer_data['ipaddress'] = primary_comm.ipaddress if primary_comm else None + + printers.append(printer_data) + + return paginated_response(printers, page, per_page, total) + + +@printers_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_printer(machine_id: int): + """Get a single printer with details.""" + machine = Machine.query.get(machine_id) + + if not machine: + return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) + + data = machine.to_dict() + data['machinetype'] = machine.machinetype.to_dict() if machine.machinetype else None + data['vendor'] = machine.vendor.to_dict() if machine.vendor else None + data['model'] = machine.model.to_dict() if machine.model else None + data['location'] = machine.location.to_dict() if machine.location else None + data['status'] = machine.status.to_dict() if machine.status else None + data['communications'] = [c.to_dict() for c in machine.communications] + + # Add printer-specific data + if machine.printerdata: + pd = machine.printerdata + data['printerdata'] = { + 'id': pd.id, + 'windowsname': pd.windowsname, + 'sharename': pd.sharename, + 'iscsf': pd.iscsf, + 'installpath': pd.installpath, + 'pin': pd.pin, + } + + return success_response(data) + + +@printers_bp.route('//printerdata', methods=['PUT']) +@jwt_required() +def update_printer_data(machine_id: int): + """Update printer-specific data.""" + machine = Machine.query.get(machine_id) + + if not machine: + return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Get or create printer data + pd = machine.printerdata + if not pd: + pd = PrinterData(machineid=machine_id) + db.session.add(pd) + + for key in ['windowsname', 'sharename', 'iscsf', 'installpath', 'pin']: + if key in data: + setattr(pd, key, data[key]) + + db.session.commit() + + return success_response({ + 'id': pd.id, + 'windowsname': pd.windowsname, + 'sharename': pd.sharename, + 'iscsf': pd.iscsf, + 'installpath': pd.installpath, + 'pin': pd.pin, + }, message='Printer data updated') + + +@printers_bp.route('//communication', methods=['PUT']) +@jwt_required() +def update_printer_communication(machine_id: int): + """Update printer communication (IP address).""" + machine = Machine.query.get(machine_id) + + if not machine: + return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Get or create IP communication type + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if not ip_comtype: + ip_comtype = CommunicationType(comtype='IP', description='IP Network') + db.session.add(ip_comtype) + db.session.flush() + + # Find existing primary communication or create new one + comm = next((c for c in machine.communications if c.isprimary), None) + if not comm: + comm = next((c for c in machine.communications if c.comtypeid == ip_comtype.comtypeid), None) + if not comm: + comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid) + db.session.add(comm) + + # Update fields + if 'ipaddress' in data: + comm.ipaddress = data['ipaddress'] + if 'isprimary' in data: + comm.isprimary = data['isprimary'] + if 'macaddress' in data: + comm.macaddress = data['macaddress'] + + db.session.commit() + + return success_response({ + 'communicationid': comm.communicationid, + 'ipaddress': comm.ipaddress, + 'isprimary': comm.isprimary, + }, message='Communication updated') + + +@printers_bp.route('//supplies', methods=['GET']) +@jwt_required(optional=True) +def get_printer_supplies(machine_id: int): + """Get supply levels from Zabbix (real-time lookup).""" + machine = Machine.query.get(machine_id) + + if not machine: + return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) + + # Get IP address + primary_comm = next((c for c in machine.communications if c.isprimary), None) + if not primary_comm and machine.communications: + primary_comm = machine.communications[0] + + if not primary_comm or not primary_comm.ipaddress: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address') + + service = ZabbixService() + if not service.isconfigured: + return error_response(ErrorCodes.SERVICE_UNAVAILABLE, 'Zabbix not configured') + + supplies = service.getsuppliesbyip(primary_comm.ipaddress) + + return success_response({ + 'ipaddress': primary_comm.ipaddress, + 'supplies': supplies or [] + }) + + +@printers_bp.route('/dashboard/summary', methods=['GET']) +@jwt_required(optional=True) +def dashboard_summary(): + """Get printer summary for dashboard.""" + printer_types = MachineType.query.filter_by(category='Printer').all() + printer_type_ids = [pt.machinetypeid for pt in printer_types] + + total = Machine.query.filter( + Machine.machinetypeid.in_(printer_type_ids), + Machine.isactive == True + ).count() + + return success_response({ + 'totalprinters': total, + 'total': total, + 'online': total, # Placeholder - would need Zabbix integration for real status + 'lowsupplies': 0, + 'criticalsupplies': 0 + }) diff --git a/plugins/printers/manifest.json b/plugins/printers/manifest.json new file mode 100644 index 0000000..72a3a08 --- /dev/null +++ b/plugins/printers/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "printers", + "version": "1.0.0", + "description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=1.0.0", + "api_prefix": "/api/printers", + "provides": { + "machine_category": "Printer", + "features": [ + "printer_extensions", + "driver_management", + "supply_tracking", + "zabbix_integration", + "qr_codes" + ] + }, + "settings": { + "zabbix_url": "", + "zabbix_token": "", + "supply_alert_threshold": 10, + "default_driver_source": "internal" + } +} diff --git a/plugins/printers/migrations/__init__.py b/plugins/printers/migrations/__init__.py new file mode 100644 index 0000000..bfb65cf --- /dev/null +++ b/plugins/printers/migrations/__init__.py @@ -0,0 +1 @@ +"""Printers plugin migrations.""" diff --git a/plugins/printers/models/__init__.py b/plugins/printers/models/__init__.py new file mode 100644 index 0000000..a653ed7 --- /dev/null +++ b/plugins/printers/models/__init__.py @@ -0,0 +1,7 @@ +"""Printers plugin models.""" + +from .printer_extension import PrinterData + +__all__ = [ + 'PrinterData', +] diff --git a/plugins/printers/models/printer_extension.py b/plugins/printers/models/printer_extension.py new file mode 100644 index 0000000..3a59258 --- /dev/null +++ b/plugins/printers/models/printer_extension.py @@ -0,0 +1,58 @@ +"""PrinterData model - printer-specific fields linked to machines.""" + +from shopdb.extensions import db +from shopdb.core.models.base import BaseModel + + +class PrinterData(BaseModel): + """ + Printer-specific data linked to Machine table. + + Printers are stored in the machines table (machinetype.category = 'Printer'). + This table only holds printer-specific fields not in machines. + + IP address is stored in the communications table. + Zabbix data is queried in real-time via API (not cached here). + """ + __tablename__ = 'printerdata' + + id = db.Column(db.Integer, primary_key=True) + + # Link to machine + machineid = db.Column( + db.Integer, + db.ForeignKey('machines.machineid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Windows/Network naming + windowsname = db.Column( + db.String(255), + comment='Windows printer name (e.g., \\\\server\\printer)' + ) + sharename = db.Column( + db.String(100), + comment='CSF/share name' + ) + + # Installation + iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer') + installpath = db.Column(db.String(255), comment='Driver install path') + + # Printer PIN (for secure print) + pin = db.Column(db.String(20)) + + # Relationship + machine = db.relationship( + 'Machine', + backref=db.backref('printerdata', uselist=False, lazy='joined') + ) + + __table_args__ = ( + db.Index('idx_printer_windowsname', 'windowsname'), + ) + + def __repr__(self): + return f"" diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py new file mode 100644 index 0000000..2332ed6 --- /dev/null +++ b/plugins/printers/plugin.py @@ -0,0 +1,174 @@ +"""Printers plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.extensions import db +from shopdb.core.models.machine import MachineType + +from .models import PrinterData +from .api import printers_bp +from .services import ZabbixService + +logger = logging.getLogger(__name__) + + +class PrintersPlugin(BasePlugin): + """ + Printers plugin - extends machines with printer-specific functionality. + + Printers use the unified Machine model with machinetype.category = 'Printer'. + This plugin adds: + - PrinterData table for printer-specific fields (windowsname, sharename, etc.) + - Zabbix integration for real-time supply level lookups + """ + + def __init__(self): + self._manifest = self._load_manifest() + self._zabbixservice = None + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'printers'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Printer management with Zabbix integration' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/printers'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return printers_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [PrinterData] + + def get_services(self) -> Dict[str, Type]: + """Return plugin services.""" + return { + 'zabbix': ZabbixService, + } + + @property + def zabbixservice(self) -> ZabbixService: + """Get Zabbix service instance.""" + if self._zabbixservice is None: + self._zabbixservice = ZabbixService() + return self._zabbixservice + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + app.config.setdefault('ZABBIX_URL', '') + app.config.setdefault('ZABBIX_TOKEN', '') + logger.info(f"Printers plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensureprintertypes() + logger.info("Printers plugin installed") + + def _ensureprintertypes(self) -> None: + """Ensure basic printer machine types exist.""" + printertypes = [ + ('Laser Printer', 'Printer', 'Standard laser printer'), + ('Inkjet Printer', 'Printer', 'Inkjet printer'), + ('Label Printer', 'Printer', 'Label/barcode printer'), + ('Multifunction Printer', 'Printer', 'MFP with scan/copy/fax'), + ('Plotter', 'Printer', 'Large format plotter'), + ] + + for name, category, description in printertypes: + existing = MachineType.query.filter_by(machinetype=name).first() + if not existing: + mt = MachineType( + machinetype=name, + category=category, + description=description, + icon='printer' + ) + db.session.add(mt) + logger.debug(f"Created machine type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Printers plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('printers') + def printerscli(): + """Printers plugin commands.""" + pass + + @printerscli.command('check-supplies') + @click.argument('ip') + def checksupplies(ip): + """Check supply levels for a printer by IP (via Zabbix).""" + from flask import current_app + + with current_app.app_context(): + service = ZabbixService() + + if not service.isconfigured: + click.echo('Error: Zabbix not configured. Set ZABBIX_URL and ZABBIX_TOKEN.') + return + + supplies = service.getsuppliesbyip(ip) + if not supplies: + click.echo(f'No supply data found for {ip}') + return + + click.echo(f'Supply levels for {ip}:') + for supply in supplies: + click.echo(f" {supply['name']}: {supply['level']}%") + + return [printerscli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Printer Status', + 'component': 'PrinterStatusWidget', + 'endpoint': '/api/printers/dashboard/summary', + 'size': 'medium', + 'position': 10, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Printers', + 'icon': 'printer', + 'route': '/printers', + 'position': 20, + }, + ] diff --git a/plugins/printers/schemas/__init__.py b/plugins/printers/schemas/__init__.py new file mode 100644 index 0000000..2d84390 --- /dev/null +++ b/plugins/printers/schemas/__init__.py @@ -0,0 +1 @@ +"""Printers plugin schemas (for future Marshmallow serialization).""" diff --git a/plugins/printers/services/__init__.py b/plugins/printers/services/__init__.py new file mode 100644 index 0000000..7791543 --- /dev/null +++ b/plugins/printers/services/__init__.py @@ -0,0 +1,5 @@ +"""Printers plugin services.""" + +from .zabbix_service import ZabbixService + +__all__ = ['ZabbixService'] diff --git a/plugins/printers/services/zabbix_service.py b/plugins/printers/services/zabbix_service.py new file mode 100644 index 0000000..de321c6 --- /dev/null +++ b/plugins/printers/services/zabbix_service.py @@ -0,0 +1,133 @@ +"""Zabbix service for real-time printer supply lookups.""" + +import logging +from typing import Dict, List, Optional + +import requests +from flask import current_app + +logger = logging.getLogger(__name__) + + +class ZabbixService: + """ + Zabbix API service for real-time printer supply lookups. + + Queries Zabbix by IP address to get current supply levels. + No caching - always returns live data. + """ + + def __init__(self): + self._url = None + self._token = None + + @property + def isconfigured(self) -> bool: + """Check if Zabbix is configured.""" + self._url = current_app.config.get('ZABBIX_URL') + self._token = current_app.config.get('ZABBIX_TOKEN') + return bool(self._url and self._token) + + def _apicall(self, method: str, params: Dict) -> Optional[Dict]: + """Make a Zabbix API call.""" + if not self.isconfigured: + return None + + payload = { + 'jsonrpc': '2.0', + 'method': method, + 'params': params, + 'auth': self._token, + 'id': 1 + } + + try: + response = requests.post( + f"{self._url}/api_jsonrpc.php", + json=payload, + headers={'Content-Type': 'application/json'}, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + if 'error' in data: + logger.error(f"Zabbix API error: {data['error']}") + return None + + return data.get('result') + + except requests.RequestException as e: + logger.error(f"Zabbix API request failed: {e}") + return None + + def gethostbyip(self, ip: str) -> Optional[Dict]: + """Find a Zabbix host by IP address.""" + result = self._apicall('host.get', { + 'output': ['hostid', 'host', 'name'], + 'filter': {'ip': ip}, + 'selectInterfaces': ['ip'] + }) + + if result: + return result[0] if result else None + return None + + def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]: + """ + Get printer supply levels by IP address. + + Returns list of supplies with name and level percentage. + """ + # Find host by IP + host = self.gethostbyip(ip) + if not host: + logger.debug(f"No Zabbix host found for IP {ip}") + return None + + hostid = host['hostid'] + + # Get supply-related items + items = self._apicall('item.get', { + 'output': ['itemid', 'name', 'lastvalue', 'key_'], + 'hostids': hostid, + 'search': { + 'key_': 'supply' # Common key pattern for printer supplies + }, + 'searchWildcardsEnabled': True + }) + + if not items: + # Try alternate patterns + items = self._apicall('item.get', { + 'output': ['itemid', 'name', 'lastvalue', 'key_'], + 'hostids': hostid, + 'search': { + 'name': 'toner' + }, + 'searchWildcardsEnabled': True + }) + + if not items: + return [] + + supplies = [] + for item in items: + try: + level = int(float(item.get('lastvalue', 0))) + except (ValueError, TypeError): + level = 0 + + supplies.append({ + 'name': item.get('name', 'Unknown'), + 'level': level, + 'itemid': item.get('itemid'), + 'key': item.get('key_'), + }) + + return supplies + + def gethostid(self, ip: str) -> Optional[str]: + """Get Zabbix host ID for an IP address.""" + host = self.gethostbyip(ip) + return host['hostid'] if host else None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f464b31 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +# Flask and extensions +flask>=3.0 +flask-sqlalchemy>=3.1 +flask-migrate>=4.0 +flask-jwt-extended>=4.6 +flask-cors>=4.0 +flask-marshmallow>=1.2 +marshmallow-sqlalchemy>=0.29 + +# Database +mysql-connector-python>=8.0 +pymysql>=1.1 + +# CLI and utilities +click>=8.1 +python-dotenv>=1.0 +tabulate>=0.9 + +# HTTP/API clients +requests>=2.31 + +# Security +werkzeug>=3.0 + +# Validation +email-validator>=2.0 + +# Testing +pytest>=7.0 +pytest-flask>=1.2 +pytest-cov>=4.0 diff --git a/scripts/import_from_mysql.py b/scripts/import_from_mysql.py new file mode 100644 index 0000000..5ccca4a --- /dev/null +++ b/scripts/import_from_mysql.py @@ -0,0 +1,581 @@ +#!/usr/bin/env python3 +""" +Import data from legacy MySQL ShopDB to new Flask ShopDB. + +Usage: + cd /home/camp/projects/shopdb-flask + source venv/bin/activate + python scripts/import_from_mysql.py +""" + +import os +import sys +import pymysql +from datetime import datetime +from dotenv import load_dotenv + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Load environment variables +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) + +# MySQL connection settings +MYSQL_CONFIG = { + 'host': '127.0.0.1', + 'port': 3306, + 'user': 'root', + 'password': 'rootpassword', + 'database': 'shopdb', + 'charset': 'utf8mb4', + 'cursorclass': pymysql.cursors.DictCursor +} + + +def get_mysql_connection(): + """Get MySQL connection.""" + return pymysql.connect(**MYSQL_CONFIG) + + +def import_vendors(mysql_conn, db, Vendor): + """Import vendors from MySQL.""" + print("Importing vendors...") + cursor = mysql_conn.cursor() + cursor.execute("SELECT * FROM vendors WHERE isactive = 1") + vendors = cursor.fetchall() + + count = 0 + for v in vendors: + existing = Vendor.query.filter_by(vendor=v['vendor']).first() + if not existing: + vendor = Vendor( + vendor=v['vendor'], + isactive=True + ) + db.session.add(vendor) + count += 1 + + db.session.commit() + print(f" Imported {count} vendors") + return count + + +def import_machinetypes(mysql_conn, db, MachineType): + """Import machine types from MySQL with category mapping.""" + print("Importing machine types...") + cursor = mysql_conn.cursor() + cursor.execute("SELECT * FROM machinetypes WHERE isactive = 1") + types = cursor.fetchall() + + # Category mapping based on machinetype name + pc_types = ['PC'] + network_types = ['Access Point', 'IDF', 'Switch', 'Server', 'Camera'] + printer_types = ['Printer'] + + count = 0 + for t in types: + existing = MachineType.query.filter_by(machinetype=t['machinetype']).first() + if not existing: + # Determine category + if t['machinetype'] in pc_types: + category = 'PC' + elif t['machinetype'] in network_types: + category = 'Network' + elif t['machinetype'] in printer_types: + category = 'Printer' + else: + category = 'Equipment' + + mt = MachineType( + machinetype=t['machinetype'], + category=category, + description=t.get('machinedescription'), + isactive=True + ) + db.session.add(mt) + count += 1 + + db.session.commit() + print(f" Imported {count} machine types") + return count + + +def import_pctypes(mysql_conn, db, PCType): + """Import PC types from MySQL.""" + print("Importing PC types...") + cursor = mysql_conn.cursor() + cursor.execute("SELECT * FROM pctype WHERE isactive = '1'") + types = cursor.fetchall() + + count = 0 + for t in types: + existing = PCType.query.filter_by(pctype=t['typename']).first() + if not existing: + pctype = PCType( + pctype=t['typename'], + description=t.get('description'), + isactive=True + ) + db.session.add(pctype) + count += 1 + + db.session.commit() + print(f" Imported {count} PC types") + return count + + +def import_businessunits(mysql_conn, db, BusinessUnit): + """Import business units from MySQL.""" + print("Importing business units...") + cursor = mysql_conn.cursor() + cursor.execute("SELECT * FROM businessunits WHERE isactive = 1") + units = cursor.fetchall() + + count = 0 + for bu in units: + existing = BusinessUnit.query.filter_by(businessunit=bu['businessunit']).first() + if not existing: + unit = BusinessUnit( + businessunit=bu['businessunit'], + isactive=True + ) + db.session.add(unit) + count += 1 + + db.session.commit() + print(f" Imported {count} business units") + return count + + +def import_statuses(mysql_conn, db, MachineStatus): + """Import machine statuses from MySQL.""" + print("Importing machine statuses...") + cursor = mysql_conn.cursor() + cursor.execute("SELECT * FROM machinestatus WHERE isactive = 1") + statuses = cursor.fetchall() + + count = 0 + for s in statuses: + existing = MachineStatus.query.filter_by(status=s['machinestatus']).first() + if not existing: + status = MachineStatus( + status=s['machinestatus'], + description=s.get('statusdescription'), + isactive=True + ) + db.session.add(status) + count += 1 + + db.session.commit() + print(f" Imported {count} statuses") + return count + + +def import_operatingsystems(mysql_conn, db, OperatingSystem): + """Import operating systems from MySQL.""" + print("Importing operating systems...") + cursor = mysql_conn.cursor() + cursor.execute("SELECT * FROM operatingsystems") + os_list = cursor.fetchall() + + count = 0 + for os_item in os_list: + os_name = os_item.get('operatingsystem') or os_item.get('osname') + if not os_name: + continue + + existing = OperatingSystem.query.filter_by(osname=os_name).first() + if not existing: + os_obj = OperatingSystem( + osname=os_name, + osversion=os_item.get('osversion'), + isactive=True + ) + db.session.add(os_obj) + count += 1 + + db.session.commit() + print(f" Imported {count} operating systems") + return count + + +def import_models(mysql_conn, db, Model, Vendor, MachineType): + """Import models from MySQL.""" + print("Importing models...") + cursor = mysql_conn.cursor() + cursor.execute(""" + SELECT m.*, v.vendor as vendor_name, mt.machinetype as type_name + FROM models m + LEFT JOIN vendors v ON m.vendorid = v.vendorid + LEFT JOIN machinetypes mt ON m.machinetypeid = mt.machinetypeid + WHERE m.isactive = 1 + """) + models = cursor.fetchall() + + count = 0 + for m in models: + existing = Model.query.filter_by(modelnumber=m['modelnumber']).first() + if not existing: + # Find vendor and machinetype in new db + vendor = Vendor.query.filter_by(vendor=m['vendor_name']).first() if m['vendor_name'] else None + machinetype = MachineType.query.filter_by(machinetype=m['type_name']).first() if m['type_name'] else None + + model = Model( + modelnumber=m['modelnumber'], + vendorid=vendor.vendorid if vendor else None, + machinetypeid=machinetype.machinetypeid if machinetype else None, + notes=m.get('notes'), + isactive=True + ) + db.session.add(model) + count += 1 + + db.session.commit() + print(f" Imported {count} models") + return count + + +def import_relationshiptypes(mysql_conn, db, RelationshipType): + """Import relationship types from MySQL.""" + print("Importing relationship/connection types...") + cursor = mysql_conn.cursor() + cursor.execute("SELECT * FROM relationshiptypes WHERE isactive = 1") + types = cursor.fetchall() + + count = 0 + for rt in types: + existing = RelationshipType.query.filter_by(relationshiptype=rt['relationshiptype']).first() + if not existing: + rel_type = RelationshipType( + relationshiptype=rt['relationshiptype'], + description=rt.get('description'), + isactive=True + ) + db.session.add(rel_type) + count += 1 + + db.session.commit() + print(f" Imported {count} relationship types") + return count + + +def import_machines(mysql_conn, db, Machine, MachineType, MachineStatus, + Vendor, Model, BusinessUnit, OperatingSystem, Location, + Communication, CommunicationType, PCType): + """Import machines (Equipment and PCs) from MySQL.""" + print("Importing machines...") + cursor = mysql_conn.cursor() + + # Get machines with related data + cursor.execute(""" + SELECT m.*, + mt.machinetype as type_name, + ms.machinestatus as status_name, + v.vendor as vendor_name, + mdl.modelnumber as model_name, + bu.businessunit as bu_name, + os.operatingsystem as os_name, + pt.typename as pctype_name + FROM machines m + LEFT JOIN machinetypes mt ON m.machinetypeid = mt.machinetypeid + LEFT JOIN machinestatus ms ON m.machinestatusid = ms.machinestatusid + LEFT JOIN models mdl ON m.modelnumberid = mdl.modelnumberid + LEFT JOIN vendors v ON mdl.vendorid = v.vendorid + LEFT JOIN businessunits bu ON m.businessunitid = bu.businessunitid + LEFT JOIN operatingsystems os ON m.osid = os.osid + LEFT JOIN pctype pt ON m.pctypeid = pt.pctypeid + WHERE m.isactive = 1 + """) + machines = cursor.fetchall() + + # Get or create IP communication type + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if not ip_comtype: + ip_comtype = CommunicationType(comtype='IP', description='IP Network') + db.session.add(ip_comtype) + db.session.flush() + + # Build lookup maps + type_map = {t.machinetype: t for t in MachineType.query.all()} + status_map = {s.status: s for s in MachineStatus.query.all()} + vendor_map = {v.vendor: v for v in Vendor.query.all()} + model_map = {m.modelnumber: m for m in Model.query.all()} + bu_map = {b.businessunit: b for b in BusinessUnit.query.all()} + os_map = {o.osname: o for o in OperatingSystem.query.all()} + pctype_map = {p.pctype: p for p in PCType.query.all()} + + # Track old->new ID mapping for relationships + machine_id_map = {} + + count = 0 + comm_count = 0 + skipped = 0 + for m in machines: + # Skip machines without a machinenumber + if not m.get('machinenumber'): + skipped += 1 + continue + + # Check if already exists + existing = Machine.query.filter_by(machinenumber=m['machinenumber']).first() + if existing: + machine_id_map[m['machineid']] = existing.machineid + continue + + # Get related objects + machinetype = type_map.get(m['type_name']) + status = status_map.get(m['status_name']) + vendor = vendor_map.get(m['vendor_name']) + model = model_map.get(m['model_name']) + bu = bu_map.get(m['bu_name']) + os_obj = os_map.get(m['os_name']) + pctype = pctype_map.get(m['pctype_name']) + + machine = Machine( + machinenumber=m['machinenumber'], + alias=m.get('alias'), + hostname=m.get('hostname'), + serialnumber=m.get('serialnumber'), + machinetypeid=machinetype.machinetypeid if machinetype else None, + pctypeid=pctype.pctypeid if pctype else None, + statusid=status.statusid if status else None, + vendorid=vendor.vendorid if vendor else None, + modelnumberid=model.modelnumberid if model else None, + businessunitid=bu.businessunitid if bu else None, + osid=os_obj.osid if os_obj else None, + mapleft=m.get('mapleft'), + maptop=m.get('maptop'), + isvnc=bool(m.get('isvnc')), + iswinrm=bool(m.get('iswinrm')), + islocationonly=bool(m.get('islocationonly')), + loggedinuser=m.get('loggedinuser'), + notes=m.get('machinenotes'), + isactive=True + ) + db.session.add(machine) + db.session.flush() # Get the new ID + + machine_id_map[m['machineid']] = machine.machineid + count += 1 + + # Import IP addresses + if m.get('ipaddress1'): + comm = Communication( + machineid=machine.machineid, + comtypeid=ip_comtype.comtypeid, + ipaddress=m['ipaddress1'], + isprimary=True + ) + db.session.add(comm) + comm_count += 1 + + if m.get('ipaddress2'): + comm = Communication( + machineid=machine.machineid, + comtypeid=ip_comtype.comtypeid, + ipaddress=m['ipaddress2'], + isprimary=False + ) + db.session.add(comm) + comm_count += 1 + + db.session.commit() + print(f" Imported {count} machines with {comm_count} IP addresses (skipped {skipped} invalid)") + return machine_id_map + + +def import_relationships(mysql_conn, db, MachineRelationship, RelationshipType, machine_id_map): + """Import machine relationships from MySQL.""" + print("Importing machine relationships...") + cursor = mysql_conn.cursor() + cursor.execute(""" + SELECT mr.*, rt.relationshiptype + FROM machinerelationships mr + JOIN relationshiptypes rt ON mr.relationshiptypeid = rt.relationshiptypeid + WHERE mr.isactive = 1 + """) + relationships = cursor.fetchall() + + # Build relationship type map + type_map = {t.relationshiptype: t for t in RelationshipType.query.all()} + + count = 0 + skipped = 0 + for r in relationships: + # Map old IDs to new IDs + parent_id = machine_id_map.get(r['machineid']) + child_id = machine_id_map.get(r['related_machineid']) + + if not parent_id or not child_id: + skipped += 1 + continue + + rel_type = type_map.get(r['relationshiptype']) + if not rel_type: + skipped += 1 + continue + + # Check if already exists + existing = MachineRelationship.query.filter_by( + parentmachineid=parent_id, + childmachineid=child_id, + relationshiptypeid=rel_type.relationshiptypeid + ).first() + + if not existing: + relationship = MachineRelationship( + parentmachineid=parent_id, + childmachineid=child_id, + relationshiptypeid=rel_type.relationshiptypeid, + notes=r.get('relationship_notes') + ) + db.session.add(relationship) + count += 1 + + db.session.commit() + print(f" Imported {count} relationships (skipped {skipped})") + return count + + +def import_printers(mysql_conn, db, Machine, MachineType, Model, Vendor, + Communication, CommunicationType): + """Import printers from MySQL.""" + print("Importing printers...") + + # First, ensure we have a Printer machine type + printer_type = MachineType.query.filter_by(machinetype='Printer').first() + if not printer_type: + printer_type = MachineType(machinetype='Printer', category='Printer') + db.session.add(printer_type) + db.session.flush() + + # Get or create IP communication type + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if not ip_comtype: + ip_comtype = CommunicationType(comtype='IP', description='IP Network') + db.session.add(ip_comtype) + db.session.flush() + + cursor = mysql_conn.cursor() + cursor.execute(""" + SELECT p.*, m.modelnumber, v.vendor as vendor_name + FROM printers p + LEFT JOIN models m ON p.modelid = m.modelnumberid + LEFT JOIN vendors v ON m.vendorid = v.vendorid + WHERE p.isactive = 1 + """) + printers = cursor.fetchall() + + # Build lookup maps + model_map = {m.modelnumber: m for m in Model.query.all()} + vendor_map = {v.vendor: v for v in Vendor.query.all()} + + count = 0 + comm_count = 0 + for p in printers: + # Use windows name as machine number + machine_number = p.get('printerwindowsname') or f"Printer_{p['printerid']}" + + existing = Machine.query.filter_by(machinenumber=machine_number).first() + if existing: + continue + + model = model_map.get(p.get('modelnumber')) + vendor = vendor_map.get(p.get('vendor_name')) + + machine = Machine( + machinenumber=machine_number, + alias=p.get('printercsfname'), + hostname=p.get('fqdn'), + serialnumber=p.get('serialnumber'), + machinetypeid=printer_type.machinetypeid, + vendorid=vendor.vendorid if vendor else None, + modelnumberid=model.modelnumberid if model else None, + mapleft=p.get('mapleft'), + maptop=p.get('maptop'), + notes=p.get('printernotes'), + isactive=True + ) + db.session.add(machine) + db.session.flush() + count += 1 + + # Import IP address + if p.get('ipaddress'): + comm = Communication( + machineid=machine.machineid, + comtypeid=ip_comtype.comtypeid, + ipaddress=p['ipaddress'], + isprimary=True + ) + db.session.add(comm) + comm_count += 1 + + db.session.commit() + print(f" Imported {count} printers with {comm_count} IP addresses") + return count + + +def main(): + """Main import function.""" + print("=" * 60) + print("ShopDB MySQL to Flask Migration") + print("=" * 60) + + # Initialize Flask app + from shopdb import create_app + from shopdb.extensions import db + from shopdb.core.models import ( + Machine, MachineType, MachineStatus, Vendor, Model, + BusinessUnit, OperatingSystem, Location, PCType + ) + from shopdb.core.models.communication import Communication, CommunicationType + from shopdb.core.models.relationship import MachineRelationship, RelationshipType + + app = create_app() + + with app.app_context(): + # Connect to MySQL + print("\nConnecting to MySQL...") + mysql_conn = get_mysql_connection() + print(" Connected!") + + try: + # Import reference data + print("\n--- Reference Data ---") + import_vendors(mysql_conn, db, Vendor) + import_machinetypes(mysql_conn, db, MachineType) + import_pctypes(mysql_conn, db, PCType) + import_businessunits(mysql_conn, db, BusinessUnit) + import_statuses(mysql_conn, db, MachineStatus) + import_operatingsystems(mysql_conn, db, OperatingSystem) + import_models(mysql_conn, db, Model, Vendor, MachineType) + import_relationshiptypes(mysql_conn, db, RelationshipType) + + # Import machines + print("\n--- Machines ---") + machine_id_map = import_machines( + mysql_conn, db, Machine, MachineType, MachineStatus, + Vendor, Model, BusinessUnit, OperatingSystem, Location, + Communication, CommunicationType, PCType + ) + + # Import relationships + print("\n--- Relationships ---") + import_relationships(mysql_conn, db, MachineRelationship, RelationshipType, machine_id_map) + + # Import printers + print("\n--- Printers ---") + import_printers(mysql_conn, db, Machine, MachineType, Model, Vendor, + Communication, CommunicationType) + + print("\n" + "=" * 60) + print("Import complete!") + print("=" * 60) + + finally: + mysql_conn.close() + + +if __name__ == '__main__': + main() diff --git a/shopdb/__init__.py b/shopdb/__init__.py new file mode 100644 index 0000000..f066481 --- /dev/null +++ b/shopdb/__init__.py @@ -0,0 +1,189 @@ +"""Flask application factory.""" + +import os +import logging +from flask import Flask, send_from_directory + +from .config import config +from .extensions import db, migrate, jwt, cors, ma, init_extensions +from .plugins import plugin_manager + + +def create_app(config_name: str = None) -> Flask: + """ + Application factory. + + Args: + config_name: Configuration name ('development', 'production', 'testing') + + Returns: + Configured Flask application + """ + if config_name is None: + config_name = os.environ.get('FLASK_ENV', 'development') + + app = Flask(__name__, instance_relative_config=True) + + # Load configuration + app.config.from_object(config.get(config_name, config['default'])) + + # Load instance config if exists + app.config.from_pyfile('config.py', silent=True) + + # Ensure instance folder exists + os.makedirs(app.instance_path, exist_ok=True) + + # Configure logging + configure_logging(app) + + # Initialize extensions + init_extensions(app) + + # Initialize plugin manager + with app.app_context(): + plugin_manager.init_app(app, db) + + # Register core blueprints + register_blueprints(app) + + # Register CLI commands + register_cli_commands(app) + + # Register error handlers + register_error_handlers(app) + + # Serve Vue frontend + register_frontend_routes(app) + + # JWT user loader (identity is a string in JWT, convert to int for DB lookup) + @jwt.user_lookup_loader + def user_lookup_callback(_jwt_header, jwt_data): + from .core.models import User + identity = jwt_data["sub"] + return User.query.get(int(identity)) + + return app + + +def register_blueprints(app: Flask): + """Register core API blueprints.""" + from .core.api import ( + auth_bp, + machines_bp, + machinetypes_bp, + pctypes_bp, + statuses_bp, + vendors_bp, + models_bp, + businessunits_bp, + locations_bp, + operatingsystems_bp, + dashboard_bp, + applications_bp, + knowledgebase_bp, + search_bp, + ) + + api_prefix = '/api' + + app.register_blueprint(auth_bp, url_prefix=f'{api_prefix}/auth') + app.register_blueprint(machines_bp, url_prefix=f'{api_prefix}/machines') + app.register_blueprint(machinetypes_bp, url_prefix=f'{api_prefix}/machinetypes') + app.register_blueprint(pctypes_bp, url_prefix=f'{api_prefix}/pctypes') + app.register_blueprint(statuses_bp, url_prefix=f'{api_prefix}/statuses') + app.register_blueprint(vendors_bp, url_prefix=f'{api_prefix}/vendors') + app.register_blueprint(models_bp, url_prefix=f'{api_prefix}/models') + app.register_blueprint(businessunits_bp, url_prefix=f'{api_prefix}/businessunits') + app.register_blueprint(locations_bp, url_prefix=f'{api_prefix}/locations') + app.register_blueprint(operatingsystems_bp, url_prefix=f'{api_prefix}/operatingsystems') + app.register_blueprint(dashboard_bp, url_prefix=f'{api_prefix}/dashboard') + app.register_blueprint(applications_bp, url_prefix=f'{api_prefix}/applications') + app.register_blueprint(knowledgebase_bp, url_prefix=f'{api_prefix}/knowledgebase') + app.register_blueprint(search_bp, url_prefix=f'{api_prefix}/search') + + +def register_cli_commands(app: Flask): + """Register Flask CLI commands.""" + from .plugins.cli import plugin_cli + from .cli import db_cli, seed_cli + + app.cli.add_command(plugin_cli) + app.cli.add_command(db_cli) + app.cli.add_command(seed_cli) + + +def register_error_handlers(app: Flask): + """Register error handlers.""" + from .utils.responses import error_response, ErrorCodes + from .exceptions import ShopDBException + + @app.errorhandler(ShopDBException) + def handle_shopdb_exception(error): + http_codes = { + 'NOT_FOUND': 404, + 'UNAUTHORIZED': 401, + 'FORBIDDEN': 403, + 'CONFLICT': 409, + 'VALIDATION_ERROR': 400, + } + http_code = http_codes.get(error.code, 400) + return error_response( + error.code, + error.message, + details=error.details, + http_code=http_code + ) + + @app.errorhandler(404) + def not_found_error(error): + return error_response( + ErrorCodes.NOT_FOUND, + 'Resource not found', + http_code=404 + ) + + @app.errorhandler(500) + def internal_error(error): + return error_response( + ErrorCodes.INTERNAL_ERROR, + 'An internal error occurred', + http_code=500 + ) + + @app.errorhandler(401) + def unauthorized_error(error): + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Authentication required', + http_code=401 + ) + + +def register_frontend_routes(app: Flask): + """Serve Vue frontend static files.""" + frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist') + + @app.route('/', defaults={'path': ''}) + @app.route('/') + def serve_frontend(path): + # Don't serve API routes as frontend + if path.startswith('api/'): + from .utils.responses import error_response, ErrorCodes + return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404) + + # Serve static assets + if path and os.path.exists(os.path.join(frontend_dist, path)): + return send_from_directory(frontend_dist, path) + + # Serve index.html for SPA routing + return send_from_directory(frontend_dist, 'index.html') + + +def configure_logging(app: Flask): + """Configure application logging.""" + log_level = app.config.get('LOG_LEVEL', 'INFO') + + logging.basicConfig( + level=getattr(logging, log_level), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py new file mode 100644 index 0000000..fb50d0b --- /dev/null +++ b/shopdb/cli/__init__.py @@ -0,0 +1,147 @@ +"""Flask CLI commands.""" + +import click +from flask.cli import with_appcontext + + +@click.group('db-utils') +def db_cli(): + """Database utility commands.""" + pass + + +@db_cli.command('create-all') +@with_appcontext +def create_all(): + """Create all database tables.""" + from shopdb.extensions import db + + db.create_all() + click.echo(click.style("All tables created.", fg='green')) + + +@db_cli.command('drop-all') +@click.confirmation_option(prompt='This will delete ALL data. Are you sure?') +@with_appcontext +def drop_all(): + """Drop all database tables.""" + from shopdb.extensions import db + + db.drop_all() + click.echo(click.style("All tables dropped.", fg='yellow')) + + +@click.group('seed') +def seed_cli(): + """Database seeding commands.""" + pass + + +@seed_cli.command('reference-data') +@with_appcontext +def seed_reference_data(): + """Seed reference data (machine types, statuses, etc.).""" + from shopdb.extensions import db + from shopdb.core.models import MachineType, MachineStatus, OperatingSystem + from shopdb.core.models.relationship import RelationshipType + + # Machine types + machine_types = [ + {'machinetype': 'CNC Mill', 'category': 'Equipment', 'description': 'CNC Milling Machine'}, + {'machinetype': 'CNC Lathe', 'category': 'Equipment', 'description': 'CNC Lathe'}, + {'machinetype': 'CMM', 'category': 'Equipment', 'description': 'Coordinate Measuring Machine'}, + {'machinetype': 'EDM', 'category': 'Equipment', 'description': 'Electrical Discharge Machine'}, + {'machinetype': 'Grinder', 'category': 'Equipment', 'description': 'Grinding Machine'}, + {'machinetype': 'Inspection Station', 'category': 'Equipment', 'description': 'Inspection Station'}, + {'machinetype': 'Desktop PC', 'category': 'PC', 'description': 'Desktop Computer'}, + {'machinetype': 'Laptop', 'category': 'PC', 'description': 'Laptop Computer'}, + {'machinetype': 'Shopfloor PC', 'category': 'PC', 'description': 'Shopfloor Computer'}, + {'machinetype': 'Server', 'category': 'Network', 'description': 'Server'}, + {'machinetype': 'Switch', 'category': 'Network', 'description': 'Network Switch'}, + {'machinetype': 'Access Point', 'category': 'Network', 'description': 'Wireless Access Point'}, + ] + + for mt_data in machine_types: + existing = MachineType.query.filter_by(machinetype=mt_data['machinetype']).first() + if not existing: + mt = MachineType(**mt_data) + db.session.add(mt) + + # Machine statuses + statuses = [ + {'status': 'In Use', 'description': 'Currently in use', 'color': '#28a745'}, + {'status': 'Spare', 'description': 'Available as spare', 'color': '#17a2b8'}, + {'status': 'Retired', 'description': 'No longer in use', 'color': '#6c757d'}, + {'status': 'In Repair', 'description': 'Currently being repaired', 'color': '#ffc107'}, + {'status': 'Pending', 'description': 'Pending installation', 'color': '#007bff'}, + ] + + for s_data in statuses: + existing = MachineStatus.query.filter_by(status=s_data['status']).first() + if not existing: + s = MachineStatus(**s_data) + db.session.add(s) + + # Operating systems + os_list = [ + {'osname': 'Windows 10', 'osversion': '10.0'}, + {'osname': 'Windows 11', 'osversion': '11.0'}, + {'osname': 'Windows Server 2019', 'osversion': '2019'}, + {'osname': 'Windows Server 2022', 'osversion': '2022'}, + {'osname': 'Linux', 'osversion': 'Various'}, + ] + + for os_data in os_list: + existing = OperatingSystem.query.filter_by(osname=os_data['osname']).first() + if not existing: + os_obj = OperatingSystem(**os_data) + db.session.add(os_obj) + + # Connection types (how PC connects to equipment) + connection_types = [ + {'relationshiptype': 'Serial Cable', 'description': 'RS-232 or similar serial connection'}, + {'relationshiptype': 'Direct Ethernet', 'description': 'Direct network cable (airgapped)'}, + {'relationshiptype': 'USB', 'description': 'USB connection'}, + {'relationshiptype': 'WiFi', 'description': 'Wireless network connection'}, + {'relationshiptype': 'Dualpath', 'description': 'Redundant/failover network path'}, + ] + + for ct_data in connection_types: + existing = RelationshipType.query.filter_by(relationshiptype=ct_data['relationshiptype']).first() + if not existing: + ct = RelationshipType(**ct_data) + db.session.add(ct) + + db.session.commit() + click.echo(click.style("Reference data seeded.", fg='green')) + + +@seed_cli.command('test-user') +@with_appcontext +def seed_test_user(): + """Create a test admin user.""" + from shopdb.extensions import db + from shopdb.core.models import User, Role + from werkzeug.security import generate_password_hash + + # Create admin role if not exists + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: + admin_role = Role(rolename='admin', description='Administrator') + db.session.add(admin_role) + + # Create test user + test_user = User.query.filter_by(username='admin').first() + if not test_user: + test_user = User( + username='admin', + email='admin@localhost', + passwordhash=generate_password_hash('admin123'), + isactive=True + ) + test_user.roles.append(admin_role) + db.session.add(test_user) + db.session.commit() + click.echo(click.style("Test user created: admin / admin123", fg='green')) + else: + click.echo(click.style("Test user already exists", fg='yellow')) diff --git a/shopdb/config.py b/shopdb/config.py new file mode 100644 index 0000000..38cd767 --- /dev/null +++ b/shopdb/config.py @@ -0,0 +1,82 @@ +"""Flask application configuration.""" + +import os +from datetime import timedelta + + +class Config: + """Base configuration.""" + + # Flask + SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') + + # SQLAlchemy + SQLALCHEMY_DATABASE_URI = os.environ.get( + 'DATABASE_URL', + 'mysql+pymysql://root:password@localhost:3306/shopdb_flask' + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_pre_ping': True, + 'pool_recycle': 300, + } + + # JWT + JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production') + JWT_ACCESS_TOKEN_EXPIRES = timedelta( + seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 3600)) + ) + JWT_REFRESH_TOKEN_EXPIRES = timedelta( + seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000)) + ) + + # CORS + CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*').split(',') + + # Logging + LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') + + # Pagination + DEFAULT_PAGE_SIZE = 20 + MAX_PAGE_SIZE = 100 + + +class DevelopmentConfig(Config): + """Development configuration.""" + + DEBUG = True + SQLALCHEMY_ECHO = True + + # Use SQLite for local development if no DATABASE_URL set + SQLALCHEMY_DATABASE_URI = os.environ.get( + 'DATABASE_URL', + 'sqlite:///shopdb_dev.db' + ) + SQLALCHEMY_ENGINE_OPTIONS = {} # SQLite doesn't need pool options + + +class TestingConfig(Config): + """Testing configuration.""" + + TESTING = True + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5) + + +class ProductionConfig(Config): + """Production configuration.""" + + DEBUG = False + SQLALCHEMY_ECHO = False + + # Stricter security in production + JWT_COOKIE_SECURE = True + JWT_COOKIE_CSRF_PROTECT = True + + +config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig +} diff --git a/shopdb/core/__init__.py b/shopdb/core/__init__.py new file mode 100644 index 0000000..6f4aecf --- /dev/null +++ b/shopdb/core/__init__.py @@ -0,0 +1 @@ +"""Core module - always loaded.""" diff --git a/shopdb/core/api/__init__.py b/shopdb/core/api/__init__.py new file mode 100644 index 0000000..0678558 --- /dev/null +++ b/shopdb/core/api/__init__.py @@ -0,0 +1,33 @@ +"""Core API blueprints.""" + +from .auth import auth_bp +from .machines import machines_bp +from .machinetypes import machinetypes_bp +from .pctypes import pctypes_bp +from .statuses import statuses_bp +from .vendors import vendors_bp +from .models import models_bp +from .businessunits import businessunits_bp +from .locations import locations_bp +from .operatingsystems import operatingsystems_bp +from .dashboard import dashboard_bp +from .applications import applications_bp +from .knowledgebase import knowledgebase_bp +from .search import search_bp + +__all__ = [ + 'auth_bp', + 'machines_bp', + 'machinetypes_bp', + 'pctypes_bp', + 'statuses_bp', + 'vendors_bp', + 'models_bp', + 'businessunits_bp', + 'locations_bp', + 'operatingsystems_bp', + 'dashboard_bp', + 'applications_bp', + 'knowledgebase_bp', + 'search_bp', +] diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py new file mode 100644 index 0000000..d74c1cd --- /dev/null +++ b/shopdb/core/api/applications.py @@ -0,0 +1,429 @@ +"""Applications API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ( + Application, AppVersion, AppOwner, SupportTeam, InstalledApp, Machine +) +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +applications_bp = Blueprint('applications', __name__) + + +@applications_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_applications(): + """List all applications.""" + page, per_page = get_pagination_params(request) + + query = Application.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Application.isactive == True) + + # Filter out hidden unless specifically requested + if request.args.get('showhidden', 'false').lower() != 'true': + query = query.filter(Application.ishidden == False) + + # Filter by installable + if request.args.get('installable') is not None: + installable = request.args.get('installable').lower() == 'true' + query = query.filter(Application.isinstallable == installable) + + if search := request.args.get('search'): + query = query.filter( + db.or_( + Application.appname.ilike(f'%{search}%'), + Application.appdescription.ilike(f'%{search}%') + ) + ) + + query = query.order_by(Application.appname) + + items, total = paginate_query(query, page, per_page) + data = [] + for app in items: + app_dict = app.to_dict() + if app.supportteam: + app_dict['supportteam'] = { + 'supportteamid': app.supportteam.supportteamid, + 'teamname': app.supportteam.teamname, + 'teamurl': app.supportteam.teamurl, + 'owner': { + 'appownerid': app.supportteam.owner.appownerid, + 'appowner': app.supportteam.owner.appowner, + 'sso': app.supportteam.owner.sso + } if app.supportteam.owner else None + } + else: + app_dict['supportteam'] = None + app_dict['installedcount'] = app.installed_on.filter_by(isactive=True).count() + data.append(app_dict) + + return paginated_response(data, page, per_page, total) + + +@applications_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_application(app_id: int): + """Get a single application with details.""" + app = Application.query.get(app_id) + + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + data = app.to_dict() + if app.supportteam: + data['supportteam'] = { + 'supportteamid': app.supportteam.supportteamid, + 'teamname': app.supportteam.teamname, + 'teamurl': app.supportteam.teamurl, + 'owner': { + 'appownerid': app.supportteam.owner.appownerid, + 'appowner': app.supportteam.owner.appowner, + 'sso': app.supportteam.owner.sso + } if app.supportteam.owner else None + } + else: + data['supportteam'] = None + data['versions'] = [v.to_dict() for v in app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()] + data['installedcount'] = app.installed_on.filter_by(isactive=True).count() + + return success_response(data) + + +@applications_bp.route('', methods=['POST']) +@jwt_required() +def create_application(): + """Create a new application.""" + data = request.get_json() + + if not data or not data.get('appname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appname is required') + + if Application.query.filter_by(appname=data['appname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Application '{data['appname']}' already exists", + http_code=409 + ) + + app = Application( + appname=data['appname'], + appdescription=data.get('appdescription'), + supportteamid=data.get('supportteamid'), + isinstallable=data.get('isinstallable', False), + applicationnotes=data.get('applicationnotes'), + installpath=data.get('installpath'), + applicationlink=data.get('applicationlink'), + documentationpath=data.get('documentationpath'), + ishidden=data.get('ishidden', False), + isprinter=data.get('isprinter', False), + islicenced=data.get('islicenced', False), + image=data.get('image') + ) + + db.session.add(app) + db.session.commit() + + return success_response(app.to_dict(), message='Application created', http_code=201) + + +@applications_bp.route('/', methods=['PUT']) +@jwt_required() +def update_application(app_id: int): + """Update an application.""" + app = Application.query.get(app_id) + + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'appname' in data and data['appname'] != app.appname: + if Application.query.filter_by(appname=data['appname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Application '{data['appname']}' already exists", + http_code=409 + ) + + fields = [ + 'appname', 'appdescription', 'supportteamid', 'isinstallable', + 'applicationnotes', 'installpath', 'applicationlink', 'documentationpath', + 'ishidden', 'isprinter', 'islicenced', 'image', 'isactive' + ] + for key in fields: + if key in data: + setattr(app, key, data[key]) + + db.session.commit() + return success_response(app.to_dict(), message='Application updated') + + +@applications_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_application(app_id: int): + """Delete (deactivate) an application.""" + app = Application.query.get(app_id) + + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + app.isactive = False + db.session.commit() + + return success_response(message='Application deleted') + + +# ---- Versions ---- + +@applications_bp.route('//versions', methods=['GET']) +@jwt_required(optional=True) +def list_versions(app_id: int): + """List all versions for an application.""" + app = Application.query.get(app_id) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + versions = app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all() + return success_response([v.to_dict() for v in versions]) + + +@applications_bp.route('//versions', methods=['POST']) +@jwt_required() +def create_version(app_id: int): + """Create a new version for an application.""" + app = Application.query.get(app_id) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + data = request.get_json() + if not data or not data.get('version'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'version is required') + + if AppVersion.query.filter_by(appid=app_id, version=data['version']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Version '{data['version']}' already exists for this application", + http_code=409 + ) + + version = AppVersion( + appid=app_id, + version=data['version'], + releasedate=data.get('releasedate'), + notes=data.get('notes') + ) + + db.session.add(version) + db.session.commit() + + return success_response(version.to_dict(), message='Version created', http_code=201) + + +# ---- Machines with this app installed ---- + +@applications_bp.route('//installed', methods=['GET']) +@jwt_required(optional=True) +def list_installed_machines(app_id: int): + """List all machines that have this application installed.""" + app = Application.query.get(app_id) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + installed = app.installed_on.filter_by(isactive=True).all() + data = [] + for i in installed: + item = i.to_dict() + if i.machine: + item['machine'] = { + 'machineid': i.machine.machineid, + 'machinenumber': i.machine.machinenumber, + 'alias': i.machine.alias, + 'hostname': i.machine.hostname + } + data.append(item) + + return success_response(data) + + +# ---- Installed Apps (per machine) ---- + +@applications_bp.route('/machines/', methods=['GET']) +@jwt_required(optional=True) +def list_machine_applications(machine_id: int): + """List all applications installed on a machine.""" + machine = Machine.query.get(machine_id) + if not machine: + return error_response(ErrorCodes.NOT_FOUND, 'Machine not found', http_code=404) + + installed = machine.installedapps.filter_by(isactive=True).all() + return success_response([i.to_dict() for i in installed]) + + +@applications_bp.route('/machines/', methods=['POST']) +@jwt_required() +def install_application(machine_id: int): + """Install an application on a machine.""" + machine = Machine.query.get(machine_id) + if not machine: + return error_response(ErrorCodes.NOT_FOUND, 'Machine not found', http_code=404) + + data = request.get_json() + if not data or not data.get('appid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required') + + app = Application.query.get(data['appid']) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + # Check if already installed + existing = InstalledApp.query.filter_by( + machineid=machine_id, + appid=data['appid'] + ).first() + + if existing: + if existing.isactive: + return error_response( + ErrorCodes.CONFLICT, + 'Application already installed on this machine', + http_code=409 + ) + # Reactivate + existing.isactive = True + existing.appversionid = data.get('appversionid') + existing.installeddate = db.func.now() + db.session.commit() + return success_response(existing.to_dict(), message='Application reinstalled') + + installed = InstalledApp( + machineid=machine_id, + appid=data['appid'], + appversionid=data.get('appversionid') + ) + + db.session.add(installed) + db.session.commit() + + return success_response(installed.to_dict(), message='Application installed', http_code=201) + + +@applications_bp.route('/machines//', methods=['DELETE']) +@jwt_required() +def uninstall_application(machine_id: int, app_id: int): + """Uninstall an application from a machine.""" + installed = InstalledApp.query.filter_by( + machineid=machine_id, + appid=app_id, + isactive=True + ).first() + + if not installed: + return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this machine', http_code=404) + + installed.isactive = False + db.session.commit() + + return success_response(message='Application uninstalled') + + +@applications_bp.route('/machines//', methods=['PUT']) +@jwt_required() +def update_installed_app(machine_id: int, app_id: int): + """Update installed application (e.g., change version).""" + installed = InstalledApp.query.filter_by( + machineid=machine_id, + appid=app_id, + isactive=True + ).first() + + if not installed: + return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this machine', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'appversionid' in data: + installed.appversionid = data['appversionid'] + + db.session.commit() + + return success_response(installed.to_dict(), message='Installation updated') + + +# ---- Support Teams ---- + +@applications_bp.route('/supportteams', methods=['GET']) +@jwt_required(optional=True) +def list_support_teams(): + """List all support teams.""" + teams = SupportTeam.query.filter_by(isactive=True).order_by(SupportTeam.teamname).all() + data = [] + for team in teams: + team_dict = team.to_dict() + team_dict['owner'] = team.owner.appowner if team.owner else None + data.append(team_dict) + return success_response(data) + + +@applications_bp.route('/supportteams', methods=['POST']) +@jwt_required() +def create_support_team(): + """Create a new support team.""" + data = request.get_json() + if not data or not data.get('teamname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'teamname is required') + + team = SupportTeam( + teamname=data['teamname'], + teamurl=data.get('teamurl'), + appownerid=data.get('appownerid') + ) + + db.session.add(team) + db.session.commit() + + return success_response(team.to_dict(), message='Support team created', http_code=201) + + +# ---- App Owners ---- + +@applications_bp.route('/appowners', methods=['GET']) +@jwt_required(optional=True) +def list_app_owners(): + """List all application owners.""" + owners = AppOwner.query.filter_by(isactive=True).order_by(AppOwner.appowner).all() + return success_response([o.to_dict() for o in owners]) + + +@applications_bp.route('/appowners', methods=['POST']) +@jwt_required() +def create_app_owner(): + """Create a new application owner.""" + data = request.get_json() + if not data or not data.get('appowner'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'appowner is required') + + owner = AppOwner( + appowner=data['appowner'], + sso=data.get('sso'), + email=data.get('email') + ) + + db.session.add(owner) + db.session.commit() + + return success_response(owner.to_dict(), message='App owner created', http_code=201) diff --git a/shopdb/core/api/auth.py b/shopdb/core/api/auth.py new file mode 100644 index 0000000..fb8b900 --- /dev/null +++ b/shopdb/core/api/auth.py @@ -0,0 +1,147 @@ +"""Authentication API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + jwt_required, + get_jwt_identity, + current_user +) +from werkzeug.security import check_password_hash + +from shopdb.extensions import db +from shopdb.core.models import User +from shopdb.utils.responses import success_response, error_response, ErrorCodes + +auth_bp = Blueprint('auth', __name__) + + +@auth_bp.route('/login', methods=['POST']) +def login(): + """ + Authenticate user and return JWT tokens. + + Request: + { + "username": "string", + "password": "string" + } + + Response: + { + "data": { + "access_token": "...", + "refresh_token": "...", + "user": {...} + } + } + """ + data = request.get_json() + + if not data or not data.get('username') or not data.get('password'): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Username and password required' + ) + + user = User.query.filter_by( + username=data['username'], + isactive=True + ).first() + + if not user or not check_password_hash(user.passwordhash, data['password']): + return error_response( + ErrorCodes.UNAUTHORIZED, + 'Invalid username or password', + http_code=401 + ) + + if user.islocked: + return error_response( + ErrorCodes.FORBIDDEN, + 'Account is locked', + http_code=403 + ) + + # Create tokens (identity must be a string in Flask-JWT-Extended 4.x) + access_token = create_access_token( + identity=str(user.userid), + additional_claims={ + 'username': user.username, + 'roles': [r.rolename for r in user.roles] + } + ) + refresh_token = create_refresh_token(identity=str(user.userid)) + + # Update last login + user.lastlogindate = db.func.now() + user.failedlogins = 0 + db.session.commit() + + return success_response({ + 'access_token': access_token, + 'refresh_token': refresh_token, + 'token_type': 'Bearer', + 'expires_in': 3600, + 'user': { + 'userid': user.userid, + 'username': user.username, + 'email': user.email, + 'firstname': user.firstname, + 'lastname': user.lastname, + 'roles': [r.rolename for r in user.roles] + } + }) + + +@auth_bp.route('/refresh', methods=['POST']) +@jwt_required(refresh=True) +def refresh(): + """Refresh access token using refresh token.""" + user_id = get_jwt_identity() + user = User.query.get(int(user_id)) + + if not user or not user.isactive: + return error_response( + ErrorCodes.UNAUTHORIZED, + 'User not found or inactive', + http_code=401 + ) + + access_token = create_access_token( + identity=str(user.userid), + additional_claims={ + 'username': user.username, + 'roles': [r.rolename for r in user.roles] + } + ) + + return success_response({ + 'access_token': access_token, + 'token_type': 'Bearer', + 'expires_in': 3600 + }) + + +@auth_bp.route('/me', methods=['GET']) +@jwt_required() +def get_current_user(): + """Get current authenticated user info.""" + return success_response({ + 'userid': current_user.userid, + 'username': current_user.username, + 'email': current_user.email, + 'firstname': current_user.firstname, + 'lastname': current_user.lastname, + 'roles': [r.rolename for r in current_user.roles], + 'permissions': current_user.getpermissions() + }) + + +@auth_bp.route('/logout', methods=['POST']) +@jwt_required() +def logout(): + """Logout user (for frontend token cleanup).""" + # In a full implementation, you'd blacklist the token + return success_response(message='Successfully logged out') diff --git a/shopdb/core/api/businessunits.py b/shopdb/core/api/businessunits.py new file mode 100644 index 0000000..e51fa1c --- /dev/null +++ b/shopdb/core/api/businessunits.py @@ -0,0 +1,144 @@ +"""Business Units API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import BusinessUnit +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +businessunits_bp = Blueprint('businessunits', __name__) + + +@businessunits_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_businessunits(): + """List all business units.""" + page, per_page = get_pagination_params(request) + + query = BusinessUnit.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(BusinessUnit.isactive == True) + + if search := request.args.get('search'): + query = query.filter( + db.or_( + BusinessUnit.businessunit.ilike(f'%{search}%'), + BusinessUnit.code.ilike(f'%{search}%') + ) + ) + + query = query.order_by(BusinessUnit.businessunit) + + items, total = paginate_query(query, page, per_page) + data = [bu.to_dict() for bu in items] + + return paginated_response(data, page, per_page, total) + + +@businessunits_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_businessunit(bu_id: int): + """Get a single business unit.""" + bu = BusinessUnit.query.get(bu_id) + + if not bu: + return error_response( + ErrorCodes.NOT_FOUND, + f'Business unit with ID {bu_id} not found', + http_code=404 + ) + + data = bu.to_dict() + data['parent'] = bu.parent.to_dict() if bu.parent else None + data['children'] = [c.to_dict() for c in bu.children] + + return success_response(data) + + +@businessunits_bp.route('', methods=['POST']) +@jwt_required() +def create_businessunit(): + """Create a new business unit.""" + data = request.get_json() + + if not data or not data.get('businessunit'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunit is required') + + if BusinessUnit.query.filter_by(businessunit=data['businessunit']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Business unit '{data['businessunit']}' already exists", + http_code=409 + ) + + bu = BusinessUnit( + businessunit=data['businessunit'], + code=data.get('code'), + description=data.get('description'), + parentid=data.get('parentid') + ) + + db.session.add(bu) + db.session.commit() + + return success_response(bu.to_dict(), message='Business unit created', http_code=201) + + +@businessunits_bp.route('/', methods=['PUT']) +@jwt_required() +def update_businessunit(bu_id: int): + """Update a business unit.""" + bu = BusinessUnit.query.get(bu_id) + + if not bu: + return error_response( + ErrorCodes.NOT_FOUND, + f'Business unit with ID {bu_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'businessunit' in data and data['businessunit'] != bu.businessunit: + if BusinessUnit.query.filter_by(businessunit=data['businessunit']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Business unit '{data['businessunit']}' already exists", + http_code=409 + ) + + for key in ['businessunit', 'code', 'description', 'parentid', 'isactive']: + if key in data: + setattr(bu, key, data[key]) + + db.session.commit() + return success_response(bu.to_dict(), message='Business unit updated') + + +@businessunits_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_businessunit(bu_id: int): + """Delete (deactivate) a business unit.""" + bu = BusinessUnit.query.get(bu_id) + + if not bu: + return error_response( + ErrorCodes.NOT_FOUND, + f'Business unit with ID {bu_id} not found', + http_code=404 + ) + + bu.isactive = False + db.session.commit() + + return success_response(message='Business unit deleted') diff --git a/shopdb/core/api/dashboard.py b/shopdb/core/api/dashboard.py new file mode 100644 index 0000000..9ad4cbb --- /dev/null +++ b/shopdb/core/api/dashboard.py @@ -0,0 +1,117 @@ +"""Dashboard API endpoints.""" + +from flask import Blueprint +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Machine, MachineType, MachineStatus +from shopdb.utils.responses import success_response + +dashboard_bp = Blueprint('dashboard', __name__) + + +@dashboard_bp.route('/summary', methods=['GET']) +@dashboard_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def get_dashboard(): + """Get dashboard summary data.""" + # Count machines by category + equipment_count = db.session.query(Machine).join(MachineType).filter( + Machine.isactive == True, + MachineType.category == 'Equipment' + ).count() + + pc_count = db.session.query(Machine).join(MachineType).filter( + Machine.isactive == True, + MachineType.category == 'PC' + ).count() + + network_count = db.session.query(Machine).join(MachineType).filter( + Machine.isactive == True, + MachineType.category == 'Network' + ).count() + + # Count by status + status_counts = db.session.query( + MachineStatus.status, + db.func.count(Machine.machineid) + ).outerjoin( + Machine, + db.and_(Machine.statusid == MachineStatus.statusid, Machine.isactive == True) + ).group_by(MachineStatus.status).all() + + # Recent machines + recent_machines = Machine.query.filter_by(isactive=True).order_by( + Machine.createddate.desc() + ).limit(10).all() + + # Build status dict + status_dict = {status: count for status, count in status_counts} + + return success_response({ + # Fields expected by frontend + 'totalmachines': equipment_count + pc_count + network_count, + 'totalequipment': equipment_count, + 'totalpc': pc_count, + 'totalnetwork': network_count, + 'activemachines': status_dict.get('In Use', 0), + 'inrepair': status_dict.get('In Repair', 0), + # Also include structured data + 'counts': { + 'equipment': equipment_count, + 'pcs': pc_count, + 'network_devices': network_count, + 'total': equipment_count + pc_count + network_count + }, + 'by_status': status_dict, + 'recent': [ + { + 'machineid': m.machineid, + 'machinenumber': m.machinenumber, + 'machinetype': m.machinetype.machinetype if m.machinetype else None, + 'createddate': m.createddate.isoformat() + 'Z' if m.createddate else None + } + for m in recent_machines + ] + }) + + +@dashboard_bp.route('/stats', methods=['GET']) +@jwt_required(optional=True) +def get_stats(): + """Get detailed statistics.""" + # Machine type breakdown + type_counts = db.session.query( + MachineType.machinetype, + MachineType.category, + db.func.count(Machine.machineid) + ).outerjoin( + Machine, + db.and_(Machine.machinetypeid == MachineType.machinetypeid, Machine.isactive == True) + ).filter(MachineType.isactive == True).group_by( + MachineType.machinetypeid + ).all() + + return success_response({ + 'by_type': [ + {'type': t, 'category': c, 'count': count} + for t, c, count in type_counts + ] + }) + + +@dashboard_bp.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint (no auth required).""" + try: + # Test database connection + db.session.execute(db.text('SELECT 1')) + db_status = 'healthy' + except Exception as e: + db_status = f'unhealthy: {str(e)}' + + return success_response({ + 'status': 'ok' if db_status == 'healthy' else 'degraded', + 'database': db_status, + 'version': '1.0.0' + }) diff --git a/shopdb/core/api/knowledgebase.py b/shopdb/core/api/knowledgebase.py new file mode 100644 index 0000000..737f595 --- /dev/null +++ b/shopdb/core/api/knowledgebase.py @@ -0,0 +1,207 @@ +"""Knowledge Base API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import KnowledgeBase, Application +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +knowledgebase_bp = Blueprint('knowledgebase', __name__) + + +@knowledgebase_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_articles(): + """List all knowledge base articles.""" + page, per_page = get_pagination_params(request) + + query = KnowledgeBase.query.filter_by(isactive=True) + + # Search + if search := request.args.get('search'): + query = query.filter( + db.or_( + KnowledgeBase.shortdescription.ilike(f'%{search}%'), + KnowledgeBase.keywords.ilike(f'%{search}%') + ) + ) + + # Filter by topic/application + if appid := request.args.get('appid'): + query = query.filter(KnowledgeBase.appid == int(appid)) + + # Sort options + sort = request.args.get('sort', 'clicks') + order = request.args.get('order', 'desc') + + if sort == 'clicks': + query = query.order_by( + KnowledgeBase.clicks.desc() if order == 'desc' else KnowledgeBase.clicks.asc(), + KnowledgeBase.lastupdated.desc() + ) + elif sort == 'topic': + query = query.join(Application).order_by( + Application.appname.desc() if order == 'desc' else Application.appname.asc() + ) + elif sort == 'description': + query = query.order_by( + KnowledgeBase.shortdescription.desc() if order == 'desc' else KnowledgeBase.shortdescription.asc() + ) + elif sort == 'lastupdated': + query = query.order_by( + KnowledgeBase.lastupdated.desc() if order == 'desc' else KnowledgeBase.lastupdated.asc() + ) + else: + query = query.order_by(KnowledgeBase.clicks.desc()) + + items, total = paginate_query(query, page, per_page) + data = [] + for article in items: + article_dict = article.to_dict() + if article.application: + article_dict['application'] = { + 'appid': article.application.appid, + 'appname': article.application.appname + } + else: + article_dict['application'] = None + data.append(article_dict) + + return paginated_response(data, page, per_page, total) + + +@knowledgebase_bp.route('/stats', methods=['GET']) +@jwt_required(optional=True) +def get_stats(): + """Get knowledge base statistics.""" + total_clicks = db.session.query( + db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0) + ).filter(KnowledgeBase.isactive == True).scalar() + + total_articles = KnowledgeBase.query.filter_by(isactive=True).count() + + return success_response({ + 'totalclicks': int(total_clicks), + 'totalarticles': total_articles + }) + + +@knowledgebase_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_article(link_id: int): + """Get a single knowledge base article.""" + article = KnowledgeBase.query.get(link_id) + + if not article or not article.isactive: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + data = article.to_dict() + if article.application: + data['application'] = { + 'appid': article.application.appid, + 'appname': article.application.appname + } + else: + data['application'] = None + + return success_response(data) + + +@knowledgebase_bp.route('//click', methods=['POST']) +@jwt_required(optional=True) +def track_click(link_id: int): + """Increment click counter and return the URL to redirect to.""" + article = KnowledgeBase.query.get(link_id) + + if not article or not article.isactive: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + article.increment_clicks() + db.session.commit() + + return success_response({ + 'linkurl': article.linkurl, + 'clicks': article.clicks + }) + + +@knowledgebase_bp.route('', methods=['POST']) +@jwt_required() +def create_article(): + """Create a new knowledge base article.""" + data = request.get_json() + + if not data or not data.get('shortdescription'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'shortdescription is required') + + if not data.get('linkurl'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'linkurl is required') + + # Validate application if provided + if data.get('appid'): + app = Application.query.get(data['appid']) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + article = KnowledgeBase( + shortdescription=data['shortdescription'], + linkurl=data['linkurl'], + appid=data.get('appid'), + keywords=data.get('keywords'), + clicks=0 + ) + + db.session.add(article) + db.session.commit() + + return success_response(article.to_dict(), message='Article created', http_code=201) + + +@knowledgebase_bp.route('/', methods=['PUT']) +@jwt_required() +def update_article(link_id: int): + """Update a knowledge base article.""" + article = KnowledgeBase.query.get(link_id) + + if not article: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Validate application if being changed + if 'appid' in data and data['appid']: + app = Application.query.get(data['appid']) + if not app: + return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404) + + fields = ['shortdescription', 'linkurl', 'appid', 'keywords', 'isactive'] + for key in fields: + if key in data: + setattr(article, key, data[key]) + + db.session.commit() + return success_response(article.to_dict(), message='Article updated') + + +@knowledgebase_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_article(link_id: int): + """Delete (deactivate) a knowledge base article.""" + article = KnowledgeBase.query.get(link_id) + + if not article: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + article.isactive = False + db.session.commit() + + return success_response(message='Article deleted') diff --git a/shopdb/core/api/locations.py b/shopdb/core/api/locations.py new file mode 100644 index 0000000..5686127 --- /dev/null +++ b/shopdb/core/api/locations.py @@ -0,0 +1,144 @@ +"""Locations API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Location +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +locations_bp = Blueprint('locations', __name__) + + +@locations_bp.route('', methods=['GET']) +@jwt_required() +def list_locations(): + """List all locations.""" + page, per_page = get_pagination_params(request) + + query = Location.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Location.isactive == True) + + if search := request.args.get('search'): + query = query.filter( + db.or_( + Location.locationname.ilike(f'%{search}%'), + Location.building.ilike(f'%{search}%') + ) + ) + + query = query.order_by(Location.locationname) + + items, total = paginate_query(query, page, per_page) + data = [loc.to_dict() for loc in items] + + return paginated_response(data, page, per_page, total) + + +@locations_bp.route('/', methods=['GET']) +@jwt_required() +def get_location(location_id: int): + """Get a single location.""" + loc = Location.query.get(location_id) + + if not loc: + return error_response( + ErrorCodes.NOT_FOUND, + f'Location with ID {location_id} not found', + http_code=404 + ) + + return success_response(loc.to_dict()) + + +@locations_bp.route('', methods=['POST']) +@jwt_required() +def create_location(): + """Create a new location.""" + data = request.get_json() + + if not data or not data.get('locationname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'locationname is required') + + if Location.query.filter_by(locationname=data['locationname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Location '{data['locationname']}' already exists", + http_code=409 + ) + + loc = Location( + locationname=data['locationname'], + building=data.get('building'), + floor=data.get('floor'), + room=data.get('room'), + description=data.get('description'), + mapimage=data.get('mapimage'), + mapwidth=data.get('mapwidth'), + mapheight=data.get('mapheight') + ) + + db.session.add(loc) + db.session.commit() + + return success_response(loc.to_dict(), message='Location created', http_code=201) + + +@locations_bp.route('/', methods=['PUT']) +@jwt_required() +def update_location(location_id: int): + """Update a location.""" + loc = Location.query.get(location_id) + + if not loc: + return error_response( + ErrorCodes.NOT_FOUND, + f'Location with ID {location_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'locationname' in data and data['locationname'] != loc.locationname: + if Location.query.filter_by(locationname=data['locationname']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Location '{data['locationname']}' already exists", + http_code=409 + ) + + for key in ['locationname', 'building', 'floor', 'room', 'description', 'mapimage', 'mapwidth', 'mapheight', 'isactive']: + if key in data: + setattr(loc, key, data[key]) + + db.session.commit() + return success_response(loc.to_dict(), message='Location updated') + + +@locations_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_location(location_id: int): + """Delete (deactivate) a location.""" + loc = Location.query.get(location_id) + + if not loc: + return error_response( + ErrorCodes.NOT_FOUND, + f'Location with ID {location_id} not found', + http_code=404 + ) + + loc.isactive = False + db.session.commit() + + return success_response(message='Location deleted') diff --git a/shopdb/core/api/machines.py b/shopdb/core/api/machines.py new file mode 100644 index 0000000..c7b757a --- /dev/null +++ b/shopdb/core/api/machines.py @@ -0,0 +1,567 @@ +"""Machines API endpoints.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, current_user + +from shopdb.extensions import db +from shopdb.core.models import Machine, MachineType +from shopdb.core.models.relationship import MachineRelationship, RelationshipType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +machines_bp = Blueprint('machines', __name__) + + +@machines_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_machines(): + """ + List all machines with filtering and pagination. + + Query params: + page: int (default 1) + per_page: int (default 20, max 100) + machinetype: int (filter by type ID) + pctype: int (filter by PC type ID) + businessunit: int (filter by business unit ID) + status: int (filter by status ID) + category: str (Equipment, PC, Network) + search: str (search in machinenumber, alias, hostname) + active: bool (default true) + sort: str (field name, prefix with - for desc) + """ + page, per_page = get_pagination_params(request) + + # Build query + query = Machine.query + + # Apply filters + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Machine.isactive == True) + + if machinetype_id := request.args.get('machinetype', type=int): + query = query.filter(Machine.machinetypeid == machinetype_id) + + if pctype_id := request.args.get('pctype', type=int): + query = query.filter(Machine.pctypeid == pctype_id) + + if businessunit_id := request.args.get('businessunit', type=int): + query = query.filter(Machine.businessunitid == businessunit_id) + + if status_id := request.args.get('status', type=int): + query = query.filter(Machine.statusid == status_id) + + if category := request.args.get('category'): + query = query.join(MachineType).filter(MachineType.category == category) + + if search := request.args.get('search'): + search_term = f'%{search}%' + query = query.filter( + db.or_( + Machine.machinenumber.ilike(search_term), + Machine.alias.ilike(search_term), + Machine.hostname.ilike(search_term), + Machine.serialnumber.ilike(search_term) + ) + ) + + # Filter for machines with map positions + if request.args.get('hasmap', '').lower() == 'true': + query = query.filter( + Machine.mapleft.isnot(None), + Machine.maptop.isnot(None) + ) + + # Apply sorting + sort_field = request.args.get('sort', 'machinenumber') + desc = sort_field.startswith('-') + if desc: + sort_field = sort_field[1:] + + if hasattr(Machine, sort_field): + order = getattr(Machine, sort_field) + query = query.order_by(order.desc() if desc else order) + + # For map view, allow fetching all machines without pagination limit + include_map_extras = request.args.get('hasmap', '').lower() == 'true' + fetch_all = request.args.get('all', '').lower() == 'true' + + if include_map_extras and fetch_all: + # Get all map machines without pagination + items = query.all() + total = len(items) + else: + # Normal pagination + items, total = paginate_query(query, page, per_page) + + # Convert to dicts with relationships + data = [] + for m in items: + d = m.to_dict() + # Get machinetype from model (single source of truth) + mt = m.derived_machinetype + d['machinetype'] = mt.machinetype if mt else None + d['machinetypeid'] = mt.machinetypeid if mt else None + d['category'] = mt.category if mt else None + d['status'] = m.status.status if m.status else None + d['statusid'] = m.statusid + d['businessunit'] = m.businessunit.businessunit if m.businessunit else None + d['businessunitid'] = m.businessunitid + d['vendor'] = m.vendor.vendor if m.vendor else None + d['model'] = m.model.modelnumber if m.model else None + d['pctype'] = m.pctype.pctype if m.pctype else None + d['serialnumber'] = m.serialnumber + d['isvnc'] = m.isvnc + d['iswinrm'] = m.iswinrm + + # Include extra fields for map view + if include_map_extras: + # Get primary IP address from communications + primary_comm = next( + (c for c in m.communications if c.isprimary and c.ipaddress), + None + ) + if not primary_comm: + # Fall back to first communication with IP + primary_comm = next( + (c for c in m.communications if c.ipaddress), + None + ) + d['ipaddress'] = primary_comm.ipaddress if primary_comm else None + + # Get connected PC (parent machine that is a PC) + connected_pc = None + for rel in m.parent_relationships: + if rel.parent_machine and rel.parent_machine.is_pc: + connected_pc = rel.parent_machine.machinenumber + break + d['connected_pc'] = connected_pc + + data.append(d) + + return paginated_response(data, page, per_page, total) + + +@machines_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_machine(machine_id: int): + """Get a single machine by ID.""" + machine = Machine.query.get(machine_id) + + if not machine: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + data = machine.to_dict() + # Add related data - machinetype comes from model (single source of truth) + mt = machine.derived_machinetype + data['machinetype'] = mt.to_dict() if mt else None + data['pctype'] = machine.pctype.to_dict() if machine.pctype else None + data['status'] = machine.status.to_dict() if machine.status else None + data['businessunit'] = machine.businessunit.to_dict() if machine.businessunit else None + data['vendor'] = machine.vendor.to_dict() if machine.vendor else None + data['model'] = machine.model.to_dict() if machine.model else None + data['location'] = machine.location.to_dict() if machine.location else None + data['operatingsystem'] = machine.operatingsystem.to_dict() if machine.operatingsystem else None + + # Add communications + data['communications'] = [c.to_dict() for c in machine.communications.all()] + + return success_response(data) + + +@machines_bp.route('', methods=['POST']) +@jwt_required() +def create_machine(): + """Create a new machine.""" + data = request.get_json() + + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('machinenumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'machinenumber is required') + + if not data.get('modelnumberid'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumberid is required (determines machine type)') + + # Check for duplicate machinenumber + if Machine.query.filter_by(machinenumber=data['machinenumber']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Machine number '{data['machinenumber']}' already exists", + http_code=409 + ) + + # Create machine + allowed_fields = [ + 'machinenumber', 'alias', 'hostname', 'serialnumber', + 'machinetypeid', 'pctypeid', 'businessunitid', 'modelnumberid', + 'vendorid', 'statusid', 'locationid', 'osid', + 'mapleft', 'maptop', 'islocationonly', + 'loggedinuser', 'isvnc', 'iswinrm', 'isshopfloor', + 'requiresmanualconfig', 'notes' + ] + + machine_data = {k: v for k, v in data.items() if k in allowed_fields} + machine = Machine(**machine_data) + machine.createdby = current_user.username + + db.session.add(machine) + db.session.commit() + + return success_response( + machine.to_dict(), + message='Machine created successfully', + http_code=201 + ) + + +@machines_bp.route('/', methods=['PUT']) +@jwt_required() +def update_machine(machine_id: int): + """Update an existing machine.""" + machine = Machine.query.get(machine_id) + + if not machine: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check for duplicate machinenumber if changed + if 'machinenumber' in data and data['machinenumber'] != machine.machinenumber: + existing = Machine.query.filter_by(machinenumber=data['machinenumber']).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Machine number '{data['machinenumber']}' already exists", + http_code=409 + ) + + # Update allowed fields + allowed_fields = [ + 'machinenumber', 'alias', 'hostname', 'serialnumber', + 'machinetypeid', 'pctypeid', 'businessunitid', 'modelnumberid', + 'vendorid', 'statusid', 'locationid', 'osid', + 'mapleft', 'maptop', 'islocationonly', + 'loggedinuser', 'isvnc', 'iswinrm', 'isshopfloor', + 'requiresmanualconfig', 'notes', 'isactive' + ] + + for key, value in data.items(): + if key in allowed_fields: + setattr(machine, key, value) + + machine.modifiedby = current_user.username + db.session.commit() + + return success_response(machine.to_dict(), message='Machine updated successfully') + + +@machines_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_machine(machine_id: int): + """Soft delete a machine.""" + machine = Machine.query.get(machine_id) + + if not machine: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + machine.soft_delete(deleted_by=current_user.username) + db.session.commit() + + return success_response(message='Machine deleted successfully') + + +@machines_bp.route('//communications', methods=['GET']) +@jwt_required() +def get_machine_communications(machine_id: int): + """Get all communications for a machine.""" + machine = Machine.query.get(machine_id) + + if not machine: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + comms = [c.to_dict() for c in machine.communications.all()] + return success_response(comms) + + +@machines_bp.route('//communication', methods=['PUT']) +@jwt_required() +def update_machine_communication(machine_id: int): + """Update machine communication (IP address).""" + from shopdb.core.models.communication import Communication, CommunicationType + + machine = Machine.query.get(machine_id) + + if not machine: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Get or create IP communication type + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if not ip_comtype: + ip_comtype = CommunicationType(comtype='IP', description='IP Network') + db.session.add(ip_comtype) + db.session.flush() + + # Find existing primary communication or create new one + comms = list(machine.communications.all()) + comm = next((c for c in comms if c.isprimary), None) + if not comm: + comm = next((c for c in comms if c.comtypeid == ip_comtype.comtypeid), None) + if not comm: + comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid) + db.session.add(comm) + + # Update fields + if 'ipaddress' in data: + comm.ipaddress = data['ipaddress'] + if 'isprimary' in data: + comm.isprimary = data['isprimary'] + if 'macaddress' in data: + comm.macaddress = data['macaddress'] + + db.session.commit() + + return success_response({ + 'communicationid': comm.communicationid, + 'ipaddress': comm.ipaddress, + 'isprimary': comm.isprimary, + }, message='Communication updated') + + +# ==================== Machine Relationships ==================== + +@machines_bp.route('//relationships', methods=['GET']) +@jwt_required(optional=True) +def get_machine_relationships(machine_id: int): + """Get all relationships for a machine (both parent and child).""" + machine = Machine.query.get(machine_id) + + if not machine: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + relationships = [] + my_category = machine.machinetype.category if machine.machinetype else None + seen_ids = set() + + # Get all relationships involving this machine + all_rels = list(machine.child_relationships) + list(machine.parent_relationships) + + for rel in all_rels: + if rel.relationshipid in seen_ids: + continue + seen_ids.add(rel.relationshipid) + + # Determine the related machine (the one that isn't us) + if rel.parentmachineid == machine.machineid: + related = rel.child_machine + else: + related = rel.parent_machine + + related_category = related.machinetype.category if related and related.machinetype else None + rel_type = rel.relationship_type.relationshiptype if rel.relationship_type else None + + # Determine direction based on relationship type and categories + if rel_type == 'Controls': + # PC controls Equipment - determine from categories + if my_category == 'PC': + direction = 'controls' + else: + direction = 'controlled_by' + elif rel_type == 'Dualpath': + direction = 'dualpath_partner' + else: + # For other types, use parent/child + if rel.parentmachineid == machine.machineid: + direction = 'controls' + else: + direction = 'controlled_by' + + relationships.append({ + 'relationshipid': rel.relationshipid, + 'direction': direction, + 'relatedmachineid': related.machineid if related else None, + 'relatedmachinenumber': related.machinenumber if related else None, + 'relatedmachinealias': related.alias if related else None, + 'relatedcategory': related_category, + 'relationshiptype': rel_type, + 'relationshiptypeid': rel.relationshiptypeid, + 'notes': rel.notes + }) + + return success_response(relationships) + + +@machines_bp.route('//relationships', methods=['POST']) +@jwt_required() +def create_machine_relationship(machine_id: int): + """Create a relationship for a machine.""" + machine = Machine.query.get(machine_id) + + if not machine: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine with ID {machine_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + related_machine_id = data.get('relatedmachineid') + relationship_type_id = data.get('relationshiptypeid') + direction = data.get('direction', 'controlled_by') # 'controls' or 'controlled_by' + + if not related_machine_id: + return error_response(ErrorCodes.VALIDATION_ERROR, 'relatedmachineid is required') + + if not relationship_type_id: + return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptypeid is required') + + related_machine = Machine.query.get(related_machine_id) + if not related_machine: + return error_response( + ErrorCodes.NOT_FOUND, + f'Related machine with ID {related_machine_id} not found', + http_code=404 + ) + + # Determine parent/child based on direction + if direction == 'controls': + parent_id = machine_id + child_id = related_machine_id + else: # controlled_by + parent_id = related_machine_id + child_id = machine_id + + # Check if relationship already exists + existing = MachineRelationship.query.filter_by( + parentmachineid=parent_id, + childmachineid=child_id, + relationshiptypeid=relationship_type_id + ).first() + + if existing: + return error_response( + ErrorCodes.CONFLICT, + 'This relationship already exists', + http_code=409 + ) + + relationship = MachineRelationship( + parentmachineid=parent_id, + childmachineid=child_id, + relationshiptypeid=relationship_type_id, + notes=data.get('notes') + ) + + db.session.add(relationship) + db.session.commit() + + return success_response({ + 'relationshipid': relationship.relationshipid, + 'parentmachineid': relationship.parentmachineid, + 'childmachineid': relationship.childmachineid, + 'relationshiptypeid': relationship.relationshiptypeid + }, message='Relationship created successfully', http_code=201) + + +@machines_bp.route('/relationships/', methods=['DELETE']) +@jwt_required() +def delete_machine_relationship(relationship_id: int): + """Delete a machine relationship.""" + relationship = MachineRelationship.query.get(relationship_id) + + if not relationship: + return error_response( + ErrorCodes.NOT_FOUND, + f'Relationship with ID {relationship_id} not found', + http_code=404 + ) + + db.session.delete(relationship) + db.session.commit() + + return success_response(message='Relationship deleted successfully') + + +@machines_bp.route('/relationshiptypes', methods=['GET']) +@jwt_required(optional=True) +def list_relationship_types(): + """List all relationship types.""" + types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all() + return success_response([{ + 'relationshiptypeid': t.relationshiptypeid, + 'relationshiptype': t.relationshiptype, + 'description': t.description + } for t in types]) + + +@machines_bp.route('/relationshiptypes', methods=['POST']) +@jwt_required() +def create_relationship_type(): + """Create a new relationship type.""" + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if not data.get('relationshiptype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required') + + existing = RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Relationship type '{data['relationshiptype']}' already exists", + http_code=409 + ) + + rel_type = RelationshipType( + relationshiptype=data['relationshiptype'], + description=data.get('description') + ) + + db.session.add(rel_type) + db.session.commit() + + return success_response({ + 'relationshiptypeid': rel_type.relationshiptypeid, + 'relationshiptype': rel_type.relationshiptype, + 'description': rel_type.description + }, message='Relationship type created successfully', http_code=201) diff --git a/shopdb/core/api/machinetypes.py b/shopdb/core/api/machinetypes.py new file mode 100644 index 0000000..5ec6582 --- /dev/null +++ b/shopdb/core/api/machinetypes.py @@ -0,0 +1,148 @@ +"""Machine Types API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, current_user + +from shopdb.extensions import db +from shopdb.core.models import MachineType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +machinetypes_bp = Blueprint('machinetypes', __name__) + + +@machinetypes_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_machinetypes(): + """List all machine types with optional filtering.""" + page, per_page = get_pagination_params(request) + + query = MachineType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(MachineType.isactive == True) + + if category := request.args.get('category'): + query = query.filter(MachineType.category == category) + + if search := request.args.get('search'): + query = query.filter(MachineType.machinetype.ilike(f'%{search}%')) + + query = query.order_by(MachineType.machinetype) + + items, total = paginate_query(query, page, per_page) + data = [mt.to_dict() for mt in items] + + return paginated_response(data, page, per_page, total) + + +@machinetypes_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_machinetype(type_id: int): + """Get a single machine type.""" + mt = MachineType.query.get(type_id) + + if not mt: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine type with ID {type_id} not found', + http_code=404 + ) + + return success_response(mt.to_dict()) + + +@machinetypes_bp.route('', methods=['POST']) +@jwt_required() +def create_machinetype(): + """Create a new machine type.""" + data = request.get_json() + + if not data or not data.get('machinetype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required') + + if MachineType.query.filter_by(machinetype=data['machinetype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Machine type '{data['machinetype']}' already exists", + http_code=409 + ) + + mt = MachineType( + machinetype=data['machinetype'], + category=data.get('category', 'Equipment'), + description=data.get('description'), + icon=data.get('icon') + ) + + db.session.add(mt) + db.session.commit() + + return success_response(mt.to_dict(), message='Machine type created', http_code=201) + + +@machinetypes_bp.route('/', methods=['PUT']) +@jwt_required() +def update_machinetype(type_id: int): + """Update a machine type.""" + mt = MachineType.query.get(type_id) + + if not mt: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # Check duplicate name + if 'machinetype' in data and data['machinetype'] != mt.machinetype: + if MachineType.query.filter_by(machinetype=data['machinetype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Machine type '{data['machinetype']}' already exists", + http_code=409 + ) + + for key in ['machinetype', 'category', 'description', 'icon', 'isactive']: + if key in data: + setattr(mt, key, data[key]) + + db.session.commit() + return success_response(mt.to_dict(), message='Machine type updated') + + +@machinetypes_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_machinetype(type_id: int): + """Delete (deactivate) a machine type.""" + mt = MachineType.query.get(type_id) + + if not mt: + return error_response( + ErrorCodes.NOT_FOUND, + f'Machine type with ID {type_id} not found', + http_code=404 + ) + + # Check if any machines use this type + from shopdb.core.models import Machine + if Machine.query.filter_by(machinetypeid=type_id, isactive=True).first(): + return error_response( + ErrorCodes.CONFLICT, + 'Cannot delete machine type: machines are using it', + http_code=409 + ) + + mt.isactive = False + db.session.commit() + + return success_response(message='Machine type deleted') diff --git a/shopdb/core/api/models.py b/shopdb/core/api/models.py new file mode 100644 index 0000000..66238ed --- /dev/null +++ b/shopdb/core/api/models.py @@ -0,0 +1,151 @@ +"""Models (equipment models) API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Model +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +models_bp = Blueprint('models', __name__) + + +@models_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_models(): + """List all equipment models.""" + page, per_page = get_pagination_params(request) + + query = Model.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Model.isactive == True) + + if vendor_id := request.args.get('vendor', type=int): + query = query.filter(Model.vendorid == vendor_id) + + if machinetype_id := request.args.get('machinetype', type=int): + query = query.filter(Model.machinetypeid == machinetype_id) + + if search := request.args.get('search'): + query = query.filter(Model.modelnumber.ilike(f'%{search}%')) + + query = query.order_by(Model.modelnumber) + + items, total = paginate_query(query, page, per_page) + + data = [] + for m in items: + d = m.to_dict() + d['vendor'] = m.vendor.vendor if m.vendor else None + d['machinetype'] = m.machinetype.machinetype if m.machinetype else None + data.append(d) + + return paginated_response(data, page, per_page, total) + + +@models_bp.route('/', methods=['GET']) +@jwt_required() +def get_model(model_id: int): + """Get a single model.""" + m = Model.query.get(model_id) + + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + data = m.to_dict() + data['vendor'] = m.vendor.to_dict() if m.vendor else None + data['machinetype'] = m.machinetype.to_dict() if m.machinetype else None + + return success_response(data) + + +@models_bp.route('', methods=['POST']) +@jwt_required() +def create_model(): + """Create a new model.""" + data = request.get_json() + + if not data or not data.get('modelnumber'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumber is required') + + # Check duplicate + existing = Model.query.filter_by( + modelnumber=data['modelnumber'], + vendorid=data.get('vendorid') + ).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Model '{data['modelnumber']}' already exists for this vendor", + http_code=409 + ) + + m = Model( + modelnumber=data['modelnumber'], + vendorid=data.get('vendorid'), + machinetypeid=data.get('machinetypeid'), + description=data.get('description'), + imageurl=data.get('imageurl'), + documentationurl=data.get('documentationurl'), + notes=data.get('notes') + ) + + db.session.add(m) + db.session.commit() + + return success_response(m.to_dict(), message='Model created', http_code=201) + + +@models_bp.route('/', methods=['PUT']) +@jwt_required() +def update_model(model_id: int): + """Update a model.""" + m = Model.query.get(model_id) + + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + for key in ['modelnumber', 'vendorid', 'machinetypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']: + if key in data: + setattr(m, key, data[key]) + + db.session.commit() + return success_response(m.to_dict(), message='Model updated') + + +@models_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_model(model_id: int): + """Delete (deactivate) a model.""" + m = Model.query.get(model_id) + + if not m: + return error_response( + ErrorCodes.NOT_FOUND, + f'Model with ID {model_id} not found', + http_code=404 + ) + + m.isactive = False + db.session.commit() + + return success_response(message='Model deleted') diff --git a/shopdb/core/api/operatingsystems.py b/shopdb/core/api/operatingsystems.py new file mode 100644 index 0000000..ec2221e --- /dev/null +++ b/shopdb/core/api/operatingsystems.py @@ -0,0 +1,131 @@ +"""Operating Systems API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import OperatingSystem +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +operatingsystems_bp = Blueprint('operatingsystems', __name__) + + +@operatingsystems_bp.route('', methods=['GET']) +@jwt_required() +def list_operatingsystems(): + """List all operating systems.""" + page, per_page = get_pagination_params(request) + + query = OperatingSystem.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(OperatingSystem.isactive == True) + + if search := request.args.get('search'): + query = query.filter(OperatingSystem.osname.ilike(f'%{search}%')) + + query = query.order_by(OperatingSystem.osname) + + items, total = paginate_query(query, page, per_page) + data = [os.to_dict() for os in items] + + return paginated_response(data, page, per_page, total) + + +@operatingsystems_bp.route('/', methods=['GET']) +@jwt_required() +def get_operatingsystem(os_id: int): + """Get a single operating system.""" + os = OperatingSystem.query.get(os_id) + + if not os: + return error_response( + ErrorCodes.NOT_FOUND, + f'Operating system with ID {os_id} not found', + http_code=404 + ) + + return success_response(os.to_dict()) + + +@operatingsystems_bp.route('', methods=['POST']) +@jwt_required() +def create_operatingsystem(): + """Create a new operating system.""" + data = request.get_json() + + if not data or not data.get('osname'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'osname is required') + + existing = OperatingSystem.query.filter_by( + osname=data['osname'], + osversion=data.get('osversion') + ).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Operating system '{data['osname']} {data.get('osversion', '')}' already exists", + http_code=409 + ) + + os = OperatingSystem( + osname=data['osname'], + osversion=data.get('osversion'), + architecture=data.get('architecture'), + endoflife=data.get('endoflife') + ) + + db.session.add(os) + db.session.commit() + + return success_response(os.to_dict(), message='Operating system created', http_code=201) + + +@operatingsystems_bp.route('/', methods=['PUT']) +@jwt_required() +def update_operatingsystem(os_id: int): + """Update an operating system.""" + os = OperatingSystem.query.get(os_id) + + if not os: + return error_response( + ErrorCodes.NOT_FOUND, + f'Operating system with ID {os_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + for key in ['osname', 'osversion', 'architecture', 'endoflife', 'isactive']: + if key in data: + setattr(os, key, data[key]) + + db.session.commit() + return success_response(os.to_dict(), message='Operating system updated') + + +@operatingsystems_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_operatingsystem(os_id: int): + """Delete (deactivate) an operating system.""" + os = OperatingSystem.query.get(os_id) + + if not os: + return error_response( + ErrorCodes.NOT_FOUND, + f'Operating system with ID {os_id} not found', + http_code=404 + ) + + os.isactive = False + db.session.commit() + + return success_response(message='Operating system deleted') diff --git a/shopdb/core/api/pctypes.py b/shopdb/core/api/pctypes.py new file mode 100644 index 0000000..8cdc14e --- /dev/null +++ b/shopdb/core/api/pctypes.py @@ -0,0 +1,141 @@ +"""PC Types API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import PCType +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +pctypes_bp = Blueprint('pctypes', __name__) + + +@pctypes_bp.route('', methods=['GET']) +@jwt_required() +def list_pctypes(): + """List all PC types.""" + page, per_page = get_pagination_params(request) + + query = PCType.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(PCType.isactive == True) + + if search := request.args.get('search'): + query = query.filter(PCType.pctype.ilike(f'%{search}%')) + + query = query.order_by(PCType.pctype) + + items, total = paginate_query(query, page, per_page) + data = [pt.to_dict() for pt in items] + + return paginated_response(data, page, per_page, total) + + +@pctypes_bp.route('/', methods=['GET']) +@jwt_required() +def get_pctype(type_id: int): + """Get a single PC type.""" + pt = PCType.query.get(type_id) + + if not pt: + return error_response( + ErrorCodes.NOT_FOUND, + f'PC type with ID {type_id} not found', + http_code=404 + ) + + return success_response(pt.to_dict()) + + +@pctypes_bp.route('', methods=['POST']) +@jwt_required() +def create_pctype(): + """Create a new PC type.""" + data = request.get_json() + + if not data or not data.get('pctype'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'pctype is required') + + if PCType.query.filter_by(pctype=data['pctype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"PC type '{data['pctype']}' already exists", + http_code=409 + ) + + pt = PCType( + pctype=data['pctype'], + description=data.get('description') + ) + + db.session.add(pt) + db.session.commit() + + return success_response(pt.to_dict(), message='PC type created', http_code=201) + + +@pctypes_bp.route('/', methods=['PUT']) +@jwt_required() +def update_pctype(type_id: int): + """Update a PC type.""" + pt = PCType.query.get(type_id) + + if not pt: + return error_response( + ErrorCodes.NOT_FOUND, + f'PC type with ID {type_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'pctype' in data and data['pctype'] != pt.pctype: + if PCType.query.filter_by(pctype=data['pctype']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"PC type '{data['pctype']}' already exists", + http_code=409 + ) + + for key in ['pctype', 'description', 'isactive']: + if key in data: + setattr(pt, key, data[key]) + + db.session.commit() + return success_response(pt.to_dict(), message='PC type updated') + + +@pctypes_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_pctype(type_id: int): + """Delete (deactivate) a PC type.""" + pt = PCType.query.get(type_id) + + if not pt: + return error_response( + ErrorCodes.NOT_FOUND, + f'PC type with ID {type_id} not found', + http_code=404 + ) + + from shopdb.core.models import Machine + if Machine.query.filter_by(pctypeid=type_id, isactive=True).first(): + return error_response( + ErrorCodes.CONFLICT, + 'Cannot delete PC type: machines are using it', + http_code=409 + ) + + pt.isactive = False + db.session.commit() + + return success_response(message='PC type deleted') diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py new file mode 100644 index 0000000..2e64a0a --- /dev/null +++ b/shopdb/core/api/search.py @@ -0,0 +1,203 @@ +"""Global search API endpoint.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import ( + Machine, Application, KnowledgeBase +) +from shopdb.utils.responses import success_response + +search_bp = Blueprint('search', __name__) + + +@search_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def global_search(): + """ + Global search across multiple entity types. + + Returns combined results from: + - Machines (equipment and PCs) + - Applications + - Knowledge Base articles + - Printers (if available) + + Results are sorted by relevance score. + """ + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return success_response({ + 'results': [], + 'query': query, + 'message': 'Search query must be at least 2 characters' + }) + + if len(query) > 200: + return success_response({ + 'results': [], + 'query': query[:200], + 'message': 'Search query too long' + }) + + results = [] + search_term = f'%{query}%' + + # Search Machines (Equipment and PCs) + machines = Machine.query.filter( + Machine.isactive == True, + db.or_( + Machine.machinenumber.ilike(search_term), + Machine.alias.ilike(search_term), + Machine.hostname.ilike(search_term), + Machine.serialnumber.ilike(search_term), + Machine.notes.ilike(search_term) + ) + ).limit(10).all() + + for m in machines: + # Determine type: PC, Printer, or Equipment + is_pc = m.pctypeid is not None + is_printer = m.is_printer + + # Calculate relevance - exact matches score higher + relevance = 15 + if m.machinenumber and query.lower() == m.machinenumber.lower(): + relevance = 100 + elif m.hostname and query.lower() == m.hostname.lower(): + relevance = 100 + elif m.alias and query.lower() in m.alias.lower(): + relevance = 50 + + display_name = m.hostname if is_pc and m.hostname else m.machinenumber + if m.alias and not is_pc: + display_name = f"{m.machinenumber} ({m.alias})" + + # Determine result type and URL + if is_printer: + result_type = 'printer' + url = f"/printers/{m.machineid}" + elif is_pc: + result_type = 'pc' + url = f"/pcs/{m.machineid}" + else: + result_type = 'machine' + url = f"/machines/{m.machineid}" + + # Get location - prefer machine's own location, fall back to parent machine's location + location_name = None + if m.location: + location_name = m.location.locationname + elif m.parent_relationships: + # Check parent machines for location + for rel in m.parent_relationships: + if rel.parent_machine and rel.parent_machine.location: + location_name = rel.parent_machine.location.locationname + break + + # Get machinetype from model (single source of truth) + mt = m.derived_machinetype + results.append({ + 'type': result_type, + 'id': m.machineid, + 'title': display_name, + 'subtitle': mt.machinetype if mt else None, + 'location': location_name, + 'url': url, + 'relevance': relevance + }) + + # Search Applications + apps = Application.query.filter( + Application.isactive == True, + db.or_( + Application.appname.ilike(search_term), + Application.appdescription.ilike(search_term) + ) + ).limit(10).all() + + for app in apps: + relevance = 20 + if query.lower() == app.appname.lower(): + relevance = 100 + elif query.lower() in app.appname.lower(): + relevance = 50 + + results.append({ + 'type': 'application', + 'id': app.appid, + 'title': app.appname, + 'subtitle': app.appdescription[:100] if app.appdescription else None, + 'url': f"/applications/{app.appid}", + 'relevance': relevance + }) + + # Search Knowledge Base + kb_articles = KnowledgeBase.query.filter( + KnowledgeBase.isactive == True, + db.or_( + KnowledgeBase.shortdescription.ilike(search_term), + KnowledgeBase.keywords.ilike(search_term) + ) + ).limit(20).all() + + for kb in kb_articles: + # Weight by clicks and keyword match + relevance = 10 + (kb.clicks or 0) * 0.1 + if kb.keywords and query.lower() in kb.keywords.lower(): + relevance += 15 + + results.append({ + 'type': 'knowledgebase', + 'id': kb.linkid, + 'title': kb.shortdescription, + 'subtitle': kb.application.appname if kb.application else None, + 'url': f"/knowledgebase/{kb.linkid}", + 'linkurl': kb.linkurl, + 'relevance': relevance + }) + + # Search Printers (check if printers model exists) + try: + from shopdb.plugins.printers.models import Printer + printers = Printer.query.filter( + Printer.isactive == True, + db.or_( + Printer.printercsfname.ilike(search_term), + Printer.printerwindowsname.ilike(search_term), + Printer.serialnumber.ilike(search_term), + Printer.fqdn.ilike(search_term) + ) + ).limit(10).all() + + for p in printers: + relevance = 15 + if p.printercsfname and query.lower() == p.printercsfname.lower(): + relevance = 100 + + display_name = p.printercsfname or p.printerwindowsname or f"Printer #{p.printerid}" + + results.append({ + 'type': 'printer', + 'id': p.printerid, + 'title': display_name, + 'subtitle': p.printerwindowsname if p.printercsfname else None, + 'url': f"/printers/{p.printerid}", + 'relevance': relevance + }) + except ImportError: + pass # Printers plugin not installed + + # Sort by relevance (highest first) + results.sort(key=lambda x: x['relevance'], reverse=True) + + # Limit total results + results = results[:30] + + return success_response({ + 'results': results, + 'query': query, + 'total': len(results) + }) diff --git a/shopdb/core/api/statuses.py b/shopdb/core/api/statuses.py new file mode 100644 index 0000000..bd8e46d --- /dev/null +++ b/shopdb/core/api/statuses.py @@ -0,0 +1,139 @@ +"""Machine Statuses API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import MachineStatus +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +statuses_bp = Blueprint('statuses', __name__) + + +@statuses_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_statuses(): + """List all machine statuses.""" + page, per_page = get_pagination_params(request) + + query = MachineStatus.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(MachineStatus.isactive == True) + + query = query.order_by(MachineStatus.status) + + items, total = paginate_query(query, page, per_page) + data = [s.to_dict() for s in items] + + return paginated_response(data, page, per_page, total) + + +@statuses_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_status(status_id: int): + """Get a single status.""" + s = MachineStatus.query.get(status_id) + + if not s: + return error_response( + ErrorCodes.NOT_FOUND, + f'Status with ID {status_id} not found', + http_code=404 + ) + + return success_response(s.to_dict()) + + +@statuses_bp.route('', methods=['POST']) +@jwt_required() +def create_status(): + """Create a new status.""" + data = request.get_json() + + if not data or not data.get('status'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required') + + if MachineStatus.query.filter_by(status=data['status']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Status '{data['status']}' already exists", + http_code=409 + ) + + s = MachineStatus( + status=data['status'], + description=data.get('description'), + color=data.get('color') + ) + + db.session.add(s) + db.session.commit() + + return success_response(s.to_dict(), message='Status created', http_code=201) + + +@statuses_bp.route('/', methods=['PUT']) +@jwt_required() +def update_status(status_id: int): + """Update a status.""" + s = MachineStatus.query.get(status_id) + + if not s: + return error_response( + ErrorCodes.NOT_FOUND, + f'Status with ID {status_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'status' in data and data['status'] != s.status: + if MachineStatus.query.filter_by(status=data['status']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Status '{data['status']}' already exists", + http_code=409 + ) + + for key in ['status', 'description', 'color', 'isactive']: + if key in data: + setattr(s, key, data[key]) + + db.session.commit() + return success_response(s.to_dict(), message='Status updated') + + +@statuses_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_status(status_id: int): + """Delete (deactivate) a status.""" + s = MachineStatus.query.get(status_id) + + if not s: + return error_response( + ErrorCodes.NOT_FOUND, + f'Status with ID {status_id} not found', + http_code=404 + ) + + from shopdb.core.models import Machine + if Machine.query.filter_by(statusid=status_id, isactive=True).first(): + return error_response( + ErrorCodes.CONFLICT, + 'Cannot delete status: machines are using it', + http_code=409 + ) + + s.isactive = False + db.session.commit() + + return success_response(message='Status deleted') diff --git a/shopdb/core/api/vendors.py b/shopdb/core/api/vendors.py new file mode 100644 index 0000000..10b6439 --- /dev/null +++ b/shopdb/core/api/vendors.py @@ -0,0 +1,137 @@ +"""Vendors API endpoints - Full CRUD.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.extensions import db +from shopdb.core.models import Vendor +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +vendors_bp = Blueprint('vendors', __name__) + + +@vendors_bp.route('', methods=['GET']) +@jwt_required() +def list_vendors(): + """List all vendors.""" + page, per_page = get_pagination_params(request) + + query = Vendor.query + + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(Vendor.isactive == True) + + if search := request.args.get('search'): + query = query.filter(Vendor.vendor.ilike(f'%{search}%')) + + query = query.order_by(Vendor.vendor) + + items, total = paginate_query(query, page, per_page) + data = [v.to_dict() for v in items] + + return paginated_response(data, page, per_page, total) + + +@vendors_bp.route('/', methods=['GET']) +@jwt_required() +def get_vendor(vendor_id: int): + """Get a single vendor.""" + v = Vendor.query.get(vendor_id) + + if not v: + return error_response( + ErrorCodes.NOT_FOUND, + f'Vendor with ID {vendor_id} not found', + http_code=404 + ) + + return success_response(v.to_dict()) + + +@vendors_bp.route('', methods=['POST']) +@jwt_required() +def create_vendor(): + """Create a new vendor.""" + data = request.get_json() + + if not data or not data.get('vendor'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required') + + if Vendor.query.filter_by(vendor=data['vendor']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Vendor '{data['vendor']}' already exists", + http_code=409 + ) + + v = Vendor( + vendor=data['vendor'], + description=data.get('description'), + website=data.get('website'), + supportphone=data.get('supportphone'), + supportemail=data.get('supportemail'), + notes=data.get('notes') + ) + + db.session.add(v) + db.session.commit() + + return success_response(v.to_dict(), message='Vendor created', http_code=201) + + +@vendors_bp.route('/', methods=['PUT']) +@jwt_required() +def update_vendor(vendor_id: int): + """Update a vendor.""" + v = Vendor.query.get(vendor_id) + + if not v: + return error_response( + ErrorCodes.NOT_FOUND, + f'Vendor with ID {vendor_id} not found', + http_code=404 + ) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + if 'vendor' in data and data['vendor'] != v.vendor: + if Vendor.query.filter_by(vendor=data['vendor']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Vendor '{data['vendor']}' already exists", + http_code=409 + ) + + for key in ['vendor', 'description', 'website', 'supportphone', 'supportemail', 'notes', 'isactive']: + if key in data: + setattr(v, key, data[key]) + + db.session.commit() + return success_response(v.to_dict(), message='Vendor updated') + + +@vendors_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_vendor(vendor_id: int): + """Delete (deactivate) a vendor.""" + v = Vendor.query.get(vendor_id) + + if not v: + return error_response( + ErrorCodes.NOT_FOUND, + f'Vendor with ID {vendor_id} not found', + http_code=404 + ) + + v.isactive = False + db.session.commit() + + return success_response(message='Vendor deleted') diff --git a/shopdb/core/models/__init__.py b/shopdb/core/models/__init__.py new file mode 100644 index 0000000..f465b91 --- /dev/null +++ b/shopdb/core/models/__init__.py @@ -0,0 +1,49 @@ +"""Core SQLAlchemy models.""" + +from .base import BaseModel, SoftDeleteMixin, AuditMixin +from .machine import Machine, MachineType, MachineStatus, PCType +from .vendor import Vendor +from .model import Model +from .businessunit import BusinessUnit +from .location import Location +from .operatingsystem import OperatingSystem +from .relationship import MachineRelationship, RelationshipType +from .communication import Communication, CommunicationType +from .user import User, Role +from .application import Application, AppVersion, AppOwner, SupportTeam, InstalledApp +from .knowledgebase import KnowledgeBase + +__all__ = [ + # Base + 'BaseModel', + 'SoftDeleteMixin', + 'AuditMixin', + # Machine + 'Machine', + 'MachineType', + 'MachineStatus', + 'PCType', + # Reference + 'Vendor', + 'Model', + 'BusinessUnit', + 'Location', + 'OperatingSystem', + # Relationships + 'MachineRelationship', + 'RelationshipType', + # Communication + 'Communication', + 'CommunicationType', + # Auth + 'User', + 'Role', + # Applications + 'Application', + 'AppVersion', + 'AppOwner', + 'SupportTeam', + 'InstalledApp', + # Knowledge Base + 'KnowledgeBase', +] diff --git a/shopdb/core/models/application.py b/shopdb/core/models/application.py new file mode 100644 index 0000000..5775349 --- /dev/null +++ b/shopdb/core/models/application.py @@ -0,0 +1,130 @@ +"""Application tracking models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class AppOwner(BaseModel): + """Application owner/contact.""" + __tablename__ = 'appowners' + + appownerid = db.Column(db.Integer, primary_key=True) + appowner = db.Column(db.String(100), nullable=False) + sso = db.Column(db.String(50)) + email = db.Column(db.String(100)) + + # Relationships + supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic') + + def __repr__(self): + return f"" + + +class SupportTeam(BaseModel): + """Application support team.""" + __tablename__ = 'supportteams' + + supportteamid = db.Column(db.Integer, primary_key=True) + teamname = db.Column(db.String(100), nullable=False) + teamurl = db.Column(db.String(255)) + appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid')) + + # Relationships + owner = db.relationship('AppOwner', back_populates='supportteams') + applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic') + + def __repr__(self): + return f"" + + +class Application(BaseModel): + """Application catalog.""" + __tablename__ = 'applications' + + appid = db.Column(db.Integer, primary_key=True) + appname = db.Column(db.String(100), unique=True, nullable=False) + appdescription = db.Column(db.String(255)) + supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid')) + isinstallable = db.Column(db.Boolean, default=False) + applicationnotes = db.Column(db.Text) + installpath = db.Column(db.String(255)) + applicationlink = db.Column(db.String(512)) + documentationpath = db.Column(db.String(512)) + ishidden = db.Column(db.Boolean, default=False) + isprinter = db.Column(db.Boolean, default=False) + islicenced = db.Column(db.Boolean, default=False) + image = db.Column(db.String(255)) + + # Relationships + supportteam = db.relationship('SupportTeam', back_populates='applications') + versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic') + installed_on = db.relationship('InstalledApp', back_populates='application', lazy='dynamic') + + def __repr__(self): + return f"" + + +class AppVersion(BaseModel): + """Application version tracking.""" + __tablename__ = 'appversions' + + appversionid = db.Column(db.Integer, primary_key=True) + appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False) + version = db.Column(db.String(50), nullable=False) + releasedate = db.Column(db.Date) + notes = db.Column(db.String(255)) + dateadded = db.Column(db.DateTime, default=db.func.now()) + + # Relationships + application = db.relationship('Application', back_populates='versions') + installations = db.relationship('InstalledApp', back_populates='appversion', lazy='dynamic') + + # Unique constraint on app + version + __table_args__ = ( + db.UniqueConstraint('appid', 'version', name='uq_app_version'), + ) + + def __repr__(self): + return f"" + + +class InstalledApp(db.Model): + """Junction table for applications installed on machines (PCs).""" + __tablename__ = 'installedapps' + + id = db.Column(db.Integer, primary_key=True) + machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'), nullable=False) + appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False) + appversionid = db.Column(db.Integer, db.ForeignKey('appversions.appversionid')) + isactive = db.Column(db.Boolean, default=True, nullable=False) + installeddate = db.Column(db.DateTime, default=db.func.now()) + + # Relationships + machine = db.relationship('Machine', back_populates='installedapps') + application = db.relationship('Application', back_populates='installed_on') + appversion = db.relationship('AppVersion', back_populates='installations') + + # Unique constraint - one app per machine (can have different versions over time) + __table_args__ = ( + db.UniqueConstraint('machineid', 'appid', name='uq_machine_app'), + ) + + def to_dict(self): + """Convert to dictionary.""" + return { + 'id': self.id, + 'machineid': self.machineid, + 'appid': self.appid, + 'appversionid': self.appversionid, + 'isactive': self.isactive, + 'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, + 'application': { + 'appid': self.application.appid, + 'appname': self.application.appname, + 'appdescription': self.application.appdescription, + } if self.application else None, + 'version': self.appversion.version if self.appversion else None + } + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/base.py b/shopdb/core/models/base.py new file mode 100644 index 0000000..f91176c --- /dev/null +++ b/shopdb/core/models/base.py @@ -0,0 +1,66 @@ +"""Base model class with common fields.""" + +from datetime import datetime +from shopdb.extensions import db + + +class BaseModel(db.Model): + """ + Abstract base model with common fields. + All models should inherit from this. + """ + __abstract__ = True + + createddate = db.Column( + db.DateTime, + default=datetime.utcnow, + nullable=False + ) + modifieddate = db.Column( + db.DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False + ) + isactive = db.Column(db.Boolean, default=True, nullable=False) + + def to_dict(self): + """Convert model to dictionary.""" + result = {} + for c in self.__table__.columns: + value = getattr(self, c.name) + if isinstance(value, datetime): + value = value.isoformat() + 'Z' + result[c.name] = value + return result + + def update(self, **kwargs): + """Update model attributes.""" + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + @classmethod + def get_active(cls): + """Return query for active records only.""" + return cls.query.filter_by(isactive=True) + + +class SoftDeleteMixin: + """Mixin for soft delete functionality.""" + + deleteddate = db.Column(db.DateTime, nullable=True) + deletedby = db.Column(db.String(100), nullable=True) + + def soft_delete(self, deleted_by: str = None): + """Mark record as deleted.""" + self.isactive = False + self.deleteddate = datetime.utcnow() + self.deletedby = deleted_by + + +class AuditMixin: + """Mixin for audit fields.""" + + createdby = db.Column(db.String(100), nullable=True) + modifiedby = db.Column(db.String(100), nullable=True) diff --git a/shopdb/core/models/businessunit.py b/shopdb/core/models/businessunit.py new file mode 100644 index 0000000..3f97163 --- /dev/null +++ b/shopdb/core/models/businessunit.py @@ -0,0 +1,31 @@ +"""Business Unit model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class BusinessUnit(BaseModel): + """Business unit / department model.""" + __tablename__ = 'businessunits' + + businessunitid = db.Column(db.Integer, primary_key=True) + businessunit = db.Column(db.String(100), unique=True, nullable=False) + code = db.Column(db.String(20), unique=True, comment='Short code') + description = db.Column(db.Text) + + # Optional parent for hierarchy + parentid = db.Column( + db.Integer, + db.ForeignKey('businessunits.businessunitid'), + nullable=True + ) + + # Self-referential relationship for hierarchy + parent = db.relationship( + 'BusinessUnit', + remote_side=[businessunitid], + backref='children' + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/communication.py b/shopdb/core/models/communication.py new file mode 100644 index 0000000..8e2acea --- /dev/null +++ b/shopdb/core/models/communication.py @@ -0,0 +1,90 @@ +"""Communication/network interface models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class CommunicationType(BaseModel): + """Types of communication interfaces.""" + __tablename__ = 'communicationtypes' + + comtypeid = db.Column(db.Integer, primary_key=True) + comtype = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + + # Types: IP, Serial, USB, VNC, FTP, DNC, Parallel, Network Interface + + def __repr__(self): + return f"" + + +class Communication(BaseModel): + """ + Communication interface for a machine. + Stores network config, serial settings, etc. + """ + __tablename__ = 'communications' + + communicationid = db.Column(db.Integer, primary_key=True) + + machineid = db.Column( + db.Integer, + db.ForeignKey('machines.machineid'), + nullable=False + ) + comtypeid = db.Column( + db.Integer, + db.ForeignKey('communicationtypes.comtypeid'), + nullable=False + ) + + # Network configuration (for IP type) + ipaddress = db.Column(db.String(50)) + subnetmask = db.Column(db.String(50)) + gateway = db.Column(db.String(50)) + dns1 = db.Column(db.String(50)) + dns2 = db.Column(db.String(50)) + macaddress = db.Column(db.String(50)) + isdhcp = db.Column(db.Boolean, default=False) + + # Serial configuration (for Serial type) + comport = db.Column(db.String(20)) + baudrate = db.Column(db.Integer) + databits = db.Column(db.Integer) + stopbits = db.Column(db.String(10)) + parity = db.Column(db.String(20)) + flowcontrol = db.Column(db.String(20)) + + # VNC/FTP configuration + port = db.Column(db.Integer) + username = db.Column(db.String(100)) + # Note: passwords should not be stored here - use secure vault + + # DNC configuration + pathname = db.Column(db.String(255)) + pathname2 = db.Column(db.String(255), comment='Secondary path for dualpath') + + # Flags + isprimary = db.Column( + db.Boolean, + default=False, + comment='Primary communication method' + ) + ismachinenetwork = db.Column( + db.Boolean, + default=False, + comment='On machine network vs office network' + ) + + notes = db.Column(db.Text) + + # Relationships + comtype = db.relationship('CommunicationType', backref='communications') + + __table_args__ = ( + db.Index('idx_comm_machine', 'machineid'), + db.Index('idx_comm_ip', 'ipaddress'), + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/knowledgebase.py b/shopdb/core/models/knowledgebase.py new file mode 100644 index 0000000..316b4e9 --- /dev/null +++ b/shopdb/core/models/knowledgebase.py @@ -0,0 +1,27 @@ +"""Knowledge Base models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class KnowledgeBase(BaseModel): + """Knowledge Base article linking to external resources.""" + __tablename__ = 'knowledgebase' + + linkid = db.Column(db.Integer, primary_key=True) + appid = db.Column(db.Integer, db.ForeignKey('applications.appid')) + shortdescription = db.Column(db.String(500), nullable=False) + linkurl = db.Column(db.String(2000)) + keywords = db.Column(db.String(500)) + clicks = db.Column(db.Integer, default=0) + lastupdated = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now()) + + # Relationships + application = db.relationship('Application', backref=db.backref('knowledgebase_articles', lazy='dynamic')) + + def __repr__(self): + return f"" + + def increment_clicks(self): + """Increment click counter.""" + self.clicks = (self.clicks or 0) + 1 diff --git a/shopdb/core/models/location.py b/shopdb/core/models/location.py new file mode 100644 index 0000000..c2d1277 --- /dev/null +++ b/shopdb/core/models/location.py @@ -0,0 +1,24 @@ +"""Location model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class Location(BaseModel): + """Physical location model.""" + __tablename__ = 'locations' + + locationid = db.Column(db.Integer, primary_key=True) + locationname = db.Column(db.String(100), unique=True, nullable=False) + building = db.Column(db.String(100)) + floor = db.Column(db.String(50)) + room = db.Column(db.String(50)) + description = db.Column(db.Text) + + # Map configuration + mapimage = db.Column(db.String(500), comment='Path to floor map image') + mapwidth = db.Column(db.Integer) + mapheight = db.Column(db.Integer) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/machine.py b/shopdb/core/models/machine.py new file mode 100644 index 0000000..f49c306 --- /dev/null +++ b/shopdb/core/models/machine.py @@ -0,0 +1,252 @@ +"""Unified Machine model - combines equipment and PCs.""" + +from shopdb.extensions import db +from .base import BaseModel, SoftDeleteMixin, AuditMixin + + +class MachineType(BaseModel): + """ + Machine type classification. + Categories: Equipment, PC, Network, Printer + """ + __tablename__ = 'machinetypes' + + machinetypeid = db.Column(db.Integer, primary_key=True) + machinetype = db.Column(db.String(100), unique=True, nullable=False) + category = db.Column( + db.String(50), + nullable=False, + default='Equipment', + comment='Equipment, PC, Network, or Printer' + ) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class MachineStatus(BaseModel): + """Machine status options.""" + __tablename__ = 'machinestatuses' + + statusid = db.Column(db.Integer, primary_key=True) + status = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String(20), comment='CSS color for UI') + + def __repr__(self): + return f"" + + +class PCType(BaseModel): + """ + PC type classification for more specific PC categorization. + Examples: Shopfloor PC, Engineer Workstation, CMM PC, etc. + """ + __tablename__ = 'pctypes' + + pctypeid = db.Column(db.Integer, primary_key=True) + pctype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + + def __repr__(self): + return f"" + + +class Machine(BaseModel, SoftDeleteMixin, AuditMixin): + """ + Unified machine model for all asset types. + + Machine types can be: + - CNC machines, CMMs, EDMs, etc. (manufacturing equipment) + - PCs (shopfloor PCs, engineer workstations, etc.) + - Network devices (servers, switches, etc.) - if network_devices plugin not used + + The machinetype.category field distinguishes between types. + """ + __tablename__ = 'machines' + + machineid = db.Column(db.Integer, primary_key=True) + + # Identification + machinenumber = db.Column( + db.String(50), + unique=True, + nullable=False, + index=True, + comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)' + ) + alias = db.Column( + db.String(100), + comment='Friendly name' + ) + hostname = db.Column( + db.String(100), + index=True, + comment='Network hostname (for PCs)' + ) + serialnumber = db.Column( + db.String(100), + index=True, + comment='Hardware serial number' + ) + + # Classification + machinetypeid = db.Column( + db.Integer, + db.ForeignKey('machinetypes.machinetypeid'), + nullable=False + ) + pctypeid = db.Column( + db.Integer, + db.ForeignKey('pctypes.pctypeid'), + nullable=True, + comment='Set for PCs, NULL for equipment' + ) + businessunitid = db.Column( + db.Integer, + db.ForeignKey('businessunits.businessunitid'), + nullable=True + ) + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + + # Status + statusid = db.Column( + db.Integer, + db.ForeignKey('machinestatuses.statusid'), + default=1, + comment='In Use, Spare, Retired, etc.' + ) + + # Location and mapping + locationid = db.Column( + db.Integer, + db.ForeignKey('locations.locationid'), + nullable=True + ) + mapleft = db.Column(db.Integer, comment='X coordinate on floor map') + maptop = db.Column(db.Integer, comment='Y coordinate on floor map') + islocationonly = db.Column( + db.Boolean, + default=False, + comment='Virtual location marker (not actual machine)' + ) + + # PC-specific fields (nullable for non-PC machines) + osid = db.Column( + db.Integer, + db.ForeignKey('operatingsystems.osid'), + nullable=True + ) + loggedinuser = db.Column(db.String(100), nullable=True) + lastreporteddate = db.Column(db.DateTime, nullable=True) + lastboottime = db.Column(db.DateTime, nullable=True) + + # Features/flags + isvnc = db.Column(db.Boolean, default=False, comment='VNC remote access enabled') + iswinrm = db.Column(db.Boolean, default=False, comment='WinRM enabled') + isshopfloor = db.Column(db.Boolean, default=False, comment='Shopfloor PC') + requiresmanualconfig = db.Column( + db.Boolean, + default=False, + comment='Multi-PC machine needs manual configuration' + ) + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Relationships + machinetype = db.relationship('MachineType', backref='machines') + pctype = db.relationship('PCType', backref='machines') + businessunit = db.relationship('BusinessUnit', backref='machines') + model = db.relationship('Model', backref='machines') + vendor = db.relationship('Vendor', backref='machines') + status = db.relationship('MachineStatus', backref='machines') + location = db.relationship('Location', backref='machines') + operatingsystem = db.relationship('OperatingSystem', backref='machines') + + # Communications (one-to-many) + communications = db.relationship( + 'Communication', + backref='machine', + cascade='all, delete-orphan', + lazy='dynamic' + ) + + # Installed applications (for PCs) + installedapps = db.relationship( + 'InstalledApp', + back_populates='machine', + cascade='all, delete-orphan', + lazy='dynamic' + ) + + # Indexes + __table_args__ = ( + db.Index('idx_machine_type_bu', 'machinetypeid', 'businessunitid'), + db.Index('idx_machine_location', 'locationid'), + db.Index('idx_machine_active', 'isactive'), + db.Index('idx_machine_hostname', 'hostname'), + ) + + def __repr__(self): + return f"" + + @property + def display_name(self): + """Get display name (alias if set, otherwise machinenumber).""" + return self.alias or self.machinenumber + + @property + def derived_machinetype(self): + """Get machinetype from model (single source of truth).""" + if self.model and self.model.machinetype: + return self.model.machinetype + return None + + @property + def is_pc(self): + """Check if this machine is a PC type.""" + mt = self.derived_machinetype + return mt.category == 'PC' if mt else False + + @property + def is_equipment(self): + """Check if this machine is equipment.""" + mt = self.derived_machinetype + return mt.category == 'Equipment' if mt else False + + @property + def is_network_device(self): + """Check if this machine is a network device.""" + mt = self.derived_machinetype + return mt.category == 'Network' if mt else False + + @property + def is_printer(self): + """Check if this machine is a printer.""" + mt = self.derived_machinetype + return mt.category == 'Printer' if mt else False + + @property + def primary_ip(self): + """Get primary IP address from communications.""" + comm = self.communications.filter_by( + isprimary=True, + comtypeid=1 # IP type + ).first() + if comm: + return comm.ipaddress + # Fall back to any IP + comm = self.communications.filter_by(comtypeid=1).first() + return comm.ipaddress if comm else None diff --git a/shopdb/core/models/model.py b/shopdb/core/models/model.py new file mode 100644 index 0000000..b95e9ab --- /dev/null +++ b/shopdb/core/models/model.py @@ -0,0 +1,43 @@ +"""Model (equipment model number) model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class Model(BaseModel): + """Equipment/device model information.""" + __tablename__ = 'models' + + modelnumberid = db.Column(db.Integer, primary_key=True) + modelnumber = db.Column(db.String(100), nullable=False) + + # Link to machine type (what kind of equipment this model is for) + machinetypeid = db.Column( + db.Integer, + db.ForeignKey('machinetypes.machinetypeid'), + nullable=True + ) + + # Link to vendor/manufacturer + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + + description = db.Column(db.Text) + imageurl = db.Column(db.String(500), comment='URL to product image') + documentationurl = db.Column(db.String(500), comment='URL to documentation') + notes = db.Column(db.Text) + + # Relationships + machinetype = db.relationship('MachineType', backref='models') + vendor = db.relationship('Vendor', backref='models') + + # Unique constraint on modelnumber + vendor + __table_args__ = ( + db.UniqueConstraint('modelnumber', 'vendorid', name='uq_model_vendor'), + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/operatingsystem.py b/shopdb/core/models/operatingsystem.py new file mode 100644 index 0000000..7bb851f --- /dev/null +++ b/shopdb/core/models/operatingsystem.py @@ -0,0 +1,22 @@ +"""Operating System model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class OperatingSystem(BaseModel): + """Operating system model.""" + __tablename__ = 'operatingsystems' + + osid = db.Column(db.Integer, primary_key=True) + osname = db.Column(db.String(100), nullable=False) + osversion = db.Column(db.String(50)) + architecture = db.Column(db.String(20), comment='x86, x64, ARM') + endoflife = db.Column(db.Date, comment='End of support date') + + __table_args__ = ( + db.UniqueConstraint('osname', 'osversion', name='uq_os_name_version'), + ) + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/relationship.py b/shopdb/core/models/relationship.py new file mode 100644 index 0000000..56d2610 --- /dev/null +++ b/shopdb/core/models/relationship.py @@ -0,0 +1,77 @@ +"""Machine relationship models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class RelationshipType(BaseModel): + """Types of relationships between machines.""" + __tablename__ = 'relationshiptypes' + + relationshiptypeid = db.Column(db.Integer, primary_key=True) + relationshiptype = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + + # Example types: + # - "Controls" (PC controls Equipment) + # - "Dualpath" (Redundant path partner) + # - "Backup" (Backup machine) + + def __repr__(self): + return f"" + + +class MachineRelationship(BaseModel): + """ + Relationships between machines. + + Examples: + - PC controls CNC machine + - Two CNCs are dualpath partners + """ + __tablename__ = 'machinerelationships' + + relationshipid = db.Column(db.Integer, primary_key=True) + + parentmachineid = db.Column( + db.Integer, + db.ForeignKey('machines.machineid'), + nullable=False + ) + childmachineid = db.Column( + db.Integer, + db.ForeignKey('machines.machineid'), + nullable=False + ) + relationshiptypeid = db.Column( + db.Integer, + db.ForeignKey('relationshiptypes.relationshiptypeid'), + nullable=False + ) + + notes = db.Column(db.Text) + + # Relationships + parent_machine = db.relationship( + 'Machine', + foreign_keys=[parentmachineid], + backref='child_relationships' + ) + child_machine = db.relationship( + 'Machine', + foreign_keys=[childmachineid], + backref='parent_relationships' + ) + relationship_type = db.relationship('RelationshipType', backref='relationships') + + __table_args__ = ( + db.UniqueConstraint( + 'parentmachineid', + 'childmachineid', + 'relationshiptypeid', + name='uq_machine_relationship' + ), + ) + + def __repr__(self): + return f" {self.childmachineid}>" diff --git a/shopdb/core/models/user.py b/shopdb/core/models/user.py new file mode 100644 index 0000000..31f8e73 --- /dev/null +++ b/shopdb/core/models/user.py @@ -0,0 +1,73 @@ +"""User and authentication models.""" + +from datetime import datetime +from shopdb.extensions import db +from .base import BaseModel + + +# Association table for user roles (many-to-many) +userroles = db.Table( + 'userroles', + db.Column('userid', db.Integer, db.ForeignKey('users.userid'), primary_key=True), + db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True) +) + + +class Role(BaseModel): + """User role model.""" + __tablename__ = 'roles' + + roleid = db.Column(db.Integer, primary_key=True) + rolename = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + + def __repr__(self): + return f"" + + +class User(BaseModel): + """User model for authentication.""" + __tablename__ = 'users' + + userid = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(100), unique=True, nullable=False, index=True) + email = db.Column(db.String(255), unique=True, nullable=False) + passwordhash = db.Column(db.String(255), nullable=False) + + # Profile + firstname = db.Column(db.String(100)) + lastname = db.Column(db.String(100)) + + # Status + lastlogindate = db.Column(db.DateTime) + failedlogins = db.Column(db.Integer, default=0) + lockeduntil = db.Column(db.DateTime) + + # Relationships + roles = db.relationship( + 'Role', + secondary=userroles, + backref=db.backref('users', lazy='dynamic') + ) + + def __repr__(self): + return f"" + + @property + def islocked(self): + """Check if account is locked.""" + if self.lockeduntil: + return datetime.utcnow() < self.lockeduntil + return False + + def hasrole(self, rolename: str) -> bool: + """Check if user has a specific role.""" + return any(r.rolename == rolename for r in self.roles) + + def getpermissions(self) -> list: + """Get list of permission names from roles.""" + # Simple role-based permissions + perms = [] + for role in self.roles: + perms.append(role.rolename) + return perms diff --git a/shopdb/core/models/vendor.py b/shopdb/core/models/vendor.py new file mode 100644 index 0000000..4653288 --- /dev/null +++ b/shopdb/core/models/vendor.py @@ -0,0 +1,20 @@ +"""Vendor model.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class Vendor(BaseModel): + """Vendor/Manufacturer model.""" + __tablename__ = 'vendors' + + vendorid = db.Column(db.Integer, primary_key=True) + vendor = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + website = db.Column(db.String(255)) + supportphone = db.Column(db.String(50)) + supportemail = db.Column(db.String(100)) + notes = db.Column(db.Text) + + def __repr__(self): + return f"" diff --git a/shopdb/core/schemas/__init__.py b/shopdb/core/schemas/__init__.py new file mode 100644 index 0000000..3760951 --- /dev/null +++ b/shopdb/core/schemas/__init__.py @@ -0,0 +1 @@ +"""Core Marshmallow schemas.""" diff --git a/shopdb/core/services/__init__.py b/shopdb/core/services/__init__.py new file mode 100644 index 0000000..0791aaa --- /dev/null +++ b/shopdb/core/services/__init__.py @@ -0,0 +1 @@ +"""Core services.""" diff --git a/shopdb/exceptions.py b/shopdb/exceptions.py new file mode 100644 index 0000000..657fba9 --- /dev/null +++ b/shopdb/exceptions.py @@ -0,0 +1,54 @@ +"""Custom exceptions for ShopDB.""" + + +class ShopDBException(Exception): + """Base exception for ShopDB.""" + + def __init__(self, message: str, code: str = None, details: dict = None): + super().__init__(message) + self.message = message + self.code = code or 'SHOPDB_ERROR' + self.details = details or {} + + +class ValidationError(ShopDBException): + """Validation error.""" + + def __init__(self, message: str, details: dict = None): + super().__init__(message, 'VALIDATION_ERROR', details) + + +class NotFoundError(ShopDBException): + """Resource not found error.""" + + def __init__(self, resource: str, identifier): + message = f"{resource} with ID {identifier} not found" + super().__init__(message, 'NOT_FOUND', {'resource': resource, 'id': identifier}) + + +class AuthenticationError(ShopDBException): + """Authentication error.""" + + def __init__(self, message: str = "Authentication required"): + super().__init__(message, 'UNAUTHORIZED') + + +class AuthorizationError(ShopDBException): + """Authorization error.""" + + def __init__(self, message: str = "Permission denied"): + super().__init__(message, 'FORBIDDEN') + + +class ConflictError(ShopDBException): + """Conflict error (e.g., duplicate entry).""" + + def __init__(self, message: str, details: dict = None): + super().__init__(message, 'CONFLICT', details) + + +class PluginError(ShopDBException): + """Plugin-related error.""" + + def __init__(self, message: str, plugin_name: str = None): + super().__init__(message, 'PLUGIN_ERROR', {'plugin': plugin_name}) diff --git a/shopdb/extensions.py b/shopdb/extensions.py new file mode 100644 index 0000000..f1eee9a --- /dev/null +++ b/shopdb/extensions.py @@ -0,0 +1,25 @@ +"""Flask extensions initialization.""" + +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_jwt_extended import JWTManager +from flask_cors import CORS +from flask_marshmallow import Marshmallow + +# Initialize extensions without app +db = SQLAlchemy() +migrate = Migrate() +jwt = JWTManager() +cors = CORS() +ma = Marshmallow() + + +def init_extensions(app): + """Initialize all Flask extensions with app.""" + db.init_app(app) + migrate.init_app(app, db) + jwt.init_app(app) + cors.init_app(app, resources={ + r"/api/*": {"origins": app.config.get('CORS_ORIGINS', '*')} + }) + ma.init_app(app) diff --git a/shopdb/plugins/__init__.py b/shopdb/plugins/__init__.py new file mode 100644 index 0000000..c468218 --- /dev/null +++ b/shopdb/plugins/__init__.py @@ -0,0 +1,276 @@ +"""Plugin manager - main entry point for plugin system.""" + +from pathlib import Path +from typing import Dict, List, Optional +from flask import Flask +import logging + +from .base import BasePlugin, PluginMeta +from .registry import PluginRegistry, PluginState +from .loader import PluginLoader +from .migrations import PluginMigrationManager + +logger = logging.getLogger(__name__) + +__all__ = [ + 'PluginManager', + 'BasePlugin', + 'PluginMeta', + 'PluginRegistry', + 'PluginState', + 'plugin_manager' +] + + +class PluginManager: + """ + Central manager for all plugin operations. + + Usage: + plugin_manager = PluginManager() + plugin_manager.init_app(app, db) + + # In CLI: + plugin_manager.install_plugin('printers') + """ + + def __init__(self): + self.registry: Optional[PluginRegistry] = None + self.loader: Optional[PluginLoader] = None + self.migration_manager: Optional[PluginMigrationManager] = None + self._app: Optional[Flask] = None + self._db = None + + def init_app(self, app: Flask, db) -> None: + """Initialize plugin manager with Flask app.""" + self._app = app + self._db = db + + # Setup paths + instance_path = Path(app.instance_path) + plugins_dir = Path(app.root_path).parent / 'plugins' + + # Initialize components + self.registry = PluginRegistry(instance_path / 'plugins.json') + self.loader = PluginLoader(plugins_dir, self.registry) + self.migration_manager = PluginMigrationManager( + plugins_dir, + app.config.get('SQLALCHEMY_DATABASE_URI') + ) + + # Load enabled plugins + self._load_enabled_plugins() + + # Store on app for access + app.extensions['plugin_manager'] = self + + def _load_enabled_plugins(self) -> None: + """Load and register all enabled plugins.""" + plugins = self.loader.load_enabled_plugins(self._app, self._db) + + for name, plugin in plugins.items(): + self._register_plugin_components(plugin) + + def _register_plugin_components(self, plugin: BasePlugin) -> None: + """Register plugin's blueprint, models, CLI commands, etc.""" + # Register blueprint + blueprint = plugin.get_blueprint() + if blueprint: + self._app.register_blueprint( + blueprint, + url_prefix=plugin.meta.api_prefix + ) + logger.debug(f"Registered blueprint: {plugin.meta.api_prefix}") + + # Register CLI commands + for cmd in plugin.get_cli_commands(): + self._app.cli.add_command(cmd) + + def discover_available(self) -> List[Dict]: + """ + Get list of all available plugins (installed or not). + Returns list of plugin info dicts. + """ + available = [] + + for name in self.loader.discover_plugins(): + plugin_class = self.loader.load_plugin_class(name) + if plugin_class: + try: + temp = plugin_class() + meta = temp.meta + state = self.registry.get(name) + + available.append({ + 'name': meta.name, + 'version': meta.version, + 'description': meta.description, + 'author': meta.author, + 'dependencies': meta.dependencies, + 'installed': state is not None, + 'enabled': state.enabled if state else False, + 'installed_at': state.installed_at if state else None + }) + except Exception as e: + logger.warning(f"Error inspecting plugin {name}: {e}") + + return available + + def install_plugin(self, name: str, run_migrations: bool = True) -> bool: + """ + Install a plugin. + + Steps: + 1. Verify plugin exists + 2. Check dependencies + 3. Run database migrations + 4. Register in registry + 5. Call plugin's on_install hook + """ + # Check if already installed + if self.registry.is_installed(name): + logger.warning(f"Plugin {name} is already installed") + return False + + # Load plugin class + plugin_class = self.loader.load_plugin_class(name) + if not plugin_class: + logger.error(f"Plugin {name} not found") + return False + + temp_plugin = plugin_class() + meta = temp_plugin.meta + + # Check dependencies + for dep in meta.dependencies: + if not self.registry.is_installed(dep): + logger.error( + f"Plugin {name} requires {dep} to be installed first" + ) + return False + + # Run migrations + if run_migrations: + success = self.migration_manager.run_plugin_migrations(name) + if not success: + logger.error(f"Failed to run migrations for {name}") + return False + + # Register plugin + self.registry.register(name, meta.version) + + # Load the plugin + plugin = self.loader.load_plugin(name, self._app, self._db) + if plugin: + self._register_plugin_components(plugin) + plugin.on_install(self._app) + + logger.info(f"Installed plugin: {name} v{meta.version}") + return True + + def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool: + """ + Uninstall a plugin. + + Args: + name: Plugin name + remove_data: If True, run downgrade migrations to remove tables + """ + if not self.registry.is_installed(name): + logger.warning(f"Plugin {name} is not installed") + return False + + # Check if other plugins depend on this one + for other_name in self.registry.get_enabled_plugins(): + if other_name == name: + continue + other_plugin = self.loader.get_loaded_plugin(other_name) + if other_plugin and name in other_plugin.meta.dependencies: + logger.error( + f"Cannot uninstall {name}: {other_name} depends on it" + ) + return False + + # Get plugin instance + plugin = self.loader.get_loaded_plugin(name) + + # Call on_uninstall hook + if plugin: + plugin.on_uninstall(self._app) + + # Optionally remove data + if remove_data: + self.migration_manager.downgrade_plugin(name) + + # Unregister + self.registry.unregister(name) + + logger.info(f"Uninstalled plugin: {name}") + return True + + def enable_plugin(self, name: str) -> bool: + """Enable a disabled plugin.""" + if not self.registry.is_installed(name): + logger.error(f"Plugin {name} is not installed") + return False + + if self.registry.is_enabled(name): + logger.info(f"Plugin {name} is already enabled") + return True + + # Check dependencies are enabled + plugin_class = self.loader.load_plugin_class(name) + if plugin_class: + temp = plugin_class() + for dep in temp.meta.dependencies: + if not self.registry.is_enabled(dep): + logger.error(f"Cannot enable {name}: {dep} is not enabled") + return False + + self.registry.enable(name) + + # Load the plugin + plugin = self.loader.load_plugin(name, self._app, self._db) + if plugin: + self._register_plugin_components(plugin) + plugin.on_enable(self._app) + + logger.info(f"Enabled plugin: {name}") + return True + + def disable_plugin(self, name: str) -> bool: + """Disable an enabled plugin.""" + if not self.registry.is_enabled(name): + logger.info(f"Plugin {name} is already disabled") + return True + + # Check if other plugins depend on this one + for other_name in self.registry.get_enabled_plugins(): + if other_name == name: + continue + other_plugin = self.loader.get_loaded_plugin(other_name) + if other_plugin and name in other_plugin.meta.dependencies: + logger.error( + f"Cannot disable {name}: {other_name} depends on it" + ) + return False + + plugin = self.loader.get_loaded_plugin(name) + if plugin: + plugin.on_disable(self._app) + + self.registry.disable(name) + logger.info(f"Disabled plugin: {name}") + return True + + def get_plugin(self, name: str) -> Optional[BasePlugin]: + """Get a loaded plugin instance.""" + return self.loader.get_loaded_plugin(name) + + def get_all_plugins(self) -> Dict[str, BasePlugin]: + """Get all loaded plugins.""" + return self.loader.get_all_loaded() + + +# Global plugin manager instance +plugin_manager = PluginManager() diff --git a/shopdb/plugins/base.py b/shopdb/plugins/base.py new file mode 100644 index 0000000..3805041 --- /dev/null +++ b/shopdb/plugins/base.py @@ -0,0 +1,122 @@ +"""Base plugin class that all plugins must inherit from.""" + +from abc import ABC, abstractmethod +from typing import List, Dict, Optional, Type +from dataclasses import dataclass, field +from flask import Flask, Blueprint + + +@dataclass +class PluginMeta: + """Plugin metadata container.""" + + name: str + version: str + description: str + author: str = "" + dependencies: List[str] = field(default_factory=list) + core_version: str = ">=1.0.0" + api_prefix: str = None + + def __post_init__(self): + if self.api_prefix is None: + self.api_prefix = f"/api/{self.name.replace('_', '-')}" + + +class BasePlugin(ABC): + """ + Base class for all ShopDB plugins. + + Plugins must implement: + - meta: PluginMeta instance + - get_blueprint(): Return Flask Blueprint for API routes + - get_models(): Return list of SQLAlchemy model classes + + Optionally implement: + - init_app(app, db): Custom initialization + - get_cli_commands(): Return Click commands + - get_services(): Return service classes + - on_install(): Called when plugin is installed + - on_uninstall(): Called when plugin is uninstalled + - on_enable(): Called when plugin is enabled + - on_disable(): Called when plugin is disabled + """ + + @property + @abstractmethod + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + pass + + @abstractmethod + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + pass + + @abstractmethod + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + pass + + def init_app(self, app: Flask, db) -> None: + """ + Initialize plugin with Flask app. + Override for custom initialization. + """ + pass + + def get_cli_commands(self) -> List: + """Return list of Click command groups/commands.""" + return [] + + def get_services(self) -> Dict[str, Type]: + """Return dict of service name -> service class.""" + return {} + + def get_event_handlers(self) -> Dict[str, callable]: + """Return dict of event name -> handler function.""" + return {} + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed via CLI.""" + pass + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled via CLI.""" + pass + + def on_enable(self, app: Flask) -> None: + """Called when plugin is enabled.""" + pass + + def on_disable(self, app: Flask) -> None: + """Called when plugin is disabled.""" + pass + + def get_dashboard_widgets(self) -> List[Dict]: + """ + Return dashboard widget definitions. + + Each widget: { + 'name': str, + 'component': str, # Frontend component name + 'endpoint': str, # API endpoint for data + 'size': str, # 'small', 'medium', 'large' + 'position': int # Order on dashboard + } + """ + return [] + + def get_navigation_items(self) -> List[Dict]: + """ + Return navigation menu items. + + Each item: { + 'name': str, + 'icon': str, + 'route': str, + 'position': int, + 'children': [] + } + """ + return [] diff --git a/shopdb/plugins/cli.py b/shopdb/plugins/cli.py new file mode 100644 index 0000000..bfd1a82 --- /dev/null +++ b/shopdb/plugins/cli.py @@ -0,0 +1,203 @@ +"""Flask CLI commands for plugin management.""" + +import click +from flask import current_app +from flask.cli import with_appcontext + + +@click.group('plugin') +def plugin_cli(): + """Plugin management commands.""" + pass + + +@plugin_cli.command('list') +@with_appcontext +def list_plugins(): + """List all available plugins.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + return + + plugins = pm.discover_available() + + if not plugins: + click.echo("No plugins found in plugins directory.") + return + + # Format output + click.echo("") + click.echo(click.style("Available Plugins:", fg='cyan', bold=True)) + click.echo("-" * 60) + + for p in plugins: + if p['enabled']: + status = click.style("[Enabled]", fg='green') + elif p['installed']: + status = click.style("[Disabled]", fg='yellow') + else: + status = click.style("[Available]", fg='white') + + click.echo(f" {p['name']:20} v{p['version']:10} {status}") + if p['description']: + click.echo(f" {p['description'][:55]}...") + if p['dependencies']: + deps = ', '.join(p['dependencies']) + click.echo(f" Dependencies: {deps}") + + click.echo("") + + +@plugin_cli.command('install') +@click.argument('name') +@click.option('--skip-migrations', is_flag=True, help='Skip database migrations') +@with_appcontext +def install_plugin(name: str, skip_migrations: bool): + """ + Install a plugin. + + Usage: flask plugin install printers + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + click.echo(f"Installing plugin: {name}") + + if pm.install_plugin(name, run_migrations=not skip_migrations): + click.echo(click.style(f"Successfully installed {name}", fg='green')) + else: + click.echo(click.style(f"Failed to install {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('uninstall') +@click.argument('name') +@click.option('--remove-data', is_flag=True, help='Remove plugin database tables') +@click.confirmation_option(prompt='Are you sure you want to uninstall this plugin?') +@with_appcontext +def uninstall_plugin(name: str, remove_data: bool): + """ + Uninstall a plugin. + + Usage: flask plugin uninstall printers + """ + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + click.echo(f"Uninstalling plugin: {name}") + + if pm.uninstall_plugin(name, remove_data=remove_data): + click.echo(click.style(f"Successfully uninstalled {name}", fg='green')) + else: + click.echo(click.style(f"Failed to uninstall {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('enable') +@click.argument('name') +@with_appcontext +def enable_plugin(name: str): + """Enable a disabled plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + if pm.enable_plugin(name): + click.echo(click.style(f"Enabled {name}", fg='green')) + else: + click.echo(click.style(f"Failed to enable {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('disable') +@click.argument('name') +@with_appcontext +def disable_plugin(name: str): + """Disable an enabled plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + if pm.disable_plugin(name): + click.echo(click.style(f"Disabled {name}", fg='green')) + else: + click.echo(click.style(f"Failed to disable {name}", fg='red')) + raise SystemExit(1) + + +@plugin_cli.command('info') +@click.argument('name') +@with_appcontext +def plugin_info(name: str): + """Show detailed information about a plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + plugin_class = pm.loader.load_plugin_class(name) + if not plugin_class: + click.echo(click.style(f"Plugin {name} not found", fg='red')) + raise SystemExit(1) + + try: + temp = plugin_class() + meta = temp.meta + except Exception as e: + click.echo(click.style(f"Error loading plugin: {e}", fg='red')) + raise SystemExit(1) + + state = pm.registry.get(name) + + click.echo("") + click.echo("=" * 50) + click.echo(click.style(f"Plugin: {meta.name}", fg='cyan', bold=True)) + click.echo("=" * 50) + click.echo(f"Version: {meta.version}") + click.echo(f"Description: {meta.description}") + click.echo(f"Author: {meta.author or 'Unknown'}") + click.echo(f"API Prefix: {meta.api_prefix}") + click.echo(f"Dependencies: {', '.join(meta.dependencies) or 'None'}") + click.echo(f"Core Version: {meta.core_version}") + click.echo("") + + if state: + status = click.style('Enabled', fg='green') if state.enabled else click.style('Disabled', fg='yellow') + click.echo(f"Status: {status}") + click.echo(f"Installed: {state.installed_at}") + click.echo(f"Migrations: {len(state.migrations_applied)} applied") + else: + click.echo(f"Status: {click.style('Not installed', fg='white')}") + + click.echo("") + + +@plugin_cli.command('migrate') +@click.argument('name') +@click.option('--revision', default='head', help='Target revision') +@with_appcontext +def migrate_plugin(name: str, revision: str): + """Run migrations for a specific plugin.""" + pm = current_app.extensions.get('plugin_manager') + if not pm: + click.echo(click.style("Plugin manager not initialized", fg='red')) + raise SystemExit(1) + + if not pm.registry.is_installed(name): + click.echo(click.style(f"Plugin {name} is not installed", fg='red')) + raise SystemExit(1) + + click.echo(f"Running migrations for {name}...") + + if pm.migration_manager.run_plugin_migrations(name, revision): + click.echo(click.style("Migrations completed", fg='green')) + else: + click.echo(click.style("Migration failed", fg='red')) + raise SystemExit(1) diff --git a/shopdb/plugins/loader.py b/shopdb/plugins/loader.py new file mode 100644 index 0000000..8023176 --- /dev/null +++ b/shopdb/plugins/loader.py @@ -0,0 +1,174 @@ +"""Plugin discovery and loading.""" + +import importlib +import importlib.util +from pathlib import Path +from typing import Dict, List, Type, Optional +from flask import Flask +import logging + +from .base import BasePlugin +from .registry import PluginRegistry + +logger = logging.getLogger(__name__) + + +class PluginLoader: + """ + Discovers and loads plugins from the plugins directory. + """ + + def __init__(self, plugins_dir: Path, registry: PluginRegistry): + self.plugins_dir = plugins_dir + self.registry = registry + self._loaded_plugins: Dict[str, BasePlugin] = {} + self._plugin_classes: Dict[str, Type[BasePlugin]] = {} + + def discover_plugins(self) -> List[str]: + """ + Discover available plugins in plugins directory. + Returns list of plugin names. + """ + available = [] + if not self.plugins_dir.exists(): + return available + + for item in self.plugins_dir.iterdir(): + if item.is_dir() and (item / 'plugin.py').exists(): + available.append(item.name) + elif item.is_dir() and (item / '__init__.py').exists(): + # Check for plugin.py in package + if (item / 'plugin.py').exists(): + available.append(item.name) + + return available + + def load_plugin_class(self, name: str) -> Optional[Type[BasePlugin]]: + """ + Load plugin class without instantiating. + Used for inspection before installation. + """ + if name in self._plugin_classes: + return self._plugin_classes[name] + + plugin_dir = self.plugins_dir / name + plugin_module_path = plugin_dir / 'plugin.py' + + if not plugin_module_path.exists(): + logger.error(f"Plugin {name} not found: {plugin_module_path}") + return None + + try: + # Import the plugin module + spec = importlib.util.spec_from_file_location( + f"plugins.{name}.plugin", + plugin_module_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Find the plugin class + for attr_name in dir(module): + attr = getattr(module, attr_name) + if (isinstance(attr, type) and + issubclass(attr, BasePlugin) and + attr is not BasePlugin): + self._plugin_classes[name] = attr + return attr + + logger.error(f"No BasePlugin subclass found in {name}") + return None + + except Exception as e: + logger.error(f"Error loading plugin {name}: {e}") + return None + + def load_plugin(self, name: str, app: Flask, db) -> Optional[BasePlugin]: + """ + Load and instantiate a plugin. + """ + if name in self._loaded_plugins: + return self._loaded_plugins[name] + + plugin_class = self.load_plugin_class(name) + if not plugin_class: + return None + + try: + # Instantiate plugin + plugin = plugin_class() + + # Check dependencies + for dep in plugin.meta.dependencies: + if not self.registry.is_enabled(dep): + logger.error( + f"Plugin {name} requires {dep} which is not enabled" + ) + return None + + # Initialize plugin + plugin.init_app(app, db) + + self._loaded_plugins[name] = plugin + return plugin + + except Exception as e: + logger.error(f"Error instantiating plugin {name}: {e}") + return None + + def load_enabled_plugins(self, app: Flask, db) -> Dict[str, BasePlugin]: + """ + Load all enabled plugins. + Returns dict of name -> plugin instance. + """ + loaded = {} + + # Sort by dependencies (simple topological sort) + enabled = self.registry.get_enabled_plugins() + sorted_plugins = self._sort_by_dependencies(enabled) + + for name in sorted_plugins: + plugin = self.load_plugin(name, app, db) + if plugin: + loaded[name] = plugin + logger.info(f"Loaded plugin: {name} v{plugin.meta.version}") + else: + logger.warning(f"Failed to load plugin: {name}") + + return loaded + + def _sort_by_dependencies(self, plugin_names: List[str]) -> List[str]: + """Sort plugins so dependencies come first.""" + sorted_list = [] + visited = set() + + def visit(name): + if name in visited: + return + visited.add(name) + + plugin_class = self.load_plugin_class(name) + if plugin_class: + # Create temporary instance to get meta + try: + temp = plugin_class() + for dep in temp.meta.dependencies: + if dep in plugin_names: + visit(dep) + except Exception: + pass + + sorted_list.append(name) + + for name in plugin_names: + visit(name) + + return sorted_list + + def get_loaded_plugin(self, name: str) -> Optional[BasePlugin]: + """Get an already loaded plugin.""" + return self._loaded_plugins.get(name) + + def get_all_loaded(self) -> Dict[str, BasePlugin]: + """Get all loaded plugins.""" + return self._loaded_plugins.copy() diff --git a/shopdb/plugins/migrations.py b/shopdb/plugins/migrations.py new file mode 100644 index 0000000..16f35b9 --- /dev/null +++ b/shopdb/plugins/migrations.py @@ -0,0 +1,173 @@ +"""Plugin migration management using Alembic.""" + +from pathlib import Path +from typing import Optional +import logging +import subprocess +import sys + +logger = logging.getLogger(__name__) + + +class PluginMigrationManager: + """ + Manages database migrations for plugins. + Each plugin has its own migrations directory. + """ + + def __init__(self, plugins_dir: Path, database_url: str): + self.plugins_dir = plugins_dir + self.database_url = database_url + + def get_migrations_dir(self, plugin_name: str) -> Optional[Path]: + """Get migrations directory for a plugin.""" + migrations_dir = self.plugins_dir / plugin_name / 'migrations' + if migrations_dir.exists(): + return migrations_dir + return None + + def run_plugin_migrations( + self, + plugin_name: str, + revision: str = 'head' + ) -> bool: + """ + Run migrations for a plugin. + + Uses flask db upgrade with the plugin's migrations directory. + """ + migrations_dir = self.get_migrations_dir(plugin_name) + + if not migrations_dir: + logger.info(f"No migrations directory for plugin {plugin_name}") + return True # No migrations to run + + try: + # Use alembic directly with plugin's migrations + from alembic.config import Config + from alembic import command + + config = Config() + config.set_main_option('script_location', str(migrations_dir)) + config.set_main_option('sqlalchemy.url', self.database_url) + + # Use plugin-specific version table + config.set_main_option( + 'version_table', + f'alembic_version_{plugin_name}' + ) + + command.upgrade(config, revision) + logger.info(f"Migrations completed for {plugin_name}") + return True + + except ImportError: + # Fallback to subprocess if alembic not available in context + logger.warning("Using subprocess for migrations") + return self._run_migrations_subprocess(plugin_name, revision) + + except Exception as e: + logger.error(f"Migration failed for {plugin_name}: {e}") + return False + + def _run_migrations_subprocess( + self, + plugin_name: str, + revision: str = 'head' + ) -> bool: + """Run migrations via subprocess as fallback.""" + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir: + return True + + try: + result = subprocess.run( + [ + sys.executable, '-m', 'alembic', + '-c', str(migrations_dir / 'alembic.ini'), + 'upgrade', revision + ], + capture_output=True, + text=True, + env={ + **dict(__import__('os').environ), + 'DATABASE_URL': self.database_url + } + ) + + if result.returncode != 0: + logger.error(f"Migration error: {result.stderr}") + return False + + return True + + except Exception as e: + logger.error(f"Migration subprocess failed: {e}") + return False + + def downgrade_plugin( + self, + plugin_name: str, + revision: str = 'base' + ) -> bool: + """ + Downgrade/rollback plugin migrations. + """ + migrations_dir = self.get_migrations_dir(plugin_name) + + if not migrations_dir: + return True + + try: + from alembic.config import Config + from alembic import command + + config = Config() + config.set_main_option('script_location', str(migrations_dir)) + config.set_main_option('sqlalchemy.url', self.database_url) + config.set_main_option( + 'version_table', + f'alembic_version_{plugin_name}' + ) + + command.downgrade(config, revision) + logger.info(f"Downgrade completed for {plugin_name}") + return True + + except Exception as e: + logger.error(f"Downgrade failed for {plugin_name}: {e}") + return False + + def get_current_revision(self, plugin_name: str) -> Optional[str]: + """Get current migration revision for a plugin.""" + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir: + return None + + try: + from alembic.config import Config + from alembic.script import ScriptDirectory + + config = Config() + config.set_main_option('script_location', str(migrations_dir)) + + script = ScriptDirectory.from_config(config) + return script.get_current_head() + + except Exception: + return None + + def has_pending_migrations(self, plugin_name: str) -> bool: + """Check if plugin has pending migrations.""" + # Simplified check - would need DB connection for full check + migrations_dir = self.get_migrations_dir(plugin_name) + if not migrations_dir: + return False + + versions_dir = migrations_dir / 'versions' + if not versions_dir.exists(): + return False + + # Check if there are any migration files + migration_files = list(versions_dir.glob('*.py')) + return len(migration_files) > 0 diff --git a/shopdb/plugins/registry.py b/shopdb/plugins/registry.py new file mode 100644 index 0000000..a9c7b66 --- /dev/null +++ b/shopdb/plugins/registry.py @@ -0,0 +1,121 @@ +"""Plugin registry for tracking installed and enabled plugins.""" + +import json +from pathlib import Path +from typing import Dict, List, Optional +from dataclasses import dataclass, field, asdict +from datetime import datetime + + +@dataclass +class PluginState: + """Persistent state for a plugin.""" + + name: str + version: str + installed_at: str + enabled: bool = True + migrations_applied: List[str] = field(default_factory=list) + config: Dict = field(default_factory=dict) + + +class PluginRegistry: + """ + Manages plugin state persistence. + Stores state in JSON file in instance folder. + """ + + def __init__(self, state_file: Path): + self.state_file = state_file + self._plugins: Dict[str, PluginState] = {} + self._load() + + def _load(self) -> None: + """Load registry from file.""" + if self.state_file.exists(): + try: + with open(self.state_file, 'r') as f: + data = json.load(f) + for name, state_data in data.get('plugins', {}).items(): + self._plugins[name] = PluginState(**state_data) + except (json.JSONDecodeError, TypeError): + # Corrupted file, start fresh + self._plugins = {} + + def _save(self) -> None: + """Save registry to file.""" + self.state_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.state_file, 'w') as f: + json.dump({ + 'plugins': { + name: asdict(state) + for name, state in self._plugins.items() + } + }, f, indent=2) + + def register(self, name: str, version: str) -> PluginState: + """Register a newly installed plugin.""" + state = PluginState( + name=name, + version=version, + installed_at=datetime.utcnow().isoformat(), + enabled=True + ) + self._plugins[name] = state + self._save() + return state + + def unregister(self, name: str) -> None: + """Remove plugin from registry.""" + if name in self._plugins: + del self._plugins[name] + self._save() + + def get(self, name: str) -> Optional[PluginState]: + """Get plugin state.""" + return self._plugins.get(name) + + def is_installed(self, name: str) -> bool: + """Check if plugin is installed.""" + return name in self._plugins + + def is_enabled(self, name: str) -> bool: + """Check if plugin is enabled.""" + state = self._plugins.get(name) + return state.enabled if state else False + + def enable(self, name: str) -> None: + """Enable a plugin.""" + if name in self._plugins: + self._plugins[name].enabled = True + self._save() + + def disable(self, name: str) -> None: + """Disable a plugin.""" + if name in self._plugins: + self._plugins[name].enabled = False + self._save() + + def get_enabled_plugins(self) -> List[str]: + """Get list of enabled plugin names.""" + return [ + name for name, state in self._plugins.items() + if state.enabled + ] + + def add_migration(self, name: str, revision: str) -> None: + """Record that a migration was applied.""" + if name in self._plugins: + if revision not in self._plugins[name].migrations_applied: + self._plugins[name].migrations_applied.append(revision) + self._save() + + def get_all(self) -> Dict[str, PluginState]: + """Get all registered plugins.""" + return self._plugins.copy() + + def update_config(self, name: str, config: Dict) -> None: + """Update plugin configuration.""" + if name in self._plugins: + self._plugins[name].config.update(config) + self._save() diff --git a/shopdb/static/images/sitemap2025-dark.png b/shopdb/static/images/sitemap2025-dark.png new file mode 100644 index 0000000..b2c29b8 Binary files /dev/null and b/shopdb/static/images/sitemap2025-dark.png differ diff --git a/shopdb/static/images/sitemap2025-light.png b/shopdb/static/images/sitemap2025-light.png new file mode 100644 index 0000000..43ace7e Binary files /dev/null and b/shopdb/static/images/sitemap2025-light.png differ diff --git a/shopdb/utils/__init__.py b/shopdb/utils/__init__.py new file mode 100644 index 0000000..93edb23 --- /dev/null +++ b/shopdb/utils/__init__.py @@ -0,0 +1,20 @@ +"""Utility modules.""" + +from .responses import ( + api_response, + success_response, + error_response, + paginated_response, + ErrorCodes +) +from .pagination import get_pagination_params, paginate_query + +__all__ = [ + 'api_response', + 'success_response', + 'error_response', + 'paginated_response', + 'ErrorCodes', + 'get_pagination_params', + 'paginate_query' +] diff --git a/shopdb/utils/pagination.py b/shopdb/utils/pagination.py new file mode 100644 index 0000000..c0aaf32 --- /dev/null +++ b/shopdb/utils/pagination.py @@ -0,0 +1,43 @@ +"""Pagination utilities.""" + +from flask import request, current_app +from typing import Tuple + + +def get_pagination_params(req=None) -> Tuple[int, int]: + """ + Extract pagination parameters from request. + + Returns: + Tuple of (page, per_page) + """ + if req is None: + req = request + + default_size = current_app.config.get('DEFAULT_PAGE_SIZE', 20) + max_size = current_app.config.get('MAX_PAGE_SIZE', 100) + + try: + page = max(1, int(req.args.get('page', 1))) + except (TypeError, ValueError): + page = 1 + + try: + per_page = int(req.args.get('per_page', default_size)) + per_page = max(1, min(per_page, max_size)) + except (TypeError, ValueError): + per_page = default_size + + return page, per_page + + +def paginate_query(query, page: int, per_page: int): + """ + Apply pagination to a SQLAlchemy query. + + Returns: + Tuple of (items, total) + """ + total = query.count() + items = query.offset((page - 1) * per_page).limit(per_page).all() + return items, total diff --git a/shopdb/utils/responses.py b/shopdb/utils/responses.py new file mode 100644 index 0000000..30b4a2d --- /dev/null +++ b/shopdb/utils/responses.py @@ -0,0 +1,171 @@ +"""Standardized API response helpers.""" + +from flask import jsonify, make_response +from typing import Any, Dict, List, Optional +from datetime import datetime +import uuid + + +class ErrorCodes: + """Standard error codes.""" + + VALIDATION_ERROR = 'VALIDATION_ERROR' + NOT_FOUND = 'NOT_FOUND' + UNAUTHORIZED = 'UNAUTHORIZED' + FORBIDDEN = 'FORBIDDEN' + CONFLICT = 'CONFLICT' + INTERNAL_ERROR = 'INTERNAL_ERROR' + BAD_REQUEST = 'BAD_REQUEST' + PLUGIN_ERROR = 'PLUGIN_ERROR' + + +def api_response( + data: Any = None, + message: str = None, + status: str = 'success', + meta: Dict = None, + http_code: int = 200 +): + """ + Create standardized API response. + + Response format: + { + "status": "success" | "error", + "data": {...} | [...], + "message": "Optional message", + "meta": { + "timestamp": "2025-01-12T...", + "request_id": "uuid" + } + } + """ + response = { + 'status': status, + 'meta': { + 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'request_id': str(uuid.uuid4())[:8], + **(meta or {}) + } + } + + if data is not None: + response['data'] = data + + if message: + response['message'] = message + + return make_response(jsonify(response), http_code) + + +def success_response( + data: Any = None, + message: str = None, + meta: Dict = None, + http_code: int = 200 +): + """Success response helper.""" + return api_response( + data=data, + message=message, + meta=meta, + status='success', + http_code=http_code + ) + + +def error_response( + code: str, + message: str, + details: Dict = None, + http_code: int = 400 +): + """ + Error response helper. + + Response format: + { + "status": "error", + "error": { + "code": "VALIDATION_ERROR", + "message": "Human-readable message", + "details": {...} + } + } + """ + error_data = { + 'code': code, + 'message': message + } + if details: + error_data['details'] = details + + return api_response( + data={'error': error_data}, + status='error', + http_code=http_code + ) + + +def api_error( + message: str, + code: str = ErrorCodes.BAD_REQUEST, + details: Dict = None, + http_code: int = 400 +): + """ + Simplified error response helper. + + Args: + message: Human-readable error message + code: Error code (default BAD_REQUEST) + details: Optional error details + http_code: HTTP status code (default 400) + """ + return error_response(code=code, message=message, details=details, http_code=http_code) + + +def paginated_response( + items: List, + page: int, + per_page: int, + total: int, + schema=None +): + """ + Paginated list response. + + Response format: + { + "status": "success", + "data": [...], + "meta": { + "pagination": { + "page": 1, + "per_page": 20, + "total": 150, + "total_pages": 8, + "has_next": true, + "has_prev": false + } + } + } + """ + total_pages = (total + per_page - 1) // per_page if per_page > 0 else 0 + + if schema: + items = schema.dump(items, many=True) + + return api_response( + data=items, + meta={ + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': total, + 'total_pages': total_pages, + 'has_next': page < total_pages, + 'has_prev': page > 1 + } + } + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..46816dd --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2fc17d6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +"""Pytest configuration and fixtures.""" + +import pytest +from shopdb import create_app +from shopdb.extensions import db as _db + + +@pytest.fixture(scope='session') +def app(): + """Create application for testing.""" + app = create_app('testing') + return app + + +@pytest.fixture(scope='session') +def db(app): + """Create database for testing.""" + with app.app_context(): + _db.create_all() + yield _db + _db.drop_all() + + +@pytest.fixture(scope='function') +def session(db): + """Create a new database session for a test.""" + connection = db.engine.connect() + transaction = connection.begin() + + options = dict(bind=connection, binds={}) + session = db.create_scoped_session(options=options) + + db.session = session + + yield session + + transaction.rollback() + connection.close() + session.remove() + + +@pytest.fixture +def client(app): + """Create test client.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """Create CLI runner.""" + return app.test_cli_runner() diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..af75811 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,14 @@ +"""WSGI entry point for the application.""" + +import os +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +from shopdb import create_app + +app = create_app(os.environ.get('FLASK_ENV', 'development')) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True)