Revert "Add files via upload"

This reverts commit f172b00737092fdf4040bffd2f8c6c2742eb7a44.
This commit is contained in:
Joshua Hare 2025-05-11 22:32:57 +10:00
parent f172b00737
commit 5efae0c64e
30 changed files with 80 additions and 2443 deletions

View File

@ -1,11 +1,57 @@
FROM python:3.11-slim
# Base image with Python and Java
FROM tomcat:10.1-jdk17
# Install build dependencies, Node.js 18, and coreutils (for stdbuf)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv curl coreutils \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install specific versions of GoFSH and SUSHI
# REMOVED pip install fhirpath from this line
RUN npm install -g gofsh fsh-sushi
# Set up Python environment
WORKDIR /app
RUN python3 -m venv /app/venv
ENV PATH="/app/venv/bin:$PATH"
# ADDED: Uninstall old fhirpath just in case it's in requirements.txt
RUN pip uninstall -y fhirpath || true
# ADDED: Install the new fhirpathpy library
RUN pip install --no-cache-dir fhirpathpy
# Copy Flask files
COPY requirements.txt .
# Install requirements (including Pydantic - check version compatibility if needed)
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ app/
COPY app.py .
COPY services.py .
COPY forms.py .
COPY package.py .
COPY templates/ templates/
COPY static/ static/
COPY instance/ instance/
COPY config.py .
COPY .env .
EXPOSE 5009
CMD ["flask", "run", "--host=0.0.0.0", "--port=5009"]
COPY tests/ tests/
# Ensure /tmp, /app/h2-data, /app/static/uploads, and /app/logs are writable
RUN mkdir -p /tmp /app/h2-data /app/static/uploads /app/logs && chmod 777 /tmp /app/h2-data /app/static/uploads /app/logs
# Copy pre-built HAPI WAR and configuration
COPY hapi-fhir-jpaserver/target/ROOT.war /usr/local/tomcat/webapps/
COPY hapi-fhir-jpaserver/target/classes/application.yaml /usr/local/tomcat/conf/
COPY hapi-fhir-jpaserver/target/classes/application.yaml /app/config/application.yaml
COPY hapi-fhir-jpaserver/target/classes/application.yaml /usr/local/tomcat/webapps/app/config/application.yaml
COPY hapi-fhir-jpaserver/custom/ /usr/local/tomcat/webapps/custom/
# Install supervisord
RUN pip install supervisor
# Configure supervisord
COPY supervisord.conf /etc/supervisord.conf
# Expose ports
EXPOSE 5000 8080
# Start supervisord
CMD ["supervisord", "-c", "/etc/supervisord.conf"]

View File

@ -1,61 +0,0 @@
import os
import hashlib
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_oauthlib.client import OAuth
from config import Config
from datetime import datetime
db = SQLAlchemy()
login_manager = LoginManager()
oauth = OAuth()
def create_app():
app = Flask(__name__, instance_relative_config=True, static_folder='/app/static')
app.config.from_object(Config)
try:
os.makedirs(app.instance_path, exist_ok=True)
os.makedirs('/app/uploads', exist_ok=True)
except OSError:
pass
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
oauth.init_app(app)
from app.models import User
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
@app.template_filter('md5')
def md5_filter(s):
if s:
return hashlib.md5(s.encode('utf-8').lower().strip()).hexdigest()
return ''
@app.template_filter('datetimeformat')
def datetimeformat(value):
if value == 'now':
return datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
return value
@app.template_filter('rejectattr')
def rejectattr_filter(d, attr):
if not isinstance(d, dict):
return {}
return {k: v for k, v in d.items() if k != attr}
from app.routes import gallery_bp
from app.auth import auth_bp
app.register_blueprint(gallery_bp)
app.register_blueprint(auth_bp, url_prefix='/auth')
with app.app_context():
db.create_all()
return app

View File

