Compare commits
2 Commits
lab-stage-
...
lab-stage-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c844d533 | ||
|
|
f5cfac33b4 |
@@ -1126,3 +1126,13 @@ export const measuringtoolsApi = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3D printed parts (printedparts plugin)
|
||||
export const printedpartsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/printedparts/items', { params })
|
||||
},
|
||||
get(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,25 +11,25 @@ export default [
|
||||
{
|
||||
path: 'printedparts',
|
||||
name: 'printedparts',
|
||||
component: () => import('../../views/printedparts/PrintedpartsList.vue'),
|
||||
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/new',
|
||||
name: 'printedparts-new',
|
||||
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
|
||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id',
|
||||
name: 'printedparts-detail',
|
||||
component: () => import('../../views/printedparts/PrintedpartsDetail.vue'),
|
||||
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id/edit',
|
||||
name: 'printedparts-edit',
|
||||
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
|
||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -101,7 +101,7 @@ import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
KeyRound, LogOut
|
||||
Box, KeyRound, LogOut
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
@@ -147,6 +147,7 @@ const iconMap = {
|
||||
'image': Image,
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
'box': Box,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
|
||||
141
frontend/src/views/printedparts/PrintedItemsList.vue
Normal file
141
frontend/src/views/printedparts/PrintedItemsList.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>3D Printed Parts</h2>
|
||||
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search code, name, description, bin..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<label class="lowstock-filter">
|
||||
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
|
||||
Low stock only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Quantity</th>
|
||||
<th>Bin</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.printeditemid"
|
||||
class="clickable-row"
|
||||
@click="$router.push(`/printedparts/${item.printeditemid}`)"
|
||||
>
|
||||
<td class="thumb-cell">
|
||||
<img
|
||||
v-if="item.imageurl"
|
||||
:src="withBase(item.imageurl)"
|
||||
:alt="item.itemname"
|
||||
class="item-thumb"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ item.itemcode || '-' }}</td>
|
||||
<td>{{ item.itemname }}</td>
|
||||
<td>
|
||||
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
||||
{{ item.quantityonhand }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.binlocation || '-' }}</td>
|
||||
<td class="truncate-cell">{{ item.itemdescription || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="6" class="empty-state">No printed parts found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
@change="setPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { printedpartsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
import { withBase } from '../../utils/basePath'
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const lowstockOnly = ref(false)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
if (lowstockOnly.value) params.lowstock = 'true'
|
||||
const response = await printedpartsApi.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading printed parts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => setSearch(search.value), 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-thumb {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.thumb-cell { width: 3rem; }
|
||||
.truncate-cell {
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lowstock-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Printedparts</h2>
|
||||
<router-link to="/printedparts/new" class="btn btn-primary">Add Printedparts</router-link>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Name</th>
|
||||
<th>Example Field</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.assetid">
|
||||
<td>{{ item.assetnumber || '-' }}</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td>{{ item.examplefield || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/printedparts/${item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No printedparts records found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/printedparts/frontend-api-snippet.js) then swap for:
|
||||
// import { printedpartsApi } from '../../api'
|
||||
const printedpartsApi = {
|
||||
list(params = {}) { return api.get('/printedparts', { params }) },
|
||||
get(itemId) { return api.get(`/printedparts/${itemId}`) },
|
||||
create(data) { return api.post('/printedparts', data) },
|
||||
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
|
||||
}
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(25)
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
const response = await printedpartsApi.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading printedparts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadItems()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(target) {
|
||||
if (target >= 1 && target <= totalPages.value) {
|
||||
page.value = target
|
||||
loadItems()
|
||||
}
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadItems()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters .form-control {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,16 @@
|
||||
"""Printedparts plugin API routes."""
|
||||
"""Printedparts plugin API routes.
|
||||
|
||||
Reads are open (jwt optional) like every list surface; mutations arrive in
|
||||
later stages with permission gates. The kiosk endpoints (unauthenticated by
|
||||
explicit decision - see the proposal) also land later.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy import or_
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
@@ -12,34 +19,48 @@ from shopdb.api import (
|
||||
paginate_query,
|
||||
)
|
||||
|
||||
from ..models import Printedparts
|
||||
|
||||
from ..models import PrintedItem
|
||||
|
||||
printedparts_bp = Blueprint('printedparts', __name__)
|
||||
|
||||
|
||||
@printedparts_bp.route('', methods=['GET'])
|
||||
@printedparts_bp.route('/items', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_printedparts():
|
||||
"""List printedparts assets, paginated."""
|
||||
def list_items():
|
||||
"""List printed items, paginated; search + low-stock filter."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = Printedparts.query
|
||||
query = PrintedItem.query
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(PrintedItem.isactive == True)
|
||||
if search := request.args.get('search'):
|
||||
like = f'%{search}%'
|
||||
query = query.filter(or_(
|
||||
PrintedItem.itemcode.ilike(like),
|
||||
PrintedItem.itemname.ilike(like),
|
||||
PrintedItem.itemdescription.ilike(like),
|
||||
PrintedItem.binlocation.ilike(like),
|
||||
))
|
||||
if request.args.get('lowstock', '').lower() == 'true':
|
||||
query = query.filter(
|
||||
PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold)
|
||||
query = query.order_by(PrintedItem.itemname)
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [item.to_dict() for item in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
return paginated_response(
|
||||
[item.to_dict() for item in items], page, per_page, total)
|
||||
|
||||
|
||||
@printedparts_bp.route('/<int:assetid>', methods=['GET'])
|
||||
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_printedparts(assetid: int):
|
||||
"""Get a single printedparts by assetid."""
|
||||
item = Printedparts.query.get(assetid)
|
||||
def get_item(item_id: int):
|
||||
"""Get one printed item with its recent transactions."""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
if not item:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Printedparts with assetid {assetid} not found',
|
||||
http_code=404,
|
||||
)
|
||||
return success_response(item.to_dict())
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Printed item {item_id} not found',
|
||||
http_code=404)
|
||||
data = item.to_dict()
|
||||
recent = (item.transactions
|
||||
.order_by(db.desc('transactiondate'))
|
||||
.limit(25).all())
|
||||
data['recenttransactions'] = [t.to_dict() for t in recent]
|
||||
return success_response(data)
|
||||
|
||||
14
plugins/printedparts/migrations/env.py
Normal file
14
plugins/printedparts/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the printedparts plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which
|
||||
filters the metadata to this plugin's tables and drives Alembic against the
|
||||
per-plugin version table alembic_version_printedparts (ADR-008). This plugin
|
||||
is NEW (post-cutover): its 0001 baseline really CREATES its tables.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'printedparts'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/printedparts/migrations/script.py.mako
Normal file
24
plugins/printedparts/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""printedparts plugin baseline (real create).
|
||||
|
||||
Post-ADR-008 plugin: this per-plugin chain is the sole authoritative creator
|
||||
of printeditems and printeditemtransactions - the core chain never knew them.
|
||||
Runs from `flask plugin install printedparts` (and `flask plugin upgrade-all`)
|
||||
after `flask db upgrade` builds the core schema.
|
||||
|
||||
Both tables are self-contained (the only FK is transactions -> items inside
|
||||
the plugin), so the shared create_plugin_tables helper would work here; the
|
||||
ops are written out explicitly anyway to match the measuringtools exemplar
|
||||
and keep the baseline reviewable.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'printedparts0001baseline'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'printeditems',
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
sa.Column('itemcode', sa.String(length=20), nullable=True),
|
||||
sa.Column('itemname', sa.String(length=120), nullable=False),
|
||||
sa.Column('itemdescription', sa.String(length=500), nullable=True),
|
||||
sa.Column('imageurl', sa.String(length=255), nullable=True),
|
||||
sa.Column('quantityonhand', sa.Integer(), nullable=False),
|
||||
sa.Column('lowstockthreshold', sa.Integer(), nullable=False),
|
||||
sa.Column('binlocation', sa.String(length=100), nullable=True),
|
||||
sa.Column('printnotes', sa.Text(), nullable=True),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('printeditemid'),
|
||||
sa.UniqueConstraint('itemcode'),
|
||||
)
|
||||
op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
|
||||
|
||||
op.create_table(
|
||||
'printeditemtransactions',
|
||||
sa.Column('transactionid', sa.Integer(), nullable=False),
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
sa.Column('transactiontype', sa.String(length=10), nullable=False),
|
||||
sa.Column('quantitychange', sa.Integer(), nullable=False),
|
||||
sa.Column('employeesso', sa.String(length=20), nullable=False),
|
||||
sa.Column('employeename', sa.String(length=120), nullable=True),
|
||||
sa.Column('reason', sa.String(length=255), nullable=True),
|
||||
sa.Column('transactiondate', sa.DateTime(), nullable=False),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['printeditemid'], ['printeditems.printeditemid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('transactionid'),
|
||||
)
|
||||
op.create_index('ix_printeditemtransactions_printeditemid',
|
||||
'printeditemtransactions', ['printeditemid'])
|
||||
op.create_index('ix_printeditemtransactions_employeesso',
|
||||
'printeditemtransactions', ['employeesso'])
|
||||
op.create_index('ix_printeditemtransactions_transactiondate',
|
||||
'printeditemtransactions', ['transactiondate'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('printeditemtransactions')
|
||||
op.drop_table('printeditems')
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Printedparts plugin models."""
|
||||
|
||||
from .printedparts import Printedparts
|
||||
from .printeditem import PrintedItem, PrintedItemTransaction, TRANSACTION_TYPES
|
||||
|
||||
__all__ = ['Printedparts']
|
||||
__all__ = ['PrintedItem', 'PrintedItemTransaction', 'TRANSACTION_TYPES']
|
||||
|
||||
95
plugins/printedparts/models/printeditem.py
Normal file
95
plugins/printedparts/models/printeditem.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Printedparts models.
|
||||
|
||||
PrintedItem is a KIND of 3D-printed part with a quantity on hand - a
|
||||
consumable, not an ADR-001 asset (which is one row per physical thing).
|
||||
PrintedItemTransaction is the ledger: every take, restock, and adjust as a
|
||||
signed quantity change attributed to a badge-resolved employee. The ledger is
|
||||
the source of truth; quantityonhand is a cache moved in the same commit as
|
||||
each ledger write, and the stock report reconciles the two.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
TRANSACTION_TYPES = ('take', 'restock', 'adjust')
|
||||
|
||||
|
||||
class PrintedItem(BaseModel):
|
||||
"""A printable part the engineers stock in bins."""
|
||||
|
||||
__tablename__ = 'printeditems'
|
||||
|
||||
printeditemid = db.Column(db.Integer, primary_key=True)
|
||||
itemcode = db.Column(db.String(20), unique=True, index=True,
|
||||
comment='Generated bin-label code, e.g. 3DP-0042')
|
||||
itemname = db.Column(db.String(120), nullable=False)
|
||||
itemdescription = db.Column(db.String(500))
|
||||
imageurl = db.Column(db.String(255))
|
||||
quantityonhand = db.Column(db.Integer, nullable=False, default=0)
|
||||
lowstockthreshold = db.Column(db.Integer, nullable=False, default=5)
|
||||
binlocation = db.Column(db.String(100))
|
||||
printnotes = db.Column(db.Text, comment='Material, print time, slicer file')
|
||||
|
||||
transactions = db.relationship(
|
||||
'PrintedItemTransaction', backref='printeditem',
|
||||
cascade='all, delete-orphan', passive_deletes=True, lazy='dynamic')
|
||||
|
||||
@property
|
||||
def islowstock(self):
|
||||
return self.quantityonhand <= self.lowstockthreshold
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'printeditemid': self.printeditemid,
|
||||
'itemcode': self.itemcode,
|
||||
'itemname': self.itemname,
|
||||
'itemdescription': self.itemdescription,
|
||||
'imageurl': self.imageurl,
|
||||
'quantityonhand': self.quantityonhand,
|
||||
'lowstockthreshold': self.lowstockthreshold,
|
||||
'islowstock': self.islowstock,
|
||||
'binlocation': self.binlocation,
|
||||
'printnotes': self.printnotes,
|
||||
'isactive': self.isactive,
|
||||
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None,
|
||||
}
|
||||
|
||||
|
||||
class PrintedItemTransaction(BaseModel):
|
||||
"""One signed stock movement, always attributed to an employee."""
|
||||
|
||||
__tablename__ = 'printeditemtransactions'
|
||||
|
||||
transactionid = db.Column(db.Integer, primary_key=True)
|
||||
printeditemid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
transactiontype = db.Column(db.String(10), nullable=False,
|
||||
comment='take, restock, or adjust')
|
||||
quantitychange = db.Column(db.Integer, nullable=False,
|
||||
comment='Negative for take, signed for adjust')
|
||||
employeesso = db.Column(db.String(20), nullable=False, index=True)
|
||||
employeename = db.Column(db.String(120))
|
||||
reason = db.Column(db.String(255))
|
||||
transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow,
|
||||
index=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'transactionid': self.transactionid,
|
||||
'printeditemid': self.printeditemid,
|
||||
'transactiontype': self.transactiontype,
|
||||
'quantitychange': self.quantitychange,
|
||||
'employeesso': self.employeesso,
|
||||
'employeename': self.employeename,
|
||||
'reason': self.reason,
|
||||
'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None,
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Printedparts model.
|
||||
|
||||
This is an Asset extension table keyed by assetid. The Asset row holds
|
||||
the platform fields (assetnumber, name, vendorid, locationid, etc.);
|
||||
this table holds the printedparts-specific fields. Replace the example fields
|
||||
below with your domain model.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class Printedparts(BaseModel):
|
||||
"""Printedparts domain entity, extending Asset by assetid."""
|
||||
|
||||
__tablename__ = 'printedparts'
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
# TODO: replace these example fields with your domain fields.
|
||||
examplefield = db.Column(db.String(255), nullable=True)
|
||||
|
||||
asset = db.relationship('Asset', backref=db.backref('printedparts', uselist=False))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'assetid': self.assetid,
|
||||
'examplefield': self.examplefield,
|
||||
}
|
||||
@@ -16,7 +16,7 @@ from flask import Flask, Blueprint
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, Setting
|
||||
|
||||
from .models import Printedparts
|
||||
from .models import PrintedItem, PrintedItemTransaction
|
||||
from .api import printedparts_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,11 +46,21 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
return printedparts_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [Printedparts]
|
||||
return [PrintedItem, PrintedItemTransaction]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
|
||||
|
||||
def get_navigation_items(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
'name': '3D Parts',
|
||||
'icon': 'box',
|
||||
'route': '/printedparts',
|
||||
'position': 46,
|
||||
},
|
||||
]
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
with app.app_context():
|
||||
self._seed_settings()
|
||||
|
||||
@@ -53,6 +53,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
||||
'notifications': ('notificationtypes', 'notifications'),
|
||||
'printedparts': ('printeditems', 'printeditemtransactions'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
|
||||
'slides': ('tvslides',),
|
||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||
|
||||
@@ -54,6 +54,8 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
|
||||
# printedparts is post-cutover: its 0001 really creates its tables.
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'
|
||||
# notifications indexes businessunitid on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user