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:
cproudlock
2026-06-26 12:09:05 -04:00
parent a58feebe7a
commit 6fb8adc256
7 changed files with 192 additions and 10 deletions

View File

@@ -202,6 +202,11 @@ export const locationsApi = {
},
delete(id) {
return api.delete(`/locations/${id}`)
},
types: {
list() {
return api.get('/locations/types')
}
}
}

View File

@@ -25,6 +25,7 @@
<thead>
<tr>
<th>Location Name</th>
<th>Type</th>
<th>Building</th>
<th>Floor</th>
<th>Room</th>
@@ -35,6 +36,7 @@
<tbody>
<tr v-for="loc in locations" :key="loc.locationid">
<td>{{ loc.locationname }}</td>
<td>{{ loc.locationtypename || '-' }}</td>
<td>{{ loc.building || '-' }}</td>
<td>{{ loc.floor || '-' }}</td>
<td>{{ loc.room || '-' }}</td>
@@ -55,7 +57,7 @@
</td>
</tr>
<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
</td>
</tr>
@@ -125,6 +127,32 @@
</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">
<label for="description">Description</label>
<textarea
@@ -180,11 +208,13 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const locations = ref([])
const locationTypes = ref([])
const allLocations = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
@@ -205,12 +235,29 @@ const form = ref({
floor: '',
room: '',
description: '',
locationtypeid: '',
parentlocationid: '',
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
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()
})
@@ -261,6 +308,8 @@ function openModal(loc = null) {
floor: loc.floor || '',
room: loc.room || '',
description: loc.description || '',
locationtypeid: loc.locationtypeid || '',
parentlocationid: loc.parentlocationid || '',
mapimage: loc.mapimage || ''
}
} else {
@@ -270,6 +319,8 @@ function openModal(loc = null) {
floor: '',
room: '',
description: '',
locationtypeid: '',
parentlocationid: '',
mapimage: ''
}
}
@@ -287,10 +338,15 @@ async function saveLocation() {
saving.value = true
try {
const payload = {
...form.value,
locationtypeid: form.value.locationtypeid || null,
parentlocationid: form.value.parentlocationid || null
}
if (editingLocation.value) {
await locationsApi.update(editingLocation.value.locationid, form.value)
await locationsApi.update(editingLocation.value.locationid, payload)
} else {
await locationsApi.create(form.value)
await locationsApi.create(payload)
}
closeModal()
loadLocations()

View 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')

View File

@@ -42,7 +42,7 @@ def seed_cli():
def seed_reference_data():
"""Seed reference data (machine types, statuses, etc.)."""
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
# Machine types
@@ -85,6 +85,14 @@ def seed_reference_data():
elif existing.isactive is not 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
os_list = [
{'osname': 'Windows 10', 'osversion': '10.0'},

View File

@@ -4,7 +4,7 @@ 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.core.models import Location, LocationType
from shopdb.utils.responses import (
success_response,
error_response,
@@ -16,6 +16,19 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
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'])
@jwt_required(optional=True)
def list_locations():
@@ -81,6 +94,8 @@ def create_location():
floor=data.get('floor'),
room=data.get('room'),
description=data.get('description'),
locationtypeid=data.get('locationtypeid'),
parentlocationid=data.get('parentlocationid'),
mapimage=data.get('mapimage'),
mapwidth=data.get('mapwidth'),
mapheight=data.get('mapheight')
@@ -117,7 +132,9 @@ def update_location(location_id: int):
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:
setattr(loc, key, data[key])

View File

@@ -6,7 +6,7 @@ from .machine import MachineType
from .vendor import Vendor
from .model import Model
from .businessunit import BusinessUnit
from .location import Location
from .location import Location, LocationType
from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType
from .communication import Communication, CommunicationType
@@ -32,6 +32,7 @@ __all__ = [
'Model',
'BusinessUnit',
'Location',
'LocationType',
'OperatingSystem',
# Relationships
'AssetRelationship',

View File

@@ -1,9 +1,25 @@
"""Location model."""
"""Location + LocationType models."""
from shopdb.extensions import db
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):
"""Physical location model."""
__tablename__ = 'locations'
@@ -15,6 +31,19 @@ class Location(BaseModel):
room = db.Column(db.String(50))
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
mapimage = db.Column(db.String(500), comment='Path to floor map image')
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')
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):
return f"<Location {self.locationname}>"