@ -1,205 +0,0 @@
import os
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
from flask_login import login_user, logout_user, login_required, current_user
from app import db, oauth
from app.models import User
from app.forms import LoginForm, RegisterForm, ChangePasswordForm
from werkzeug.security import generate_password_hash
auth_bp = Blueprint('auth', __name__)
google = None
github = None
if os.getenv('GOOGLE_CLIENT_ID') and os.getenv('GOOGLE_CLIENT_SECRET') and \
os.getenv('GOOGLE_CLIENT_ID') != 'your-google-client-id' and \
os.getenv('GOOGLE_CLIENT_SECRET') != 'your-google-client-secret':
google = oauth.remote_app(
'google',
consumer_key=os.getenv('GOOGLE_CLIENT_ID'),
consumer_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
request_token_params={'scope': 'email profile'},
base_url='https://www.googleapis.com/oauth2/v1/',
request_token_url=None,
access_token_method='POST',
access_token_url='https://accounts.google.com/o/oauth2/token',
authorize_url='https://accounts.google.com/o/oauth2/auth',
)
if os.getenv('GITHUB_CLIENT_ID') and os.getenv('GITHUB_CLIENT_SECRET'):
github = oauth.remote_app(
'github',
consumer_key=os.getenv('GITHUB_CLIENT_ID'),
consumer_secret=os.getenv('GITHUB_CLIENT_SECRET'),
request_token_params={'scope': 'user:email'},
base_url='https://api.github.com/',
request_token_url=None,
access_token_method='POST',
access_token_url='https://github.com/login/oauth/access_token',
authorize_url='https://github.com/login/oauth/authorize',
)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
if current_user.force_password_change:
return redirect(url_for('auth.change_password'))
return redirect(url_for('gallery.landing'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user and user.check_password(form.password.data):
login_user(user)
if user.force_password_change:
flash('Please change your password before continuing.', 'warning')
return redirect(url_for('auth.change_password'))
flash('Logged in successfully!', 'success')
return redirect(url_for('gallery.landing'))
flash('Invalid email or password.', 'danger')
return render_template('login.html', form=form)
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
if current_user.force_password_change:
return redirect(url_for('auth.change_password'))
return redirect(url_for('gallery.landing'))
form = RegisterForm()
if form.validate_on_submit():
if User.query.filter_by(email=form.email.data).first():
flash('Email already registered.', 'danger')
return render_template('register_user.html', form=form)
if User.query.filter_by(username=form.username.data).first():
flash('Username already taken.', 'danger')
return render_template('register_user.html', form=form)
user = User(
username=form.username.data,
email=form.email.data,
is_admin=False,
force_password_change=False
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
login_user(user)
flash('Registration successful! You are now logged in.', 'success')
return redirect(url_for('gallery.landing'))
return render_template('register_user.html', form=form)
@auth_bp.route('/login/google')
def login_google():
if not google:
flash('Google login is not configured.', 'danger')
return redirect(url_for('auth.login'))
return google.authorize(callback=url_for('auth.google_authorized', _external=True))
@auth_bp.route('/login/google/authorized')
def google_authorized():
if not google:
flash('Google login is not configured.', 'danger')
return redirect(url_for('auth.login'))
resp = google.authorized_response()
if resp is None or resp.get('access_token') is None:
flash('Google login failed.', 'danger')
return redirect(url_for('auth.login'))
session['google_token'] = (resp['access_token'], '')
user_info = google.get('userinfo').data
user = User.query.filter_by(oauth_provider='google', oauth_id=user_info['id']).first()
if not user:
email = user_info.get('email')
if not email:
flash('Google account has no verified email.', 'danger')
return redirect(url_for('auth.login'))
user = User(
username=email.split('@')[0],
email=email,
oauth_provider='google',
oauth_id=user_info['id'],
is_admin=False,
force_password_change=False
)
db.session.add(user)
db.session.commit()
login_user(user)
if user.force_password_change:
flash('Please change your password before continuing.', 'warning')
return redirect(url_for('auth.change_password'))
flash('Logged in with Google!', 'success')
return redirect(url_for('gallery.landing'))
@auth_bp.route('/login/github')
def login_github():
if not github:
flash('GitHub login is not configured.', 'danger')
return redirect(url_for('auth.login'))
return github.authorize(callback=url_for('auth.github_authorized', _external=True))
@auth_bp.route('/login/github/authorized')
def github_authorized():
if not github:
flash('GitHub login is not configured.', 'danger')
return redirect(url_for('auth.login'))
resp = github.authorized_response()
if resp is None or resp.get('access_token') is None:
flash('GitHub login failed.', 'danger')
return redirect(url_for('auth.login'))
session['github_token'] = (resp['access_token'], '')
user_info = github.get('user').data
emails = github.get('user/emails').data
primary_email = None
for email in emails:
if email.get('primary') and email.get('verified'):
primary_email = email['email']
break
if not primary_email:
primary_email = f"{user_info['login']}@github.com"
user = User.query.filter_by(oauth_provider='github', oauth_id=str(user_info['id'])).first()
if not user:
user = User(
username=user_info['login'],
email=primary_email,
oauth_provider='github',
oauth_id=str(user_info['id']),
is_admin=False,
force_password_change=False
)
db.session.add(user)
db.session.commit()
login_user(user)
if user.force_password_change:
flash('Please change your password before continuing.', 'warning')
return redirect(url_for('auth.change_password'))
flash('Logged in with GitHub!', 'success')
return redirect(url_for('gallery.landing'))
@auth_bp.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
form = ChangePasswordForm()
if form.validate_on_submit():
if not current_user.check_password(form.current_password.data):
flash('Current password is incorrect.', 'danger')
return render_template('change_password.html', form=form)
current_user.set_password(form.new_password.data)
current_user.force_password_change = False
db.session.commit()
flash('Password changed successfully!', 'success')
return redirect(url_for('gallery.landing'))
return render_template('change_password.html', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
flash('Logged out successfully.', 'success')
return redirect(url_for('gallery.landing'))
if google:
@google.tokengetter
def get_google_oauth_token():
return session.get('google_token')
if github:
@github.tokengetter
def get_github_oauth_token():
return session.get('github_token')

View File

@ -1,98 +0,0 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, MultipleFileField
from wtforms import StringField, TextAreaField, SubmitField, PasswordField, SelectField, SelectMultipleField, BooleanField
from wtforms.validators import DataRequired, Email, Length, EqualTo, Optional, ValidationError
import re
from app.models import Category, OSSupport, FHIRSupport, PricingLicense, DesignedFor, User
from app import db
def validate_url_or_path(form, field):
if not field.data:
return
# Allow upload paths like /uploads/<uuid>_<filename>.jpg|png
if field.data.startswith('/uploads/'):
path_pattern = r'^/uploads/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_[\w\-\.]+\.(jpg|png)$'
if re.match(path_pattern, field.data, re.I):
return
# Allow external URLs
url_pattern = r'^(https?:\/\/)?([\w\-]+\.)+[\w\-]+(\/[\w\-\.]*)*\/?(\?[^\s]*)?(#[^\s]*)?$'
if not re.match(url_pattern, field.data):
raise ValidationError('Invalid URL or file path.')
def validate_username(form, field):
if form.user and field.data != form.user.username: # Check if username changed
existing_user = User.query.filter_by(username=field.data).first()
if existing_user:
raise ValidationError('Username already taken.')
def validate_email(form, field):
if form.user and field.data != form.user.email: # Check if email changed
existing_user = User.query.filter_by(email=field.data).first()
if existing_user:
raise ValidationError('Email already registered.')
class FHIRAppForm(FlaskForm):
name = StringField('App Name', validators=[DataRequired(), Length(min=3, max=100)])
description = TextAreaField('Description', validators=[DataRequired(), Length(min=10, max=500)])
developer = StringField('Developer/Organization', validators=[DataRequired(), Length(min=3, max=100)])
contact_email = StringField('Contact Email', validators=[DataRequired(), Email()])
logo_url = StringField('Logo URL', validators=[Optional(), validate_url_or_path], render_kw={"placeholder": "https://example.com/logo.png or leave blank to upload"})
logo_upload = FileField('Upload Logo', validators=[FileAllowed(['jpg', 'png'], 'Images only!')])
launch_url = StringField('Launch URL', validators=[DataRequired(), Length(max=200)])
website = StringField('Company Website', validators=[Optional(), Length(max=200)], render_kw={"placeholder": "https://example.com"})
designed_for = SelectField('Designed For', coerce=int, validators=[DataRequired()])
fhir_compatibility = SelectField('FHIR Compatibility', coerce=int, validators=[DataRequired()])
categories = SelectMultipleField('Categories', coerce=int, validators=[DataRequired()])
licensing_pricing = SelectField('Licensing & Pricing', coerce=int, validators=[DataRequired()])
os_support = SelectMultipleField('OS Support', coerce=int, validators=[DataRequired()])
app_image_urls = TextAreaField('App Image URLs (one per line)', validators=[Optional(), Length(max=1000)], render_kw={"placeholder": "e.g., https://example.com/image1.png"})
app_image_uploads = MultipleFileField('Upload App Images', validators=[FileAllowed(['jpg', 'png'], 'Images only!')])
submit = SubmitField('Register App')
def __init__(self, *args, **kwargs):
super(FHIRAppForm, self).__init__(*args, **kwargs)
self.categories.choices = [(c.id, c.name) for c in Category.query.all()]
self.os_support.choices = [(o.id, o.name) for o in OSSupport.query.all()]
self.fhir_compatibility.choices = [(f.id, f.name) for f in FHIRSupport.query.all()]
self.licensing_pricing.choices = [(p.id, p.name) for p in PricingLicense.query.all()]
self.designed_for.choices = [(d.id, d.name) for d in DesignedFor.query.all()]
class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Log In')
class RegisterForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=80)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=6)])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
class GalleryFilterForm(FlaskForm):
categories = StringField('Categories', validators=[Length(max=500)], render_kw={"placeholder": "e.g., Clinical, Billing"})
fhir_compatibility = StringField('FHIR Compatibility', validators=[Length(max=200)], render_kw={"placeholder": "e.g., R4, US Core"})
submit = SubmitField('Filter')
class CategoryForm(FlaskForm):
name = StringField('Category Name', validators=[DataRequired(), Length(min=3, max=100)])
submit = SubmitField('Save Category')
class ChangePasswordForm(FlaskForm):
current_password = PasswordField('Current Password', validators=[DataRequired()])
new_password = PasswordField('New Password', validators=[DataRequired(), Length(min=6)])
confirm_password = PasswordField('Confirm New Password', validators=[DataRequired(), EqualTo('new_password')])
submit = SubmitField('Change Password')
class UserEditForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=80), validate_username])
email = StringField('Email', validators=[DataRequired(), Email(), validate_email])
is_admin = BooleanField('Admin Status')
force_password_change = BooleanField('Force Password Change')
reset_password = PasswordField('Reset Password', validators=[Optional(), Length(min=6)])
confirm_reset_password = PasswordField('Confirm Reset Password', validators=[Optional(), EqualTo('reset_password')])
submit = SubmitField('Save Changes')
def __init__(self, user=None, *args, **kwargs):
super(UserEditForm, self).__init__(*args, **kwargs)
self.user = user # Store the user object for validation

View File

