feat: 3-tier role system across all platform apps

Portal:
- Replace is_admin boolean with role column (admin/advanced/standard)
- Settings UI: 3-tier portal role select + per-app role dropdowns
- /portal-return endpoint: re-establishes session from JWT for sub-app back-links
- /api/internal/nv-users: internal endpoint for NetworkView user sync
- portal/migrate_roles.py: one-time DB migration script
- Role badges (admin/advanced/standard) in topbar and settings table

DigiServer:
- Add editor and viewer roles (portal advanced->editor, standard->viewer)
- PlaylistPermission model: grant viewer users edit access to specific playlists
- app/utils/access.py: shared editor_required, admin_required, can_edit_playlist helpers
- Content routes: editor_required on create/delete, per-playlist permission check on mutations
- Admin: playlist_permissions route + template to manage viewer playlist grants
- Base template: hide Admin nav for viewers, Portal button (⬡) returns to portal
- content_list_new: hide create/delete for viewers; Manage vs View button per permission
- manage_playlist_content: view-only mode when user lacks edit permission

NetworkView:
- backend/src/middleware/rbac.js: requireRole + requireWriteAccess helpers
- site_permissions table: one site per advanced user
- All mutating routes guarded (admin=all, advanced=assigned site, viewer=read-only)
- Portal SSO auto-upsert: user row synced from X-Auth-Role on every request
- GET /api/users: merges portal users list with local NV data (all 4 portal users visible)
- GET/PUT /api/users/:id/site-permission: assign site to advanced user
- Settings Users tab: role badges, site dropdown for advanced, (portal only) indicator
- Sidebar: ⬡ Portal button between Settings and Logout
- Frontend build: VITE_API_BASE=/networkview/api now set in start-dev.sh

IT Assets / Server Monitor:
- portal_sso.py updated: map advanced->editor/viewer, standard->readonly
- AdminUser model: add editor role + is_editor property
This commit is contained in:
ske087
2026-07-07 00:08:46 +03:00
parent 5762dd420d
commit 1f6217d347
41 changed files with 1028 additions and 153 deletions
+28 -7
View File
@@ -1,7 +1,7 @@
"""Content blueprint - New playlist-centric workflow."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app)
from flask_login import login_required
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from typing import Optional
import os
@@ -15,6 +15,7 @@ from app.models import Content, Playlist, Player
from app.models.playlist import playlist_content
from app.utils.logger import log_action
from app.utils.uploads import process_video_file, set_upload_progress
from app.utils.access import editor_required, can_edit_playlist, get_editable_playlist_ids
# Store for background processing status
_background_tasks = {}
@@ -30,12 +31,14 @@ def content_list():
media_files = Content.query.order_by(Content.uploaded_at.desc()).limit(3).all() # Only last 3
total_media_count = Content.query.count() # Total count for display
players = Player.query.order_by(Player.name).all()
editable_ids = get_editable_playlist_ids(current_user)
return render_template('content/content_list_new.html',
playlists=playlists,
media_files=media_files,
total_media_count=total_media_count,
players=players)
players=players,
editable_ids=editable_ids)
@content_bp.route('/media-library')
@@ -68,6 +71,7 @@ def media_library():
@content_bp.route('/media/<int:media_id>/delete', methods=['POST'])
@login_required
@editor_required
def delete_media(media_id: int):
"""Delete a media file and remove it from all playlists."""
try:
@@ -132,6 +136,7 @@ def delete_media(media_id: int):
@content_bp.route('/playlist/create', methods=['POST'])
@login_required
@editor_required
def create_playlist():
"""Create a new playlist."""
try:
@@ -170,6 +175,7 @@ def create_playlist():
@content_bp.route('/playlist/<int:playlist_id>/delete', methods=['POST'])
@login_required
@editor_required
def delete_playlist(playlist_id: int):
"""Delete a playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
@@ -200,27 +206,32 @@ def delete_playlist(playlist_id: int):
def manage_playlist_content(playlist_id: int):
"""Manage content in a specific playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
can_edit = can_edit_playlist(current_user, playlist_id)
# Get content in playlist (ordered)
playlist_content = playlist.get_content_ordered()
# Get all available content not in this playlist.
# Web links are created on demand per playlist, so they are not offered
# as reusable library items here.
all_content = Content.query.filter(Content.content_type != 'weblink').all()
playlist_content_ids = {c.id for c in playlist_content}
available_content = [c for c in all_content if c.id not in playlist_content_ids]
return render_template('content/manage_playlist_content.html',
playlist=playlist,
playlist_content=playlist_content,
available_content=available_content)
available_content=available_content,
can_edit=can_edit)
@content_bp.route('/playlist/<int:playlist_id>/add-content', methods=['POST'])
@login_required
def add_content_to_playlist(playlist_id: int):
"""Add content to playlist."""
if not can_edit_playlist(current_user, playlist_id):
flash('You do not have permission to edit this playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
playlist = Playlist.query.get_or_404(playlist_id)
try:
@@ -351,6 +362,9 @@ def add_weblink():
@login_required
def add_weblink_to_playlist(playlist_id: int):
"""Create a web link content item and add it to the playlist."""
if not can_edit_playlist(current_user, playlist_id):
flash('You do not have permission to edit this playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
playlist = Playlist.query.get_or_404(playlist_id)
try:
@@ -417,6 +431,9 @@ def add_weblink_to_playlist(playlist_id: int):
@login_required
def remove_content_from_playlist(playlist_id: int, content_id: int):
"""Remove content from playlist."""
if not can_edit_playlist(current_user, playlist_id):
flash('You do not have permission to edit this playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
playlist = Playlist.query.get_or_404(playlist_id)
try:
@@ -454,6 +471,8 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
@login_required
def bulk_remove_from_playlist(playlist_id: int):
"""Remove multiple content items from playlist."""
if not can_edit_playlist(current_user, playlist_id):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
playlist = Playlist.query.get_or_404(playlist_id)
try:
@@ -496,6 +515,8 @@ def bulk_remove_from_playlist(playlist_id: int):
@login_required
def reorder_playlist_content(playlist_id: int):
"""Reorder content in playlist."""
if not can_edit_playlist(current_user, playlist_id):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
playlist = Playlist.query.get_or_404(playlist_id)
try: