Add files via upload

This commit is contained in:
Joshua Hare 2025-05-11 18:24:10 +10:00 committed by GitHub
parent 3d7b1a8ea9
commit cb46f24f9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2636 additions and 0 deletions

11
Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ app/
COPY static/ static/
COPY instance/ instance/
COPY config.py .
COPY .env .
EXPOSE 5009
CMD ["flask", "run", "--host=0.0.0.0"]

61
app/__init__.py Normal file
View File

@ -0,0 +1,61 @@
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

205
app/auth.py Normal file
View File

@ -0,0 +1,205 @@
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')

79
app/forms.py Normal file
View File

@ -0,0 +1,79 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import StringField, TextAreaField, SubmitField, PasswordField, SelectField, SelectMultipleField
from wtforms.validators import DataRequired, Email, Length, EqualTo, Optional, ValidationError
import re
from app.models import ApplicationType, Category, OSSupport, FHIRSupport, Speciality, PricingLicense, DesignedFor, EHRSupport
from app import db
def validate_url_or_path(form, field):
if not field.data:
return
if field.data.startswith('/app/uploads/'):
path_pattern = r'^/app/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
url_pattern = r'^(https?:\/\/)?([\w\-]+\.)+[\w\-]+(\/[\w\-\.]*)*\/?(\?[^\s]*)?(#[^\s]*)?$'
if not re.match(url_pattern, field.data):
raise ValidationError('Invalid URL or file path.')
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)])
client_id = StringField('Client ID', validators=[DataRequired(), Length(min=3, max=100)])
scopes = TextAreaField('Scopes (comma-separated)', validators=[DataRequired(), Length(max=500)], render_kw={"placeholder": "patient/Patient.read,launch/patient"})
website = StringField('Company Website', validators=[Optional(), Length(max=200)], render_kw={"placeholder": "https://example.com"})
designed_for = SelectField('Designed For', coerce=int, validators=[DataRequired()])
application_type = SelectField('Application Type', coerce=int, validators=[DataRequired()])
fhir_compatibility = SelectField('FHIR Compatibility', coerce=int, validators=[DataRequired()])
categories = SelectMultipleField('Categories', coerce=int, validators=[DataRequired()])
specialties = SelectMultipleField('Specialties', 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 = FileField('Upload App Images', validators=[FileAllowed(['jpg', 'png'], 'Images only!')])
ehr_support = SelectMultipleField('EHR Support', coerce=int, validators=[DataRequired()])
submit = SubmitField('Register App')
def __init__(self, *args, **kwargs):
super(FHIRAppForm, self).__init__(*args, **kwargs)
self.application_type.choices = [(t.id, t.name) for t in ApplicationType.query.all()]
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.specialties.choices = [(s.id, s.name) for s in Speciality.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()]
self.ehr_support.choices = [(e.id, e.name) for e in EHRSupport.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')

91
app/models.py Normal file
View File

@ -0,0 +1,91 @@
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 ApplicationType(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
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 Speciality(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 EHRSupport(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)
client_id = db.Column(db.String(100), nullable=False)
scopes = db.Column(db.Text)
website = db.Column(db.String(200))
designed_for_id = db.Column(db.Integer, db.ForeignKey('designed_for.id'))
application_type_id = db.Column(db.Integer, db.ForeignKey('application_type.id'))
fhir_compatibility_id = db.Column(db.Integer, db.ForeignKey('fhir_support.id'))
categories = db.Column(db.Text) # Comma-separated Category IDs
specialties = db.Column(db.Text) # Comma-separated Speciality 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)
ehr_support = db.Column(db.Text) # Comma-separated EHRSupport IDs
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)

504
app/routes.py Normal file
View File

@ -0,0 +1,504 @@
# app/routes.py
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 SmartApp, ApplicationType, Category, OSSupport, FHIRSupport, Speciality, PricingLicense, DesignedFor, EHRSupport
from app.forms import SmartAppForm, GalleryFilterForm
from sqlalchemy import or_, and_
import os
import logging
from werkzeug.utils import secure_filename
import uuid
# Setup logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
gallery_bp = Blueprint('gallery', __name__)
# Absolute path to the upload folder inside the container
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.gallery'))
@gallery_bp.route('/gallery', methods=['GET', 'POST'])
def gallery():
form = GalleryFilterForm()
query = SmartApp.query
filter_params = {}
# Handle search
search_term = request.args.get('search', '').strip()
if search_term:
query = query.filter(
or_(
SmartApp.name.ilike(f'%{search_term}%'),
SmartApp.description.ilike(f'%{search_term}%'),
SmartApp.developer.ilike(f'%{search_term}%')
)
)
filter_params['search'] = search_term
# Existing filter logic
application_type_ids = request.args.getlist('application_type', type=int)
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)
speciality_ids = request.args.getlist('speciality', type=int)
pricing_license_ids = request.args.getlist('pricing_license', type=int)
designed_for_ids = request.args.getlist('designed_for', type=int)
ehr_support_ids = request.args.getlist('ehr_support', type=int)
if application_type_ids:
query = query.filter(SmartApp.application_type_id.in_(application_type_ids))
filter_params['application_type'] = application_type_ids
if category_ids:
query = query.filter(or_(*[SmartApp.categories.contains(str(cid)) for cid in category_ids]))
filter_params['category'] = category_ids
if os_support_ids:
query = query.filter(or_(*[SmartApp.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(SmartApp.fhir_compatibility_id.in_(fhir_support_ids))
filter_params['fhir_support'] = fhir_support_ids
if speciality_ids:
query = query.filter(or_(*[SmartApp.specialties.contains(str(sid)) for sid in speciality_ids]))
filter_params['speciality'] = speciality_ids
if pricing_license_ids:
query = query.filter(SmartApp.licensing_pricing_id.in_(pricing_license_ids))
filter_params['pricing_license'] = pricing_license_ids
if designed_for_ids:
query = query.filter(SmartApp.designed_for_id.in_(designed_for_ids))
filter_params['designed_for'] = designed_for_ids
if ehr_support_ids:
query = query.filter(or_(*[SmartApp.ehr_support.contains(str(eid)) for eid in ehr_support_ids]))
filter_params['ehr_support'] = ehr_support_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,
application_types=ApplicationType.query.all(),
categories=Category.query.all(),
os_supports=OSSupport.query.all(),
fhir_supports=FHIRSupport.query.all(),
specialties=Speciality.query.all(),
pricing_licenses=PricingLicense.query.all(),
designed_fors=DesignedFor.query.all(),
ehr_supports=EHRSupport.query.all()
)
@gallery_bp.route('/gallery/<int:app_id>')
def app_detail(app_id):
app = SmartApp.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_specialties = []
if app.specialties:
speciality_ids = [int(sid) for sid in app.specialties.split(',') if sid]
app_specialties = Speciality.query.filter(Speciality.id.in_(speciality_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()
app_ehr_supports = []
if app.ehr_support:
ehr_ids = [int(eid) for eid in app.ehr_support.split(',') if eid]
app_ehr_supports = EHRSupport.query.filter(EHRSupport.id.in_(ehr_ids)).all()
return render_template(
'app_detail.html',
app=app,
app_categories=app_categories,
app_specialties=app_specialties,
app_os_supports=app_os_supports,
app_ehr_supports=app_ehr_supports
)
@gallery_bp.route('/gallery/register', methods=['GET', 'POST'])
@login_required
def register():
form = SmartAppForm()
if form.validate_on_submit():
scopes = form.scopes.data
valid_scopes = all(
scope.strip().startswith(('patient/', 'user/', 'launch', 'openid', 'fhirUser', 'offline_access', 'online_access'))
for scope in scopes.split(',')
if scope.strip()
)
if not valid_scopes or not scopes.strip():
flash('Invalid SMART scopes. Use formats like patient/Patient.read, launch/patient.', 'danger')
return render_template('register.html', form=form)
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)
if form.application_type_new.data:
app_type = ApplicationType(name=form.application_type_new.data)
db.session.add(app_type)
db.session.commit()
form.application_type.data = app_type.id
if form.categories_new.data:
category = Category(name=form.categories_new.data)
db.session.add(category)
db.session.commit()
form.categories.data.append(category.id)
if form.os_support_new.data:
os_support = OSSupport(name=form.os_support_new.data)
db.session.add(os_support)
db.session.commit()
form.os_support.data.append(os_support.id)
if form.fhir_compatibility_new.data:
fhir = FHIRSupport(name=form.fhir_compatibility_new.data)
db.session.add(fhir)
db.session.commit()
form.fhir_compatibility.data = fhir.id
if form.specialties_new.data:
speciality = Speciality(name=form.specialties_new.data)
db.session.add(speciality)
db.session.commit()
form.specialties.data.append(speciality.id)
if form.licensing_pricing_new.data:
pricing = PricingLicense(name=form.licensing_pricing_new.data)
db.session.add(pricing)
db.session.commit()
form.licensing_pricing.data = pricing.id
if form.designed_for_new.data:
designed = DesignedFor(name=form.designed_for_new.data)
db.session.add(designed)
db.session.commit()
form.designed_for.data = designed.id
if form.ehr_support_new.data:
ehr = EHRSupport(name=form.ehr_support_new.data)
db.session.add(ehr)
db.session.commit()
form.ehr_support.data.append(ehr.id)
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:
file = 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 = SmartApp(
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,
client_id=form.client_id.data,
scopes=scopes,
website=form.website.data or None,
designed_for_id=form.designed_for.data,
application_type_id=form.application_type.data,
fhir_compatibility_id=form.fhir_compatibility.data,
categories=','.join(map(str, form.categories.data)) if form.categories.data else None,
specialties=','.join(map(str, form.specialties.data)) if form.specialties.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,
ehr_support=','.join(map(str, form.ehr_support.data)) if form.ehr_support.data 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 = SmartApp.query.get_or_404(app_id)
if 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 = SmartAppForm(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.specialties:
form.specialties.data = [int(sid) for sid in app.specialties.split(',') if sid]
if app.os_support:
form.os_support.data = [int(oid) for oid in app.os_support.split(',') if oid]
if app.ehr_support:
form.ehr_support.data = [int(eid) for eid in app.ehr_support.split(',') if eid]
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():
scopes = form.scopes.data
valid_scopes = all(
scope.strip().startswith(('patient/', 'user/', 'launch', 'openid', 'fhirUser', 'offline_access', 'online_access'))
for scope in scopes.split(',')
if scope.strip()
)
if not valid_scopes or not scopes.strip():
flash('Invalid SMART scopes. Use formats like patient/Patient.read, launch/patient.', 'danger')
return render_template('edit_app.html', form=form, app=app)
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)
if form.application_type_new.data:
app_type = ApplicationType(name=form.application_type_new.data)
db.session.add(app_type)
db.session.commit()
form.application_type.data = app_type.id
if form.categories_new.data:
category = Category(name=form.categories_new.data)
db.session.add(category)
db.session.commit()
if form.categories.data is None:
form.categories.data = []
form.categories.data.append(category.id)
if form.os_support_new.data:
os_support = OSSupport(name=form.os_support_new.data)
db.session.add(os_support)
db.session.commit()
if form.os_support.data is None:
form.os_support.data = []
form.os_support.data.append(os_support.id)
if form.fhir_compatibility_new.data:
fhir = FHIRSupport(name=form.fhir_compatibility_new.data)
db.session.add(fhir)
db.session.commit()
form.fhir_compatibility.data = fhir.id
if form.specialties_new.data:
speciality = Speciality(name=form.specialties_new.data)
db.session.add(speciality)
db.session.commit()
if form.specialties.data is None:
form.specialties.data = []
form.specialties.data.append(speciality.id)
if form.licensing_pricing_new.data:
pricing = PricingLicense(name=form.licensing_pricing_new.data)
db.session.add(pricing)
db.session.commit()
form.licensing_pricing.data = pricing.id
if form.designed_for_new.data:
designed = DesignedFor(name=form.designed_for_new.data)
db.session.add(designed)
db.session.commit()
form.designed_for.data = designed.id
if form.ehr_support_new.data:
ehr = EHRSupport(name=form.ehr_support_new.data)
db.session.add(ehr)
db.session.commit()
if form.ehr_support.data is None:
form.ehr_support.data = []
form.ehr_support.data.append(ehr.id)
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:
file = 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.client_id = form.client_id.data
app.scopes = scopes
app.website = form.website.data or None
app.designed_for_id = form.designed_for.data
app.application_type_id = form.application_type.data
app.fhir_compatibility_id = form.fhir_compatibility.data
app.categories = ','.join(map(str, form.categories.data)) if form.categories.data else None
app.specialties = ','.join(map(str, form.specialties.data)) if form.specialties.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
app.ehr_support = ','.join(map(str, form.ehr_support.data)) if form.ehr_support.data 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 = SmartApp.query.get_or_404(app_id)
if 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 = SmartApp.query.filter_by(user_id=current_user.id).all()
return render_template('my_listings.html', apps=apps)
@gallery_bp.route('/test/add')
@login_required
def add_test_app():
logo_url = "https://via.placeholder.com/150"
app_images = "https://via.placeholder.com/300"
test_app = SmartApp(
name="Test App",
description="A sample SMART on FHIR app.",
developer="Test Developer",
contact_email="test@example.com",
logo_url=logo_url,
launch_url="https://example.com/launch",
client_id="test-client-id",
scopes="patient/Patient.read,launch/patient",
website="https://example.com",
designed_for_id=DesignedFor.query.filter_by(name="Clinicians").first().id if DesignedFor.query.filter_by(name="Clinicians").first() else None,
application_type_id=ApplicationType.query.filter_by(name="SMART").first().id if ApplicationType.query.filter_by(name="SMART").first() else None,
fhir_compatibility_id=FHIRSupport.query.filter_by(name="R4").first().id if FHIRSupport.query.filter_by(name="R4").first() else None,
categories=','.join(str(c.id) for c in Category.query.filter(Category.name.in_(["Clinical", "Patient Engagement"])).all()) if Category.query.filter(Category.name.in_(["Clinical", "Patient Engagement"])).all() else None,
specialties=','.join(str(s.id) for s in Speciality.query.filter(Speciality.name.in_(["Cardiology", "General Practice"])).all()) if Speciality.query.filter(Speciality.name.in_(["Cardiology", "General Practice"])).all() else None,
licensing_pricing_id=PricingLicense.query.filter_by(name="Free").first().id if PricingLicense.query.filter_by(name="Free").first() else None,
os_support=','.join(str(o.id) for o in OSSupport.query.filter(OSSupport.name.in_(["Web", "iOS", "Android"])).all()) if OSSupport.query.filter(OSSupport.name.in_(["Web", "iOS", "Android"])).all() else None,
app_images=app_images,
ehr_support=','.join(str(e.id) for e in EHRSupport.query.filter(EHRSupport.name.in_(["Epic", "Cerner"])).all()) if EHRSupport.query.filter(EHRSupport.name.in_(["Epic", "Cerner"])).all() else None,
user_id=current_user.id
)
db.session.add(test_app)
db.session.commit()
logger.debug(f"Test app ID: {test_app.id}, logo_url: {test_app.logo_url}")
flash('Test app added successfully!', 'success')
return redirect(url_for('gallery.gallery'))

View File

@ -0,0 +1,37 @@
{% 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

@ -0,0 +1,42 @@
{% 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

@ -0,0 +1,296 @@
{% 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" aria-label="Visit {{ app.name }} website">Website</a>
{% else %}
<button class="btn btn-primary" disabled>Website</button>
{% endif %}
<a href="{{ app.launch_url }}" class="btn btn-outline-primary" aria-label="Try {{ app.name }} with sandbox data">Try App</a>
</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>
<section class="mb-3">
<h5>Client ID</h5>
<hr class="small-hr">
<p>{{ app.client_id or 'Not specified.' }}</p>
</section>
<section class="mb-3">
<h5>Scopes</h5>
<hr class="small-hr">
<p>{{ app.scopes 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">{{ 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">{{ 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">{{ 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>Application Type</h5>
<hr class="small-hr">
<p>
{% if app.application_type %}
<a href="{{ url_for('gallery.gallery', application_type=app.application_type_id) }}" class="text-decoration-none">{{ app.application_type.name }}</a>
{% else %}
Not specified.
{% endif %}
</p>
</div>
</div>
</section>
<section class="mb-3">
<div class="row g-2">
<div class="col-6">
<h5>FHIR Compatibility</h5>
<hr 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 class="col-6">
<h5>EHR Support</h5>
<hr class="small-hr">
<p>
{% if app_ehr_supports %}
{% for ehr in app_ehr_supports %}
<a href="{{ url_for('gallery.gallery', ehr_support=ehr.id) }}" class="text-decoration-none">{{ ehr.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
Not specified.
{% endif %}
</p>
</div>
</div>
</section>
<section class="mb-3">
<h5>Specialties</h5>
<hr class="small-hr">
<p>
{% if app_specialties %}
{% for speciality in app_specialties %}
<a href="{{ url_for('gallery.gallery', speciality=speciality.id) }}" class="text-decoration-none">{{ speciality.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
Not specified.
{% endif %}
</p>
</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 {
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 %}

475
app/templates/base.html Normal file
View File

@ -0,0 +1,475 @@
<!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>
{% 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

@ -0,0 +1,34 @@
{% 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 %}

171
app/templates/edit_app.html Normal file
View File

@ -0,0 +1,171 @@
{% 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.client_id.label(class="form-label") }}
{{ form.client_id(class="form-control") }}
{% for error in form.client_id.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.scopes.label(class="form-label") }}
{{ form.scopes(class="form-control", rows=3) }}
{% for error in form.scopes.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
{% if app.scopes and app.scopes.strip() %}
<p class="mt-2"><strong>Current Scopes:</strong>
{% for scope in app.scopes.split(',') %}
{% if scope.strip() %}
<span class="badge bg-primary me-1">{{ scope.strip() }}</span>
{% endif %}
{% endfor %}
</p>
{% else %}
<p class="mt-2 text-muted">No scopes defined.</p>
{% endif %}
</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.application_type.label(class="form-label") }}
{{ form.application_type(class="form-control") }}
{% for error in form.application_type.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.specialties.label(class="form-label") }}
{{ form.specialties(class="form-control") }}
{% for error in form.specialties.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") }}
{% for error in form.app_image_uploads.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.ehr_support.label(class="form-label") }}
{{ form.ehr_support(class="form-control") }}
{% for error in form.ehr_support.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 %}

206
app/templates/gallery.html Normal file
View File

@ -0,0 +1,206 @@
{% 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('application_type') %}active{% endif %}" href="#" id="appTypeDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Application Type
</a>
<ul class="dropdown-menu" aria-labelledby="appTypeDropdown">
{% for app_type in application_types %}
<li>
<a class="dropdown-item {% if app_type.id in filter_params.get('application_type', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'application_type', app_type.id, filter_params, request.args.get('search', '')) }}">{{ app_type.name }}</a>
</li>
{% endfor %}
</ul>
</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('speciality') %}active{% endif %}" href="#" id="specialityDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Speciality
</a>
<ul class="dropdown-menu" aria-labelledby="specialityDropdown">
{% for speciality in specialties %}
<li>
<a class="dropdown-item {% if speciality.id in filter_params.get('speciality', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'speciality', speciality.id, filter_params, request.args.get('search', '')) }}">{{ speciality.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>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if filter_params.get('ehr_support') %}active{% endif %}" href="#" id="ehrSupportDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
EHR Support
</a>
<ul class="dropdown-menu" aria-labelledby="ehrSupportDropdown">
{% for ehr in ehr_supports %}
<li>
<a class="dropdown-item {% if ehr.id in filter_params.get('ehr_support', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'ehr_support', ehr.id, filter_params, request.args.get('search', '')) }}">{{ ehr.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 = {'application_type': application_types, 'category': categories, 'os_support': os_supports, 'fhir_support': fhir_supports, 'speciality': specialties, 'pricing_license': pricing_licenses, 'designed_for': designed_fors, 'ehr_support': ehr_supports}[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.style.display='none';">
{% 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>
<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

@ -0,0 +1,53 @@
{% 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;">
{% 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>
<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 %}

32
app/templates/login.html Normal file
View File

@ -0,0 +1,32 @@
<!---app/templates/login.html--->
{% 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

@ -0,0 +1,30 @@
{% 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 %}

150
app/templates/register.html Normal file
View File

@ -0,0 +1,150 @@
{% 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 %}
<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.client_id.label(class="form-label") }}
{{ form.client_id(class="form-control") }}
{% for error in form.client_id.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
</div>
<div class="mb-3">
{{ form.scopes.label(class="form-label") }}
{{ form.scopes(class="form-control", rows=3) }}
{% for error in form.scopes.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.application_type.label(class="form-label") }}
{{ form.application_type(class="form-control") }}
{% for error in form.application_type.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.specialties.label(class="form-label") }}
{{ form.specialties(class="form-control") }}
{% for error in form.specialties.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>
<div class="mb-3">
{{ form.ehr_support.label(class="form-label") }}
{{ form.ehr_support(class="form-control") }}
{% for error in form.ehr_support.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

@ -0,0 +1,37 @@
{% 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 %}

3
command.md Normal file
View File

@ -0,0 +1,3 @@
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

7
config.py Normal file
View File

@ -0,0 +1,7 @@
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')

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
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"]

Binary file not shown.

11
requirements.txt Normal file
View File

@ -0,0 +1,11 @@
Flask==2.3.3
Flask-SQLAlchemy==3.0.5
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

75
seed.py Normal file
View File

@ -0,0 +1,75 @@
from app import db, create_app
from app.models import ApplicationType, Category, OSSupport, FHIRSupport, Speciality, PricingLicense, DesignedFor, EHRSupport, 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 application types
app_types = ['FHIR App', 'Bulk Data', 'Health Cards']
seed_table(ApplicationType, app_types)
# 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 specialties
specialties = [
'Anesthesiology', 'Cardiology', 'Gastroenterology', 'Infectious Disease', 'Neurology',
'Obstetrics', 'Oncology', 'Pediatrics', 'Pulmonology', 'Nephrology', 'Rheumatology',
'Trauma', 'Primary Care'
]
seed_table(Speciality, specialties)
# 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)
# Seed EHR support
ehr_supports = ['Allscripts', 'Athenahealth', 'Epic', 'Cerner', 'Meditech']
seed_table(EHRSupport, ehr_supports)
print("Database seeded successfully!")
except Exception as e:
print(f"Seeding failed: {str(e)}")
db.session.rollback()

BIN
static/FhirPad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

7
static/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
static/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB