Add locationtypes + location tree (ADR-001)
- New locationtypes lookup (LocationType model) seeded with section, cell, subcell, operation, meetingroom, lab, office, storage, hallway, networkcloset, building. - locations gains locationtypeid + parentlocationid (self-FK) for the site location tree. Migration 7c03; cli reference-data seeds the types. - GET /api/locations/types; locations CRUD accepts type + parent; list/detail return locationtypename + parentlocationname. - Locations settings page: Type column + Type/Parent selectors in the modal. Realizes the operation-as-Location model (operations are locations with locationtypeid='operation'). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -202,6 +202,11 @@ export const locationsApi = {
|
|||||||
},
|
},
|
||||||
delete(id) {
|
delete(id) {
|
||||||
return api.delete(`/locations/${id}`)
|
return api.delete(`/locations/${id}`)
|
||||||
|
},
|
||||||
|
types: {
|
||||||
|
list() {
|
||||||
|
return api.get('/locations/types')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Location Name</th>
|
<th>Location Name</th>
|
||||||
|
<th>Type</th>
|
||||||
<th>Building</th>
|
<th>Building</th>
|
||||||
<th>Floor</th>
|
<th>Floor</th>
|
||||||
<th>Room</th>
|
<th>Room</th>
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="loc in locations" :key="loc.locationid">
|
<tr v-for="loc in locations" :key="loc.locationid">
|
||||||
<td>{{ loc.locationname }}</td>
|
<td>{{ loc.locationname }}</td>
|
||||||
|
<td>{{ loc.locationtypename || '-' }}</td>
|
||||||
<td>{{ loc.building || '-' }}</td>
|
<td>{{ loc.building || '-' }}</td>
|
||||||
<td>{{ loc.floor || '-' }}</td>
|
<td>{{ loc.floor || '-' }}</td>
|
||||||
<td>{{ loc.room || '-' }}</td>
|
<td>{{ loc.room || '-' }}</td>
|
||||||
@@ -55,7 +57,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="locations.length === 0">
|
<tr v-if="locations.length === 0">
|
||||||
<td colspan="6" style="text-align: center; color: var(--text-light);">
|
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||||
No locations found
|
No locations found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -125,6 +127,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="locationtypeid">Type</label>
|
||||||
|
<select id="locationtypeid" v-model="form.locationtypeid" class="form-control">
|
||||||
|
<option value="">Select type...</option>
|
||||||
|
<option v-for="t in locationTypes" :key="t.locationtypeid" :value="t.locationtypeid">
|
||||||
|
{{ t.locationtype }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="parentlocationid">Parent Location</label>
|
||||||
|
<select id="parentlocationid" v-model="form.parentlocationid" class="form-control">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option
|
||||||
|
v-for="l in parentOptions"
|
||||||
|
:key="l.locationid"
|
||||||
|
:value="l.locationid"
|
||||||
|
>
|
||||||
|
{{ l.locationname }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description">Description</label>
|
<label for="description">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -180,11 +208,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { locationsApi } from '../../api'
|
import { locationsApi } from '../../api'
|
||||||
import PaginationBar from '../../components/PaginationBar.vue'
|
import PaginationBar from '../../components/PaginationBar.vue'
|
||||||
|
|
||||||
const locations = ref([])
|
const locations = ref([])
|
||||||
|
const locationTypes = ref([])
|
||||||
|
const allLocations = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
@@ -205,12 +235,29 @@ const form = ref({
|
|||||||
floor: '',
|
floor: '',
|
||||||
room: '',
|
room: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
locationtypeid: '',
|
||||||
|
parentlocationid: '',
|
||||||
mapimage: ''
|
mapimage: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// parent options = all locations except the one being edited (no self-parent)
|
||||||
|
const parentOptions = computed(() =>
|
||||||
|
allLocations.value.filter(l => l.locationid !== editingLocation.value?.locationid)
|
||||||
|
)
|
||||||
|
|
||||||
let searchTimeout = null
|
let searchTimeout = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [typesRes, allRes] = await Promise.all([
|
||||||
|
locationsApi.types.list(),
|
||||||
|
locationsApi.list({ perpage: 100 })
|
||||||
|
])
|
||||||
|
locationTypes.value = typesRes.data.data || []
|
||||||
|
allLocations.value = allRes.data.data || []
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading location types:', err)
|
||||||
|
}
|
||||||
loadLocations()
|
loadLocations()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,6 +308,8 @@ function openModal(loc = null) {
|
|||||||
floor: loc.floor || '',
|
floor: loc.floor || '',
|
||||||
room: loc.room || '',
|
room: loc.room || '',
|
||||||
description: loc.description || '',
|
description: loc.description || '',
|
||||||
|
locationtypeid: loc.locationtypeid || '',
|
||||||
|
parentlocationid: loc.parentlocationid || '',
|
||||||
mapimage: loc.mapimage || ''
|
mapimage: loc.mapimage || ''
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -270,6 +319,8 @@ function openModal(loc = null) {
|
|||||||
floor: '',
|
floor: '',
|
||||||
room: '',
|
room: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
locationtypeid: '',
|
||||||
|
parentlocationid: '',
|
||||||
mapimage: ''
|
mapimage: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,10 +338,15 @@ async function saveLocation() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form.value,
|
||||||
|
locationtypeid: form.value.locationtypeid || null,
|
||||||
|
parentlocationid: form.value.parentlocationid || null
|
||||||
|
}
|
||||||
if (editingLocation.value) {
|
if (editingLocation.value) {
|
||||||
await locationsApi.update(editingLocation.value.locationid, form.value)
|
await locationsApi.update(editingLocation.value.locationid, payload)
|
||||||
} else {
|
} else {
|
||||||
await locationsApi.create(form.value)
|
await locationsApi.create(payload)
|
||||||
}
|
}
|
||||||
closeModal()
|
closeModal()
|
||||||
loadLocations()
|
loadLocations()
|
||||||
|
|||||||
56
migrations/versions/7c03_locationtypes.py
Normal file
56
migrations/versions/7c03_locationtypes.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Add locationtypes + locations tree (ADR-001)
|
||||||
|
|
||||||
|
Creates the locationtypes lookup and extends locations with locationtypeid +
|
||||||
|
parentlocationid (self-FK), so sites can classify locations and build a
|
||||||
|
location tree (sections, cells, sub-cells, operations, etc.). Seeds the
|
||||||
|
canonical location types.
|
||||||
|
|
||||||
|
Revision ID: 7c03_locationtypes
|
||||||
|
Revises: 7c02_app_isrequired
|
||||||
|
Create Date: 2026-06-26
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = '7c03_locationtypes'
|
||||||
|
down_revision = '7c02_app_isrequired'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TYPES = ['section', 'cell', 'subcell', 'operation', 'meetingroom', 'lab',
|
||||||
|
'office', 'storage', 'hallway', 'networkcloset', 'building']
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'locationtypes',
|
||||||
|
sa.Column('locationtypeid', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('locationtype', sa.String(length=50), nullable=False, unique=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('createddate', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('modifieddate', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('isactive', sa.Boolean(), nullable=True),
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('locations') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('locationtypeid', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('parentlocationid', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key('fk_locations_locationtype', 'locationtypes',
|
||||||
|
['locationtypeid'], ['locationtypeid'])
|
||||||
|
batch_op.create_foreign_key('fk_locations_parent', 'locations',
|
||||||
|
['parentlocationid'], ['locationid'])
|
||||||
|
|
||||||
|
lt = sa.table('locationtypes',
|
||||||
|
sa.column('locationtype', sa.String),
|
||||||
|
sa.column('isactive', sa.Boolean))
|
||||||
|
op.bulk_insert(lt, [{'locationtype': t, 'isactive': True} for t in _TYPES])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table('locations') as batch_op:
|
||||||
|
batch_op.drop_constraint('fk_locations_parent', type_='foreignkey')
|
||||||
|
batch_op.drop_constraint('fk_locations_locationtype', type_='foreignkey')
|
||||||
|
batch_op.drop_column('parentlocationid')
|
||||||
|
batch_op.drop_column('locationtypeid')
|
||||||
|
op.drop_table('locationtypes')
|
||||||
@@ -42,7 +42,7 @@ def seed_cli():
|
|||||||
def seed_reference_data():
|
def seed_reference_data():
|
||||||
"""Seed reference data (machine types, statuses, etc.)."""
|
"""Seed reference data (machine types, statuses, etc.)."""
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import MachineType, OperatingSystem, AssetStatus
|
from shopdb.core.models import MachineType, OperatingSystem, AssetStatus, LocationType
|
||||||
from shopdb.core.models.relationship import RelationshipType
|
from shopdb.core.models.relationship import RelationshipType
|
||||||
|
|
||||||
# Machine types
|
# Machine types
|
||||||
@@ -85,6 +85,14 @@ def seed_reference_data():
|
|||||||
elif existing.isactive is not True:
|
elif existing.isactive is not True:
|
||||||
existing.isactive = True
|
existing.isactive = True
|
||||||
|
|
||||||
|
# Location types (ADR-001)
|
||||||
|
location_types = ['section', 'cell', 'subcell', 'operation', 'meetingroom',
|
||||||
|
'lab', 'office', 'storage', 'hallway', 'networkcloset',
|
||||||
|
'building']
|
||||||
|
for lt in location_types:
|
||||||
|
if not LocationType.query.filter_by(locationtype=lt).first():
|
||||||
|
db.session.add(LocationType(locationtype=lt, isactive=True))
|
||||||
|
|
||||||
# Operating systems
|
# Operating systems
|
||||||
os_list = [
|
os_list = [
|
||||||
{'osname': 'Windows 10', 'osversion': '10.0'},
|
{'osname': 'Windows 10', 'osversion': '10.0'},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from flask import Blueprint, request
|
|||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import Location
|
from shopdb.core.models import Location, LocationType
|
||||||
from shopdb.utils.responses import (
|
from shopdb.utils.responses import (
|
||||||
success_response,
|
success_response,
|
||||||
error_response,
|
error_response,
|
||||||
@@ -16,6 +16,19 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
locations_bp = Blueprint('locations', __name__)
|
locations_bp = Blueprint('locations', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@locations_bp.route('/types', methods=['GET'])
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
def list_location_types():
|
||||||
|
"""List all location types."""
|
||||||
|
types = LocationType.query.filter_by(isactive=True).order_by(
|
||||||
|
LocationType.locationtype).all()
|
||||||
|
return success_response([{
|
||||||
|
'locationtypeid': t.locationtypeid,
|
||||||
|
'locationtype': t.locationtype,
|
||||||
|
'description': t.description,
|
||||||
|
} for t in types])
|
||||||
|
|
||||||
|
|
||||||
@locations_bp.route('', methods=['GET'])
|
@locations_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@jwt_required(optional=True)
|
||||||
def list_locations():
|
def list_locations():
|
||||||
@@ -81,6 +94,8 @@ def create_location():
|
|||||||
floor=data.get('floor'),
|
floor=data.get('floor'),
|
||||||
room=data.get('room'),
|
room=data.get('room'),
|
||||||
description=data.get('description'),
|
description=data.get('description'),
|
||||||
|
locationtypeid=data.get('locationtypeid'),
|
||||||
|
parentlocationid=data.get('parentlocationid'),
|
||||||
mapimage=data.get('mapimage'),
|
mapimage=data.get('mapimage'),
|
||||||
mapwidth=data.get('mapwidth'),
|
mapwidth=data.get('mapwidth'),
|
||||||
mapheight=data.get('mapheight')
|
mapheight=data.get('mapheight')
|
||||||
@@ -117,7 +132,9 @@ def update_location(location_id: int):
|
|||||||
http_code=409
|
http_code=409
|
||||||
)
|
)
|
||||||
|
|
||||||
for key in ['locationname', 'building', 'floor', 'room', 'description', 'mapimage', 'mapwidth', 'mapheight', 'isactive']:
|
for key in ['locationname', 'building', 'floor', 'room', 'description',
|
||||||
|
'locationtypeid', 'parentlocationid', 'mapimage', 'mapwidth',
|
||||||
|
'mapheight', 'isactive']:
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(loc, key, data[key])
|
setattr(loc, key, data[key])
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from .machine import MachineType
|
|||||||
from .vendor import Vendor
|
from .vendor import Vendor
|
||||||
from .model import Model
|
from .model import Model
|
||||||
from .businessunit import BusinessUnit
|
from .businessunit import BusinessUnit
|
||||||
from .location import Location
|
from .location import Location, LocationType
|
||||||
from .operatingsystem import OperatingSystem
|
from .operatingsystem import OperatingSystem
|
||||||
from .relationship import AssetRelationship, RelationshipType
|
from .relationship import AssetRelationship, RelationshipType
|
||||||
from .communication import Communication, CommunicationType
|
from .communication import Communication, CommunicationType
|
||||||
@@ -32,6 +32,7 @@ __all__ = [
|
|||||||
'Model',
|
'Model',
|
||||||
'BusinessUnit',
|
'BusinessUnit',
|
||||||
'Location',
|
'Location',
|
||||||
|
'LocationType',
|
||||||
'OperatingSystem',
|
'OperatingSystem',
|
||||||
# Relationships
|
# Relationships
|
||||||
'AssetRelationship',
|
'AssetRelationship',
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
"""Location model."""
|
"""Location + LocationType models."""
|
||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from .base import BaseModel
|
from .base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class LocationType(BaseModel):
|
||||||
|
"""Location classification (ADR-001 shared reference data).
|
||||||
|
|
||||||
|
Seeded values: section, cell, subcell, operation, meetingroom, lab,
|
||||||
|
office, storage, hallway, networkcloset, building. Sites can extend.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'locationtypes'
|
||||||
|
|
||||||
|
locationtypeid = db.Column(db.Integer, primary_key=True)
|
||||||
|
locationtype = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
|
description = db.Column(db.Text)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<LocationType {self.locationtype}>"
|
||||||
|
|
||||||
|
|
||||||
class Location(BaseModel):
|
class Location(BaseModel):
|
||||||
"""Physical location model."""
|
"""Physical location model."""
|
||||||
__tablename__ = 'locations'
|
__tablename__ = 'locations'
|
||||||
@@ -15,6 +31,19 @@ class Location(BaseModel):
|
|||||||
room = db.Column(db.String(50))
|
room = db.Column(db.String(50))
|
||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
|
|
||||||
|
# Classification + tree (ADR-001)
|
||||||
|
locationtypeid = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey('locationtypes.locationtypeid'),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
parentlocationid = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey('locations.locationid'),
|
||||||
|
nullable=True,
|
||||||
|
comment='Parent location for the site location tree'
|
||||||
|
)
|
||||||
|
|
||||||
# Map configuration
|
# Map configuration
|
||||||
mapimage = db.Column(db.String(500), comment='Path to floor map image')
|
mapimage = db.Column(db.String(500), comment='Path to floor map image')
|
||||||
mapwidth = db.Column(db.Integer)
|
mapwidth = db.Column(db.Integer)
|
||||||
@@ -26,5 +55,15 @@ class Location(BaseModel):
|
|||||||
mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location')
|
mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location')
|
||||||
mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location')
|
mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location')
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
locationtype = db.relationship('LocationType')
|
||||||
|
parent = db.relationship('Location', remote_side=[locationid])
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = super().to_dict()
|
||||||
|
data['locationtypename'] = self.locationtype.locationtype if self.locationtype else None
|
||||||
|
data['parentlocationname'] = self.parent.locationname if self.parent else None
|
||||||
|
return data
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Location {self.locationname}>"
|
return f"<Location {self.locationname}>"
|
||||||
|
|||||||
Reference in New Issue
Block a user