@ -1,74 +0,0 @@
from app import db
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=True)
password_hash = db.Column(db.String(255))
oauth_provider = db.Column(db.String(50))
oauth_id = db.Column(db.String(100))
is_admin = db.Column(db.Boolean, default=False)
force_password_change = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
@property
def is_authenticated(self):
return True
@property
def is_active(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return str(self.id)
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
class OSSupport(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
class FHIRSupport(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
class PricingLicense(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
class DesignedFor(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
class FHIRApp(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=False)
developer = db.Column(db.String(100), nullable=False)
contact_email = db.Column(db.String(120), nullable=False)
logo_url = db.Column(db.String(200))
launch_url = db.Column(db.String(200), nullable=False)
website = db.Column(db.String(200))
designed_for_id = db.Column(db.Integer, db.ForeignKey('designed_for.id'))
fhir_compatibility_id = db.Column(db.Integer, db.ForeignKey('fhir_support.id'))
categories = db.Column(db.Text) # Comma-separated Category IDs
licensing_pricing_id = db.Column(db.Integer, db.ForeignKey('pricing_license.id'))
os_support = db.Column(db.Text) # Comma-separated OSSupport IDs
app_images = db.Column(db.Text)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
registration_date = db.Column(db.DateTime, default=datetime.utcnow)
last_updated = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@ -1,392 +0,0 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, send_from_directory, abort
from flask_login import login_required, current_user
from app import db
from app.models import FHIRApp, Category, OSSupport, FHIRSupport, PricingLicense, DesignedFor, User
from app.forms import FHIRAppForm, GalleryFilterForm, CategoryForm, UserEditForm
from sqlalchemy import or_
import os
import logging
from werkzeug.utils import secure_filename
import uuid
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
gallery_bp = Blueprint('gallery', __name__)
UPLOAD_FOLDER = '/app/uploads/'
ALLOWED_EXTENSIONS = {'jpg', 'png'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@gallery_bp.route('/uploads/<path:filename>')
def uploaded_file(filename):
absolute_upload_folder = os.path.abspath(UPLOAD_FOLDER)
logger.debug(f"Attempting to serve file: {filename} from {absolute_upload_folder}")
try:
return send_from_directory(absolute_upload_folder, filename)
except FileNotFoundError:
logger.error(f"File not found: {os.path.join(absolute_upload_folder, filename)}")
abort(404)
@gallery_bp.route('/')
def index():
return redirect(url_for('gallery.landing'))
@gallery_bp.route('/landing')
def landing():
featured_apps = FHIRApp.query.order_by(FHIRApp.registration_date.desc()).limit(6).all()
categories = Category.query.all()
return render_template('landing.html', featured_apps=featured_apps, categories=categories)
@gallery_bp.route('/gallery', methods=['GET', 'POST'])
def gallery():
form = GalleryFilterForm()
query = FHIRApp.query
filter_params = {}
search_term = request.args.get('search', '').strip()
if search_term:
query = query.filter(
or_(
FHIRApp.name.ilike(f'%{search_term}%'),
FHIRApp.description.ilike(f'%{search_term}%'),
FHIRApp.developer.ilike(f'%{search_term}%')
)
)
filter_params['search'] = search_term
category_ids = request.args.getlist('category', type=int)
os_support_ids = request.args.getlist('os_support', type=int)
fhir_support_ids = request.args.getlist('fhir_support', type=int)
pricing_license_ids = request.args.getlist('pricing_license', type=int)
designed_for_ids = request.args.getlist('designed_for', type=int)
if category_ids:
query = query.filter(or_(*[FHIRApp.categories.contains(str(cid)) for cid in category_ids]))
filter_params['category'] = category_ids
if os_support_ids:
query = query.filter(or_(*[FHIRApp.os_support.contains(str(oid)) for oid in os_support_ids]))
filter_params['os_support'] = os_support_ids
if fhir_support_ids:
query = query.filter(FHIRApp.fhir_compatibility_id.in_(fhir_support_ids))
filter_params['fhir_support'] = fhir_support_ids
if pricing_license_ids:
query = query.filter(FHIRApp.licensing_pricing_id.in_(pricing_license_ids))
filter_params['pricing_license'] = pricing_license_ids
if designed_for_ids:
query = query.filter(FHIRApp.designed_for_id.in_(designed_for_ids))
filter_params['designed_for'] = designed_for_ids
apps = query.all()
for app in apps:
logger.debug(f"App ID: {app.id}, logo_url: {app.logo_url}")
return render_template(
'gallery.html',
apps=apps,
form=form,
filter_params=filter_params,
categories=Category.query.all(),
os_supports=OSSupport.query.all(),
fhir_supports=FHIRSupport.query.all(),
pricing_licenses=PricingLicense.query.all(),
designed_fors=DesignedFor.query.all()
)
@gallery_bp.route('/gallery/<int:app_id>')
def app_detail(app_id):
app = FHIRApp.query.get_or_404(app_id)
logger.debug(f"App Detail ID: {app_id}, logo_url: {app.logo_url}, app_images: {app.app_images}")
app_categories = []
if app.categories:
category_ids = [int(cid) for cid in app.categories.split(',') if cid]
app_categories = Category.query.filter(Category.id.in_(category_ids)).all()
app_os_supports = []
if app.os_support:
os_ids = [int(oid) for oid in app.os_support.split(',') if oid]
app_os_supports = OSSupport.query.filter(OSSupport.id.in_(os_ids)).all()
return render_template(
'app_detail.html',
app=app,
app_categories=app_categories,
app_os_supports=app_os_supports
)
@gallery_bp.route('/gallery/register', methods=['GET', 'POST'])
@login_required
def register():
form = FHIRAppForm()
if form.validate_on_submit():
try:
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
logger.debug(f"Ensured {UPLOAD_FOLDER} exists")
except Exception as e:
logger.error(f"Failed to create {UPLOAD_FOLDER}: {e}")
flash('Error creating upload directory.', 'danger')
return render_template('register.html', form=form)
logo_url = form.logo_url.data
if form.logo_upload.data:
file = form.logo_upload.data
if allowed_file(file.filename):
filename = secure_filename(f"{uuid.uuid4()}_{file.filename}")
save_path = os.path.join(UPLOAD_FOLDER, filename)
logger.debug(f"Attempting to save logo to {save_path}")
try:
file.save(save_path)
if os.path.exists(save_path):
logger.debug(f"Successfully saved logo to {save_path}")
else:
logger.error(f"Failed to save logo to {save_path}")
flash('Failed to save logo.', 'danger')
return render_template('register.html', form=form)
logo_url = f"/uploads/{filename}"
logger.debug(f"Set logo_url to {logo_url}")
except Exception as e:
logger.error(f"Error saving logo to {save_path}: {e}")
flash('Error saving logo.', 'danger')
return render_template('register.html', form=form)
app_images = []
if form.app_image_urls.data:
app_images.extend([url.strip() for url in form.app_image_urls.data.splitlines() if url.strip().startswith(('http://', 'https://'))])
if form.app_image_uploads.data:
for file in form.app_image_uploads.data:
if file and allowed_file(file.filename):
filename = secure_filename(f"{uuid.uuid4()}_{file.filename}")
save_path = os.path.join(UPLOAD_FOLDER, filename)
logger.debug(f"Attempting to save app image to {save_path}")
try:
file.save(save_path)
if os.path.exists(save_path):
logger.debug(f"Successfully saved app image to {save_path}")
else:
logger.error(f"Failed to save app image to {save_path}")
flash('Failed to save app image.', 'danger')
return render_template('register.html', form=form)
app_images.append(f"/uploads/{filename}")
except Exception as e:
logger.error(f"Error saving app image to {save_path}: {e}")
flash('Error saving app image.', 'danger')
return render_template('register.html', form=form)
app = FHIRApp(
name=form.name.data,
description=form.description.data,
developer=form.developer.data,
contact_email=form.contact_email.data,
logo_url=logo_url or None,
launch_url=form.launch_url.data,
website=form.website.data or None,
designed_for_id=form.designed_for.data,
fhir_compatibility_id=form.fhir_compatibility.data,
categories=','.join(map(str, form.categories.data)) if form.categories.data else None,
licensing_pricing_id=form.licensing_pricing.data,
os_support=','.join(map(str, form.os_support.data)) if form.os_support.data else None,
app_images=','.join(app_images) if app_images else None,
user_id=current_user.id
)
db.session.add(app)
try:
db.session.commit()
logger.debug(f"Registered app ID: {app.id}, logo_url: {app.logo_url}, app_images: {app.app_images}")
except Exception as e:
logger.error(f"Error committing app to database: {e}")
db.session.rollback()
flash('Error saving app to database.', 'danger')
return render_template('register.html', form=form)
flash('App registered successfully!', 'success')
return redirect(url_for('gallery.gallery'))
return render_template('register.html', form=form)
@gallery_bp.route('/gallery/edit/<int:app_id>', methods=['GET', 'POST'])
@login_required
def edit_app(app_id):
app = FHIRApp.query.get_or_404(app_id)
if not current_user.is_admin and app.user_id != current_user.id:
flash('You can only edit your own apps.', 'danger')
return redirect(url_for('gallery.app_detail', app_id=app_id))
form = FHIRAppForm(obj=app)
if not form.is_submitted():
if app.categories:
form.categories.data = [int(cid) for cid in app.categories.split(',') if cid]
if app.os_support:
form.os_support.data = [int(oid) for oid in app.os_support.split(',') if oid]
if app.app_images:
current_images = [img for img in app.app_images.split(',') if img.startswith(('http://', 'https://', '/uploads/'))]
form.app_image_urls.data = '\n'.join(current_images)
else:
form.app_image_urls.data = ''
if form.validate_on_submit():
try:
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
logger.debug(f"Ensured {UPLOAD_FOLDER} exists")
except Exception as e:
logger.error(f"Failed to create {UPLOAD_FOLDER}: {e}")
flash('Error creating upload directory.', 'danger')
return render_template('edit_app.html', form=form, app=app)
logo_url = form.logo_url.data
if form.logo_upload.data:
file = form.logo_upload.data
if allowed_file(file.filename):
filename = secure_filename(f"{uuid.uuid4()}_{file.filename}")
save_path = os.path.join(UPLOAD_FOLDER, filename)
logger.debug(f"Attempting to save updated logo to {save_path}")
try:
file.save(save_path)
if os.path.exists(save_path):
logger.debug(f"Successfully saved updated logo to {save_path}")
else:
logger.error(f"Failed to save updated logo to {save_path}")
flash('Failed to save logo.', 'danger')
return render_template('edit_app.html', form=form, app=app)
logo_url = f"/uploads/{filename}"
logger.debug(f"Set logo_url to {logo_url}")
except Exception as e:
logger.error(f"Error saving updated logo to {save_path}: {e}")
flash('Error saving logo.', 'danger')
return render_template('edit_app.html', form=form, app=app)
elif not logo_url:
logo_url = app.logo_url
app_images = [url.strip() for url in form.app_image_urls.data.splitlines() if url.strip()]
if form.app_image_uploads.data:
for file in form.app_image_uploads.data:
if file and allowed_file(file.filename):
filename = secure_filename(f"{uuid.uuid4()}_{file.filename}")
save_path = os.path.join(UPLOAD_FOLDER, filename)
logger.debug(f"Attempting to save updated app image to {save_path}")
try:
file.save(save_path)
if os.path.exists(save_path):
logger.debug(f"Successfully saved updated app image to {save_path}")
else:
logger.error(f"Failed to save updated app image to {save_path}")
flash('Failed to save app image.', 'danger')
return render_template('edit_app.html', form=form, app=app)
app_images.append(f"/uploads/{filename}")
except Exception as e:
logger.error(f"Error saving updated app image to {save_path}: {e}")
flash('Error saving app image.', 'danger')
return render_template('edit_app.html', form=form, app=app)
app.name = form.name.data
app.description = form.description.data
app.developer = form.developer.data
app.contact_email = form.contact_email.data
app.logo_url = logo_url
app.launch_url = form.launch_url.data
app.website = form.website.data or None
app.designed_for_id = form.designed_for.data
app.fhir_compatibility_id = form.fhir_compatibility.data
app.categories = ','.join(map(str, form.categories.data)) if form.categories.data else None
app.licensing_pricing_id = form.licensing_pricing.data
app.os_support = ','.join(map(str, form.os_support.data)) if form.os_support.data else None
app.app_images = ','.join(app_images) if app_images else None
try:
db.session.commit()
logger.debug(f"Updated app ID: {app.id}, logo_url: {app.logo_url}, app_images: {app.app_images}")
except Exception as e:
logger.error(f"Error committing app update to database: {e}")
db.session.rollback()
flash('Error updating app in database.', 'danger')
return render_template('edit_app.html', form=form, app=app)
flash('App updated successfully!', 'success')
return redirect(url_for('gallery.app_detail', app_id=app_id))
return render_template('edit_app.html', form=form, app=app)
@gallery_bp.route('/gallery/delete/<int:app_id>', methods=['POST'])
@login_required
def delete_app(app_id):
app = FHIRApp.query.get_or_404(app_id)
if not current_user.is_admin and app.user_id != current_user.id:
flash('You can only delete your own apps.', 'danger')
return redirect(url_for('gallery.app_detail', app_id=app_id))
db.session.delete(app)
db.session.commit()
flash(f'App "{app.name}" deleted successfully.', 'success')
return redirect(url_for('gallery.gallery'))
@gallery_bp.route('/my-listings')
@login_required
def my_listings():
apps = FHIRApp.query.filter_by(user_id=current_user.id).all()
return render_template('my_listings.html', apps=apps)
@gallery_bp.route('/admin/categories', methods=['GET', 'POST'])
@login_required
def manage_categories():
if not current_user.is_admin:
flash('Admin access required.', 'danger')
return redirect(url_for('gallery.landing'))
form = CategoryForm()
if form.validate_on_submit():
category = Category(name=form.name.data)
db.session.add(category)
db.session.commit()
flash('Category added successfully!', 'success')
return redirect(url_for('gallery.manage_categories'))
categories = Category.query.all()
return render_template('admin_categories.html', form=form, categories=categories)
@gallery_bp.route('/admin/categories/delete/<int:category_id>', methods=['POST'])
@login_required
def delete_category(category_id):
if not current_user.is_admin:
flash('Admin access required.', 'danger')
return redirect(url_for('gallery.landing'))
category = Category.query.get_or_404(category_id)
db.session.delete(category)
db.session.commit()
flash(f'Category "{category.name}" deleted successfully.', 'success')
return redirect(url_for('gallery.manage_categories'))
@gallery_bp.route('/admin/apps')
@login_required
def admin_apps():
if not current_user.is_admin:
flash('Admin access required.', 'danger')
return redirect(url_for('gallery.landing'))
apps = FHIRApp.query.all()
return render_template('admin_apps.html', apps=apps)
@gallery_bp.route('/admin/users', methods=['GET'])
@login_required
def admin_users():
if not current_user.is_admin:
flash('Admin access required.', 'danger')
return redirect(url_for('gallery.landing'))
users = User.query.all()
return render_template('admin_users.html', users=users)
@gallery_bp.route('/admin/users/edit/<int:user_id>', methods=['GET', 'POST'])
@login_required
def edit_user(user_id):
if not current_user.is_admin:
flash('Admin access required.', 'danger')
return redirect(url_for('gallery.landing'))
user = User.query.get_or_404(user_id)
form = UserEditForm(user=user, obj=user)
if form.validate_on_submit():
user.username = form.username.data
user.email = form.email.data
user.is_admin = form.is_admin.data
user.force_password_change = form.force_password_change.data
if form.reset_password.data:
user.set_password(form.reset_password.data)
try:
db.session.commit()
flash(f'User "{user.username}" updated successfully.', 'success')
except Exception as e:
logger.error(f"Error updating user: {e}")
db.session.rollback()
flash('Error updating user.', 'danger')
return redirect(url_for('gallery.admin_users'))
return render_template('admin_edit_user.html', form=form, user=user)

View File

@ -1,37 +0,0 @@
{% extends "base.html" %}
{% block title %}Manage All Apps{% endblock %}
{% block content %}
<div class="container mt-4">
<h1>Manage All Apps</h1>
{% if apps %}
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Developer</th>
<th>Submitted By</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for app in apps %}
<tr>
<td>{{ app.name }}</td>
<td>{{ app.developer }}</td>
<td>{{ app.user.username }}</td>
<td>
<a href="{{ url_for('gallery.app_detail', app_id=app.id) }}" class="btn btn-primary btn-sm">View</a>
<a href="{{ url_for('gallery.edit_app', app_id=app.id) }}" class="btn btn-warning btn-sm">Edit</a>
<form action="{{ url_for('gallery.delete_app', app_id=app.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to delete {{ app.name }}?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No apps registered yet.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -1,42 +0,0 @@
{% extends "base.html" %}
{% block title %}Manage Categories{% endblock %}
{% block content %}
<div class="container mt-4">
<h1>Manage Categories</h1>
<form method="POST" class="mb-4">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
{% for error in form.name.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
{% if categories %}
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td>{{ category.name }}</td>
<td>
<form action="{{ url_for('gallery.delete_category', category_id=category.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to delete {{ category.name }}?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No categories defined yet.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block title %}Edit User{% endblock %}
{% block content %}
<div class="container mt-4">
<h1>Edit User: {{ user.username }}</h1>
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control") }}
{% for error in form.username.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% for error in form.email.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.is_admin.label(class="form-check-label") }}
{{ form.is_admin(class="form-check-input") }}
{% for error in form.is_admin.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.force_password_change.label(class="form-check-label") }}
{{ form.force_password_change(class="form-check-input") }}
{% for error in form.force_password_change.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.reset_password.label(class="form-label") }}
{{ form.reset_password(class="form-control") }}
<small class="form-text text-muted">Leave blank to keep the current password.</small>
{% for error in form.reset_password.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.confirm_reset_password.label(class="form-label") }}
{{ form.confirm_reset_password(class="form-control") }}
{% for error in form.confirm_reset_password.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('gallery.admin_users') }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}

View File

@ -1,35 +0,0 @@
{% extends "base.html" %}
{% block title %}Manage Users{% endblock %}
{% block content %}
<div class="container mt-4">
<h1>Manage Users</h1>
{% if users %}
<table class="table table-hover">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Admin</th>
<th>Force Password Change</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>{{ 'Yes' if user.is_admin else 'No' }}</td>
<td>{{ 'Yes' if user.force_password_change else 'No' }}</td>
<td>
<a href="{{ url_for('gallery.edit_user', user_id=user.id) }}" class="btn btn-warning btn-sm">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No users found.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -1,248 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ app.name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="card shadow-sm mx-auto" style="max-width: 900px; border: none; border-radius: 10px; animation: fadeIn 0.5s;">
<div class="card-body p-4">
{% if app.logo_url %}
<img src="{{ app.logo_url }}" alt="{{ app.name }} logo" class="d-block mx-auto mb-3" style="width: 100px; height: 100px; object-fit: contain;" onerror="this.src='https://via.placeholder.com/100?text=No+Logo';">
{% else %}
<img src="https://via.placeholder.com/100?text=No+Logo" alt="No Logo" class="d-block mx-auto mb-3" style="width: 100px; height: 100px; object-fit: contain;">
{% endif %}
<h1 class="text-center mb-2">{{ app.name }}</h1>
<h5 class="text-center text-muted mb-3">
<a href="{{ url_for('gallery.gallery', search=app.developer) }}" class="text-decoration-none">{{ app.developer }}</a>
</h5>
<div class="d-flex justify-content-center gap-2 mb-4">
{% if app.website %}
<a href="{{ app.website }}" class="btn btn-primary" target="_blank" rel="noopener noreferrer" aria-label="Visit {{ app.name }} website">Website</a>
{% else %}
<button class="btn btn-primary" disabled>Website</button>
{% endif %}
{% if app.launch_url %}
<a href="{{ app.launch_url }}" class="btn btn-outline-primary" target="_blank" rel="noopener noreferrer" aria-label="Try {{ app.name }} with sandbox data">Try App</a>
{% endif %}
</div>
<hr class="my-4">
<div class="row g-4">
<div class="col-md-6">
{% if app.app_images %}
<div id="appImagesCarousel" class="carousel slide mb-4" data-bs-ride="carousel">
<div class="carousel-inner">
{% for img_url in app.app_images.split(',') %}
<div class="carousel-item {% if loop.first %}active{% endif %}">
<img src="{{ img_url }}" class="d-block w-100" alt="App Screenshot {{ loop.index }}" style="max-height: 300px; object-fit: contain;" onerror="this.src='https://via.placeholder.com/300?text=No+Image';">
</div>
{% endfor %}
</div>
<button class="carousel-control-prev" type="button" data-bs-target="#appImagesCarousel" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#appImagesCarousel" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
</button>
</div>
{% else %}
<img src="https://via.placeholder.com/300?text=No+Image" class="d-block w-100 mb-4" alt="No Image" style="max-height: 300px; object-fit: contain;">
{% endif %}
<section class="mb-3">
<h5>Description</h5>
<hr class="small-hr">
<p>{{ app.description or 'No description provided.' }}</p>
</section>
<section class="mb-3">
<h5>Contact Email</h5>
<hr class="small-hr">
<p>{{ app.contact_email or 'Not specified.' }}</p>
</section>
</div>
<div class="col-md-6">
<section class="mb-3">
<h5>Website</h5>
<hr class="small-hr">
<p>
{% if app.website %}
<a href="{{ app.website }}" class="text-decoration-none" target="_blank" rel="noopener noreferrer">{{ app.website }}</a>
{% else %}
Not specified.
{% endif %}
</p>
</section>
{% if app.website %}
<section class="mb-3">
<h5>Security and Privacy Policy</h5>
<hr class="small-hr">
<p><a href="{{ app.website }}/security-and-privacy" class="text-decoration-none" target="_blank" rel="noopener noreferrer">{{ app.website }}/security-and-privacy</a></p>
</section>
{% endif %}
<section class="mb-3">
<h5>Launch URL</h5>
<hr class="small-hr">
<p><a href="{{ app.launch_url }}" class="text-decoration-none" target="_blank" rel="noopener noreferrer">{{ app.launch_url }}</a></p>
</section>
<section class="mb-3">
<div class="row g-2">
<div class="col-6">
<h5>Designed For</h5>
<hr class="small-hr">
<p>
{% if app.designed_for %}
<a href="{{ url_for('gallery.gallery', designed_for=app.designed_for_id) }}" class="text-decoration-none">{{ app.designed_for.name }}</a>
{% else %}
Not specified.
{% endif %}
</p>
</div>
<div class="col-6">
<h5>FHIR Compatibility</h5>
<.ttf class="small-hr">
<p>
{% if app.fhir_compatibility %}
<a href="{{ url_for('gallery.gallery', fhir_support=app.fhir_compatibility_id) }}" class="text-decoration-none">{{ app.fhir_compatibility.name }}</a>
{% else %}
Not specified.
{% endif %}
</p>
</div>
</div>
</section>
<section class="mb-3">
<h5>OS Support</h5>
<hr class="small-hr">
<p>
{% if app_os_supports %}
{% for os in app_os_supports %}
<a href="{{ url_for('gallery.gallery', os_support=os.id) }}" class="text-decoration-none">{{ os.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
Not specified.
{% endif %}
</p>
</section>
<section class="mb-3">
<h5>Categories</h5>
<hr class="small-hr">
<p>
{% if app_categories %}
{% for category in app_categories %}
<a href="{{ url_for('gallery.gallery', category=category.id) }}" class="text-decoration-none">{{ category.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
Not specified.
{% endif %}
</p>
</section>
<section class="mb-3">
<h5>Licensing & Pricing</h5>
<hr class="small-hr">
<p>
{% if app.licensing_pricing %}
<a href="{{ url_for('gallery.gallery', pricing_license=app.licensing_pricing_id) }}" class="text-decoration-none">{{ app.licensing_pricing.name }}</a>
{% else %}
Not specified.
{% endif %}
</p>
</section>
</div>
</div>
{% if current_user.is_authenticated and (current_user.is_admin or current_user.id == app.user_id) %}
<div class="d-flex justify-content-center gap-2 mt-4">
<a href="{{ url_for('gallery.edit_app', app_id=app.id) }}" class="btn btn-primary" aria-label="Edit {{ app.name }}">Edit App</a>
<form action="{{ url_for('gallery.delete_app', app_id=app.id) }}" method="POST">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this app?');" aria-label="Delete {{ app.name }}">Delete App</button>
</form>
</div>
{% endif %}
</div>
</div>
<div class="mt-3 d-flex justify-content-center">
<a href="mailto:support@fhirpad.com?subject=Report Listing: {{ app.name }}" class="text-danger text-decoration-none" aria-label="Report issue with {{ app.name }} listing">
<i class="fas fa-exclamation-circle me-1"></i> Report Listing
</a>
</div>
</div>
<style>
.card {
background-color: #ffffff;
}
html[data-theme="dark"] .card {
background-color: #343a40;
color: #f8f9fa;
}
html[data-theme="dark"] .text-muted {
color: #adb5bd !important;
}
html[data-theme="dark"] hr {
border-top: 1px solid #495057;
}
.small-hr {
border-top: 1px solid #e0e0e0;
margin: 0.5rem 0;
}
html[data-theme="dark"] .small-hr {
border-top: 1px solid #495057;
}
.carousel-control-prev,
.carousel-control-next {
width: 10%;
background: rgba(0, 0, 0, 0.3);
}
.btn-primary {
background-color: #007bff;
border-color: #007bff;
}
html[data-theme="dark"] .btn-primary {
background-color: #4dabf7;
border-color: #4dabf7;
}
.btn-outline-primary {
color: #007bff;
border-color: #007bff;
}
html[data-theme="dark"] .btn-outline-primary {
color: #4dabf7;
border-color: #4dabf7;
}
.btn-outline-primary:hover {
THESE BUTTONS ARE CURRENTLY DISABLED ON THE DETAIL PAGE
background-color: #007bff;
color: white;
}
html[data-theme="dark"] .btn-outline-primary:hover {
background-color: #4dabf7;
color: white;
}
h5 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
p {
margin-bottom: 0.5rem;
}
.carousel + section {
margin-top: 0;
}
@media (max-width: 576px) {
.card-body {
padding: 1.5rem;
}
.carousel {
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
}
h5 {
font-size: 0.95rem;
}
p {
font-size: 0.85rem;
}
.row.g-2 > .col-6 {
width: 100%;
}
}
</style>
{% endblock %}

View File

@ -1,478 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FHIRPAD - FHIR App Platform - {% block title %}{% endblock %}</title>
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<style>
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f8f9fa;
color: #212529;
display: flex;
flex-direction: column;
min-height: 100vh;
margin: 0;
}
main {
flex-grow: 1;
}
h1, h5 {
font-weight: 600;
}
.navbar-light {
background-color: #ffffff !important;
border-bottom: 1px solid #e0e0e0;
}
.navbar-brand {
font-weight: bold;
color: #007bff !important;
}
.nav-link {
color: #333 !important;
transition: color 0.2s ease;
}
.nav-link:hover {
color: #007bff !important;
}
.nav-link.active {
color: #007bff !important;
font-weight: bold;
border-bottom: 2px solid #007bff;
}
.dropdown-menu {
list-style: none !important;
position: absolute;
background-color: #ffffff;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 0;
min-width: 160px;
z-index: 1000;
}
.dropdown-item {
padding: 0.5rem 1rem;
transition: background-color 0.2s ease;
}
.dropdown-item:hover {
background-color: #e9ecef;
}
.dropdown-item.active {
background-color: #007bff;
color: white !important;
}
.app-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border-radius: 10px;
overflow: hidden;
background-color: #ffffff;
color: #212529;
}
.app-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15) !important;
}
.card-img-top {
background-color: #f8f9fa;
}
.card {
background-color: #ffffff;
}
.text-muted {
color: #6c757d !important;
}
.form-check-input:checked {
background-color: #007bff;
border-color: #007bff;
}
.form-check-label i {
font-size: 1.2rem;
margin-left: 0.5rem;
}
.navbar-controls {
gap: 0.5rem;
padding-right: 0;
}
.navbar-search input,
.view-toggle {
height: 38px;
}
.navbar-search {
width: 200px;
}
.nav-link.login-link {
line-height: 38px;
padding: 0 0.5rem;
}
footer {
background-color: #ffffff;
height: 56px;
display: flex;
align-items: center;
padding: 0.5rem 1rem;
border-top: 1px solid #e0e0e0;
}
.footer-left,
.footer-right {
display: inline-flex;
gap: 0.75rem;
align-items: center;
}
.footer-left a,
.footer-right a {
color: #007bff;
text-decoration: none;
font-size: 0.85rem;
}
.footer-left a:hover,
.footer-right a:hover {
text-decoration: underline;
}
.initials-avatar {
display: inline-block;
width: 30px;
height: 30px;
border-radius: 50%;
background-color: #007bff;
color: white;
text-align: center;
line-height: 30px;
font-weight: bold;
font-size: 14px;
vertical-align: middle;
}
.user-avatar {
display: inline-block;
border-radius: 50%;
vertical-align: middle;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
html[data-theme="dark"] body {
background-color: #212529;
color: #f8f9fa;
}
html[data-theme="dark"] .navbar-light {
background-color: #343a40 !important;
border-bottom: 1px solid #495057;
}
html[data-theme="dark"] .navbar-brand {
color: #4dabf7 !important;
}
html[data-theme="dark"] .nav-link {
color: #f8f9fa !important;
}
html[data-theme="dark"] .nav-link:hover,
html[data-theme="dark"] .nav-link.active {
color: #4dabf7 !important;
border-bottom-color: #4dabf7;
}
html[data-theme="dark"] .dropdown-menu {
background-color: #495057;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
html[data-theme="dark"] .dropdown-item {
color: #f8f9fa;
}
html[data-theme="dark"] .dropdown-item:hover {
background-color: #6c757d;
}
html[data-theme="dark"] .dropdown-item.active {
background-color: #4dabf7;
color: white !important;
}
html[data-theme="dark"] .app-card {
background-color: #343a40;
color: #f8f9fa;
border: 1px solid #495057;
}
html[data-theme="dark"] .card {
background-color: #343a40;
border: 1px solid #495057;
}
html[data-theme="dark"] .card-img-top {
background-color: #495057;
}
html[data-theme="dark"] .text-muted {
color: #adb5bd !important;
}
html[data-theme="dark"] h1,
html[data-theme="dark"] h5 {
color: #f8f9fa;
}
html[data-theme="dark"] .form-label {
color: #f8f9fa;
}
html[data-theme="dark"] .form-control {
background-color: #495057;
color: #f8f9fa;
border-color: #6c757d;
}
html[data-theme="dark"] .form-control::placeholder {
color: #adb5bd;
}
html[data-theme="dark"] .form-select {
background-color: #495057;
color: #f8f9fa;
border-color: #6c757d;
}
html[data-theme="dark"] .alert-danger {
background-color: #dc3545;
color: white;
border-color: #dc3545;
}
html[data-theme="dark"] .alert-success {
background-color: #198754;
color: white;
border-color: #198754;
}
html[data-theme="dark"] .form-check-input:checked {
background-color: #4dabf7;
border-color: #4dabf7;
}
html[data-theme="dark"] footer {
background-color: #343a40;
border-top: 1px solid #495057;
}
html[data-theme="dark"] .footer-left a,
html[data-theme="dark"] .footer-right a {
color: #4dabf7;
}
@media (max-width: 576px) {
.navbar-controls {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.navbar-search {
width: 100%;
}
.navbar-search input,
.view-toggle {
width: 100%;
}
.navbar-controls .nav-link,
.navbar-controls .form-check,
.navbar-controls .dropdown {
margin-bottom: 0.5rem;
}
footer {
height: auto;
padding: 0.5rem;
flex-direction: column;
gap: 0.5rem;
}
.footer-left,
.footer-right {
flex-direction: column;
text-align: center;
gap: 0.5rem;
}
.footer-left a,
.footer-right a {
font-size: 0.8rem;
}
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('gallery.landing') }}">FHIRPAD - FHIR App Platform</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse d-flex justify-content-between" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'gallery.landing' %}active{% endif %}" href="{{ url_for('gallery.landing') }}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'gallery.gallery' %}active{% endif %}" href="{{ url_for('gallery.gallery') }}">Gallery</a>
</li>
{% if current_user.is_authenticated %}
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'gallery.register' %}active{% endif %}" href="{{ url_for('gallery.register') }}">Register App</a>
</li>
{% if current_user.is_admin %}
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'gallery.manage_categories' %}active{% endif %}" href="{{ url_for('gallery.manage_categories') }}">Manage Categories</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'gallery.admin_apps' %}active{% endif %}" href="{{ url_for('gallery.admin_apps') }}">Manage Apps</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'gallery.admin_users' %}active{% endif %}" href="{{ url_for('gallery.admin_users') }}">Manage Users</a>
</li>
{% endif %}
{% endif %}
</ul>
<div class="navbar-controls d-flex align-items-center ms-auto">
{% if request.endpoint == 'gallery.gallery' %}
<form class="navbar-search d-flex" method="GET" action="{{ url_for('gallery.gallery') }}">
<input class="form-control form-control-sm me-2" type="search" name="search" placeholder="Search apps..." aria-label="Search apps by name, description, or developer" value="{{ request.args.get('search', '') }}">
<button class="btn btn-outline-primary btn-sm" type="submit"><i class="fas fa-search"></i></button>
</form>
<button class="btn btn-outline-secondary btn-sm view-toggle" id="viewToggle" onclick="toggleView()" aria-label="Toggle between grid and list view">
<i class="fas fa-th"></i> <span id="viewText">Grid View</span>
</button>
{% endif %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="themeToggle" onchange="toggleTheme()" aria-label="Toggle dark mode" {% if request.cookies.get('theme') == 'dark' %}checked{% endif %}>
<label class="form-check-label" for="themeToggle">
<i class="fas fa-sun"></i>
<i class="fas fa-moon d-none"></i>
</label>
</div>
{% if current_user.is_authenticated and current_user.username %}
<div class="dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{% if current_user.email %}
<img src="https://www.gravatar.com/avatar/{{ current_user.email | md5 }}?s=30&d=identicon" class="user-avatar" alt="User Avatar" onerror="this.style.display='none';this.nextElementSibling.style.display='inline-block';">
{% endif %}
<span class="initials-avatar" style="{% if current_user.email %}display: none;{% endif %}">{{ current_user.username[:2] | upper }}</span>
{{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<li><a class="dropdown-item {% if request.endpoint == 'gallery.my_listings' %}active{% endif %}" href="{{ url_for('gallery.my_listings') }}">My Listings</a></li>
{% if current_user.force_password_change %}
<li><a class="dropdown-item {% if request.endpoint == 'auth.change_password' %}active{% endif %}" href="{{ url_for('auth.change_password') }}">Change Password</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
</ul>
</div>
{% else %}
<a class="nav-link login-link {% if request.endpoint == 'auth.login' %}active{% endif %}" href="{{ url_for('auth.login') }}">Login</a>
<a class="nav-link login-link {% if request.endpoint == 'auth.register' %}active{% endif %}" href="{{ url_for('auth.register') }}">Register</a>
{% endif %}
</div>
</div>
</div>
</nav>
<main class="flex-grow-1">
<div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</main>
<footer class="main-footer" role="contentinfo">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center">
<div class="footer-left">
<a href="https://www.hl7.org/fhir/" target="_blank" rel="noreferrer" aria-label="Visit FHIR website">FHIR</a>
<a href="/about" aria-label="Learn about FHIRPAD">What is FHIRPAD</a>
<a href="mailto:support@fhirpad.com" aria-label="Contact FHIRPAD support">Contact Us</a>
</div>
<div class="footer-right">
<a href="/documents/privacy.pdf" aria-label="View Privacy Policy">Privacy</a>
<a href="/documents/terms.pdf" aria-label="View Terms of Use">Terms of Use</a>
<a href="/documents/disclaimer.pdf" aria-label="View Disclaimer">Disclaimer</a>
</div>
</div>
</div>
</footer>
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
<script>
function toggleTheme() {
const html = document.documentElement;
const toggle = document.getElementById('themeToggle');
const sunIcon = document.querySelector('.fa-sun');
const moonIcon = document.querySelector('.fa-moon');
if (toggle.checked) {
html.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
sunIcon.classList.add('d-none');
moonIcon.classList.remove('d-none');
} else {
html.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
sunIcon.classList.remove('d-none');
moonIcon.classList.add('d-none');
}
}
function toggleView() {
const container = document.getElementById('appContainer');
const table = document.getElementById('appTable');
const toggle = document.getElementById('viewToggle');
const viewText = document.getElementById('viewText');
if (!container || !table) return;
if (container.classList.contains('d-none')) {
container.classList.remove('d-none');
table.classList.add('d-none');
viewText.textContent = 'Grid View';
toggle.querySelector('i').className = 'fas fa-th';
localStorage.setItem('view', 'grid');
} else {
container.classList.add('d-none');
table.classList.remove('d-none');
viewText.textContent = 'List View';
toggle.querySelector('i').className = 'fas fa-list';
localStorage.setItem('view', 'list');
}
}
function removeFilter(key, value) {
const url = new URL(window.location);
const params = new URLSearchParams(url.search);
if (key === 'search') {
params.delete('search');
} else {
const values = params.getAll(key).filter(v => v !== value.toString());
params.delete(key);
values.forEach(v => params.append(key, v));
}
window.location.href = `${url.pathname}?${params.toString()}`;
}
document.addEventListener('DOMContentLoaded', () => {
const savedTheme = localStorage.getItem('theme');
const themeToggle = document.getElementById('themeToggle');
const sunIcon = document.querySelector('.fa-sun');
const moonIcon = document.querySelector('.fa-moon');
if (savedTheme === 'dark' && themeToggle) {
document.documentElement.setAttribute('data-theme', 'dark');
themeToggle.checked = true;
sunIcon.classList.add('d-none');
moonIcon.classList.remove('d-none');
}
const savedView = localStorage.getItem('view');
if (savedView === 'list' && document.getElementById('viewToggle')) {
toggleView();
}
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach(t => new bootstrap.Tooltip(t));
const currentPath = window.location.pathname;
document.querySelectorAll('#navbarNav .nav-link').forEach(link => {
if (link.getAttribute('href') === currentPath) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
});
document.querySelectorAll('#userDropdown + .dropdown-menu .dropdown-item').forEach(link => {
if (link.getAttribute('href') === currentPath) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
});
});
</script>
</body>
</html>

View File

@ -1,34 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h1>Change Password</h1>
{% if current_user.force_password_change %}
<p class="text-warning">You must change your password before continuing.</p>
{% endif %}
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.current_password.label(class="form-label") }}
{{ form.current_password(class="form-control") }}
{% for error in form.current_password.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.new_password.label(class="form-label") }}
{{ form.new_password(class="form-control") }}
{% for error in form.new_password.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-control") }}
{% for error in form.confirm_password.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
</div>
{% endblock %}

View File

@ -1,126 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Edit App: {{ app.name }}</h1>
<form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
{% for error in form.name.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows=4) }}
{% for error in form.description.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.developer.label(class="form-label") }}
{{ form.developer(class="form-control") }}
{% for error in form.developer.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.contact_email.label(class="form-label") }}
{{ form.contact_email(class="form-control") }}
{% for error in form.contact_email.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.logo_url.label(class="form-label") }}
{{ form.logo_url(class="form-control") }}
<small class="form-text text-muted">Enter a URL or upload a new logo below. Leave blank to keep current logo.</small>
{% for error in form.logo_url.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
{% if app.logo_url %}
<p class="mt-2"><strong>Current Logo:</strong></p>
<img src="{{ app.logo_url }}" alt="Current Logo" style="max-height: 100px;">
{% endif %}
</div>
<div class="mb-3">
{{ form.logo_upload.label(class="form-label") }}
{{ form.logo_upload(class="form-control") }}
{% for error in form.logo_upload.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.launch_url.label(class="form-label") }}
{{ form.launch_url(class="form-control") }}
{% for error in form.launch_url.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.website.label(class="form-label") }}
{{ form.website(class="form-control") }}
{% for error in form.website.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.designed_for.label(class="form-label") }}
{{ form.designed_for(class="form-control") }}
{% for error in form.designed_for.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.fhir_compatibility.label(class="form-label") }}
{{ form.fhir_compatibility(class="form-control") }}
{% for error in form.fhir_compatibility.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.categories.label(class="form-label") }}
{{ form.categories(class="form-control") }}
{% for error in form.categories.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.licensing_pricing.label(class="form-label") }}
{{ form.licensing_pricing(class="form-control") }}
{% for error in form.licensing_pricing.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.os_support.label(class="form-label") }}
{{ form.os_support(class="form-control") }}
{% for error in form.os_support.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.app_image_urls.label(class="form-label") }}
{{ form.app_image_urls(class="form-control", rows=3) }}
{% for error in form.app_image_urls.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
{% if app.app_images %}
<p class="mt-2"><strong>Current Images:</strong></p>
{% for image in app.app_images.split(',') %}
<img src="{{ image }}" alt="App Image" style="max-height: 100px; margin: 5px;">
{% endfor %}
{% endif %}
</div>
<div class="mb-3">
{{ form.app_image_uploads.label(class="form-label") }}
{{ form.app_image_uploads(class="form-control", multiple=True) }}
<small class="form-text text-muted">Select multiple images to upload.</small>
{% for error in form.app_image_uploads.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('gallery.app_detail', app_id=app.id) }}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock %}

