Merge pull request #10 from mattintech/main

Updating Develop
This commit is contained in:
2025-10-20 21:03:23 -04:00
committed by GitHub
14 changed files with 751 additions and 20 deletions

50
.dockerignore Normal file
View File

@@ -0,0 +1,50 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
*.egg-info/
dist/
build/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Git
.git/
.gitignore
# Environment files (will be mounted separately)
.env
# Documentation
README.md
# CI/CD
.github/
# Claude
.claude/
# Instance data (will be mounted as volume)
src/app/instance/
src/app/static/images/
src/app/static/barcodes/
# Test files
*.pyc
.pytest_cache/
.coverage
htmlcov/
# OS
.DS_Store
Thumbs.db

View File

@@ -2,6 +2,10 @@
SECRET_KEY=your-secret-key-change-this-in-production
FLASK_DEBUG=1
# Authentication Mode
# Options: 'entra' (Entra ID/Azure AD) or 'none' (no authentication for local testing)
AUTH_MODE=none
# Entra ID (Azure AD) Configuration
# Get these values from your Azure Portal > App Registrations
AZURE_CLIENT_ID=your-client-id-here

127
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,127 @@
name: Bug Report
description: Report a bug or issue with the Flask AR API Demo Server
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the information below to help us resolve the issue.
- type: dropdown
id: component
attributes:
label: Component
description: Which component is affected?
options:
- API Endpoints (login, arcontentfields, arinfo)
- Admin Interface
- Barcode Generation
- Image Serving
- Authentication (Entra ID)
- Database/Models
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is.
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Navigate to '...'
2. Click on '...'
3. Make API call to '...'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what should happen...
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what actually happened...
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error Logs/Screenshots
description: If applicable, add error logs, stack traces, or screenshots
placeholder: Paste logs or drag and drop screenshots here...
validations:
required: false
- type: dropdown
id: environment
attributes:
label: Environment
description: Where are you running the application?
options:
- Local Development
- Docker
- Cloud Deployment
- Other
validations:
required: true
- type: input
id: python-version
attributes:
label: Python Version
description: What version of Python are you using?
placeholder: e.g., 3.9.0, 3.10.2, 3.11.5
validations:
required: false
- type: input
id: flask-version
attributes:
label: Flask Version
description: What version of Flask are you using?
placeholder: e.g., 2.2.5
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context about the problem here
placeholder: Any additional information...
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Pre-submission Checklist
description: Please confirm the following
options:
- label: I have searched existing issues to ensure this bug hasn't been reported
required: true
- label: I have included all relevant information above
required: true
- label: I can reproduce this bug consistently
required: false

View File

@@ -0,0 +1,155 @@
name: Feature Request
description: Suggest a new feature or enhancement for the Flask AR API Demo Server
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a new feature! Please provide as much detail as possible to help us understand your request.
- type: dropdown
id: component
attributes:
label: Component
description: Which component would this feature affect?
options:
- API Endpoints
- Admin Interface
- Barcode Generation
- Image Management
- Authentication/Authorization
- Database/Models
- Product Management
- Tenant Management
- Documentation
- Other
validations:
required: true
- type: dropdown
id: feature-type
attributes:
label: Feature Type
description: What type of feature is this?
options:
- New Functionality
- Enhancement to Existing Feature
- UI/UX Improvement
- Performance Improvement
- Developer Experience
- Documentation
- Testing
- Security
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem Statement
description: Is your feature request related to a problem? Please describe.
placeholder: I'm frustrated when... / There's no way to... / It would be helpful if...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like to see
placeholder: I would like to be able to... / The system should... / Add a new endpoint that...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or workarounds?
placeholder: I've thought about... / Another approach could be... / Currently using...
validations:
required: false
- type: textarea
id: use-case
attributes:
label: Use Case
description: Describe your use case and how this feature would benefit you or others
placeholder: |
This feature would allow me to...
It would be useful for...
The expected workflow would be...
validations:
required: true
- type: textarea
id: implementation
attributes:
label: Implementation Ideas
description: If you have ideas about how to implement this feature, please share them
placeholder: |
This could be implemented by...
Possible technical approach...
Required changes...
validations:
required: false
- type: dropdown
id: priority
attributes:
label: Priority
description: How important is this feature to you?
options:
- Nice to have
- Would be useful
- Important
- Critical
validations:
required: true
- type: dropdown
id: breaking
attributes:
label: Breaking Change
description: Would this feature require breaking changes to the API or existing functionality?
options:
- "No"
- "Yes"
- "Unsure"
validations:
required: true
- type: textarea
id: mockups
attributes:
label: Mockups/Examples
description: If applicable, add mockups, screenshots, or examples from other systems
placeholder: Drag and drop images or paste links here...
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, links, or references about the feature request
placeholder: Related to... / Similar to... / Reference documentation...
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Pre-submission Checklist
description: Please confirm the following
options:
- label: I have searched existing issues to ensure this feature hasn't been requested
required: true
- label: I have clearly described the feature and its benefits
required: true
- label: I am willing to help test this feature if implemented
required: false
- label: I am willing to contribute to the implementation
required: false

