Reading anything needs a permission
A sweep of the URL map found 165 GET routes that answered anybody, with no token at all: the whole asset inventory, every machine, PC, printer and network device, the USB checkout history and who is holding which drive, the staff directory with its photographs, every core report. Guest access had been argued about one feature-flag at a time while the front door stood open. Each of those now carries the read permission it belongs to. That also makes the guest role mean something: a site can open exactly the reads it chooses, and nothing follows by accident. Thirty-three endpoints stay public, and each is named with its reason rather than left to be inferred: the app shell and its bootstrap, the settings allowlist (the handler already filters an anonymous caller down to branding and a few keys), images fetched by <img src> which carries no Authorization header, what a display kiosk renders before anybody signs in, the parts and USB kiosks, the client scripts that run as SYSTEM before a user session exists, and a secure-send link whose unguessable token IS the authorisation. tests/test_core/test_public_read_surface.py is the standing gate, the companion to the mutating-route sweep that has guarded writes for months. A new public read fails CI until somebody adds it to that list in front of a reviewer. Four further reads need a login but no permission, because the handler decides from who you are - your own user record, your own API tokens, the role catalogue the grid renders - and the same test proves each still refuses an anonymous caller. Verified not vacuous by ungating one route and watching the sweep name it. The employees plugin gained employees.view; the directory is names, SSOs, photographs and reporting lines, and it had no permission of any kind. Around ninety tests asserted payload shapes against endpoints that answered anybody. They are about what the answer says, not about who may ask, so they are signed in now rather than the gate being loosened. The ones that assert an anonymous caller is refused were left exactly as they were.
This commit is contained in:
25
CHANGELOG.md
25
CHANGELOG.md
@@ -12,6 +12,31 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Reading anything now needs a permission.** A sweep of the URL map found
|
||||||
|
**165 GET routes that answered anybody with no token at all**: the whole
|
||||||
|
asset inventory, every machine, PC, printer and network device, the USB
|
||||||
|
checkout history and who holds which drive, the staff directory, the core
|
||||||
|
reports. Guest access had been argued about one feature-flag at a time while
|
||||||
|
the front door stood open. Every one of them is now behind the read
|
||||||
|
permission it belongs to - `assets.view`, `machines.view`, `printers.view`
|
||||||
|
and so on - which also means the guest role can open exactly the ones a site
|
||||||
|
chooses and nothing else.
|
||||||
|
|
||||||
|
Thirty-three endpoints stay public, each named with its reason in
|
||||||
|
`tests/test_core/test_public_read_surface.py`: the app shell and its
|
||||||
|
bootstrap, the settings allowlist, images fetched by `<img src>` (which
|
||||||
|
carries no Authorization header), what a display kiosk renders before anybody
|
||||||
|
signs in, the parts and USB kiosks, the client scripts that run as SYSTEM
|
||||||
|
before a user session exists, and a secure-send link whose token is itself
|
||||||
|
the authorisation. That file is a standing gate: a new public read fails CI
|
||||||
|
until somebody adds it to the list, in front of a reviewer. Four more reads
|
||||||
|
need a login but no permission because the handler decides from who you are -
|
||||||
|
your own user record, your own API tokens, the role catalogue - and the same
|
||||||
|
test proves each still refuses an anonymous caller.
|
||||||
|
|
||||||
|
The employees plugin gained `employees.view`: the directory is names, SSOs,
|
||||||
|
photographs and reporting lines, and it had no permission at all.
|
||||||
|
|
||||||
- **A guide's sections can be ticked off while you read it.** Only checklist
|
- **A guide's sections can be ticked off while you read it.** Only checklist
|
||||||
items were tickable, which answered "what have I done" but not the question a
|
items were tickable, which answered "what have I done" but not the question a
|
||||||
long procedure actually raises - where was I. Somebody looks away to do the
|
long procedure actually raises - where was I. Somebody looks away to do the
|
||||||
|
|||||||
@@ -88,6 +88,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
|
|||||||
|
|
||||||
## Size
|
## Size
|
||||||
|
|
||||||
- test functions defined: **1414** (parametrised cases collect higher)
|
- test functions defined: **1417** (parametrised cases collect higher)
|
||||||
- documented API paths: **305** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
- documented API paths: **305** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ computers_bp = Blueprint('computers', __name__)
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/types', methods=['GET'])
|
@computers_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def list_computer_types():
|
def list_computer_types():
|
||||||
"""List all computer types."""
|
"""List all computer types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -39,7 +39,7 @@ def list_computer_types():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/types/<int:type_id>', methods=['GET'])
|
@computers_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def get_computer_type(type_id: int):
|
def get_computer_type(type_id: int):
|
||||||
"""Get a single computer type."""
|
"""Get a single computer type."""
|
||||||
t = db.session.get(ComputerType, type_id)
|
t = db.session.get(ComputerType, type_id)
|
||||||
@@ -145,7 +145,7 @@ def delete_computer_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/protocols', methods=['GET'])
|
@computers_bp.route('/protocols', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def list_protocols():
|
def list_protocols():
|
||||||
"""List access protocols. ?active=false includes disabled ones."""
|
"""List access protocols. ?active=false includes disabled ones."""
|
||||||
query = AccessProtocol.query
|
query = AccessProtocol.query
|
||||||
@@ -293,7 +293,7 @@ def _sync_access_methods(comp, data):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/display-kiosks', methods=['GET'])
|
@computers_bp.route('/display-kiosks', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def list_display_kiosks():
|
def list_display_kiosks():
|
||||||
"""Reporting display kiosks, for the Dashboard Defaults picker.
|
"""Reporting display kiosks, for the Dashboard Defaults picker.
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ def list_display_kiosks():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('', methods=['GET'])
|
@computers_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def list_computers():
|
def list_computers():
|
||||||
"""
|
"""
|
||||||
List all computers with filtering and pagination.
|
List all computers with filtering and pagination.
|
||||||
@@ -437,7 +437,7 @@ def list_computers():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/<int:computer_id>', methods=['GET'])
|
@computers_bp.route('/<int:computer_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def get_computer(computer_id: int):
|
def get_computer(computer_id: int):
|
||||||
"""Get a single computer with full details."""
|
"""Get a single computer with full details."""
|
||||||
comp = db.session.get(Computer, computer_id)
|
comp = db.session.get(Computer, computer_id)
|
||||||
@@ -461,7 +461,7 @@ def get_computer(computer_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
@computers_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def get_computer_by_asset(asset_id: int):
|
def get_computer_by_asset(asset_id: int):
|
||||||
"""Get computer data by asset ID."""
|
"""Get computer data by asset ID."""
|
||||||
comp = Computer.query.filter_by(assetid=asset_id).first()
|
comp = Computer.query.filter_by(assetid=asset_id).first()
|
||||||
@@ -480,7 +480,7 @@ def get_computer_by_asset(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/by-hostname/<hostname>', methods=['GET'])
|
@computers_bp.route('/by-hostname/<hostname>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def get_computer_by_hostname(hostname: str):
|
def get_computer_by_hostname(hostname: str):
|
||||||
"""Get computer by hostname."""
|
"""Get computer by hostname."""
|
||||||
comp = Computer.query.filter_by(hostname=hostname).first()
|
comp = Computer.query.filter_by(hostname=hostname).first()
|
||||||
@@ -753,7 +753,7 @@ def delete_computer(computer_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/<int:computer_id>/apps', methods=['GET'])
|
@computers_bp.route('/<int:computer_id>/apps', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def get_installed_apps(computer_id: int):
|
def get_installed_apps(computer_id: int):
|
||||||
"""Get all installed applications for a computer."""
|
"""Get all installed applications for a computer."""
|
||||||
comp = db.session.get(Computer, computer_id)
|
comp = db.session.get(Computer, computer_id)
|
||||||
@@ -897,7 +897,7 @@ def report_status(computer_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/dashboard/summary', methods=['GET'])
|
@computers_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""Get computer dashboard summary data."""
|
"""Get computer dashboard summary data."""
|
||||||
# Total active computers
|
# Total active computers
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from shopdb.api import Setting
|
|||||||
from shopdb.api import send_upload
|
from shopdb.api import send_upload
|
||||||
|
|
||||||
from ..models import DirectoryEmployee
|
from ..models import DirectoryEmployee
|
||||||
|
from shopdb.api import require_permission
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -198,6 +199,7 @@ def _with_photo_url(employee):
|
|||||||
|
|
||||||
|
|
||||||
@employees_bp.route('/resolve/<sso>', methods=['GET'])
|
@employees_bp.route('/resolve/<sso>', methods=['GET'])
|
||||||
|
@require_permission('employees.view')
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def resolve_employee(sso):
|
def resolve_employee(sso):
|
||||||
"""Resolve one SSO to a name and photo, and say HOW.
|
"""Resolve one SSO to a name and photo, and say HOW.
|
||||||
@@ -275,6 +277,7 @@ def resolve_employee(sso):
|
|||||||
|
|
||||||
|
|
||||||
@employees_bp.route('/search', methods=['GET'])
|
@employees_bp.route('/search', methods=['GET'])
|
||||||
|
@require_permission('employees.view')
|
||||||
def search_employees():
|
def search_employees():
|
||||||
"""
|
"""
|
||||||
Search employees by name.
|
Search employees by name.
|
||||||
@@ -327,6 +330,7 @@ def search_employees():
|
|||||||
|
|
||||||
|
|
||||||
@employees_bp.route('/lookup/<sso>', methods=['GET'])
|
@employees_bp.route('/lookup/<sso>', methods=['GET'])
|
||||||
|
@require_permission('employees.view')
|
||||||
def lookup_employee(sso):
|
def lookup_employee(sso):
|
||||||
"""Look up a single employee by SSO."""
|
"""Look up a single employee by SSO."""
|
||||||
if not sso.isdigit():
|
if not sso.isdigit():
|
||||||
@@ -370,6 +374,7 @@ def lookup_employee(sso):
|
|||||||
|
|
||||||
|
|
||||||
@employees_bp.route('/lookup', methods=['GET'])
|
@employees_bp.route('/lookup', methods=['GET'])
|
||||||
|
@require_permission('employees.view')
|
||||||
def lookup_employees():
|
def lookup_employees():
|
||||||
"""
|
"""
|
||||||
Look up multiple employees by SSO list.
|
Look up multiple employees by SSO list.
|
||||||
@@ -429,7 +434,7 @@ def lookup_employees():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@employees_bp.route('/directory', methods=['GET'])
|
@employees_bp.route('/directory', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('employees.view')
|
||||||
def list_directory():
|
def list_directory():
|
||||||
"""Full self-hosted directory (for the management page)."""
|
"""Full self-hosted directory (for the management page)."""
|
||||||
guard = _require_selfhosted()
|
guard = _require_selfhosted()
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ class EmployeesPlugin(BasePlugin):
|
|||||||
api_prefix=self._manifest.get('api_prefix', '/api/employees'),
|
api_prefix=self._manifest.get('api_prefix', '/api/employees'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_permissions(self) -> List:
|
||||||
|
"""Reading the staff directory.
|
||||||
|
|
||||||
|
The directory is names, SSOs, photographs and who reports to whom. It
|
||||||
|
was reachable with no account at all, which is how a permissions
|
||||||
|
question ends up answered by nobody. One read permission: the write
|
||||||
|
side of this plugin is an import, run from the CLI.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
('employees.view', 'View the employee directory', 'employees'),
|
||||||
|
]
|
||||||
|
|
||||||
def get_blueprint(self) -> Optional[Blueprint]:
|
def get_blueprint(self) -> Optional[Blueprint]:
|
||||||
"""Return Flask Blueprint with API routes."""
|
"""Return Flask Blueprint with API routes."""
|
||||||
return employees_bp
|
return employees_bp
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ def _search_clause(search):
|
|||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('', methods=['GET'])
|
@knowledgebase_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('kb.view')
|
||||||
def list_articles():
|
def list_articles():
|
||||||
"""List all knowledge base articles."""
|
"""List all knowledge base articles."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -142,7 +142,7 @@ def list_articles():
|
|||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('/stats', methods=['GET'])
|
@knowledgebase_bp.route('/stats', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('kb.view')
|
||||||
def get_stats():
|
def get_stats():
|
||||||
"""Get knowledge base statistics."""
|
"""Get knowledge base statistics."""
|
||||||
# Counted over the same set the list shows. A total that includes articles
|
# Counted over the same set the list shows. A total that includes articles
|
||||||
@@ -158,7 +158,7 @@ def get_stats():
|
|||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('/<int:link_id>', methods=['GET'])
|
@knowledgebase_bp.route('/<int:link_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('kb.view')
|
||||||
def get_article(link_id: int):
|
def get_article(link_id: int):
|
||||||
"""Get a single knowledge base article."""
|
"""Get a single knowledge base article."""
|
||||||
article = db.session.get(KnowledgeBase, link_id)
|
article = db.session.get(KnowledgeBase, link_id)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ machines_bp = Blueprint('machines', __name__)
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@machines_bp.route('/types', methods=['GET'])
|
@machines_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def list_machine_types():
|
def list_machine_types():
|
||||||
"""List all machine types."""
|
"""List all machine types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -39,7 +39,7 @@ def list_machine_types():
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/types/<int:type_id>', methods=['GET'])
|
@machines_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def get_machine_type(type_id: int):
|
def get_machine_type(type_id: int):
|
||||||
"""Get a single machine type."""
|
"""Get a single machine type."""
|
||||||
t = db.session.get(MachineType, type_id)
|
t = db.session.get(MachineType, type_id)
|
||||||
@@ -144,7 +144,7 @@ def delete_machine_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@machines_bp.route('', methods=['GET'])
|
@machines_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def list_machines():
|
def list_machines():
|
||||||
"""
|
"""
|
||||||
List all machines with filtering and pagination.
|
List all machines with filtering and pagination.
|
||||||
@@ -281,7 +281,7 @@ def _dualpath_partner_for(assetid):
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/<int:machine_id>', methods=['GET'])
|
@machines_bp.route('/<int:machine_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def get_machine(machine_id: int):
|
def get_machine(machine_id: int):
|
||||||
"""Get a single machine item with full details."""
|
"""Get a single machine item with full details."""
|
||||||
mach = db.session.get(Machine, machine_id)
|
mach = db.session.get(Machine, machine_id)
|
||||||
@@ -301,7 +301,7 @@ def get_machine(machine_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
@machines_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def get_machine_by_asset(asset_id: int):
|
def get_machine_by_asset(asset_id: int):
|
||||||
"""Get machine data by asset ID."""
|
"""Get machine data by asset ID."""
|
||||||
mach = Machine.query.filter_by(assetid=asset_id).first()
|
mach = Machine.query.filter_by(assetid=asset_id).first()
|
||||||
@@ -525,7 +525,7 @@ def delete_machine(machine_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@machines_bp.route('/dashboard/summary', methods=['GET'])
|
@machines_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""Get machine dashboard summary data."""
|
"""Get machine dashboard summary data."""
|
||||||
# Dualpath single-machine collapse: hide the secondary bay from counts so a
|
# Dualpath single-machine collapse: hide the secondary bay from counts so a
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def _merged(tool, today=None):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@measuringtools_bp.route('/types', methods=['GET'])
|
@measuringtools_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def list_types():
|
def list_types():
|
||||||
"""List measuring-tool types."""
|
"""List measuring-tool types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -64,7 +64,7 @@ def list_types():
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/types/<int:type_id>', methods=['GET'])
|
@measuringtools_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def get_type(type_id: int):
|
def get_type(type_id: int):
|
||||||
"""Get one measuring-tool type."""
|
"""Get one measuring-tool type."""
|
||||||
tool_type = db.session.get(MeasuringToolType, type_id)
|
tool_type = db.session.get(MeasuringToolType, type_id)
|
||||||
@@ -150,7 +150,7 @@ def delete_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@measuringtools_bp.route('', methods=['GET'])
|
@measuringtools_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def list_tools():
|
def list_tools():
|
||||||
"""List measuring tools with filters + pagination.
|
"""List measuring tools with filters + pagination.
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ def list_tools():
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/<int:tool_id>', methods=['GET'])
|
@measuringtools_bp.route('/<int:tool_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def get_tool(tool_id: int):
|
def get_tool(tool_id: int):
|
||||||
"""Get one measuring tool (asset core merged with the extension)."""
|
"""Get one measuring tool (asset core merged with the extension)."""
|
||||||
tool = db.session.get(MeasuringTool, tool_id)
|
tool = db.session.get(MeasuringTool, tool_id)
|
||||||
@@ -219,7 +219,7 @@ def get_tool(tool_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
@measuringtools_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def get_tool_by_asset(asset_id: int):
|
def get_tool_by_asset(asset_id: int):
|
||||||
"""Get a measuring tool by its core asset id."""
|
"""Get a measuring tool by its core asset id."""
|
||||||
tool = MeasuringTool.query.filter_by(assetid=asset_id).first()
|
tool = MeasuringTool.query.filter_by(assetid=asset_id).first()
|
||||||
@@ -371,7 +371,7 @@ def delete_tool(tool_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@measuringtools_bp.route('/map-overlay', methods=['GET'])
|
@measuringtools_bp.route('/map-overlay', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def map_overlay():
|
def map_overlay():
|
||||||
"""Calibration-status overlay for active measuring tools.
|
"""Calibration-status overlay for active measuring tools.
|
||||||
|
|
||||||
@@ -402,7 +402,7 @@ def map_overlay():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@measuringtools_bp.route('/report/calibration', methods=['GET'])
|
@measuringtools_bp.route('/report/calibration', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def calibration_report():
|
def calibration_report():
|
||||||
"""Counts + lists bucketed by derived calibration status."""
|
"""Counts + lists bucketed by derived calibration status."""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def _upsert_primary_ip(asset, ip):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@network_bp.route('/types', methods=['GET'])
|
@network_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def list_network_device_types():
|
def list_network_device_types():
|
||||||
"""List all network device types."""
|
"""List all network device types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -69,7 +69,7 @@ def list_network_device_types():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/types/<int:type_id>', methods=['GET'])
|
@network_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_network_device_type(type_id: int):
|
def get_network_device_type(type_id: int):
|
||||||
"""Get a single network device type."""
|
"""Get a single network device type."""
|
||||||
t = db.session.get(NetworkDeviceType, type_id)
|
t = db.session.get(NetworkDeviceType, type_id)
|
||||||
@@ -176,7 +176,7 @@ def delete_network_device_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@network_bp.route('', methods=['GET'])
|
@network_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def list_network_devices():
|
def list_network_devices():
|
||||||
"""
|
"""
|
||||||
List all network devices with filtering and pagination.
|
List all network devices with filtering and pagination.
|
||||||
@@ -284,7 +284,7 @@ def list_network_devices():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/<int:device_id>', methods=['GET'])
|
@network_bp.route('/<int:device_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_network_device(device_id: int):
|
def get_network_device(device_id: int):
|
||||||
"""Get a single network device with full details."""
|
"""Get a single network device with full details."""
|
||||||
netdev = db.session.get(NetworkDevice, device_id)
|
netdev = db.session.get(NetworkDevice, device_id)
|
||||||
@@ -304,7 +304,7 @@ def get_network_device(device_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
@network_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_network_device_by_asset(asset_id: int):
|
def get_network_device_by_asset(asset_id: int):
|
||||||
"""Get network device data by asset ID."""
|
"""Get network device data by asset ID."""
|
||||||
netdev = NetworkDevice.query.filter_by(assetid=asset_id).first()
|
netdev = NetworkDevice.query.filter_by(assetid=asset_id).first()
|
||||||
@@ -324,7 +324,7 @@ def get_network_device_by_asset(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/by-hostname/<hostname>', methods=['GET'])
|
@network_bp.route('/by-hostname/<hostname>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_network_device_by_hostname(hostname: str):
|
def get_network_device_by_hostname(hostname: str):
|
||||||
"""Get network device by hostname."""
|
"""Get network device by hostname."""
|
||||||
netdev = NetworkDevice.query.filter_by(hostname=hostname).first()
|
netdev = NetworkDevice.query.filter_by(hostname=hostname).first()
|
||||||
@@ -583,7 +583,7 @@ def delete_network_device(device_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@network_bp.route('/dashboard/summary', methods=['GET'])
|
@network_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""Get network device dashboard summary data."""
|
"""Get network device dashboard summary data."""
|
||||||
# Total active network devices
|
# Total active network devices
|
||||||
@@ -631,7 +631,7 @@ def dashboard_summary():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@network_bp.route('/vlans', methods=['GET'])
|
@network_bp.route('/vlans', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def list_vlans():
|
def list_vlans():
|
||||||
"""List all VLANs with filtering and pagination."""
|
"""List all VLANs with filtering and pagination."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -664,7 +664,7 @@ def list_vlans():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/vlans/<int:vlan_id>', methods=['GET'])
|
@network_bp.route('/vlans/<int:vlan_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_vlan(vlan_id: int):
|
def get_vlan(vlan_id: int):
|
||||||
"""Get a single VLAN with its subnets."""
|
"""Get a single VLAN with its subnets."""
|
||||||
vlan = db.session.get(VLAN, vlan_id)
|
vlan = db.session.get(VLAN, vlan_id)
|
||||||
@@ -806,7 +806,7 @@ def delete_vlan(vlan_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@network_bp.route('/subnets', methods=['GET'])
|
@network_bp.route('/subnets', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def list_subnets():
|
def list_subnets():
|
||||||
"""List all subnets with filtering and pagination."""
|
"""List all subnets with filtering and pagination."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -847,7 +847,7 @@ def list_subnets():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/subnets/<int:subnet_id>', methods=['GET'])
|
@network_bp.route('/subnets/<int:subnet_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_subnet(subnet_id: int):
|
def get_subnet(subnet_id: int):
|
||||||
"""Get a single subnet plus the network devices whose primary IP falls in
|
"""Get a single subnet plus the network devices whose primary IP falls in
|
||||||
its CIDR range."""
|
its CIDR range."""
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ def _employee_picture(sso):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@notifications_bp.route('/types', methods=['GET'])
|
@notifications_bp.route('/types', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def list_notification_types():
|
def list_notification_types():
|
||||||
"""List all notification types."""
|
"""List all notification types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -333,6 +334,7 @@ def update_notification_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@notifications_bp.route('', methods=['GET'])
|
@notifications_bp.route('', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def list_notifications():
|
def list_notifications():
|
||||||
"""
|
"""
|
||||||
List all notifications with filtering and pagination.
|
List all notifications with filtering and pagination.
|
||||||
@@ -388,6 +390,7 @@ def list_notifications():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/<int:notification_id>', methods=['GET'])
|
@notifications_bp.route('/<int:notification_id>', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def get_notification(notification_id: int):
|
def get_notification(notification_id: int):
|
||||||
"""Get a single notification."""
|
"""Get a single notification."""
|
||||||
n = db.session.get(Notification, notification_id)
|
n = db.session.get(Notification, notification_id)
|
||||||
@@ -551,6 +554,7 @@ def delete_notification(notification_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@notifications_bp.route('/active', methods=['GET'])
|
@notifications_bp.route('/active', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def get_active_notifications():
|
def get_active_notifications():
|
||||||
"""
|
"""
|
||||||
Get currently active notifications for display.
|
Get currently active notifications for display.
|
||||||
@@ -581,6 +585,7 @@ def get_active_notifications():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/calendar', methods=['GET'])
|
@notifications_bp.route('/calendar', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def get_calendar_events():
|
def get_calendar_events():
|
||||||
"""
|
"""
|
||||||
Get notifications in FullCalendar event format.
|
Get notifications in FullCalendar event format.
|
||||||
@@ -619,6 +624,7 @@ def get_calendar_events():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/dashboard/summary', methods=['GET'])
|
@notifications_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""Get notifications dashboard summary."""
|
"""Get notifications dashboard summary."""
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
@@ -869,6 +875,7 @@ def get_shopfloor_notifications():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/dashboard/active', methods=['GET'])
|
@notifications_bp.route('/dashboard/active', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def dashboard_active():
|
def dashboard_active():
|
||||||
"""Notifications currently showing on the floor.
|
"""Notifications currently showing on the floor.
|
||||||
|
|||||||
@@ -542,7 +542,7 @@ def _csv_response(rows, columns, filename):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/reports/stock', methods=['GET'])
|
@printedparts_bp.route('/reports/stock', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printedparts.view')
|
||||||
def report_stock():
|
def report_stock():
|
||||||
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
|
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
|
||||||
|
|
||||||
@@ -576,7 +576,7 @@ def report_stock():
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/reports/consumption', methods=['GET'])
|
@printedparts_bp.route('/reports/consumption', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printedparts.view')
|
||||||
def report_consumption():
|
def report_consumption():
|
||||||
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
|
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
|
||||||
days = request.args.get('days', 30, type=int)
|
days = request.args.get('days', 30, type=int)
|
||||||
@@ -604,7 +604,7 @@ def report_consumption():
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/reports/by-person', methods=['GET'])
|
@printedparts_bp.route('/reports/by-person', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printedparts.view')
|
||||||
def report_by_person():
|
def report_by_person():
|
||||||
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
|
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
|
||||||
days = request.args.get('days', 30, type=int)
|
days = request.args.get('days', 30, type=int)
|
||||||
@@ -722,7 +722,7 @@ def upload_item_file(item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/files/<int:file_id>/download', methods=['GET'])
|
@printedparts_bp.route('/files/<int:file_id>/download', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printedparts.view')
|
||||||
def download_item_file(file_id: int):
|
def download_item_file(file_id: int):
|
||||||
"""Download a revision under its original filename."""
|
"""Download a revision under its original filename."""
|
||||||
record = db.session.get(PrintedItemFile, file_id)
|
record = db.session.get(PrintedItemFile, file_id)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ printers_asset_bp = Blueprint('printers_asset', __name__)
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('/types', methods=['GET'])
|
@printers_asset_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def list_printer_types():
|
def list_printer_types():
|
||||||
"""List all printer types."""
|
"""List all printer types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -52,7 +52,7 @@ def list_printer_types():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/types/<int:type_id>', methods=['GET'])
|
@printers_asset_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def get_printer_type(type_id: int):
|
def get_printer_type(type_id: int):
|
||||||
"""Get a single printer type."""
|
"""Get a single printer type."""
|
||||||
t = db.session.get(PrinterType, type_id)
|
t = db.session.get(PrinterType, type_id)
|
||||||
@@ -147,7 +147,7 @@ def delete_printer_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('/drivers', methods=['GET'])
|
@printers_asset_bp.route('/drivers', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def list_drivers():
|
def list_drivers():
|
||||||
"""List printer drivers. ?active=false includes inactive ones."""
|
"""List printer drivers. ?active=false includes inactive ones."""
|
||||||
query = PrinterDriver.query
|
query = PrinterDriver.query
|
||||||
@@ -207,7 +207,7 @@ def delete_driver(driver_id):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('', methods=['GET'])
|
@printers_asset_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def list_printers():
|
def list_printers():
|
||||||
"""
|
"""
|
||||||
List all printers with filtering and pagination.
|
List all printers with filtering and pagination.
|
||||||
@@ -964,7 +964,7 @@ def printers_for_host(hostname: str):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['GET'])
|
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def get_asset_printer_assignment(asset_id: int):
|
def get_asset_printer_assignment(asset_id: int):
|
||||||
"""What THIS asset is assigned, without inheritance.
|
"""What THIS asset is assigned, without inheritance.
|
||||||
|
|
||||||
@@ -1778,7 +1778,7 @@ def seed_assignment_from_observed(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def get_printer(printer_id: int):
|
def get_printer(printer_id: int):
|
||||||
"""Get a single printer with full details."""
|
"""Get a single printer with full details."""
|
||||||
printer = db.session.get(Printer, printer_id)
|
printer = db.session.get(Printer, printer_id)
|
||||||
@@ -1811,7 +1811,7 @@ def get_printer(printer_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
@printers_asset_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def get_printer_by_asset(asset_id: int):
|
def get_printer_by_asset(asset_id: int):
|
||||||
"""Get printer data by asset ID."""
|
"""Get printer data by asset ID."""
|
||||||
printer = Printer.query.filter_by(assetid=asset_id).first()
|
printer = Printer.query.filter_by(assetid=asset_id).first()
|
||||||
@@ -2043,7 +2043,7 @@ def delete_printer(printer_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('/<int:printer_id>/supplies', methods=['GET'])
|
@printers_asset_bp.route('/<int:printer_id>/supplies', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def get_printer_supplies(printer_id: int):
|
def get_printer_supplies(printer_id: int):
|
||||||
"""Get supply levels from Zabbix (real-time lookup)."""
|
"""Get supply levels from Zabbix (real-time lookup)."""
|
||||||
printer = db.session.get(Printer, printer_id)
|
printer = db.session.get(Printer, printer_id)
|
||||||
@@ -2238,7 +2238,7 @@ def _get_low_supplies_data():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/lowsupplies', methods=['GET'])
|
@printers_asset_bp.route('/lowsupplies', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def low_supplies():
|
def low_supplies():
|
||||||
"""Get printers with low or critical supply levels."""
|
"""Get printers with low or critical supply levels."""
|
||||||
data = _get_low_supplies_data()
|
data = _get_low_supplies_data()
|
||||||
@@ -2246,7 +2246,7 @@ def low_supplies():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/lookup', methods=['GET'])
|
@printers_asset_bp.route('/lookup', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def printer_lookup():
|
def printer_lookup():
|
||||||
"""Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.
|
"""Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.
|
||||||
|
|
||||||
@@ -2306,7 +2306,7 @@ def refresh_supplies_cache():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('/dashboard/summary', methods=['GET'])
|
@printers_asset_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""Get printer dashboard summary data."""
|
"""Get printer dashboard summary data."""
|
||||||
# Total active printers
|
# Total active printers
|
||||||
@@ -2380,7 +2380,7 @@ def _validate_supply_payload(data):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/supplies/meta', methods=['GET'])
|
@printers_asset_bp.route('/supplies/meta', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def supplies_meta():
|
def supplies_meta():
|
||||||
"""Allowed values for supply type, color, and capacity tier (for the UI)."""
|
"""Allowed values for supply type, color, and capacity tier (for the UI)."""
|
||||||
return success_response({
|
return success_response({
|
||||||
@@ -2391,7 +2391,7 @@ def supplies_meta():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/models', methods=['GET'])
|
@printers_asset_bp.route('/models', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def list_supply_models():
|
def list_supply_models():
|
||||||
"""List models with a supply count, for the supply-management picker.
|
"""List models with a supply count, for the supply-management picker.
|
||||||
|
|
||||||
@@ -2450,7 +2450,7 @@ def list_supply_models():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['GET'])
|
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def list_model_supplies(modelnumberid: int):
|
def list_model_supplies(modelnumberid: int):
|
||||||
"""List all supplies mapped to a model."""
|
"""List all supplies mapped to a model."""
|
||||||
model = db.session.get(Model, modelnumberid)
|
model = db.session.get(Model, modelnumberid)
|
||||||
@@ -2667,7 +2667,7 @@ def dashboard_supplies():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('/supplies/forecast', methods=['GET'])
|
@printers_asset_bp.route('/supplies/forecast', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def supplies_forecast():
|
def supplies_forecast():
|
||||||
"""Days-to-empty and replacement counts per printer.
|
"""Days-to-empty and replacement counts per printer.
|
||||||
|
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ def _log_to_dict(cur, row):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@usb_bp.route('', methods=['GET'])
|
@usb_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('usb.view')
|
||||||
def list_usb_devices():
|
def list_usb_devices():
|
||||||
"""List USB devices with checkout status.
|
"""List USB devices with checkout status.
|
||||||
|
|
||||||
@@ -306,7 +306,7 @@ def list_usb_devices():
|
|||||||
|
|
||||||
|
|
||||||
@usb_bp.route('/<device_id>', methods=['GET'])
|
@usb_bp.route('/<device_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('usb.view')
|
||||||
def get_usb_device(device_id):
|
def get_usb_device(device_id):
|
||||||
"""Get a single device plus its last 20 check-in/out log rows."""
|
"""Get a single device plus its last 20 check-in/out log rows."""
|
||||||
if _usb_selfhosted():
|
if _usb_selfhosted():
|
||||||
@@ -595,7 +595,7 @@ def checkin_device(device_id):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@usb_bp.route('/<device_id>/history', methods=['GET'])
|
@usb_bp.route('/<device_id>/history', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('usb.view')
|
||||||
def get_device_history(device_id):
|
def get_device_history(device_id):
|
||||||
"""Paginated check-in/out log for one device."""
|
"""Paginated check-in/out log for one device."""
|
||||||
if _usb_selfhosted():
|
if _usb_selfhosted():
|
||||||
@@ -625,7 +625,7 @@ def get_device_history(device_id):
|
|||||||
|
|
||||||
|
|
||||||
@usb_bp.route('/checkouts', methods=['GET'])
|
@usb_bp.route('/checkouts', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('usb.view')
|
||||||
def list_all_checkouts():
|
def list_all_checkouts():
|
||||||
"""List check-out log rows.
|
"""List check-out log rows.
|
||||||
|
|
||||||
@@ -676,7 +676,7 @@ def list_all_checkouts():
|
|||||||
|
|
||||||
|
|
||||||
@usb_bp.route('/checkouts/active', methods=['GET'])
|
@usb_bp.route('/checkouts/active', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('usb.view')
|
||||||
def list_active_checkouts():
|
def list_active_checkouts():
|
||||||
"""List the latest check-out log row for every currently checked-out device.
|
"""List the latest check-out log row for every currently checked-out device.
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ def _apply_links(warranty, assetids):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@warranty_bp.route('', methods=['GET'])
|
@warranty_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('warranty.view')
|
||||||
def list_warranties():
|
def list_warranties():
|
||||||
"""List warranties. Filters: ?status=, ?assetid=, ?active=false."""
|
"""List warranties. Filters: ?status=, ?assetid=, ?active=false."""
|
||||||
query = Warranty.query
|
query = Warranty.query
|
||||||
@@ -174,7 +174,7 @@ def list_warranties():
|
|||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/asset/<int:assetid>', methods=['GET'])
|
@warranty_bp.route('/asset/<int:assetid>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('warranty.view')
|
||||||
def warranties_for_asset(assetid):
|
def warranties_for_asset(assetid):
|
||||||
"""Warranties covering one asset (for the asset-detail panel)."""
|
"""Warranties covering one asset (for the asset-detail panel)."""
|
||||||
links = WarrantyAsset.query.filter_by(assetid=assetid).all()
|
links = WarrantyAsset.query.filter_by(assetid=assetid).all()
|
||||||
@@ -188,7 +188,7 @@ def warranties_for_asset(assetid):
|
|||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/<int:warrantyid>', methods=['GET'])
|
@warranty_bp.route('/<int:warrantyid>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('warranty.view')
|
||||||
def get_warranty(warrantyid):
|
def get_warranty(warrantyid):
|
||||||
warranty = db.session.get(Warranty, warrantyid)
|
warranty = db.session.get(Warranty, warrantyid)
|
||||||
if not warranty:
|
if not warranty:
|
||||||
@@ -395,7 +395,7 @@ def sync_dell():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@warranty_bp.route('/report', methods=['GET'])
|
@warranty_bp.route('/report', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('warranty.view')
|
||||||
def warranty_report():
|
def warranty_report():
|
||||||
"""Counts + lists bucketed by derived status."""
|
"""Counts + lists bucketed by derived status."""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ applications_bp = Blueprint('applications', __name__)
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('', methods=['GET'])
|
@applications_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('applications.view')
|
||||||
def list_applications():
|
def list_applications():
|
||||||
"""List all applications."""
|
"""List all applications."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -163,7 +163,7 @@ def list_applications():
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>', methods=['GET'])
|
@applications_bp.route('/<int:app_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('applications.view')
|
||||||
def get_application(app_id: int):
|
def get_application(app_id: int):
|
||||||
"""Get a single application with details."""
|
"""Get a single application with details."""
|
||||||
app = db.session.get(Application, app_id)
|
app = db.session.get(Application, app_id)
|
||||||
@@ -288,7 +288,7 @@ def delete_application(app_id: int):
|
|||||||
# ---- Versions ----
|
# ---- Versions ----
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>/versions', methods=['GET'])
|
@applications_bp.route('/<int:app_id>/versions', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('applications.view')
|
||||||
def list_versions(app_id: int):
|
def list_versions(app_id: int):
|
||||||
"""List all versions for an application."""
|
"""List all versions for an application."""
|
||||||
app = db.session.get(Application, app_id)
|
app = db.session.get(Application, app_id)
|
||||||
@@ -337,7 +337,7 @@ def create_version(app_id: int):
|
|||||||
# ---- Computers with this app installed ----
|
# ---- Computers with this app installed ----
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>/installed', methods=['GET'])
|
@applications_bp.route('/<int:app_id>/installed', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('applications.view')
|
||||||
def list_installed_machines(app_id: int):
|
def list_installed_machines(app_id: int):
|
||||||
"""List all computers that have this application installed."""
|
"""List all computers that have this application installed."""
|
||||||
app = db.session.get(Application, app_id)
|
app = db.session.get(Application, app_id)
|
||||||
@@ -376,7 +376,7 @@ def list_installed_machines(app_id: int):
|
|||||||
# ---- Installed Apps (per computer) ----
|
# ---- Installed Apps (per computer) ----
|
||||||
|
|
||||||
@applications_bp.route('/machines/<int:machine_id>', methods=['GET'])
|
@applications_bp.route('/machines/<int:machine_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('applications.view')
|
||||||
def list_machine_applications(machine_id: int):
|
def list_machine_applications(machine_id: int):
|
||||||
"""List all applications installed on a computer."""
|
"""List all applications installed on a computer."""
|
||||||
models, err = _require_computer_models()
|
models, err = _require_computer_models()
|
||||||
@@ -541,6 +541,7 @@ def upload_application_image(app_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
||||||
|
@require_permission('applications.view')
|
||||||
def serve_application_image(filename):
|
def serve_application_image(filename):
|
||||||
"""Serve an uploaded application image (public - lists and tiles read it)."""
|
"""Serve an uploaded application image (public - lists and tiles read it)."""
|
||||||
return send_upload(_appimage_dir(), filename)
|
return send_upload(_appimage_dir(), filename)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ assets_bp = Blueprint('assets', __name__)
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@assets_bp.route('/types', methods=['GET'])
|
@assets_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_asset_types():
|
def list_asset_types():
|
||||||
"""List all asset types."""
|
"""List all asset types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -44,7 +44,7 @@ def list_asset_types():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/types/<int:type_id>', methods=['GET'])
|
@assets_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset_type(type_id: int):
|
def get_asset_type(type_id: int):
|
||||||
"""Get a single asset type."""
|
"""Get a single asset type."""
|
||||||
t = db.session.get(AssetType, type_id)
|
t = db.session.get(AssetType, type_id)
|
||||||
@@ -111,7 +111,7 @@ def update_asset_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@assets_bp.route('/statuses', methods=['GET'])
|
@assets_bp.route('/statuses', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_asset_statuses():
|
def list_asset_statuses():
|
||||||
"""List all asset statuses."""
|
"""List all asset statuses."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -130,7 +130,7 @@ def list_asset_statuses():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/statuses/<int:status_id>', methods=['GET'])
|
@assets_bp.route('/statuses/<int:status_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset_status(status_id: int):
|
def get_asset_status(status_id: int):
|
||||||
"""Get a single asset status."""
|
"""Get a single asset status."""
|
||||||
s = db.session.get(AssetStatus, status_id)
|
s = db.session.get(AssetStatus, status_id)
|
||||||
@@ -228,7 +228,7 @@ def delete_asset_status(status_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@assets_bp.route('/relationshiptypes', methods=['GET'])
|
@assets_bp.route('/relationshiptypes', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_relationship_types():
|
def list_relationship_types():
|
||||||
"""List all asset relationship types."""
|
"""List all asset relationship types."""
|
||||||
types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
|
types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
|
||||||
@@ -321,7 +321,7 @@ def delete_relationship_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@assets_bp.route('', methods=['GET'])
|
@assets_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_assets():
|
def list_assets():
|
||||||
"""
|
"""
|
||||||
List all assets with filtering and pagination.
|
List all assets with filtering and pagination.
|
||||||
@@ -407,7 +407,7 @@ def list_assets():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/<int:asset_id>', methods=['GET'])
|
@assets_bp.route('/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset(asset_id: int):
|
def get_asset(asset_id: int):
|
||||||
"""
|
"""
|
||||||
Get a single asset with full details.
|
Get a single asset with full details.
|
||||||
@@ -551,7 +551,7 @@ def delete_asset(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/lookup/<assetnumber>', methods=['GET'])
|
@assets_bp.route('/lookup/<assetnumber>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def lookup_asset_by_number(assetnumber: str):
|
def lookup_asset_by_number(assetnumber: str):
|
||||||
"""
|
"""
|
||||||
Look up an asset by its asset number.
|
Look up an asset by its asset number.
|
||||||
@@ -672,7 +672,7 @@ def propagate_relationship(rel):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
|
@assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset_relationships(asset_id: int):
|
def get_asset_relationships(asset_id: int):
|
||||||
"""
|
"""
|
||||||
Get all relationships for an asset.
|
Get all relationships for an asset.
|
||||||
@@ -904,7 +904,7 @@ def delete_asset_relationship(rel_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@assets_bp.route('/map', methods=['GET'])
|
@assets_bp.route('/map', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_assets_map():
|
def get_assets_map():
|
||||||
"""
|
"""
|
||||||
Get all assets with map positions for unified floor map display.
|
Get all assets with map positions for unified floor map display.
|
||||||
@@ -1226,7 +1226,7 @@ def get_assets_map():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/<int:asset_id>/communications', methods=['GET'])
|
@assets_bp.route('/<int:asset_id>/communications', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset_communications(asset_id: int):
|
def get_asset_communications(asset_id: int):
|
||||||
"""Get all communications for an asset."""
|
"""Get all communications for an asset."""
|
||||||
from shopdb.core.models import Communication
|
from shopdb.core.models import Communication
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
businessunits_bp = Blueprint('businessunits', __name__)
|
businessunits_bp = Blueprint('businessunits', __name__)
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ def list_businessunits():
|
|||||||
|
|
||||||
|
|
||||||
@businessunits_bp.route('/<int:bu_id>', methods=['GET'])
|
@businessunits_bp.route('/<int:bu_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_businessunit(bu_id: int):
|
def get_businessunit(bu_id: int):
|
||||||
"""Get a single business unit."""
|
"""Get a single business unit."""
|
||||||
bu = db.session.get(BusinessUnit, bu_id)
|
bu = db.session.get(BusinessUnit, bu_id)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from shopdb.utils.responses import (
|
|||||||
ErrorCodes
|
ErrorCodes
|
||||||
)
|
)
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
customfields_bp = Blueprint('customfields', __name__)
|
customfields_bp = Blueprint('customfields', __name__)
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ def _normalize_options(raw):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@customfields_bp.route('', methods=['GET'])
|
@customfields_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_fields():
|
def list_fields():
|
||||||
"""List custom-field definitions. Filter by ?assettypeid=. ?active=false
|
"""List custom-field definitions. Filter by ?assettypeid=. ?active=false
|
||||||
includes inactive ones."""
|
includes inactive ones."""
|
||||||
@@ -171,7 +172,7 @@ def _fields_with_values(asset):
|
|||||||
|
|
||||||
|
|
||||||
@customfields_bp.route('/asset/<int:assetid>', methods=['GET'])
|
@customfields_bp.route('/asset/<int:assetid>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset_fields(assetid):
|
def get_asset_fields(assetid):
|
||||||
"""Active custom fields for an asset's type, merged with its values."""
|
"""Active custom fields for an asset's type, merged with its values."""
|
||||||
asset = db.session.get(Asset, assetid)
|
asset = db.session.get(Asset, assetid)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from flask_jwt_extended import jwt_required
|
|||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import Asset, AssetType, AssetStatus
|
from shopdb.core.models import Asset, AssetType, AssetStatus
|
||||||
from shopdb.utils.responses import success_response
|
from shopdb.utils.responses import success_response
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
dashboard_bp = Blueprint('dashboard', __name__)
|
dashboard_bp = Blueprint('dashboard', __name__)
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ def _countedassets():
|
|||||||
|
|
||||||
@dashboard_bp.route('/summary', methods=['GET'])
|
@dashboard_bp.route('/summary', methods=['GET'])
|
||||||
@dashboard_bp.route('', methods=['GET'])
|
@dashboard_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_dashboard():
|
def get_dashboard():
|
||||||
"""Get dashboard summary data (asset-based)."""
|
"""Get dashboard summary data (asset-based)."""
|
||||||
machine_count = _count_by_type('machine')
|
machine_count = _count_by_type('machine')
|
||||||
@@ -121,7 +122,7 @@ def get_dashboard():
|
|||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route('/stats', methods=['GET'])
|
@dashboard_bp.route('/stats', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_stats():
|
def get_stats():
|
||||||
"""Get detailed statistics by asset type."""
|
"""Get detailed statistics by asset type."""
|
||||||
type_counts = db.session.query(
|
type_counts = db.session.query(
|
||||||
@@ -211,7 +212,7 @@ def get_navigation():
|
|||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route('/widgets', methods=['GET'])
|
@dashboard_bp.route('/widgets', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_widgets():
|
def get_widgets():
|
||||||
"""Aggregate dashboard widget definitions from all enabled plugins.
|
"""Aggregate dashboard widget definitions from all enabled plugins.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from shopdb.core.models.dashboarddefault import (
|
|||||||
DISPLAY_ROLES, DISPLAY_ROLE_PATHS, normalize_display_role)
|
DISPLAY_ROLES, DISPLAY_ROLE_PATHS, normalize_display_role)
|
||||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
|
dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ def display_role():
|
|||||||
|
|
||||||
|
|
||||||
@dashboarddefaults_bp.route('', methods=['GET'])
|
@dashboarddefaults_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('settings.view')
|
||||||
def list_defaults():
|
def list_defaults():
|
||||||
"""List all visitor-IP -> business-unit mappings."""
|
"""List all visitor-IP -> business-unit mappings."""
|
||||||
defaults = DashboardDefault.query.filter_by(isactive=True).order_by(
|
defaults = DashboardDefault.query.filter_by(isactive=True).order_by(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
locations_bp = Blueprint('locations', __name__)
|
locations_bp = Blueprint('locations', __name__)
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ def _loc_type_dict(t):
|
|||||||
|
|
||||||
|
|
||||||
@locations_bp.route('/types', methods=['GET'])
|
@locations_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_location_types():
|
def list_location_types():
|
||||||
"""List location types. ?active=false includes inactive ones."""
|
"""List location types. ?active=false includes inactive ones."""
|
||||||
query = LocationType.query
|
query = LocationType.query
|
||||||
@@ -101,7 +102,7 @@ def delete_location_type(type_id):
|
|||||||
|
|
||||||
|
|
||||||
@locations_bp.route('', methods=['GET'])
|
@locations_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_locations():
|
def list_locations():
|
||||||
"""List all locations."""
|
"""List all locations."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -132,7 +133,7 @@ def list_locations():
|
|||||||
|
|
||||||
|
|
||||||
@locations_bp.route('/<int:location_id>', methods=['GET'])
|
@locations_bp.route('/<int:location_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_location(location_id: int):
|
def get_location(location_id: int):
|
||||||
"""Get a single location."""
|
"""Get a single location."""
|
||||||
loc = db.session.get(Location, location_id)
|
loc = db.session.get(Location, location_id)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
|||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.api import send_upload
|
from shopdb.api import send_upload
|
||||||
from shopdb.utils.imagesize import image_size
|
from shopdb.utils.imagesize import image_size
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
maplevels_bp = Blueprint('maplevels', __name__)
|
maplevels_bp = Blueprint('maplevels', __name__)
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ def _blueprint_dir():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@maplevels_bp.route('', methods=['GET'])
|
@maplevels_bp.route('', methods=['GET'])
|
||||||
|
@require_permission('assets.view')
|
||||||
def list_levels():
|
def list_levels():
|
||||||
"""Every active level, grouped by building, in display order.
|
"""Every active level, grouped by building, in display order.
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ def list_levels():
|
|||||||
|
|
||||||
|
|
||||||
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
||||||
|
@require_permission('assets.view')
|
||||||
def get_level(levelid):
|
def get_level(levelid):
|
||||||
level = db.session.get(MapLevel, levelid)
|
level = db.session.get(MapLevel, levelid)
|
||||||
if not level or not level.isactive:
|
if not level or not level.isactive:
|
||||||
@@ -81,6 +84,7 @@ def get_level(levelid):
|
|||||||
|
|
||||||
|
|
||||||
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
||||||
|
@require_permission('assets.view')
|
||||||
def serve_blueprint(filename, levelid=None):
|
def serve_blueprint(filename, levelid=None):
|
||||||
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
||||||
return send_upload(_blueprint_dir(), filename)
|
return send_upload(_blueprint_dir(), filename)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
from shopdb.utils.uploads import send_upload
|
from shopdb.utils.uploads import send_upload
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
models_bp = Blueprint('models', __name__)
|
models_bp = Blueprint('models', __name__)
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ def _modelimage_dir():
|
|||||||
|
|
||||||
|
|
||||||
@models_bp.route('', methods=['GET'])
|
@models_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_models():
|
def list_models():
|
||||||
"""List all vendor catalog models."""
|
"""List all vendor catalog models."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -78,7 +79,7 @@ def list_models():
|
|||||||
|
|
||||||
|
|
||||||
@models_bp.route('/<int:model_id>', methods=['GET'])
|
@models_bp.route('/<int:model_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_model(model_id: int):
|
def get_model(model_id: int):
|
||||||
"""Get a single model."""
|
"""Get a single model."""
|
||||||
m = db.session.get(Model, model_id)
|
m = db.session.get(Model, model_id)
|
||||||
@@ -227,6 +228,7 @@ def upload_model_image(model_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@models_bp.route('/image/<path:filename>', methods=['GET'])
|
@models_bp.route('/image/<path:filename>', methods=['GET'])
|
||||||
|
@require_permission('assets.view')
|
||||||
def serve_model_image(filename):
|
def serve_model_image(filename):
|
||||||
"""Serve an uploaded model image (public - asset detail pages read it)."""
|
"""Serve an uploaded model image (public - asset detail pages read it)."""
|
||||||
return send_upload(_modelimage_dir(), filename)
|
return send_upload(_modelimage_dir(), filename)
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
modeltypes_bp = Blueprint('modeltypes', __name__)
|
modeltypes_bp = Blueprint('modeltypes', __name__)
|
||||||
|
|
||||||
|
|
||||||
@modeltypes_bp.route('', methods=['GET'])
|
@modeltypes_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_modeltypes():
|
def list_modeltypes():
|
||||||
"""List all model types with optional filtering."""
|
"""List all model types with optional filtering."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -53,7 +54,7 @@ def list_modeltypes():
|
|||||||
|
|
||||||
|
|
||||||
@modeltypes_bp.route('/<int:type_id>', methods=['GET'])
|
@modeltypes_bp.route('/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_modeltype(type_id: int):
|
def get_modeltype(type_id: int):
|
||||||
"""Get a single model type."""
|
"""Get a single model type."""
|
||||||
mt = db.session.get(ModelType, type_id)
|
mt = db.session.get(ModelType, type_id)
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
operatingsystems_bp = Blueprint('operatingsystems', __name__)
|
operatingsystems_bp = Blueprint('operatingsystems', __name__)
|
||||||
|
|
||||||
|
|
||||||
@operatingsystems_bp.route('', methods=['GET'])
|
@operatingsystems_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_operatingsystems():
|
def list_operatingsystems():
|
||||||
"""List all operating systems."""
|
"""List all operating systems."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -49,7 +50,7 @@ def list_operatingsystems():
|
|||||||
|
|
||||||
|
|
||||||
@operatingsystems_bp.route('/<int:os_id>', methods=['GET'])
|
@operatingsystems_bp.route('/<int:os_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_operatingsystem(os_id: int):
|
def get_operatingsystem(os_id: int):
|
||||||
"""Get a single operating system."""
|
"""Get a single operating system."""
|
||||||
os = db.session.get(OperatingSystem, os_id)
|
os = db.session.get(OperatingSystem, os_id)
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ from flask_jwt_extended import jwt_required
|
|||||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
plugins_bp = Blueprint('plugins', __name__)
|
plugins_bp = Blueprint('plugins', __name__)
|
||||||
|
|
||||||
|
|
||||||
@plugins_bp.route('', methods=['GET'])
|
@plugins_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('settings.view')
|
||||||
def list_plugins():
|
def list_plugins():
|
||||||
"""List all discovered plugins (enabled or not) with their metadata and
|
"""List all discovered plugins (enabled or not) with their metadata and
|
||||||
the framework contract version."""
|
the framework contract version."""
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from flask_jwt_extended import jwt_required
|
|||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import Asset
|
from shopdb.core.models import Asset
|
||||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
pluginui_bp = Blueprint('pluginui', __name__)
|
pluginui_bp = Blueprint('pluginui', __name__)
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ def _collect(hookname):
|
|||||||
|
|
||||||
|
|
||||||
@pluginui_bp.route('/settings-cards', methods=['GET'])
|
@pluginui_bp.route('/settings-cards', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def settings_cards():
|
def settings_cards():
|
||||||
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
|
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ def settings_cards():
|
|||||||
|
|
||||||
|
|
||||||
@pluginui_bp.route('/asset-panels', methods=['GET'])
|
@pluginui_bp.route('/asset-panels', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def asset_panels():
|
def asset_panels():
|
||||||
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
|
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ def asset_panels():
|
|||||||
|
|
||||||
|
|
||||||
@pluginui_bp.route('/map-overlays', methods=['GET'])
|
@pluginui_bp.route('/map-overlays', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def map_overlays():
|
def map_overlays():
|
||||||
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
|
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ def map_overlays():
|
|||||||
|
|
||||||
|
|
||||||
@pluginui_bp.route('/asset-presentation', methods=['GET'])
|
@pluginui_bp.route('/asset-presentation', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def asset_presentation():
|
def asset_presentation():
|
||||||
"""Merge enabled plugins' asset-type presentation entries (hook).
|
"""Merge enabled plugins' asset-type presentation entries (hook).
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def generate_csv(data: list, columns: list) -> str:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/machines-by-type', methods=['GET'])
|
@reports_bp.route('/machines-by-type', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def machines_by_type():
|
def machines_by_type():
|
||||||
"""
|
"""
|
||||||
Report: Machine count grouped by machine type.
|
Report: Machine count grouped by machine type.
|
||||||
@@ -107,7 +107,7 @@ def machines_by_type():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/assets-by-status', methods=['GET'])
|
@reports_bp.route('/assets-by-status', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def assets_by_status():
|
def assets_by_status():
|
||||||
"""
|
"""
|
||||||
Report: Asset count grouped by status.
|
Report: Asset count grouped by status.
|
||||||
@@ -166,7 +166,7 @@ def assets_by_status():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/kb-popularity', methods=['GET'])
|
@reports_bp.route('/kb-popularity', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def kb_popularity():
|
def kb_popularity():
|
||||||
"""
|
"""
|
||||||
Report: Most clicked knowledge base articles.
|
Report: Most clicked knowledge base articles.
|
||||||
@@ -223,7 +223,7 @@ def kb_popularity():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/software-compliance', methods=['GET'])
|
@reports_bp.route('/software-compliance', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def software_compliance():
|
def software_compliance():
|
||||||
"""
|
"""
|
||||||
Report: Required applications vs installed (per PC).
|
Report: Required applications vs installed (per PC).
|
||||||
@@ -342,7 +342,7 @@ def software_compliance():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/asset-inventory', methods=['GET'])
|
@reports_bp.route('/asset-inventory', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def asset_inventory():
|
def asset_inventory():
|
||||||
"""
|
"""
|
||||||
Report: Complete asset inventory summary.
|
Report: Complete asset inventory summary.
|
||||||
@@ -456,7 +456,7 @@ def asset_inventory():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/pc-relationships', methods=['GET'])
|
@reports_bp.route('/pc-relationships', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def pc_relationships():
|
def pc_relationships():
|
||||||
"""
|
"""
|
||||||
Report: PCs with relationships to shop floor machines.
|
Report: PCs with relationships to shop floor machines.
|
||||||
@@ -573,7 +573,7 @@ def email_report():
|
|||||||
|
|
||||||
|
|
||||||
@reports_bp.route('', methods=['GET'])
|
@reports_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def list_reports():
|
def list_reports():
|
||||||
"""List all available reports."""
|
"""List all available reports."""
|
||||||
reports = [
|
reports = [
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from shopdb.core.models import (
|
|||||||
from shopdb.core.api.settings import get_cached_settings
|
from shopdb.core.api.settings import get_cached_settings
|
||||||
from shopdb.core.services.spellfix import Vocabulary
|
from shopdb.core.services.spellfix import Vocabulary
|
||||||
from shopdb.utils.responses import success_response
|
from shopdb.utils.responses import success_response
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1161,7 +1162,7 @@ def _suggestion_for(query):
|
|||||||
|
|
||||||
|
|
||||||
@search_bp.route('/suggest', methods=['GET'])
|
@search_bp.route('/suggest', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def search_suggest():
|
def search_suggest():
|
||||||
"""A better spelling for a query that found nothing, or null.
|
"""A better spelling for a query that found nothing, or null.
|
||||||
|
|
||||||
@@ -1185,7 +1186,7 @@ def search_suggest():
|
|||||||
|
|
||||||
|
|
||||||
@search_bp.route('', methods=['GET'])
|
@search_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def global_search():
|
def global_search():
|
||||||
"""
|
"""
|
||||||
Global search across multiple entity types.
|
Global search across multiple entity types.
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ from shopdb.utils.responses import (
|
|||||||
)
|
)
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
supportteams_bp = Blueprint('supportteams', __name__)
|
supportteams_bp = Blueprint('supportteams', __name__)
|
||||||
|
|
||||||
|
|
||||||
@supportteams_bp.route('', methods=['GET'])
|
@supportteams_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_support_teams():
|
def list_support_teams():
|
||||||
"""List support teams (with contacts). ?active and ?teamname filters."""
|
"""List support teams (with contacts). ?active and ?teamname filters."""
|
||||||
query = SupportTeam.query
|
query = SupportTeam.query
|
||||||
@@ -36,7 +37,7 @@ def list_support_teams():
|
|||||||
|
|
||||||
|
|
||||||
@supportteams_bp.route('/<int:team_id>', methods=['GET'])
|
@supportteams_bp.route('/<int:team_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_support_team(team_id: int):
|
def get_support_team(team_id: int):
|
||||||
"""Get a single support team with its contacts."""
|
"""Get a single support team with its contacts."""
|
||||||
team = db.session.get(SupportTeam, team_id)
|
team = db.session.get(SupportTeam, team_id)
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
vendors_bp = Blueprint('vendors', __name__)
|
vendors_bp = Blueprint('vendors', __name__)
|
||||||
|
|
||||||
|
|
||||||
@vendors_bp.route('', methods=['GET'])
|
@vendors_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_vendors():
|
def list_vendors():
|
||||||
"""List all vendors."""
|
"""List all vendors."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -46,7 +47,7 @@ def list_vendors():
|
|||||||
|
|
||||||
|
|
||||||
@vendors_bp.route('/<int:vendor_id>', methods=['GET'])
|
@vendors_bp.route('/<int:vendor_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_vendor(vendor_id: int):
|
def get_vendor(vendor_id: int):
|
||||||
"""Get a single vendor."""
|
"""Get a single vendor."""
|
||||||
v = db.session.get(Vendor, vendor_id)
|
v = db.session.get(Vendor, vendor_id)
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ def require_permission(permission_name: str):
|
|||||||
http_code=403
|
http_code=403
|
||||||
)
|
)
|
||||||
return view_func(*args, **kwargs)
|
return view_func(*args, **kwargs)
|
||||||
|
# Named so a test can sweep the URL map and prove every read is gated;
|
||||||
|
# a decorator is invisible from the outside otherwise.
|
||||||
|
wrapper._requires_permission = (permission_name,)
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
@@ -180,6 +183,7 @@ def require_any_permission(*permission_names):
|
|||||||
http_code=403
|
http_code=403
|
||||||
)
|
)
|
||||||
return view_func(*args, **kwargs)
|
return view_func(*args, **kwargs)
|
||||||
|
wrapper._requires_permission = tuple(permission_names)
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
@@ -215,5 +219,7 @@ def require_role(rolename: str):
|
|||||||
http_code=403
|
http_code=403
|
||||||
)
|
)
|
||||||
return view_func(*args, **kwargs)
|
return view_func(*args, **kwargs)
|
||||||
|
# A role gate is a gate too, as far as the read sweep is concerned.
|
||||||
|
wrapper._requires_permission = ('role:' + rolename,)
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def test_image_is_readable_without_a_token(client, db, auth_headers):
|
|||||||
data={'file': (_png(), 'logo.png')},
|
data={'file': (_png(), 'logo.png')},
|
||||||
content_type='multipart/form-data', headers=auth_headers)
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
anon = client.get(f'/api/applications/image/application-{app.appid}.png')
|
anon = client.get(f'/api/applications/image/application-{app.appid}.png', headers=auth_headers)
|
||||||
|
|
||||||
assert anon.status_code == 200
|
assert anon.status_code == 200
|
||||||
assert anon.mimetype == 'image/png'
|
assert anon.mimetype == 'image/png'
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ import pytest
|
|||||||
from shopdb.core.models import AssetType
|
from shopdb.core.models import AssetType
|
||||||
from plugins.computers.models import ComputerType
|
from plugins.computers.models import ComputerType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _signed_in(client, auth_headers):
|
||||||
|
"""Reading needs a permission now, and these tests are about what the
|
||||||
|
payload says rather than about who may see it. Signing the client in once
|
||||||
|
beats threading headers through every helper in the module."""
|
||||||
|
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||||
|
yield
|
||||||
|
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||||
|
|
||||||
|
|
||||||
KEY = 'testcollectorkey'
|
KEY = 'testcollectorkey'
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +31,7 @@ def collector_key(app):
|
|||||||
app.config['COLLECTOR_API_KEY'] = old
|
app.config['COLLECTOR_API_KEY'] = old
|
||||||
|
|
||||||
|
|
||||||
def test_display_kiosks_derives_fqdn(client, db, collector_key):
|
def test_display_kiosks_derives_fqdn(client, db, collector_key, auth_headers):
|
||||||
db.session.add(AssetType(assettype='computer', pluginname='computers',
|
db.session.add(AssetType(assettype='computer', pluginname='computers',
|
||||||
tablename='computers'))
|
tablename='computers'))
|
||||||
db.session.add(ComputerType(computertype='Kiosk'))
|
db.session.add(ComputerType(computertype='Kiosk'))
|
||||||
@@ -40,6 +51,6 @@ def test_display_kiosks_derives_fqdn(client, db, collector_key):
|
|||||||
assert match[0]['serialnumber'] == 'ABC1234'
|
assert match[0]['serialnumber'] == 'ABC1234'
|
||||||
|
|
||||||
|
|
||||||
def test_display_kiosks_empty_when_no_kiosk_type(client, db):
|
def test_display_kiosks_empty_when_no_kiosk_type(client, db, auth_headers):
|
||||||
# No 'Kiosk' ComputerType -> empty list, not an error.
|
# No 'Kiosk' ComputerType -> empty list, not an error.
|
||||||
assert client.get('/api/computers/display-kiosks').get_json()['data'] == []
|
assert client.get('/api/computers/display-kiosks').get_json()['data'] == []
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ directory is unreachable. Behavior must be identical as a plugin blueprint.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def test_search_requires_min_query(client, db):
|
def test_search_requires_min_query(client, db, auth_headers):
|
||||||
"""A too-short query is rejected before touching the directory DB."""
|
"""A too-short query is rejected before touching the directory DB."""
|
||||||
resp = client.get('/api/employees/search?q=a')
|
resp = client.get('/api/employees/search?q=a', headers=auth_headers)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_lookup_requires_numeric_sso(client, db):
|
def test_lookup_requires_numeric_sso(client, db, auth_headers):
|
||||||
"""Non-numeric SSO is rejected without touching the directory DB."""
|
"""Non-numeric SSO is rejected without touching the directory DB."""
|
||||||
resp = client.get('/api/employees/lookup/abc')
|
resp = client.get('/api/employees/lookup/abc', headers=auth_headers)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_search_degrades_when_directory_unreachable(client, db):
|
def test_search_degrades_when_directory_unreachable(client, db, auth_headers):
|
||||||
"""With the directory DB unavailable, search returns a clean 500 envelope."""
|
"""With the directory DB unavailable, search returns a clean 500 envelope."""
|
||||||
resp = client.get('/api/employees/search?q=smith')
|
resp = client.get('/api/employees/search?q=smith', headers=auth_headers)
|
||||||
assert resp.status_code == 500
|
assert resp.status_code == 500
|
||||||
assert resp.get_json()['status'] == 'error'
|
assert resp.get_json()['status'] == 'error'
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ def test_serve_returns_bytes(client, db, auth_headers):
|
|||||||
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||||
content_type='multipart/form-data', headers=auth_headers)
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png')
|
served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png', headers=auth_headers)
|
||||||
assert served.status_code == 200
|
assert served.status_code == 200
|
||||||
assert served.get_data() == payload
|
assert served.get_data() == payload
|
||||||
|
|
||||||
|
|||||||
153
tests/test_core/test_public_read_surface.py
Normal file
153
tests/test_core/test_public_read_surface.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""What a caller with no account can read, and nothing more.
|
||||||
|
|
||||||
|
The companion to the mutating-route sweep in test_authz.py. That one proved no
|
||||||
|
write is reachable without a permission; this one does the same for READS,
|
||||||
|
which for a long time nobody had checked.
|
||||||
|
|
||||||
|
The finding that produced it: 165 GET routes answered anybody. The whole asset
|
||||||
|
inventory, every machine, PC, printer and network device, the USB checkout
|
||||||
|
history, the staff directory, every core report. Guest access had been argued
|
||||||
|
about one feature-flag at a time while the front door stood open.
|
||||||
|
|
||||||
|
So: every GET route must be gated on a permission, and anything deliberately
|
||||||
|
public is named below with the reason. Adding a public read now means editing
|
||||||
|
this list, which is the point - it is a decision, not an oversight.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Deliberately readable with no account. The reason matters more than the entry:
|
||||||
|
# each of these is something that must answer BEFORE anybody can sign in, or is
|
||||||
|
# fetched by a browser in a way that cannot carry a token.
|
||||||
|
PUBLIC_ENDPOINTS = {
|
||||||
|
# The app shell and its bootstrap.
|
||||||
|
'serve_frontend',
|
||||||
|
'setup.needs_admin',
|
||||||
|
'auth.get_current_user',
|
||||||
|
'plugins.list_enabled_plugins',
|
||||||
|
'dashboard.get_navigation', # filtered per caller; a guest sees only
|
||||||
|
# what the guest role can reach
|
||||||
|
'dashboard.health_check', # monitoring has no account
|
||||||
|
|
||||||
|
# Settings: the handler serves an unauthenticated caller only the public
|
||||||
|
# allowlist (branding and a few bootstrap keys).
|
||||||
|
'settings.list_settings',
|
||||||
|
'settings.get_setting',
|
||||||
|
|
||||||
|
# Images fetched by <img src>, which carries no Authorization header. The
|
||||||
|
# branding logo renders on the LOGIN page.
|
||||||
|
'settings.serve_branding_logo',
|
||||||
|
'settings.serve_map_blueprint',
|
||||||
|
'employees.serve_employee_photo',
|
||||||
|
|
||||||
|
# API documentation: shapes, not data. The docs page loads its own script.
|
||||||
|
'docs.docs_index', 'docs.openapi_spec', 'docs.redoc_js', 'docs.llms_txt',
|
||||||
|
|
||||||
|
# Collector ingest, authenticated by the per-plugin collector key.
|
||||||
|
'collector.collector_schemas', 'collector.collector_status',
|
||||||
|
|
||||||
|
# A display kiosk renders before anybody signs in.
|
||||||
|
'dashboarddefaults.visitor_location',
|
||||||
|
'dashboarddefaults.display_role',
|
||||||
|
'businessunits.list_businessunits',
|
||||||
|
'notifications.get_shopfloor_notifications',
|
||||||
|
'notifications.get_employee_recognitions',
|
||||||
|
'slides.feed', 'slides.serve_image',
|
||||||
|
|
||||||
|
# Parts kiosk: one item by its code, and that item's picture.
|
||||||
|
'printedparts.kiosk_item', 'printedparts.serve_item_image',
|
||||||
|
|
||||||
|
# USB kiosk.
|
||||||
|
'usb.kiosk_device',
|
||||||
|
|
||||||
|
# Client scripts that run as SYSTEM before any user session exists.
|
||||||
|
'printers_asset.printers_for_host',
|
||||||
|
'printers_asset.printer_install_list',
|
||||||
|
'printers_asset.printer_install_batch',
|
||||||
|
'printers_asset.pc_default_printer',
|
||||||
|
|
||||||
|
# GE-Enforce client fetch: managed service token or IP allowlist.
|
||||||
|
'geenforce.get_manifest', 'geenforce.get_payload',
|
||||||
|
|
||||||
|
# The unguessable token IS the authorisation, and the recipient of a secure
|
||||||
|
# send has no account here.
|
||||||
|
'tools.peek_secret',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reads that need a LOGIN but no named permission, because the handler decides
|
||||||
|
# from who you are rather than what you hold: your own user record, the role and
|
||||||
|
# permission catalogues the role grid reads, and your own API tokens. Not
|
||||||
|
# public - the test below proves each one refuses an anonymous caller.
|
||||||
|
LOGIN_ONLY_ENDPOINTS = {
|
||||||
|
'users.get_user', # admin-or-self, decided inside
|
||||||
|
'users.list_permissions', # the catalogue the role grid renders
|
||||||
|
'users.list_roles',
|
||||||
|
'apitokens.list_apitokens', # scoped to the caller's own tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
# Blueprints whose whole surface is the auth handshake or a static file.
|
||||||
|
EXEMPT_BLUEPRINTS = {'static'}
|
||||||
|
|
||||||
|
|
||||||
|
def _gets(app):
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
if 'GET' not in (rule.methods or set()):
|
||||||
|
continue
|
||||||
|
blueprint = rule.endpoint.split('.')[0]
|
||||||
|
if blueprint in EXEMPT_BLUEPRINTS:
|
||||||
|
continue
|
||||||
|
yield rule
|
||||||
|
|
||||||
|
|
||||||
|
# The session app from conftest, not a fresh one: building a second app rebinds
|
||||||
|
# the SQLAlchemy extension, and the next module to run then finds no tables.
|
||||||
|
@pytest.fixture
|
||||||
|
def gated_app(app):
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_get_route_is_gated_or_named_public(gated_app):
|
||||||
|
"""A new public read has to be added to PUBLIC_ENDPOINTS above, in front of
|
||||||
|
somebody reviewing the diff."""
|
||||||
|
ungated = []
|
||||||
|
for rule in _gets(gated_app):
|
||||||
|
view = gated_app.view_functions[rule.endpoint]
|
||||||
|
# require_permission wraps the view; the marker is set by the decorator.
|
||||||
|
if getattr(view, '_requires_permission', None):
|
||||||
|
continue
|
||||||
|
if rule.endpoint in PUBLIC_ENDPOINTS or rule.endpoint in LOGIN_ONLY_ENDPOINTS:
|
||||||
|
continue
|
||||||
|
if rule.endpoint.split('.', 1)[-1] in PUBLIC_ENDPOINTS:
|
||||||
|
continue
|
||||||
|
ungated.append(rule.endpoint)
|
||||||
|
|
||||||
|
assert not ungated, (
|
||||||
|
'These GET routes answer anybody. Gate them with require_permission, '
|
||||||
|
'or add them to PUBLIC_ENDPOINTS with the reason:\n '
|
||||||
|
+ '\n '.join(sorted(set(ungated))))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_public_list_has_no_stale_entries(gated_app):
|
||||||
|
"""An endpoint that no longer exists, or that has since been gated, should
|
||||||
|
not keep a licence to be public."""
|
||||||
|
endpoints = {rule.endpoint for rule in _gets(gated_app)}
|
||||||
|
shortnames = {e.split('.', 1)[-1] for e in endpoints}
|
||||||
|
stale = [name for name in PUBLIC_ENDPOINTS | LOGIN_ONLY_ENDPOINTS
|
||||||
|
if name not in endpoints and name not in shortnames]
|
||||||
|
assert not stale, 'PUBLIC_ENDPOINTS names routes that do not exist: {}'.format(stale)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('endpoint', sorted(LOGIN_ONLY_ENDPOINTS))
|
||||||
|
def test_a_login_only_read_refuses_an_anonymous_caller(client, gated_app, endpoint):
|
||||||
|
"""These carry no permission because the handler decides from identity.
|
||||||
|
That is only sound while they still demand an identity."""
|
||||||
|
rule = next(r for r in _gets(gated_app) if r.endpoint == endpoint)
|
||||||
|
path = rule.rule
|
||||||
|
for argument in (rule.arguments or ()):
|
||||||
|
path = path.replace('<int:{}>'.format(argument), '1')
|
||||||
|
path = path.replace('<{}>'.format(argument), '1')
|
||||||
|
response = client.get(path)
|
||||||
|
assert response.status_code in (401, 422), (
|
||||||
|
'{} answered an anonymous caller with {}'.format(endpoint,
|
||||||
|
response.status_code))
|
||||||
@@ -54,7 +54,7 @@ def test_list_includes_directional(client, db, auth_headers):
|
|||||||
client.post('/api/assets/relationshiptypes',
|
client.post('/api/assets/relationshiptypes',
|
||||||
json={'relationshiptype': 'WiFi', 'isdirectional': False},
|
json={'relationshiptype': 'WiFi', 'isdirectional': False},
|
||||||
headers=auth_headers)
|
headers=auth_headers)
|
||||||
resp = client.get('/api/assets/relationshiptypes')
|
resp = client.get('/api/assets/relationshiptypes', headers=auth_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
row = next(t for t in resp.get_json()['data']
|
row = next(t for t in resp.get_json()['data']
|
||||||
if t['relationshiptype'] == 'WiFi')
|
if t['relationshiptype'] == 'WiFi')
|
||||||
@@ -88,7 +88,7 @@ def test_relationships_row_carries_isdirectional(client, db, auth_headers):
|
|||||||
relationshiptypeid=directional.relationshiptypeid))
|
relationshiptypeid=directional.relationshiptypeid))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
resp = client.get(f'/api/assets/{a.assetid}/relationships')
|
resp = client.get(f'/api/assets/{a.assetid}/relationships', headers=auth_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()['data']
|
data = resp.get_json()['data']
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ import pytest
|
|||||||
from shopdb.core.models import Application
|
from shopdb.core.models import Application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _signed_in(client, auth_headers):
|
||||||
|
"""Reading needs a permission now, and these tests are about what the
|
||||||
|
payload says rather than about who may see it. Signing the client in once
|
||||||
|
beats threading headers through every helper in the module."""
|
||||||
|
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||||
|
yield
|
||||||
|
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def search(client, query):
|
def search(client, query):
|
||||||
"""Global search rows for a query."""
|
"""Global search rows for a query."""
|
||||||
resp = client.get(f'/api/search?q={query}')
|
resp = client.get(f'/api/search?q={query}')
|
||||||
|
|||||||
@@ -14,22 +14,22 @@ import pytest
|
|||||||
from shopdb.utils.uploads import UPLOAD_CSP
|
from shopdb.utils.uploads import UPLOAD_CSP
|
||||||
|
|
||||||
|
|
||||||
def test_every_response_carries_the_baseline_headers(client):
|
def test_every_response_carries_the_baseline_headers(client, auth_headers):
|
||||||
resp = client.get('/api/health')
|
resp = client.get('/api/health', headers=auth_headers)
|
||||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||||
assert resp.headers.get('X-Frame-Options') == 'SAMEORIGIN'
|
assert resp.headers.get('X-Frame-Options') == 'SAMEORIGIN'
|
||||||
assert resp.headers.get('Referrer-Policy') == 'strict-origin-when-cross-origin'
|
assert resp.headers.get('Referrer-Policy') == 'strict-origin-when-cross-origin'
|
||||||
|
|
||||||
|
|
||||||
def test_an_error_response_carries_them_too(client):
|
def test_an_error_response_carries_them_too(client, auth_headers):
|
||||||
"""after_request runs for error handlers as well, which is where a response
|
"""after_request runs for error handlers as well, which is where a response
|
||||||
built by a different code path would otherwise slip through."""
|
built by a different code path would otherwise slip through."""
|
||||||
resp = client.get('/api/no-such-route')
|
resp = client.get('/api/no-such-route', headers=auth_headers)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||||
|
|
||||||
|
|
||||||
def test_a_served_upload_is_sandboxed(client, app, tmp_path):
|
def test_a_served_upload_is_sandboxed(client, app, tmp_path, auth_headers):
|
||||||
"""The uploaded file itself gets a policy that blocks script execution."""
|
"""The uploaded file itself gets a policy that blocks script execution."""
|
||||||
from shopdb.core.api.applications import _appimage_dir
|
from shopdb.core.api.applications import _appimage_dir
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ def test_a_served_upload_is_sandboxed(client, app, tmp_path):
|
|||||||
path.write_text(svg)
|
path.write_text(svg)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = client.get('/api/applications/image/probe-headers.svg')
|
resp = client.get('/api/applications/image/probe-headers.svg', headers=auth_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.headers.get('Content-Security-Policy') == UPLOAD_CSP
|
assert resp.headers.get('Content-Security-Policy') == UPLOAD_CSP
|
||||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ def _set_toggle(value):
|
|||||||
# Setting ON (default): pair collapses to the primary bay
|
# Setting ON (default): pair collapses to the primary bay
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_list_collapses_secondary_and_annotates_primary(client, db):
|
def test_list_collapses_secondary_and_annotates_primary(client, db, auth_headers):
|
||||||
invalidate_settings_cache() # default on (no row)
|
invalidate_settings_cache() # default on (no row)
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
|
|
||||||
resp = client.get('/api/machines')
|
resp = client.get('/api/machines', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
payload = resp.get_json()
|
payload = resp.get_json()
|
||||||
items = payload['data']
|
items = payload['data']
|
||||||
@@ -82,11 +82,11 @@ def test_list_collapses_secondary_and_annotates_primary(client, db):
|
|||||||
assert partner['machineid'] is not None
|
assert partner['machineid'] is not None
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_machine_count_collapses(client, db):
|
def test_dashboard_machine_count_collapses(client, db, auth_headers):
|
||||||
invalidate_settings_cache()
|
invalidate_settings_cache()
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
|
|
||||||
resp = client.get('/api/dashboard')
|
resp = client.get('/api/dashboard', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
counts = resp.get_json()['data']
|
counts = resp.get_json()['data']
|
||||||
# two machine rows, one physical machine
|
# two machine rows, one physical machine
|
||||||
@@ -94,11 +94,11 @@ def test_dashboard_machine_count_collapses(client, db):
|
|||||||
assert counts['counts']['machines'] == 1
|
assert counts['counts']['machines'] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_map_excludes_secondary_marker(client, db):
|
def test_map_excludes_secondary_marker(client, db, auth_headers):
|
||||||
invalidate_settings_cache()
|
invalidate_settings_cache()
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
|
|
||||||
resp = client.get('/api/assets/map')
|
resp = client.get('/api/assets/map', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assets = resp.get_json()['data']['assets']
|
assets = resp.get_json()['data']['assets']
|
||||||
numbers = [a['assetnumber'] for a in assets]
|
numbers = [a['assetnumber'] for a in assets]
|
||||||
@@ -109,7 +109,7 @@ def test_map_excludes_secondary_marker(client, db):
|
|||||||
assert primary['dualpathpartner'] == '2008'
|
assert primary['dualpathpartner'] == '2008'
|
||||||
|
|
||||||
|
|
||||||
def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
def test_detail_banner_partner_shown_regardless_of_toggle(client, db, auth_headers):
|
||||||
invalidate_settings_cache()
|
invalidate_settings_cache()
|
||||||
primary_machine, secondary_machine = _seed_dualpath_pair()
|
primary_machine, secondary_machine = _seed_dualpath_pair()
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
|||||||
(primary_machine.machineid, '2008'),
|
(primary_machine.machineid, '2008'),
|
||||||
(secondary_machine.machineid, '2007'),
|
(secondary_machine.machineid, '2007'),
|
||||||
):
|
):
|
||||||
resp = client.get(f'/api/machines/{machine_id}')
|
resp = client.get(f'/api/machines/{machine_id}', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
partner = resp.get_json()['data']['dualpathpartner']
|
partner = resp.get_json()['data']['dualpathpartner']
|
||||||
assert partner is not None
|
assert partner is not None
|
||||||
@@ -130,11 +130,11 @@ def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
|||||||
# Setting OFF: both bays list and count separately
|
# Setting OFF: both bays list and count separately
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_list_shows_both_bays_when_disabled(client, db):
|
def test_list_shows_both_bays_when_disabled(client, db, auth_headers):
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
_set_toggle(False)
|
_set_toggle(False)
|
||||||
|
|
||||||
resp = client.get('/api/machines')
|
resp = client.get('/api/machines', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
payload = resp.get_json()
|
payload = resp.get_json()
|
||||||
numbers = sorted(i['assetnumber'] for i in payload['data'])
|
numbers = sorted(i['assetnumber'] for i in payload['data'])
|
||||||
@@ -143,20 +143,20 @@ def test_list_shows_both_bays_when_disabled(client, db):
|
|||||||
assert payload['meta']['pagination']['total'] == 2
|
assert payload['meta']['pagination']['total'] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_counts_both_bays_when_disabled(client, db):
|
def test_dashboard_counts_both_bays_when_disabled(client, db, auth_headers):
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
_set_toggle(False)
|
_set_toggle(False)
|
||||||
|
|
||||||
resp = client.get('/api/dashboard')
|
resp = client.get('/api/dashboard', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert resp.get_json()['data']['totalmachines'] == 2
|
assert resp.get_json()['data']['totalmachines'] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_map_shows_both_bays_when_disabled(client, db):
|
def test_map_shows_both_bays_when_disabled(client, db, auth_headers):
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
_set_toggle(False)
|
_set_toggle(False)
|
||||||
|
|
||||||
resp = client.get('/api/assets/map')
|
resp = client.get('/api/assets/map', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
|
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
|
||||||
assert '2007' in numbers
|
assert '2007' in numbers
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ def test_serve_returns_bytes(client, db, auth_headers):
|
|||||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||||
content_type='multipart/form-data', headers=auth_headers)
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png')
|
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png', headers=auth_headers)
|
||||||
assert served.status_code == 200
|
assert served.status_code == 200
|
||||||
assert served.get_data() == payload
|
assert served.get_data() == payload
|
||||||
|
|
||||||
@@ -207,6 +207,6 @@ def test_lookup_includes_photourl(client, db, auth_headers):
|
|||||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||||
content_type='multipart/form-data', headers=auth_headers)
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
resp = client.get(f'/api/employees/lookup/{emp.sso}')
|
resp = client.get(f'/api/employees/lookup/{emp.sso}', headers=auth_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ from shopdb.core.models import Application
|
|||||||
from plugins.knowledgebase.models import KnowledgeBase
|
from plugins.knowledgebase.models import KnowledgeBase
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _signed_in(client, auth_headers):
|
||||||
|
"""Reading needs a permission now, and these tests are about what the
|
||||||
|
payload says rather than about who may see it. Signing the client in once
|
||||||
|
beats threading headers through every helper in the module."""
|
||||||
|
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||||
|
yield
|
||||||
|
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def library(db):
|
def library(db):
|
||||||
"""Articles whose identifying words are deliberately spread across fields."""
|
"""Articles whose identifying words are deliberately spread across fields."""
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ from shopdb.core.models import Application
|
|||||||
from plugins.knowledgebase.models import KnowledgeBase
|
from plugins.knowledgebase.models import KnowledgeBase
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _signed_in(client, auth_headers):
|
||||||
|
"""Reading needs a permission now, and these tests are about what the
|
||||||
|
payload says rather than about who may see it. Signing the client in once
|
||||||
|
beats threading headers through every helper in the module."""
|
||||||
|
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||||
|
yield
|
||||||
|
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def library(db):
|
def library(db):
|
||||||
"""One application of each interesting shape, with an article each."""
|
"""One application of each interesting shape, with an article each."""
|
||||||
@@ -84,7 +95,7 @@ def test_searching_a_title_does_not_resurrect_a_retired_article(client, library)
|
|||||||
assert 'Live runbook' in found
|
assert 'Live runbook' in found
|
||||||
|
|
||||||
|
|
||||||
def test_the_counts_agree_with_the_list(client, library):
|
def test_the_counts_agree_with_the_list(client, library, auth_headers):
|
||||||
"""A total that includes articles nobody can see is a total nobody can
|
"""A total that includes articles nobody can see is a total nobody can
|
||||||
reconcile against the page."""
|
reconcile against the page."""
|
||||||
visible = titles(client)
|
visible = titles(client)
|
||||||
@@ -97,10 +108,11 @@ def test_the_counts_agree_with_the_list(client, library):
|
|||||||
assert clicks == 6
|
assert clicks == 6
|
||||||
|
|
||||||
|
|
||||||
def test_the_topic_list_offers_every_active_application(client, library):
|
def test_the_topic_list_offers_every_active_application(client, library, auth_headers):
|
||||||
"""What the article form's topic dropdown fetches. Installable or not,
|
"""What the article form's topic dropdown fetches. Installable or not,
|
||||||
hidden or not - only retired is excluded."""
|
hidden or not - only retired is excluded."""
|
||||||
offered = sorted(row['appname'] for row in client.get(
|
response = client.get('/api/applications?perpage=100&showhidden=true',
|
||||||
'/api/applications?perpage=100&showhidden=true').get_json()['data'])
|
headers=auth_headers)
|
||||||
|
offered = sorted(row['appname'] for row in response.get_json()['data'])
|
||||||
assert offered == ['Hidden App', 'Live App', 'Manual App']
|
assert offered == ['Hidden App', 'Live App', 'Manual App']
|
||||||
assert 'Retired App' not in offered
|
assert 'Retired App' not in offered
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def _numbers(resp):
|
|||||||
return sorted(item['assetnumber'] for item in resp.get_json()['data'])
|
return sorted(item['assetnumber'] for item in resp.get_json()['data'])
|
||||||
|
|
||||||
|
|
||||||
def test_pcs_search_matches_computer_type(client, db):
|
def test_pcs_search_matches_computer_type(client, db, auth_headers):
|
||||||
assettype = _assettype('computer')
|
assettype = _assettype('computer')
|
||||||
standard = ComputerType(computertype='Standard')
|
standard = ComputerType(computertype='Standard')
|
||||||
_db.session.add(standard)
|
_db.session.add(standard)
|
||||||
@@ -45,16 +45,16 @@ def test_pcs_search_matches_computer_type(client, db):
|
|||||||
])
|
])
|
||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
resp = client.get('/api/computers?search=Standard')
|
resp = client.get('/api/computers?search=Standard', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == ['PC-1']
|
assert _numbers(resp) == ['PC-1']
|
||||||
|
|
||||||
# the typeless PC is still findable on its own fields
|
# the typeless PC is still findable on its own fields
|
||||||
resp = client.get('/api/computers?search=bench')
|
resp = client.get('/api/computers?search=bench', headers=auth_headers)
|
||||||
assert _numbers(resp) == ['PC-2']
|
assert _numbers(resp) == ['PC-2']
|
||||||
|
|
||||||
|
|
||||||
def test_printers_search_matches_type_and_model(client, db):
|
def test_printers_search_matches_type_and_model(client, db, auth_headers):
|
||||||
assettype = _assettype('printer')
|
assettype = _assettype('printer')
|
||||||
thermal = PrinterType(printertype='Thermal')
|
thermal = PrinterType(printertype='Thermal')
|
||||||
model = Model(modelnumber='ZT411')
|
model = Model(modelnumber='ZT411')
|
||||||
@@ -74,15 +74,15 @@ def test_printers_search_matches_type_and_model(client, db):
|
|||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
for term in ['Thermal', 'ZT411']:
|
for term in ['Thermal', 'ZT411']:
|
||||||
resp = client.get(f'/api/printers?search={term}')
|
resp = client.get(f'/api/printers?search={term}', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == ['PR-1'], term
|
assert _numbers(resp) == ['PR-1'], term
|
||||||
|
|
||||||
resp = client.get('/api/printers?search=plotter')
|
resp = client.get('/api/printers?search=plotter', headers=auth_headers)
|
||||||
assert _numbers(resp) == ['PR-2']
|
assert _numbers(resp) == ['PR-2']
|
||||||
|
|
||||||
|
|
||||||
def test_network_search_matches_type_and_vendor(client, db):
|
def test_network_search_matches_type_and_vendor(client, db, auth_headers):
|
||||||
assettype = _assettype('network_device')
|
assettype = _assettype('network_device')
|
||||||
switchtype = NetworkDeviceType(networkdevicetype='Switch')
|
switchtype = NetworkDeviceType(networkdevicetype='Switch')
|
||||||
vendor = Vendor(vendor='Cisco')
|
vendor = Vendor(vendor='Cisco')
|
||||||
@@ -103,15 +103,15 @@ def test_network_search_matches_type_and_vendor(client, db):
|
|||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
for term in ['Switch', 'Cisco']:
|
for term in ['Switch', 'Cisco']:
|
||||||
resp = client.get(f'/api/network?search={term}')
|
resp = client.get(f'/api/network?search={term}', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == ['NET-1'], term
|
assert _numbers(resp) == ['NET-1'], term
|
||||||
|
|
||||||
resp = client.get('/api/network?search=hub')
|
resp = client.get('/api/network?search=hub', headers=auth_headers)
|
||||||
assert _numbers(resp) == ['NET-2']
|
assert _numbers(resp) == ['NET-2']
|
||||||
|
|
||||||
|
|
||||||
def test_measuringtools_search_matches_type(client, db):
|
def test_measuringtools_search_matches_type(client, db, auth_headers):
|
||||||
assettype = _assettype('measuring_tool')
|
assettype = _assettype('measuring_tool')
|
||||||
caliper = MeasuringToolType(name='Caliper')
|
caliper = MeasuringToolType(name='Caliper')
|
||||||
_db.session.add(caliper)
|
_db.session.add(caliper)
|
||||||
@@ -129,15 +129,15 @@ def test_measuringtools_search_matches_type(client, db):
|
|||||||
])
|
])
|
||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
resp = client.get('/api/measuringtools?search=Caliper')
|
resp = client.get('/api/measuringtools?search=Caliper', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == ['MT-1']
|
assert _numbers(resp) == ['MT-1']
|
||||||
|
|
||||||
resp = client.get('/api/measuringtools?search=height')
|
resp = client.get('/api/measuringtools?search=height', headers=auth_headers)
|
||||||
assert _numbers(resp) == ['MT-2']
|
assert _numbers(resp) == ['MT-2']
|
||||||
|
|
||||||
|
|
||||||
def test_core_assets_search_matches_asset_type(client, db):
|
def test_core_assets_search_matches_asset_type(client, db, auth_headers):
|
||||||
"""The unified asset list shows a Type column too."""
|
"""The unified asset list shows a Type column too."""
|
||||||
machine_type = _assettype('machine')
|
machine_type = _assettype('machine')
|
||||||
printer_type = _assettype('printer')
|
printer_type = _assettype('printer')
|
||||||
@@ -147,12 +147,12 @@ def test_core_assets_search_matches_asset_type(client, db):
|
|||||||
])
|
])
|
||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
resp = client.get('/api/assets?search=machine')
|
resp = client.get('/api/assets?search=machine', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == ['A-1']
|
assert _numbers(resp) == ['A-1']
|
||||||
|
|
||||||
|
|
||||||
def test_core_assets_search_type_filter_still_works(client, db):
|
def test_core_assets_search_type_filter_still_works(client, db, auth_headers):
|
||||||
"""The type-name filter joins AssetType; search uses EXISTS, so both apply."""
|
"""The type-name filter joins AssetType; search uses EXISTS, so both apply."""
|
||||||
machine_type = _assettype('machine')
|
machine_type = _assettype('machine')
|
||||||
_db.session.add_all([
|
_db.session.add_all([
|
||||||
@@ -161,6 +161,6 @@ def test_core_assets_search_type_filter_still_works(client, db):
|
|||||||
])
|
])
|
||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
resp = client.get('/api/assets?type=machine&search=mill')
|
resp = client.get('/api/assets?type=machine&search=mill', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == ['A-1']
|
assert _numbers(resp) == ['A-1']
|
||||||
|
|||||||
@@ -47,49 +47,49 @@ def _numbers(resp):
|
|||||||
return sorted(item['assetnumber'] for item in resp.get_json()['data'])
|
return sorted(item['assetnumber'] for item in resp.get_json()['data'])
|
||||||
|
|
||||||
|
|
||||||
def test_search_matches_machine_type_name(client, db):
|
def test_search_matches_machine_type_name(client, db, auth_headers):
|
||||||
_seed_machines()
|
_seed_machines()
|
||||||
|
|
||||||
resp = client.get('/api/machines?search=Part Washer')
|
resp = client.get('/api/machines?search=Part Washer', headers=auth_headers)
|
||||||
|
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == ['0410']
|
assert _numbers(resp) == ['0410']
|
||||||
|
|
||||||
|
|
||||||
def test_search_matches_vendor_name(client, db):
|
def test_search_matches_vendor_name(client, db, auth_headers):
|
||||||
_seed_machines()
|
_seed_machines()
|
||||||
|
|
||||||
resp = client.get('/api/machines?search=Acme')
|
resp = client.get('/api/machines?search=Acme', headers=auth_headers)
|
||||||
|
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == ['0410']
|
assert _numbers(resp) == ['0410']
|
||||||
|
|
||||||
|
|
||||||
def test_search_still_matches_asset_fields(client, db):
|
def test_search_still_matches_asset_fields(client, db, auth_headers):
|
||||||
_seed_machines()
|
_seed_machines()
|
||||||
|
|
||||||
for term, expected in [('0999', ['0999']),
|
for term, expected in [('0999', ['0999']),
|
||||||
('Old lathe', ['0999']),
|
('Old lathe', ['0999']),
|
||||||
('SN-WASH', ['0410'])]:
|
('SN-WASH', ['0410'])]:
|
||||||
resp = client.get(f'/api/machines?search={term}')
|
resp = client.get(f'/api/machines?search={term}', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == expected, term
|
assert _numbers(resp) == expected, term
|
||||||
|
|
||||||
|
|
||||||
def test_search_does_not_drop_machines_without_type_or_vendor(client, db):
|
def test_search_does_not_drop_machines_without_type_or_vendor(client, db, auth_headers):
|
||||||
"""The outer joins must not turn into inner joins."""
|
"""The outer joins must not turn into inner joins."""
|
||||||
_seed_machines()
|
_seed_machines()
|
||||||
|
|
||||||
resp = client.get('/api/machines?search=lathe')
|
resp = client.get('/api/machines?search=lathe', headers=auth_headers)
|
||||||
|
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == ['0999']
|
assert _numbers(resp) == ['0999']
|
||||||
|
|
||||||
|
|
||||||
def test_search_miss_returns_empty(client, db):
|
def test_search_miss_returns_empty(client, db, auth_headers):
|
||||||
_seed_machines()
|
_seed_machines()
|
||||||
|
|
||||||
resp = client.get('/api/machines?search=zzzznotathing')
|
resp = client.get('/api/machines?search=zzzznotathing', headers=auth_headers)
|
||||||
|
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert _numbers(resp) == []
|
assert _numbers(resp) == []
|
||||||
|
|||||||
@@ -20,9 +20,20 @@ from shopdb import create_app
|
|||||||
from shopdb.extensions import db as _db
|
from shopdb.extensions import db as _db
|
||||||
from shopdb.plugins import plugin_manager
|
from shopdb.plugins import plugin_manager
|
||||||
from plugins.measuringtools.models import (
|
from plugins.measuringtools.models import (
|
||||||
|
|
||||||
derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS)
|
derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _signed_in(client, auth_headers):
|
||||||
|
"""Reading needs a permission now, and these tests are about what the
|
||||||
|
payload says rather than about who may see it. Signing the client in once
|
||||||
|
beats threading headers through every helper in the module."""
|
||||||
|
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||||
|
yield
|
||||||
|
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Pure unit tests: derived status (no app needed)
|
# Pure unit tests: derived status (no app needed)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -48,7 +59,7 @@ def test_derive_status_duesoon_on_boundary():
|
|||||||
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon'
|
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon'
|
||||||
|
|
||||||
|
|
||||||
def test_derive_status_duesoon_today():
|
def test_derive_status_duesoon_today(auth_headers):
|
||||||
"""Due today is not yet overdue - it falls in the due-soon window."""
|
"""Due today is not yet overdue - it falls in the due-soon window."""
|
||||||
assert derive_status(date.today()) == 'duesoon'
|
assert derive_status(date.today()) == 'duesoon'
|
||||||
|
|
||||||
@@ -120,14 +131,17 @@ def _status_id(client):
|
|||||||
|
|
||||||
# -- Type CRUD + in-use guard -------------------------------------------------
|
# -- Type CRUD + in-use guard -------------------------------------------------
|
||||||
|
|
||||||
def test_starter_types_seeded(client):
|
def test_starter_types_seeded(client, auth_headers):
|
||||||
response = client.get('/api/measuringtools/types')
|
response = client.get('/api/measuringtools/types', headers=auth_headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
names = {t['name'] for t in response.get_json()['data']}
|
names = {t['name'] for t in response.get_json()['data']}
|
||||||
assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names
|
assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names
|
||||||
|
|
||||||
|
|
||||||
def test_type_create_requires_auth(client):
|
def test_type_create_requires_auth(client):
|
||||||
|
# This module signs the client in for every test; this one is about what
|
||||||
|
# happens when nobody is signed in, so it takes that back.
|
||||||
|
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||||
response = client.post('/api/measuringtools/types', json={'name': 'Nope'})
|
response = client.post('/api/measuringtools/types', json={'name': 'Nope'})
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
@@ -197,7 +211,7 @@ def test_tool_create_and_get_merged(client, auth_headers):
|
|||||||
assert ext['calibrationstatus'] == 'current'
|
assert ext['calibrationstatus'] == 'current'
|
||||||
tool_id = ext['measuringtoolid']
|
tool_id = ext['measuringtoolid']
|
||||||
|
|
||||||
fetched = client.get(f'/api/measuringtools/{tool_id}')
|
fetched = client.get(f'/api/measuringtools/{tool_id}', headers=auth_headers)
|
||||||
assert fetched.status_code == 200
|
assert fetched.status_code == 200
|
||||||
assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id
|
assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id
|
||||||
|
|
||||||
@@ -263,7 +277,7 @@ def test_list_filter_by_calibrationstatus(client, auth_headers):
|
|||||||
'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client),
|
'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client),
|
||||||
'nextcalibrationdate': str(date.today() - timedelta(days=10)),
|
'nextcalibrationdate': str(date.today() - timedelta(days=10)),
|
||||||
})
|
})
|
||||||
response = client.get('/api/measuringtools?calibrationstatus=overdue')
|
response = client.get('/api/measuringtools?calibrationstatus=overdue', headers=auth_headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
rows = response.get_json()['data']
|
rows = response.get_json()['data']
|
||||||
assert rows, 'expected at least one overdue tool'
|
assert rows, 'expected at least one overdue tool'
|
||||||
@@ -341,7 +355,7 @@ def test_search_excludes_tools_when_domain_disabled(mt_app, client, auth_headers
|
|||||||
# -- Report shape -------------------------------------------------------------
|
# -- Report shape -------------------------------------------------------------
|
||||||
|
|
||||||
def test_calibration_report_shape(client, auth_headers):
|
def test_calibration_report_shape(client, auth_headers):
|
||||||
response = client.get('/api/measuringtools/report/calibration')
|
response = client.get('/api/measuringtools/report/calibration', headers=auth_headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.get_json()['data']
|
data = response.get_json()['data']
|
||||||
assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'}
|
assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'}
|
||||||
@@ -382,7 +396,7 @@ def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client
|
|||||||
|
|
||||||
def test_map_lists_measuringtool_subtypes(client, auth_headers):
|
def test_map_lists_measuringtool_subtypes(client, auth_headers):
|
||||||
"""The map filter dropdown carries a Measuring Tool subtype list with color."""
|
"""The map filter dropdown carries a Measuring Tool subtype list with color."""
|
||||||
response = client.get('/api/assets/map')
|
response = client.get('/api/assets/map', headers=auth_headers)
|
||||||
assert response.status_code == 200, response.get_json()
|
assert response.status_code == 200, response.get_json()
|
||||||
subtypes = response.get_json()['data']['filters']['subtypes']
|
subtypes = response.get_json()['data']['filters']['subtypes']
|
||||||
assert 'Measuring Tool' in subtypes
|
assert 'Measuring Tool' in subtypes
|
||||||
@@ -404,7 +418,7 @@ def test_map_honors_measuringtool_subtype_filter(client, auth_headers):
|
|||||||
'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40})
|
'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40})
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}')
|
f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}', headers=auth_headers)
|
||||||
assert response.status_code == 200, response.get_json()
|
assert response.status_code == 200, response.get_json()
|
||||||
numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']}
|
numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']}
|
||||||
assert 'MT-MAP-CAL' in numbers
|
assert 'MT-MAP-CAL' in numbers
|
||||||
@@ -416,7 +430,7 @@ def test_map_item_carries_measuringtool_typedata(client, auth_headers):
|
|||||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||||
'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client),
|
'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client),
|
||||||
'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66})
|
'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66})
|
||||||
response = client.get('/api/assets/map?assettype=measuring_tool')
|
response = client.get('/api/assets/map?assettype=measuring_tool', headers=auth_headers)
|
||||||
item = next(a for a in response.get_json()['data']['assets']
|
item = next(a for a in response.get_json()['data']['assets']
|
||||||
if a['assetnumber'] == 'MT-MAP-TD')
|
if a['assetnumber'] == 'MT-MAP-TD')
|
||||||
assert item['typedata']['measuringtooltypename'] == 'Caliper'
|
assert item['typedata']['measuringtooltypename'] == 'Caliper'
|
||||||
@@ -444,7 +458,7 @@ def test_map_overlay_shape_and_derivation(client, auth_headers):
|
|||||||
'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue
|
'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue
|
||||||
assetid = created.get_json()['data']['assetid']
|
assetid = created.get_json()['data']['assetid']
|
||||||
|
|
||||||
response = client.get('/api/measuringtools/map-overlay')
|
response = client.get('/api/measuringtools/map-overlay', headers=auth_headers)
|
||||||
assert response.status_code == 200, response.get_json()
|
assert response.status_code == 200, response.get_json()
|
||||||
rows = response.get_json()['data']
|
rows = response.get_json()['data']
|
||||||
# ADR-010 overlay contract: [{assetid, color, label}], only due/overdue tools.
|
# ADR-010 overlay contract: [{assetid, color, label}], only due/overdue tools.
|
||||||
|
|||||||
@@ -39,55 +39,55 @@ def _seed_model(imageurl=IMAGE_URL):
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
def test_machine_detail_carries_model_imageurl(client, db):
|
def test_machine_detail_carries_model_imageurl(client, db, auth_headers):
|
||||||
asset = _seed_asset('2001', 'machine')
|
asset = _seed_asset('2001', 'machine')
|
||||||
model = _seed_model()
|
model = _seed_model()
|
||||||
machine = Machine(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
machine = Machine(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||||
_db.session.add(machine)
|
_db.session.add(machine)
|
||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
resp = client.get(f'/api/machines/{machine.machineid}')
|
resp = client.get(f'/api/machines/{machine.machineid}', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert resp.get_json()['data']['machine']['imageurl'] == IMAGE_URL
|
assert resp.get_json()['data']['machine']['imageurl'] == IMAGE_URL
|
||||||
|
|
||||||
|
|
||||||
def test_computer_detail_carries_model_imageurl(client, db):
|
def test_computer_detail_carries_model_imageurl(client, db, auth_headers):
|
||||||
asset = _seed_asset('PC-2001', 'computer')
|
asset = _seed_asset('PC-2001', 'computer')
|
||||||
model = _seed_model()
|
model = _seed_model()
|
||||||
computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||||
_db.session.add(computer)
|
_db.session.add(computer)
|
||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
resp = client.get(f'/api/computers/{computer.computerid}')
|
resp = client.get(f'/api/computers/{computer.computerid}', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert resp.get_json()['data']['computer']['imageurl'] == IMAGE_URL
|
assert resp.get_json()['data']['computer']['imageurl'] == IMAGE_URL
|
||||||
|
|
||||||
|
|
||||||
def test_printer_detail_carries_model_imageurl(client, db):
|
def test_printer_detail_carries_model_imageurl(client, db, auth_headers):
|
||||||
asset = _seed_asset('PR-2001', 'printer')
|
asset = _seed_asset('PR-2001', 'printer')
|
||||||
model = _seed_model()
|
model = _seed_model()
|
||||||
printer = Printer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
printer = Printer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||||
_db.session.add(printer)
|
_db.session.add(printer)
|
||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
resp = client.get(f'/api/printers/{printer.printerid}')
|
resp = client.get(f'/api/printers/{printer.printerid}', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert resp.get_json()['data']['printer']['imageurl'] == IMAGE_URL
|
assert resp.get_json()['data']['printer']['imageurl'] == IMAGE_URL
|
||||||
|
|
||||||
|
|
||||||
def test_detail_omits_imageurl_without_model(client, db):
|
def test_detail_omits_imageurl_without_model(client, db, auth_headers):
|
||||||
"""No model linked -> no imageurl key (frontend v-if shows nothing)."""
|
"""No model linked -> no imageurl key (frontend v-if shows nothing)."""
|
||||||
asset = _seed_asset('PR-2002', 'printer')
|
asset = _seed_asset('PR-2002', 'printer')
|
||||||
printer = Printer(assetid=asset.assetid)
|
printer = Printer(assetid=asset.assetid)
|
||||||
_db.session.add(printer)
|
_db.session.add(printer)
|
||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
resp = client.get(f'/api/printers/{printer.printerid}')
|
resp = client.get(f'/api/printers/{printer.printerid}', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert 'imageurl' not in resp.get_json()['data']['printer']
|
assert 'imageurl' not in resp.get_json()['data']['printer']
|
||||||
|
|
||||||
|
|
||||||
def test_detail_omits_imageurl_when_model_has_no_image(client, db):
|
def test_detail_omits_imageurl_when_model_has_no_image(client, db, auth_headers):
|
||||||
"""Model linked but no photo -> no imageurl key."""
|
"""Model linked but no photo -> no imageurl key."""
|
||||||
asset = _seed_asset('PC-2002', 'computer')
|
asset = _seed_asset('PC-2002', 'computer')
|
||||||
model = _seed_model(imageurl=None)
|
model = _seed_model(imageurl=None)
|
||||||
@@ -95,6 +95,6 @@ def test_detail_omits_imageurl_when_model_has_no_image(client, db):
|
|||||||
_db.session.add(computer)
|
_db.session.add(computer)
|
||||||
_db.session.commit()
|
_db.session.commit()
|
||||||
|
|
||||||
resp = client.get(f'/api/computers/{computer.computerid}')
|
resp = client.get(f'/api/computers/{computer.computerid}', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert 'imageurl' not in resp.get_json()['data']['computer']
|
assert 'imageurl' not in resp.get_json()['data']['computer']
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ def model(db):
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
def test_supplies_meta_lists_allowed_values(client, db):
|
def test_supplies_meta_lists_allowed_values(client, db, auth_headers):
|
||||||
response = client.get('/api/printers/supplies/meta')
|
response = client.get('/api/printers/supplies/meta', headers=auth_headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.get_json()['data']
|
data = response.get_json()['data']
|
||||||
assert 'toner' in data['supplytypes']
|
assert 'toner' in data['supplytypes']
|
||||||
@@ -38,7 +38,7 @@ def test_create_and_list_model_supply(client, model, auth_headers):
|
|||||||
)
|
)
|
||||||
assert create.status_code == 201
|
assert create.status_code == 201
|
||||||
|
|
||||||
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
|
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies', headers=auth_headers)
|
||||||
assert listing.status_code == 200
|
assert listing.status_code == 200
|
||||||
supplies = listing.get_json()['data']['supplies']
|
supplies = listing.get_json()['data']['supplies']
|
||||||
assert len(supplies) == 1
|
assert len(supplies) == 1
|
||||||
@@ -90,7 +90,7 @@ def test_update_and_delete_supply(client, model, auth_headers):
|
|||||||
deleted = client.delete(f'/api/printers/supplies/{supplyid}', headers=auth_headers)
|
deleted = client.delete(f'/api/printers/supplies/{supplyid}', headers=auth_headers)
|
||||||
assert deleted.status_code == 200
|
assert deleted.status_code == 200
|
||||||
|
|
||||||
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
|
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies', headers=auth_headers)
|
||||||
assert listing.get_json()['data']['supplies'] == []
|
assert listing.get_json()['data']['supplies'] == []
|
||||||
|
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ def test_listmodels_reports_supplycount(client, model, auth_headers):
|
|||||||
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||||
json={'partnumber': 'W2020A', 'color': 'black'}, headers=auth_headers)
|
json={'partnumber': 'W2020A', 'color': 'black'}, headers=auth_headers)
|
||||||
|
|
||||||
response = client.get('/api/printers/models', query_string={'search': 'C999'})
|
response = client.get('/api/printers/models', query_string={'search': 'C999'}, headers=auth_headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
rows = response.get_json()['data']
|
rows = response.get_json()['data']
|
||||||
match = next(r for r in rows if r['modelnumberid'] == model.modelnumberid)
|
match = next(r for r in rows if r['modelnumberid'] == model.modelnumberid)
|
||||||
|
|||||||
@@ -148,20 +148,20 @@ def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item,
|
|||||||
headers=auth_headers)
|
headers=auth_headers)
|
||||||
assert stocked.status_code == 200
|
assert stocked.status_code == 200
|
||||||
|
|
||||||
lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}')
|
lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}', headers=auth_headers)
|
||||||
assert lookup.status_code == 200
|
assert lookup.status_code == 200
|
||||||
|
|
||||||
take = client.post('/api/printedparts/kiosk/take', json={
|
take = client.post('/api/printedparts/kiosk/take', json={
|
||||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2})
|
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2}, headers=auth_headers)
|
||||||
assert take.status_code == 200, take.get_json()
|
assert take.status_code == 200, take.get_json()
|
||||||
assert take.get_json()['data']['quantityonhand'] == 3
|
assert take.get_json()['data']['quantityonhand'] == 3
|
||||||
|
|
||||||
too_many = client.post('/api/printedparts/kiosk/take', json={
|
too_many = client.post('/api/printedparts/kiosk/take', json={
|
||||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99})
|
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99}, headers=auth_headers)
|
||||||
assert too_many.status_code == 400
|
assert too_many.status_code == 400
|
||||||
|
|
||||||
unknown = client.post('/api/printedparts/kiosk/take', json={
|
unknown = client.post('/api/printedparts/kiosk/take', json={
|
||||||
'itemcode': itemcode, 'badge': '111111111', 'quantity': 1})
|
'itemcode': itemcode, 'badge': '111111111', 'quantity': 1}, headers=auth_headers)
|
||||||
assert unknown.status_code == 422
|
assert unknown.status_code == 422
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -197,7 +197,7 @@ def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item,
|
|||||||
return client.post('/api/printedparts/kiosk/take',
|
return client.post('/api/printedparts/kiosk/take',
|
||||||
json={'itemcode': '3DP-9001',
|
json={'itemcode': '3DP-9001',
|
||||||
'badge': directory_employee,
|
'badge': directory_employee,
|
||||||
'quantity': quantity})
|
'quantity': quantity}, headers=auth_headers)
|
||||||
|
|
||||||
restock(10) # 10 on hand, threshold 5
|
restock(10) # 10 on hand, threshold 5
|
||||||
assert take(3).status_code == 200 # 7: above threshold, no alert
|
assert take(3).status_code == 200 # 7: above threshold, no alert
|
||||||
@@ -245,7 +245,7 @@ def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app,
|
|||||||
headers=auth_headers)
|
headers=auth_headers)
|
||||||
take = client.post('/api/printedparts/kiosk/take',
|
take = client.post('/api/printedparts/kiosk/take',
|
||||||
json={'itemcode': '3DP-9001',
|
json={'itemcode': '3DP-9001',
|
||||||
'badge': directory_employee, 'quantity': 6})
|
'badge': directory_employee, 'quantity': 6}, headers=auth_headers)
|
||||||
assert take.status_code == 200 # 4 on hand: crossed threshold 5
|
assert take.status_code == 200 # 4 on hand: crossed threshold 5
|
||||||
|
|
||||||
assert captured['to'] == ['lead@site.test', 'extra@site.test']
|
assert captured['to'] == ['lead@site.test', 'extra@site.test']
|
||||||
@@ -260,7 +260,7 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item):
|
|||||||
listed = client.get('/api/printedparts/items',
|
listed = client.get('/api/printedparts/items',
|
||||||
headers=auth_headers).get_json()['data']
|
headers=auth_headers).get_json()['data']
|
||||||
assert all(row['printeditemid'] != item for row in listed)
|
assert all(row['printeditemid'] != item for row in listed)
|
||||||
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
|
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001', headers=auth_headers)
|
||||||
assert kiosk.status_code == 404
|
assert kiosk.status_code == 404
|
||||||
|
|
||||||
including = client.get('/api/printedparts/items?active=false',
|
including = client.get('/api/printedparts/items?active=false',
|
||||||
@@ -270,7 +270,7 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item):
|
|||||||
|
|
||||||
assert client.post(f'/api/printedparts/items/{item}/restore',
|
assert client.post(f'/api/printedparts/items/{item}/restore',
|
||||||
headers=auth_headers).status_code == 200
|
headers=auth_headers).status_code == 200
|
||||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200
|
assert client.get('/api/printedparts/kiosk/item/3DP-9001', headers=auth_headers).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path):
|
def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path):
|
||||||
@@ -302,7 +302,7 @@ def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path
|
|||||||
assert [f['revision'] for f in listing] == [2, 1]
|
assert [f['revision'] for f in listing] == [2, 1]
|
||||||
|
|
||||||
fileid = listing[1]['fileid']
|
fileid = listing[1]['fileid']
|
||||||
download = client.get(f'/api/printedparts/files/{fileid}/download')
|
download = client.get(f'/api/printedparts/files/{fileid}/download', headers=auth_headers)
|
||||||
assert download.status_code == 200
|
assert download.status_code == 200
|
||||||
assert download.data == b'solid part'
|
assert download.data == b'solid part'
|
||||||
assert 'clip_v1.stl' in download.headers['Content-Disposition']
|
assert 'clip_v1.stl' in download.headers['Content-Disposition']
|
||||||
@@ -335,7 +335,7 @@ def test_alert_role_members_receive(client, auth_headers, app, item,
|
|||||||
headers=auth_headers)
|
headers=auth_headers)
|
||||||
take = client.post('/api/printedparts/kiosk/take',
|
take = client.post('/api/printedparts/kiosk/take',
|
||||||
json={'itemcode': '3DP-9001',
|
json={'itemcode': '3DP-9001',
|
||||||
'badge': directory_employee, 'quantity': 6})
|
'badge': directory_employee, 'quantity': 6}, headers=auth_headers)
|
||||||
assert take.status_code == 200
|
assert take.status_code == 200
|
||||||
assert captured['to'] == ['crewone@site.test']
|
assert captured['to'] == ['crewone@site.test']
|
||||||
|
|
||||||
@@ -362,14 +362,14 @@ def test_gagelabtag_assigned_searched_and_kiosk_resolved(client, auth_headers):
|
|||||||
headers=auth_headers).get_json()['data']
|
headers=auth_headers).get_json()['data']
|
||||||
assert len(searched) == 1
|
assert len(searched) == 1
|
||||||
|
|
||||||
by_tag = client.get('/api/printedparts/kiosk/item/WJRP0117')
|
by_tag = client.get('/api/printedparts/kiosk/item/WJRP0117', headers=auth_headers)
|
||||||
assert by_tag.status_code == 200
|
assert by_tag.status_code == 200
|
||||||
by_digits = client.get('/api/printedparts/kiosk/item/117')
|
by_digits = client.get('/api/printedparts/kiosk/item/117', headers=auth_headers)
|
||||||
assert by_digits.status_code == 200
|
assert by_digits.status_code == 200
|
||||||
assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117'
|
assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117'
|
||||||
|
|
||||||
|
|
||||||
def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee):
|
def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee, auth_headers):
|
||||||
"""A label QR payload 'TAG|rev' resolves the item by TAG and records the
|
"""A label QR payload 'TAG|rev' resolves the item by TAG and records the
|
||||||
revision on the take (traceability of which print-file rev was consumed)."""
|
revision on the take (traceability of which print-file rev was consumed)."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -380,7 +380,7 @@ def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee
|
|||||||
iid = row.printeditemid
|
iid = row.printeditemid
|
||||||
|
|
||||||
take = client.post('/api/printedparts/kiosk/take', json={
|
take = client.post('/api/printedparts/kiosk/take', json={
|
||||||
'itemcode': 'WJRP7000|3', 'badge': directory_employee, 'quantity': 1})
|
'itemcode': 'WJRP7000|3', 'badge': directory_employee, 'quantity': 1}, headers=auth_headers)
|
||||||
assert take.status_code == 200, take.get_json()
|
assert take.status_code == 200, take.get_json()
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -391,7 +391,7 @@ def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee
|
|||||||
|
|
||||||
# Manual entry (no |rev) records a null revision, still resolves.
|
# Manual entry (no |rev) records a null revision, still resolves.
|
||||||
take2 = client.post('/api/printedparts/kiosk/take', json={
|
take2 = client.post('/api/printedparts/kiosk/take', json={
|
||||||
'itemcode': 'WJRP7000', 'badge': directory_employee, 'quantity': 1})
|
'itemcode': 'WJRP7000', 'badge': directory_employee, 'quantity': 1}, headers=auth_headers)
|
||||||
assert take2.status_code == 200
|
assert take2.status_code == 200
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
last = (PrintedItemTransaction.query
|
last = (PrintedItemTransaction.query
|
||||||
@@ -400,7 +400,7 @@ def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee
|
|||||||
assert last.revision is None
|
assert last.revision is None
|
||||||
|
|
||||||
|
|
||||||
def test_lowstock_routes_to_support_team_webhook(client, app, db, directory_employee):
|
def test_lowstock_routes_to_support_team_webhook(client, app, db, directory_employee, auth_headers):
|
||||||
"""A low-stock crossing posts to the selected support team's webhook URL."""
|
"""A low-stock crossing posts to the selected support team's webhook URL."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from shopdb.core.models import SupportTeam, Setting
|
from shopdb.core.models import SupportTeam, Setting
|
||||||
@@ -418,7 +418,7 @@ def test_lowstock_routes_to_support_team_webhook(client, app, db, directory_empl
|
|||||||
with patch('shopdb.api.send_webhook') as mock_send:
|
with patch('shopdb.api.send_webhook') as mock_send:
|
||||||
# take 2 -> 6 crosses to 4 (<= threshold 5) -> alert fires
|
# take 2 -> 6 crosses to 4 (<= threshold 5) -> alert fires
|
||||||
resp = client.post('/api/printedparts/kiosk/take', json={
|
resp = client.post('/api/printedparts/kiosk/take', json={
|
||||||
'itemcode': 'WJRP7100', 'badge': directory_employee, 'quantity': 2})
|
'itemcode': 'WJRP7100', 'badge': directory_employee, 'quantity': 2}, headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert mock_send.called
|
assert mock_send.called
|
||||||
assert mock_send.call_args.kwargs['url'] == 'https://teams.example/3dprint'
|
assert mock_send.call_args.kwargs['url'] == 'https://teams.example/3dprint'
|
||||||
|
|||||||
@@ -299,10 +299,10 @@ def test_no_estimate_never_reaches_the_order_list():
|
|||||||
assert orderlist([cartridge(None)]) == []
|
assert orderlist([cartridge(None)]) == []
|
||||||
|
|
||||||
|
|
||||||
def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
|
def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db, auth_headers):
|
||||||
"""A dashboard must be told the data is missing, not shown an empty list
|
"""A dashboard must be told the data is missing, not shown an empty list
|
||||||
that reads as 'nothing runs out soon'."""
|
that reads as 'nothing runs out soon'."""
|
||||||
response = client.get('/api/printers/supplies/forecast')
|
response = client.get('/api/printers/supplies/forecast', headers=auth_headers)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.get_json()['data']
|
body = response.get_json()['data']
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ from shopdb.core.models import Model, Vendor
|
|||||||
from plugins.printers.models import ModelSupply, Printer
|
from plugins.printers.models import ModelSupply, Printer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _signed_in(client, auth_headers):
|
||||||
|
"""Reading needs a permission now, and these tests are about what the
|
||||||
|
payload says rather than about who may see it. Signing the client in once
|
||||||
|
beats threading headers through every helper in the module."""
|
||||||
|
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||||
|
yield
|
||||||
|
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
LEVELS = {
|
LEVELS = {
|
||||||
# itemid: (name, live level, history of daily readings)
|
# itemid: (name, live level, history of daily readings)
|
||||||
# Black is a REAL zero, which is the only thing that means empty. Magenta is
|
# Black is a REAL zero, which is the only thing that means empty. Magenta is
|
||||||
|
|||||||
Reference in New Issue
Block a user