View File

@ -1,178 +0,0 @@
{% extends "base.html" %}
{% block title %}Gallery{% endblock %}
{% macro generate_filter_url(endpoint, filter_key, filter_value, filter_params, search) %}
{% set params = {} %}
{% for key, values in filter_params.items() %}
{% if key != filter_key %}
{% for value in values %}
{% if params[key] is not defined %}
{% set _ = params.update({key: [value]}) %}
{% else %}
{% set _ = params[key].append(value) %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% set _ = params.update({filter_key: filter_value}) %}
{% if search %}
{% set _ = params.update({'search': search}) %}
{% endif %}
{{ url_for(endpoint, **params) }}
{% endmacro %}
{% block content %}
<div class="container-fluid">
<nav class="navbar navbar-expand-lg navbar-light sticky-top shadow-sm mb-4">
<div class="container-fluid">
<span class="navbar-brand d-flex align-items-center">
<img src="{{ url_for('static', filename='fhirpad.png') }}" alt="FHIRPAD" height="40" class="me-2" onerror="this.style.display='none';">
Filter Apps
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#filterNavbar" aria-controls="filterNavbar" aria-expanded="false" aria-label="Toggle filters">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="filterNavbar">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link {% if not filter_params and not request.args.get('search') %}active{% endif %}" href="{{ url_for('gallery.gallery') }}">All Apps</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if filter_params.get('category') %}active{% endif %}" href="#" id="categoryDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Categories
</a>
<ul class="dropdown-menu" aria-labelledby="categoryDropdown">
{% for category in categories %}
<li>
<a class="dropdown-item {% if category.id in filter_params.get('category', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'category', category.id, filter_params, request.args.get('search', '')) }}">{{ category.name }}</a>
</li>
{% endfor %}
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if filter_params.get('os_support') %}active{% endif %}" href="#" id="osSupportDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
OS Support
</a>
<ul class="dropdown-menu" aria-labelledby="osSupportDropdown">
{% for os in os_supports %}
<li>
<a class="dropdown-item {% if os.id in filter_params.get('os_support', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'os_support', os.id, filter_params, request.args.get('search', '')) }}">{{ os.name }}</a>
</li>
{% endfor %}
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if filter_params.get('fhir_support') %}active{% endif %}" href="#" id="fhirSupportDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
FHIR Support
</a>
<ul class="dropdown-menu" aria-labelledby="fhirSupportDropdown">
{% for fhir in fhir_supports %}
<li>
<a class="dropdown-item {% if fhir.id in filter_params.get('fhir_support', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'fhir_support', fhir.id, filter_params, request.args.get('search', '')) }}">{{ fhir.name }}</a>
</li>
{% endfor %}
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if filter_params.get('pricing_license') %}active{% endif %}" href="#" id="pricingDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Pricing/License
</a>
<ul class="dropdown-menu" aria-labelledby="pricingDropdown">
{% for pricing in pricing_licenses %}
<li>
<a class="dropdown-item {% if pricing.id in filter_params.get('pricing_license', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'pricing_license', pricing.id, filter_params, request.args.get('search', '')) }}">{{ pricing.name }}</a>
</li>
{% endfor %}
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if filter_params.get('designed_for') %}active{% endif %}" href="#" id="designedForDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Designed For
</a>
<ul class="dropdown-menu" aria-labelledby="designedForDropdown">
{% for designed in designed_fors %}
<li>
<a class="dropdown-item {% if designed.id in filter_params.get('designed_for', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'designed_for', designed.id, filter_params, request.args.get('search', '')) }}">{{ designed.name }}</a>
</li>
{% endfor %}
</ul>
</li>
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
<p>Explore FHIR-based apps. Filter to find the perfect app.</p>
{% if filter_params or request.args.get('search') %}
<div class="mb-3">
<h5>Active Filters:</h5>
{% for key, values in filter_params.items() %}
{% for value in values %}
{% set item = {'category': categories, 'os_support': os_supports, 'fhir_support': fhir_supports, 'pricing_license': pricing_licenses, 'designed_for': designed_fors}[key] | selectattr('id', 'equalto', value) | first %}
{% if item %}
<span class="badge bg-primary me-1 filter-chip" data-key="{{ key }}" data-value="{{ value }}" data-bs-toggle="tooltip" title="Click to remove filter">{{ item.name }} <i class="fas fa-times ms-1" onclick="removeFilter('{{ key }}', {{ value }})"></i></span>
{% endif %}
{% endfor %}
{% endfor %}
{% if request.args.get('search') %}
<span class="badge bg-primary me-1 filter-chip" data-key="search" data-value="{{ request.args.get('search') | urlencode }}" data-bs-toggle="tooltip" title="Click to clear search">Search: {{ request.args.get('search') }} <i class="fas fa-times ms-1" onclick="removeFilter('search', '{{ request.args.get('search') | urlencode }}')"></i></span>
{% endif %}
<a href="{{ url_for('gallery.gallery') }}" class="btn btn-sm btn-outline-danger ms-2">Clear All</a>
</div>
{% endif %}
{% if apps %}
<div id="appContainer" class="row">
{% for app in apps %}
<div class="app-item col-md-4 mb-3" style="animation: fadeIn 0.5s;">
<div class="card app-card shadow-sm h-100">
{% if app.logo_url %}
<img src="{{ app.logo_url }}" class="card-img-top" alt="{{ app.name }} logo" style="max-height: 150px; object-fit: contain; padding: 1rem;" onerror="this.src='https://via.placeholder.com/150?text=No+Logo';">
{% else %}
<img src="https://via.placeholder.com/150?text=No+Logo" class="card-img-top" alt="No Logo" style="max-height: 150px; object-fit: contain; padding: 1rem;">
{% endif %}
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ app.name }}</h5>
<p class="card-text flex-grow-1">{{ app.description | truncate(100) }}</p>
<p class="card-text"><small class="text-muted">By {{ app.developer }}</small></p>
<div class="d-flex flex-wrap gap-2 mb-2">
{% if app.website %}
<a href="{{ app.website }}" class="btn btn-primary btn-sm" target="_blank" rel="noopener noreferrer" aria-label="Visit {{ app.name }} website">Website</a>
{% endif %}
{% if app.launch_url %}
<a href="{{ app.launch_url }}" class="btn btn-outline-primary btn-sm" target="_blank" rel="noopener noreferrer" aria-label="Try {{ app.name }} with sandbox data">Try App</a>
{% endif %}
</div>
<a href="{{ url_for('gallery.app_detail', app_id=app.id) }}" class="btn btn-primary mt-auto">View Details</a>
</div>
</div>
</div>
{% endfor %}
</div>
<div id="appTable" class="d-none">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Developer</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for app in apps %}
<tr style="animation: fadeIn 0.5s;">
<td>{{ app.name }}</td>
<td>{{ app.description | truncate(100) }}</td>
<td>{{ app.developer }}</td>
<td>
<a href="{{ url_for('gallery.app_detail', app_id=app.id) }}" class="btn btn-primary btn-sm">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No apps match your filters or search. Try adjusting the criteria.</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -1,61 +0,0 @@
{% extends "base.html" %}
{% block title %}FHIRPAD Home{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="text-center mb-5">
<img src="{{ url_for('static', filename='fhirpad.png') }}" alt="FHIRPAD" height="60" class="mb-3" onerror="this.style.display='none';">
<h1>Welcome to FHIRPAD</h1>
<p class="lead">Discover and share FHIR-based applications to enhance healthcare interoperability.</p>
<a href="{{ url_for('gallery.register') }}" class="btn btn-primary btn-lg">Submit Your App</a>
<a href="{{ url_for('gallery.gallery') }}" class="btn btn-outline-primary btn-lg">Browse Apps</a>
</div>
<!-- Featured Apps -->
<h2 class="mb-4">Featured Apps</h2>
{% if featured_apps %}
<div class="row">
{% for app in featured_apps %}
<div class="col-md-4 mb-3">
<div class="card app-card shadow-sm h-100">
{% if app.logo_url %}
<img src="{{ app.logo_url }}" class="card-img-top" alt="{{ app.name }} logo" style="max-height: 150px; object-fit: contain; padding: 1rem;" onerror="this.src='https://via.placeholder.com/150?text=No+Logo';">
{% else %}
<img src="https://via.placeholder.com/150?text=No+Logo" class="card-img-top" alt="No Logo" style="max-height: 150px; object-fit: contain; padding: 1rem;">
{% endif %}
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ app.name }}</h5>
<p class="card-text flex-grow-1">{{ app.description | truncate(100) }}</p>
<p class="card-text"><small class="text-muted">By {{ app.developer }}</small></p>
<div class="d-flex flex-wrap gap-2 mb-2">
{% if app.website %}
<a href="{{ app.website }}" class="btn btn-primary btn-sm" target="_blank" rel="noopener noreferrer" aria-label="Visit {{ app.name }} website">Website</a>
{% endif %}
{% if app.launch_url %}
<a href="{{ app.launch_url }}" class="btn btn-outline-primary btn-sm" target="_blank" rel="noopener noreferrer" aria-label="Try {{ app.name }} with sandbox data">Try App</a>
{% endif %}
</div>
<a href="{{ url_for('gallery.app_detail', app_id=app.id) }}" class="btn btn-primary mt-auto">View Details</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No featured apps yet. Be the first to submit one!</p>
{% endif %}
<!-- Categories -->
<h2 class="mt-5 mb-4">Explore by Category</h2>
{% if categories %}
<div class="row">
{% for category in categories %}
<div class="col-md-3 mb-3">
<a href="{{ url_for('gallery.gallery', category=category.id) }}" class="btn btn-outline-secondary w-100">{{ category.name }}</a>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No categories defined yet.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -1,28 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Login</h1>
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% for error in form.email.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% for error in form.password.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
<div class="mt-3">
<p>Or login with:</p>
<a href="{{ url_for('auth.login_google') }}" class="btn btn-outline-primary">Google</a>
<a href="{{ url_for('auth.login_github') }}" class="btn btn-outline-dark">GitHub</a>
</div>
<p class="mt-3">Don't have an account? <a href="{{ url_for('auth.register') }}">Register</a></p>
{% endblock %}