35
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Build and Push Docker Image
on:
push:
tags:
- 'v*' # triggers only on version tags like v1.0.0, v25.04, etc.
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version from tag
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Build and push Docker image with two tags
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
mattintech/kcapdemoserver:latest
mattintech/kcapdemoserver:${{ env.VERSION }}

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Use Python 3.11 slim image as base
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_APP=src/run.py
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better layer caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create necessary directories for runtime
RUN mkdir -p src/app/static/images src/app/static/barcodes src/app/instance
# Expose port 5555
EXPOSE 5555
# Run the application
CMD ["python", "src/run.py"]

View File

@@ -36,6 +36,70 @@ This project is a simple Flask API for demonstrating AR content retrieval for ba
## Setup
### Option 1: Docker Hub (Recommended)
Pull and run the pre-built image from Docker Hub:
```bash
# Pull the latest image
docker pull mattintech/kcapdemoserver:latest
# Run the container
docker run -p 5555:5000 \
-e SECRET_KEY=your-secret-key-here \
-e DATABASE_URL=sqlite:///data/kcap_demo.db \
-v $(pwd)/data:/app/data \
mattintech/kcapdemoserver:latest
```
Or use a specific version:
```bash
docker pull mattintech/kcapdemoserver:v1.0.0
docker run -p 5555:5000 --env-file .env mattintech/kcapdemoserver:v1.0.0
```
### Option 2: Docker Compose
1. **Clone the Repository:**
```bash
git clone https://github.com/mattintech/KCAPDemoServer.git
cd KCAPDemoServer
```
2. **Configure Environment:**
Copy `.env.example` to `.env` and update with your Azure AD credentials:
```bash
cp .env.example .env
```
Edit `.env` and set your Azure AD configuration values.
3. **Run with Docker Compose:**
```bash
docker-compose up -d
```
The application will be available at `http://localhost:5555`
4. **View Logs:**
```bash
docker-compose logs -f
```
5. **Stop the Application:**
```bash
docker-compose down
```
### Option 3: Local Python Setup
1. **Clone the Repository:**
```bash
@@ -49,23 +113,26 @@ cd <repository_folder>
pip install -r requirements.txt
```
3. **Run the Server:**
3. **Configure Environment:**
Copy `.env.example` to `.env` and update with your configuration.
4. **Run the Server:**
```bash
python app.py
python src/run.py
```
The application will automatically create the necessary directories and initialize the products.json file if it doesn't exist.
The application will automatically create the necessary directories and initialize the database if it doesn't exist.
4. **Access the Admin Interface:**
### Accessing the Application
Open your browser and navigate to:
**Admin Interface:**
```
http://localhost:5555/admin
```
5. **Test the Endpoints:**
**API Endpoints:**
* Login: `GET http://localhost:5555/login`
* Content Fields: `GET http://localhost:5555/arcontentfields`
* AR Info (Example): `GET http://localhost:5555/arinfo?barcode=123456`
@@ -105,6 +172,18 @@ The following barcode types are available:
└── generate_barcode.html # Barcode generation page
```
## Docker Hub
Pre-built Docker images are automatically published to Docker Hub on each release:
**Repository**: [mattintech/kcapdemoserver](https://hub.docker.com/r/mattintech/kcapdemoserver)
**Available Tags**:
- `latest` - Most recent release
- `v1.0.0`, `v1.0.1`, etc. - Specific version releases
For maintainers: See [DOCKER_HUB_SETUP.md](DOCKER_HUB_SETUP.md) for information on configuring automated builds.
## Implementation Notes
* The application is designed as a single server hosting both the API endpoints and admin interface.

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
version: '3.8'
services:
web:
build: .
container_name: kcap-demo-server
ports:
- "5555:5555"
environment:
- FLASK_DEBUG=1
env_file:
- .env
volumes:
# Mount static directories for persistent storage
- ./src/app/static/images:/app/src/app/static/images
- ./src/app/static/barcodes:/app/src/app/static/barcodes
# Mount instance directory for database persistence
- ./src/app/instance:/app/src/app/instance
restart: unless-stopped

View File

@@ -1,14 +1,20 @@
"""Authentication routes for Entra ID login/logout"""
"""Authentication routes for Entra ID login/logout and no-auth mode"""
import secrets
from flask import session, redirect, url_for, request, current_app, render_template, flash
from flask import session, redirect, url_for, request, current_app, render_template, flash, abort
from . import auth_bp
from app.services.msal_service import MSALService
from app.models.user import UserModel
@auth_bp.route('/login')
def login():
"""Initiate Entra ID login flow"""
"""Initiate Entra ID login flow or redirect to role selection in no-auth mode"""
# Check if we're in no-auth mode
if current_app.config.get('AUTH_MODE') == 'none':
return redirect(url_for('auth.select_role'))
# Entra ID mode
from app.services.msal_service import MSALService
# Generate state token for CSRF protection
state = secrets.token_urlsafe(16)
session['auth_state'] = state
@@ -23,9 +29,51 @@ def login():
return redirect(auth_url)
@auth_bp.route('/select-role', methods=['GET', 'POST'])
def select_role():
"""Role selection page for no-auth mode"""
# Only available in no-auth mode
if current_app.config.get('AUTH_MODE') != 'none':
current_app.logger.warning(f"Attempted to access role selection in {current_app.config.get('AUTH_MODE')} mode")
abort(403, description="Role selection is only available in no-auth mode")
if request.method == 'POST':
role = request.form.get('role', 'user')
# Validate role
if role not in ['admin', 'user']:
role = 'user'
# Create a mock user for testing
# Use a consistent ID based on role for session persistence
user_id = f"test-{role}-user"
# Store user in session
session['user'] = {
'id': user_id,
'email': f'{role}@test.local',
'name': f'Test {role.capitalize()}',
'role': role
}
current_app.logger.info(f"No-auth mode: User selected {role} role")
# Redirect to the originally requested page or home
next_url = session.pop('next', None)
if next_url:
return redirect(next_url)
return redirect(url_for('main.index'))
return render_template('select_role.html')
@auth_bp.route('/callback')
def callback():
"""Handle the callback from Entra ID after authentication"""
# Import MSAL service only when needed (Entra ID mode)
from app.services.msal_service import MSALService
# Verify state to prevent CSRF
state = request.args.get('state')
if state != session.get('auth_state'):
@@ -104,6 +152,14 @@ def logout():
# Clear session
session.clear()
# Check if we're in no-auth mode
if current_app.config.get('AUTH_MODE') == 'none':
# Just redirect to home in no-auth mode
return redirect(url_for('main.index'))
# Entra ID mode - logout from Azure AD
from app.services.msal_service import MSALService
# Remove from MSAL cache
MSALService.remove_account()

View File

@@ -28,6 +28,10 @@ class Config:
SESSION_USE_SIGNER = True
SESSION_FILE_DIR = os.path.join(DATA_FOLDER, 'flask_session')
# Authentication Mode
# Options: 'entra' (Entra ID/Azure AD) or 'none' (no authentication)
AUTH_MODE = os.environ.get('AUTH_MODE', 'entra').lower()
# Entra ID (Azure AD) Configuration
AZURE_CLIENT_ID = os.environ.get('AZURE_CLIENT_ID')
AZURE_CLIENT_SECRET = os.environ.get('AZURE_CLIENT_SECRET')

View File

@@ -4,17 +4,36 @@ from flask import session, redirect, url_for, request, abort, current_app
from app.models.user import UserModel
def _ensure_no_auth_user():
"""
Helper function to ensure a user exists in session when AUTH_MODE is 'none'.
If no user in session, redirect to role selection page.
"""
if current_app.config.get('AUTH_MODE') == 'none' and 'user' not in session:
# Redirect to role selection page
session['next'] = request.url
return redirect(url_for('auth.select_role'))
return None
def login_required(f):
"""
Decorator to require user authentication.
Redirects to login if user is not authenticated.
In 'none' auth mode, redirects to role selection if no session exists.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
# Store the requested URL to redirect back after login
# Check if we're in no-auth mode and need to set up a user
if current_app.config.get('AUTH_MODE') == 'none':
redirect_response = _ensure_no_auth_user()
if redirect_response:
return redirect_response
elif 'user' not in session:
# Entra ID mode - redirect to login
session['next'] = request.url
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
@@ -26,7 +45,13 @@ def admin_required(f):
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
# Check if we're in no-auth mode and need to set up a user
if current_app.config.get('AUTH_MODE') == 'none':
redirect_response = _ensure_no_auth_user()
if redirect_response:
return redirect_response
elif 'user' not in session:
# Entra ID mode - redirect to login
session['next'] = request.url
return redirect(url_for('auth.login'))
@@ -47,7 +72,13 @@ def tenant_access_required(f):
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
# Check if we're in no-auth mode and need to set up a user
if current_app.config.get('AUTH_MODE') == 'none':
redirect_response = _ensure_no_auth_user()
if redirect_response:
return redirect_response
elif 'user' not in session:
# Entra ID mode - redirect to login
session['next'] = request.url
return redirect(url_for('auth.login'))
@@ -76,7 +107,13 @@ def settings_access_required(f):
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
# Check if we're in no-auth mode and need to set up a user
if current_app.config.get('AUTH_MODE') == 'none':
redirect_response = _ensure_no_auth_user()
if redirect_response:
return redirect_response
elif 'user' not in session:
# Entra ID mode - redirect to login
session['next'] = request.url
return redirect(url_for('auth.login'))

View File

@@ -67,8 +67,22 @@ class UserModel:
return None
@staticmethod
def get_by_id(user_id: int) -> Optional[Dict]:
"""Get user by ID"""
def get_by_id(user_id) -> Optional[Dict]:
"""Get user by ID (supports both integer IDs and string test IDs for no-auth mode)"""
# Handle test user IDs for no-auth mode
if isinstance(user_id, str) and user_id.startswith('test-'):
# Mock test user
role = 'admin' if 'admin' in user_id else 'user'
return {
'id': user_id,
'email': f'{role}@test.local',
'azure_oid': None,
'name': f'Test {role.capitalize()}',
'role': role,
'created_at': None,
'updated_at': None
}
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
@@ -211,13 +225,17 @@ class UserModel:
return [row[0] for row in cursor.fetchall()]
@staticmethod
def has_access_to_tenant(user_id: int, tenant_id: str) -> bool:
def has_access_to_tenant(user_id, tenant_id: str) -> bool:
"""Check if user has access to a specific tenant"""
# Admin has access to everything
user = UserModel.get_by_id(user_id)
if user and user['role'] == UserModel.ROLE_ADMIN:
return True
# Test users in no-auth mode don't have tenant associations
if isinstance(user_id, str) and user_id.startswith('test-'):
return False
# Check if user owns the tenant
with get_db() as conn:
cursor = conn.cursor()
@@ -228,7 +246,7 @@ class UserModel:
return cursor.fetchone() is not None
@staticmethod
def is_admin(user_id: int) -> bool:
def is_admin(user_id) -> bool:
"""Check if user has admin role"""
user = UserModel.get_by_id(user_id)
return user and user['role'] == UserModel.ROLE_ADMIN

View File

@@ -72,6 +72,15 @@
{% endif %}
</ul>
<ul class="navbar-nav ms-auto">
{% if config.AUTH_MODE == 'none' %}
<li class="nav-item">
<span class="nav-link">
<span class="badge bg-warning text-dark">
<i class="bi bi-shield-lock"></i> No Auth Mode
</span>
</span>
</li>
{% endif %}
{% if session.user %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">

View File

@@ -0,0 +1,105 @@
{% extends "layout.html" %}
{% block title %}Select Role - KCAP Demo Server{% endblock %}
{% block extra_css %}
<style>
.role-selection-container {
max-width: 600px;
margin: 100px auto;
}
.role-card {
transition: all 0.3s ease;
cursor: pointer;
border: 2px solid transparent;
}
.role-card:hover {
transform: translateY(-5px);
border-color: #0d6efd;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.role-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.auth-mode-badge {
position: fixed;
top: 80px;
right: 20px;
z-index: 1000;
}
</style>
{% endblock %}
{% block content %}
<span class="badge bg-warning text-dark auth-mode-badge">
<i class="bi bi-shield-lock"></i> No Auth Mode
</span>
<div class="role-selection-container">
<div class="text-center mb-4">
<h2>Welcome to KCAP Demo Server</h2>
<p class="text-muted">Select your role to continue</p>
<small class="text-muted">
<i class="bi bi-info-circle"></i> Running in no-auth mode for local testing
</small>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="card role-card h-100 text-center" onclick="selectRole('admin')">
<div class="card-body p-4">
<div class="role-icon text-danger">
<i class="bi bi-shield-fill-check"></i>
</div>
<h4>Admin</h4>
<p class="text-muted mb-0">
Full access to all tenants, server settings, and user management
</p>
<ul class="list-unstyled text-start mt-3">
<li><i class="bi bi-check-circle-fill text-success"></i> Manage all tenants</li>
<li><i class="bi bi-check-circle-fill text-success"></i> Server settings</li>
<li><i class="bi bi-check-circle-fill text-success"></i> User management</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card role-card h-100 text-center" onclick="selectRole('user')">
<div class="card-body p-4">
<div class="role-icon text-primary">
<i class="bi bi-person-circle"></i>
</div>
<h4>User</h4>
<p class="text-muted mb-0">
Access to assigned tenants only
</p>
<ul class="list-unstyled text-start mt-3">
<li><i class="bi bi-check-circle-fill text-success"></i> Assigned tenants</li>
<li><i class="bi bi-x-circle-fill text-danger"></i> Server settings</li>
<li><i class="bi bi-x-circle-fill text-danger"></i> User management</li>
</ul>
</div>
</div>
</div>
</div>
<div class="alert alert-info mt-4" role="alert">
<i class="bi bi-lightbulb"></i>
<strong>Testing Mode:</strong> This selection is temporary and only for local development.
To use Entra ID authentication, set <code>AUTH_MODE=entra</code> in your .env file.
</div>
</div>
<form id="roleForm" method="POST" action="{{ url_for('auth.select_role') }}" style="display: none;">
<input type="hidden" name="role" id="roleInput">
</form>
<script>
function selectRole(role) {
document.getElementById('roleInput').value = role;
document.getElementById('roleForm').submit();
}
</script>
{% endblock %}