View File

@ -1,30 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>My Listings</h1>
{% if apps %}
<div class="row">
{% for app in apps %}
<div class="col-md-4 mb-3">
<div class="card">
{% if app.logo_url %}
<img src="{{ app.logo_url }}" class="card-img-top" alt="{{ app.name }} logo" style="max-height: 150px; object-fit: contain;">
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ app.name }}</h5>
<p class="card-text">{{ app.description | truncate(100) }}</p>
<p class="card-text"><small class="text-muted">By {{ app.developer }}</small></p>
<a href="{{ url_for('gallery.app_detail', app_id=app.id) }}" class="btn btn-primary">View Details</a>
<a href="{{ url_for('gallery.edit_app', app_id=app.id) }}" class="btn btn-warning btn-sm">Edit</a>
<form action="{{ url_for('gallery.delete_app', app_id=app.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to delete {{ app.name }}?')">Delete</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p>You haven't registered any apps yet.</p>
<a href="{{ url_for('gallery.register') }}" class="btn btn-primary">Register an App</a>
{% endif %}
{% endblock %}

View File

@ -1,116 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Register New App</h1>
<form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
{% for error in form.name.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows=4) }}
{% for error in form.description.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.developer.label(class="form-label") }}
{{ form.developer(class="form-control") }}
{% for error in form.developer.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.contact_email.label(class="form-label") }}
{{ form.contact_email(class="form-control") }}
{% for error in form.contact_email.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.logo_url.label(class="form-label") }}
{{ form.logo_url(class="form-control") }}
<small class="form-text text-muted">Enter a URL or upload a logo below.</small>
{% for error in form.logo_url.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.logo_upload.label(class="form-label") }}
{{ form.logo_upload(class="form-control") }}
{% for error in form.logo_upload.errors %}
provocateur = provocateur + 1
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.launch_url.label(class="form-label") }}
{{ form.launch_url(class="form-control") }}
{% for error in form.launch_url.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.website.label(class="form-label") }}
{{ form.website(class="form-control") }}
{% for error in form.website.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.designed_for.label(class="form-label") }}
{{ form.designed_for(class="form-control") }}
{% for error in form.designed_for.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.fhir_compatibility.label(class="form-label") }}
{{ form.fhir_compatibility(class="form-control") }}
{% for error in form.fhir_compatibility.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.categories.label(class="form-label") }}
{{ form.categories(class="form-control") }}
{% for error in form.categories.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.licensing_pricing.label(class="form-label") }}
{{ form.licensing_pricing(class="form-control") }}
{% for error in form.licensing_pricing.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.os_support.label(class="form-label") }}
{{ form.os_support(class="form-control") }}
{% for error in form.os_support.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.app_image_urls.label(class="form-label") }}
{{ form.app_image_urls(class="form-control", rows=3) }}
{% for error in form.app_image_urls.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.app_image_uploads.label(class="form-label") }}
{{ form.app_image_uploads(class="form-control") }}
{% for error in form.app_image_uploads.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('gallery.gallery') }}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock %}

View File

@ -1,37 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Register</h1>
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control") }}
{% for error in form.username.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% for error in form.email.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% for error in form.password.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-control") }}
{% for error in form.confirm_password.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
<p class="mt-3">Already have an account? <a href="{{ url_for('auth.login') }}">Login</a></p>
{% endblock %}

View File

@ -1,3 +0,0 @@
commands:
docker build -t smartflare .
docker run -d -p 5000:5000 -v "$(pwd)/uploads:/app/uploads"-v "$(pwd)/instance:/app/instance" --name smartflare-app2 smartflare

View File

@ -1,7 +0,0 @@
import os
class Config:
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(BASE_DIR, "instance", "fhirpad.db")}'
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = os.getenv('FHIRPAD_SECRET_KEY', 'your-secure-secret-key')

View File

@ -1,13 +1,20 @@
version: '3.8'
services:
fhirpad:
build: .
ports:
- "5009:5009"
volumes:
- ./instance:/app/instance
- ./uploads:/app/uploads
environment:
- FLASK_APP=app
- FLASK_ENV=development
command: ["flask", "run", "--host=0.0.0.0", "--port=5009"]
version: '3.8'
services:
fhirflare:
build:
context: .
dockerfile: Dockerfile
ports:
- "5000:5000"
- "8080:8080" # Keep port exposed, even if Tomcat isn't running useful stuff in Lite
volumes:
- ./instance:/app/instance
- ./static/uploads:/app/static/uploads
- ./instance/hapi-h2-data/:/app/h2-data # Keep volume mounts consistent
- ./logs:/app/logs
environment:
- FLASK_APP=app.py
- FLASK_ENV=development
- NODE_PATH=/usr/lib/node_modules
- APP_MODE=lite
command: supervisord -c /etc/supervisord.conf

Binary file not shown.

View File

@ -4,9 +4,10 @@ Werkzeug==2.3.7
requests==2.31.0
Flask-WTF==1.2.1
WTForms==3.1.2
email-validator==2.2.0
Flask-Login==0.6.3
Flask-OAuthlib==0.9.6
python-dotenv==1.0.1
bcrypt==4.2.0
flask-migrate==4.0.7
Pytest
pyyaml==6.0.1
fhir.resources==8.0.0
Flask-Migrate==4.1.0
cachetools
beautifulsoup4
feedparser==6.0.11

59
seed.py
View File

@ -1,59 +0,0 @@
from app import db, create_app
from app.models import Category, OSSupport, FHIRSupport, PricingLicense, DesignedFor, User
app = create_app()
def seed_table(model, names):
for name in names:
if not model.query.filter_by(name=name).first():
db.session.add(model(name=name))
db.session.commit()
def seed_admin_user():
if not User.query.filter_by(username='admin').first():
admin = User(
username='admin',
email='admin@fhirpad.com',
is_admin=True,
force_password_change=True
)
admin.set_password('admin123')
db.session.add(admin)
db.session.commit()
print("Default admin user created: username=admin, password=admin123")
with app.app_context():
db.create_all()
try:
# Seed admin user
seed_admin_user()
# Seed categories
categories = [
'Care Coordination', 'Clinical Research', 'Data Visualization', ' Disease Management',
'Genomics', 'Medication Management', 'Patient Engagement', 'Population Health',
'Risk Calculation', 'FHIR Tools', 'Telehealth'
]
seed_table(Category, categories)
# Seed OS support
os_supports = ['iOS', 'Android', 'Web', 'Mac', 'Windows', 'Linux']
seed_table(OSSupport, os_supports)
# Seed FHIR support
fhir_supports = ['DSTU 2', 'STU 3', 'R4', 'R5']
seed_table(FHIRSupport, fhir_supports)
# Seed pricing/licenses
pricings = ['Open Source', 'Free', 'Per User', 'Site-Based', 'Subscription']
seed_table(PricingLicense, pricings)
# Seed designed for
designed_fors = ['Clinicians', 'Patients', 'Patients & Clinicians', 'IT Professionals']
seed_table(DesignedFor, designed_fors)
print("Database seeded successfully!")
except Exception as e:
print(f"Seeding failed: {str(e)}")
db.session.rollback()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 819 KiB