diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0bf687d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +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 uploads app/uploads/ +COPY config.py . +COPY .env . +EXPOSE 5000 +CMD ["flask", "run", "--host=0.0.0.0"] \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3621b35 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,63 @@ +#app/__init__.py + +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 \ No newline at end of file diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..87d49be --- /dev/null +++ b/app/auth.py @@ -0,0 +1,177 @@ +#app/auth.py + +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 +from werkzeug.security import generate_password_hash + +auth_bp = Blueprint('auth', __name__) + +# OAuth setup +google = None +github = None + +# Initialize Google OAuth only if valid credentials are provided +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', + ) + +# Initialize GitHub OAuth only if valid credentials are provided +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: + return redirect(url_for('gallery.gallery')) + 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) + flash('Logged in successfully!', 'success') + return redirect(url_for('gallery.gallery')) + 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: + return redirect(url_for('gallery.gallery')) + 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 + ) + 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.gallery')) + 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'] + ) + db.session.add(user) + db.session.commit() + login_user(user) + flash('Logged in with Google!', 'success') + return redirect(url_for('gallery.gallery')) + +@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']) + ) + db.session.add(user) + db.session.commit() + login_user(user) + flash('Logged in with GitHub!', 'success') + return redirect(url_for('gallery.gallery')) + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + flash('Logged out successfully.', 'success') + return redirect(url_for('gallery.gallery')) + +# Define token getters only if OAuth is configured +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') \ No newline at end of file diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..d40420c --- /dev/null +++ b/app/forms.py @@ -0,0 +1,82 @@ +#app/forms.py + +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 + # Validate file paths like /static/uploads/_.jpg|png + if field.data.startswith('/app/uploads/'): + # Allow UUIDs (hex with hyphens), underscores, mixed-case filenames + 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 + # Validate URLs + url_pattern = r'^(https?:\/\/)?([\w\-]+\.)+[\w\-]+(\/[\w\-\.]*)*\/?(\?[^\s]*)?(#[^\s]*)?$' + if not re.match(url_pattern, field.data): + raise ValidationError('Invalid URL or file path.') + +class SmartAppForm(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()]) + designed_for_new = StringField('Add New Designed For', validators=[Optional(), Length(max=100)]) + application_type = SelectField('Application Type', coerce=int, validators=[DataRequired()]) + application_type_new = StringField('Add New Application Type', validators=[Optional(), Length(max=100)]) + fhir_compatibility = SelectField('FHIR Compatibility', coerce=int, validators=[DataRequired()]) + fhir_compatibility_new = StringField('Add New FHIR Compatibility', validators=[Optional(), Length(max=100)]) + categories = SelectMultipleField('Categories', coerce=int, validators=[DataRequired()]) + categories_new = StringField('Add New Category', validators=[Optional(), Length(max=100)]) + specialties = SelectMultipleField('Specialties', coerce=int, validators=[DataRequired()]) + specialties_new = StringField('Add New Speciality', validators=[Optional(), Length(max=100)]) + licensing_pricing = SelectField('Licensing & Pricing', coerce=int, validators=[DataRequired()]) + licensing_pricing_new = StringField('Add New Licensing/Pricing', validators=[Optional(), Length(max=100)]) + os_support = SelectMultipleField('OS Support', coerce=int, validators=[DataRequired()]) + os_support_new = StringField('Add New OS Support', validators=[Optional(), Length(max=100)]) + 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()]) + ehr_support_new = StringField('Add New EHR Support', validators=[Optional(), Length(max=100)]) + submit = SubmitField('Register App') + + def __init__(self, *args, **kwargs): + super(SmartAppForm, 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') \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..0d7a213 --- /dev/null +++ b/app/models.py @@ -0,0 +1,90 @@ +#app/models.py +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)) + 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 SmartApp(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) \ No newline at end of file diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..2980bfd --- /dev/null +++ b/app/routes.py @@ -0,0 +1,475 @@ +#app/routes.py +from flask import Blueprint, render_template, redirect, url_for, flash, request +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__) + +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('/') +def index(): + return redirect(url_for('gallery.gallery')) + +@gallery_bp.route('/gallery', methods=['GET', 'POST']) +def gallery(): + form = GalleryFilterForm() + query = SmartApp.query + filter_params = {} + + 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/') +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) + + # Ensure upload folder exists + 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) + + # Handle new filter options + 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) + + # Handle logo + 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"/app/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) + + # Handle app images + 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()]) + if form.app_image_uploads.data: + file = form.app_image_uploads.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 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"/app/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/', 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: + form.app_image_urls.data = '\n'.join(app.app_images.split(',') if app.app_images else []) + + 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) + + # Ensure upload folder exists + 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() + 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('edit_app.html', form=form, app=app) + logo_url = f"/app/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('edit_app.html', form=form, app=app) + elif not logo_url: + logo_url = app.logo_url + + 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()]) + if form.app_image_uploads.data: + file = form.app_image_uploads.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 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('edit_app.html', form=form, app=app) + app_images.append(f"/app/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('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 or None + 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/', 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')) \ No newline at end of file diff --git a/app/templates/app_detail.html b/app/templates/app_detail.html new file mode 100644 index 0000000..5d8804a --- /dev/null +++ b/app/templates/app_detail.html @@ -0,0 +1,58 @@ + + + +{% extends "base.html" %} + +{% block title %}{{ app.name }}{% endblock %} + +{% block content %} +
+

{{ app.name }}

+
+
+ {% if app.logo_url %} + {{ app.name }} logo + {% else %} + No Logo + {% endif %} +

Description: {{ app.description }}

+

Developer: {{ app.developer }}

+

Contact Email: {{ app.contact_email }}

+

Launch URL: {{ app.launch_url }}

+

Client ID: {{ app.client_id }}

+

Scopes: {{ app.scopes }}

+ {% if app.website %} +

Website: {{ app.website }}

+ {% endif %} + {% if app_categories %} +

Categories: {{ app_categories | map(attribute='name') | join(', ') }}

+ {% endif %} + {% if app_specialties %} +

Specialties: {{ app_specialties | map(attribute='name') | join(', ') }}

+ {% endif %} + {% if app_os_supports %} +

OS Support: {{ app_os_supports | map(attribute='name') | join(', ') }}

+ {% endif %} + {% if app_ehr_supports %} +

EHR Support: {{ app_ehr_supports | map(attribute='name') | join(', ') }}

+ {% endif %} + {% if app.app_images %} +
Additional Images:
+
+ {% for img_url in app.app_images.split(',') %} +
+ App Image +
+ {% endfor %} +
+ {% endif %} + {% if current_user.is_authenticated and current_user.id == app.user_id %} + Edit App +
+ +
+ {% endif %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..d57faf5 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,183 @@ + + + + + + + SMARTFLARE - Smart App Gallery - {% block title %}{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + + \ No newline at end of file diff --git a/app/templates/edit_app.html b/app/templates/edit_app.html new file mode 100644 index 0000000..ddb38d7 --- /dev/null +++ b/app/templates/edit_app.html @@ -0,0 +1,214 @@ + + +{% extends "base.html" %} + +{% block content %} +

Edit App: {{ app.name }}

+
+ {{ form.hidden_tag() }} +
+ {{ form.name.label(class="form-label") }} + {{ form.name(class="form-control") }} + {% for error in form.name.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.description.label(class="form-label") }} + {{ form.description(class="form-control", rows=4) }} + {% for error in form.description.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.developer.label(class="form-label") }} + {{ form.developer(class="form-control") }} + {% for error in form.developer.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.contact_email.label(class="form-label") }} + {{ form.contact_email(class="form-control") }} + {% for error in form.contact_email.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.logo_url.label(class="form-label") }} + {{ form.logo_url(class="form-control") }} + Enter a URL or upload a new logo below. Leave blank to keep current logo. + {% for error in form.logo_url.errors %} + {{ error }} + {% endfor %} + {% if app.logo_url %} +

Current Logo:

+ Current Logo + {% endif %} +
+
+ {{ form.logo_upload.label(class="form-label") }} + {{ form.logo_upload(class="form-control") }} + {% for error in form.logo_upload.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.launch_url.label(class="form-label") }} + {{ form.launch_url(class="form-control") }} + {% for error in form.launch_url.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.client_id.label(class="form-label") }} + {{ form.client_id(class="form-control") }} + {% for error in form.client_id.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.scopes.label(class="form-label") }} + {{ form.scopes(class="form-control", rows=3) }} + {% for error in form.scopes.errors %} + {{ error }} + {% endfor %} + {% if app.scopes and app.scopes.strip() %} +

Current Scopes: + {% for scope in app.scopes.split(',') %} + {% if scope.strip() %} + {{ scope.strip() }} + {% endif %} + {% endfor %} +

+ {% else %} +

No scopes defined.

+ {% endif %} +
+
+ {{ form.website.label(class="form-label") }} + {{ form.website(class="form-control") }} + {% for error in form.website.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.designed_for.label(class="form-label") }} + {{ form.designed_for(class="form-control") }} + {% for error in form.designed_for.errors %} + {{ error }} + {% endfor %} + {{ form.designed_for_new.label(class="form-label mt-2") }} + {{ form.designed_for_new(class="form-control") }} + {% for error in form.designed_for_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.application_type.label(class="form-label") }} + {{ form.application_type(class="form-control") }} + {% for error in form.application_type.errors %} + {{ error }} + {% endfor %} + {{ form.application_type_new.label(class="form-label mt-2") }} + {{ form.application_type_new(class="form-control") }} + {% for error in form.application_type_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.fhir_compatibility.label(class="form-label") }} + {{ form.fhir_compatibility(class="form-control") }} + {% for error in form.fhir_compatibility.errors %} + {{ error }} + {% endfor %} + {{ form.fhir_compatibility_new.label(class="form-label mt-2") }} + {{ form.fhir_compatibility_new(class="form-control") }} + {% for error in form.fhir_compatibility_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.categories.label(class="form-label") }} + {{ form.categories(class="form-control") }} + {% for error in form.categories.errors %} + {{ error }} + {% endfor %} + {{ form.categories_new.label(class="form-label mt-2") }} + {{ form.categories_new(class="form-control") }} + {% for error in form.categories_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.specialties.label(class="form-label") }} + {{ form.specialties(class="form-control") }} + {% for error in form.specialties.errors %} + {{ error }} + {% endfor %} + {{ form.specialties_new.label(class="form-label mt-2") }} + {{ form.specialties_new(class="form-control") }} + {% for error in form.specialties_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.licensing_pricing.label(class="form-label") }} + {{ form.licensing_pricing(class="form-control") }} + {% for error in form.licensing_pricing.errors %} + {{ error }} + {% endfor %} + {{ form.licensing_pricing_new.label(class="form-label mt-2") }} + {{ form.licensing_pricing_new(class="form-control") }} + {% for error in form.licensing_pricing_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.os_support.label(class="form-label") }} + {{ form.os_support(class="form-control") }} + {% for error in form.os_support.errors %} + {{ error }} + {% endfor %} + {{ form.os_support_new.label(class="form-label mt-2") }} + {{ form.os_support_new(class="form-control") }} + {% for error in form.os_support_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ 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 %} + {{ error }} + {% endfor %} + {% if app.app_images %} +

Current Images:

+ {% for image in app.app_images.split(',') %} + App Image + {% endfor %} + {% endif %} +
+
+ {{ form.app_image_uploads.label(class="form-label") }} + {{ form.app_image_uploads(class="form-control") }} + {% for error in form.app_image_uploads.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.ehr_support.label(class="form-label") }} + {{ form.ehr_support(class="form-control") }} + {% for error in form.ehr_support.errors %} + {{ error }} + {% endfor %} + {{ form.ehr_support_new.label(class="form-label mt-2") }} + {{ form.ehr_support_new(class="form-control") }} + {% for error in form.ehr_support_new.errors %} + {{ error }} + {% endfor %} +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/gallery.html b/app/templates/gallery.html new file mode 100644 index 0000000..6d79af6 --- /dev/null +++ b/app/templates/gallery.html @@ -0,0 +1,247 @@ + + +{% extends "base.html" %} + +{% block title %}Gallery{% endblock %} + +{% block content %} +
+ + + + +
+

Explore SMART on FHIR apps. Filter above to find the perfect app.

+ {% if apps %} +
+ {% for app in apps %} +
+
+ {% if app.logo_url %} + {{ app.name }} logo + {% else %} + No Logo + {% endif %} +
+
{{ app.name }}
+

{{ app.description | truncate(100) }}

+

By {{ app.developer }}

+ View Details +
+
+
+ {% endfor %} +
+ {% else %} +

No apps match your filters. Try adjusting the filters above.

+ {% endif %} +
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..8ab20b8 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,32 @@ + + + +{% extends "base.html" %} + +{% block content %} +

Login

+
+ {{ form.hidden_tag() }} +
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control") }} + {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ {{ form.submit(class="btn btn-primary") }} +
+
+

Or login with:

+ Google + GitHub +
+

Don't have an account? Register

+{% endblock %} \ No newline at end of file diff --git a/app/templates/my_listings.html b/app/templates/my_listings.html new file mode 100644 index 0000000..2b0af4e --- /dev/null +++ b/app/templates/my_listings.html @@ -0,0 +1,33 @@ + + +{% extends "base.html" %} + +{% block content %} +

My Listings

+ {% if apps %} +
+ {% for app in apps %} +
+
+ {% if app.logo_url %} + {{ app.name }} logo + {% endif %} +
+
{{ app.name }}
+

{{ app.description | truncate(100) }}

+

By {{ app.developer }}

+ View Details + Edit +
+ +
+
+
+
+ {% endfor %} +
+ {% else %} +

You haven't registered any apps yet.

+ Register an App + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/register.html b/app/templates/register.html new file mode 100644 index 0000000..822eb1d --- /dev/null +++ b/app/templates/register.html @@ -0,0 +1,193 @@ + + +{% extends "base.html" %} + +{% block content %} +

Register New App

+
+ {{ form.hidden_tag() }} +
+ {{ form.name.label(class="form-label") }} + {{ form.name(class="form-control") }} + {% for error in form.name.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.description.label(class="form-label") }} + {{ form.description(class="form-control", rows=4) }} + {% for error in form.description.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.developer.label(class="form-label") }} + {{ form.developer(class="form-control") }} + {% for error in form.developer.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.contact_email.label(class="form-label") }} + {{ form.contact_email(class="form-control") }} + {% for error in form.contact_email.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.logo_url.label(class="form-label") }} + {{ form.logo_url(class="form-control") }} + Enter a URL or upload a logo below. + {% for error in form.logo_url.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.logo_upload.label(class="form-label") }} + {{ form.logo_upload(class="form-control") }} + {% for error in form.logo_upload.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.launch_url.label(class="form-label") }} + {{ form.launch_url(class="form-control") }} + {% for error in form.launch_url.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.client_id.label(class="form-label") }} + {{ form.client_id(class="form-control") }} + {% for error in form.client_id.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.scopes.label(class="form-label") }} + {{ form.scopes(class="form-control", rows=3) }} + {% for error in form.scopes.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.website.label(class="form-label") }} + {{ form.website(class="form-control") }} + {% for error in form.website.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.designed_for.label(class="form-label") }} + {{ form.designed_for(class="form-control") }} + {% for error in form.designed_for.errors %} + {{ error }} + {% endfor %} + {{ form.designed_for_new.label(class="form-label mt-2") }} + {{ form.designed_for_new(class="form-control") }} + {% for error in form.designed_for_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.application_type.label(class="form-label") }} + {{ form.application_type(class="form-control") }} + {% for error in form.application_type.errors %} + {{ error }} + {% endfor %} + {{ form.application_type_new.label(class="form-label mt-2") }} + {{ form.application_type_new(class="form-control") }} + {% for error in form.application_type_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.fhir_compatibility.label(class="form-label") }} + {{ form.fhir_compatibility(class="form-control") }} + {% for error in form.fhir_compatibility.errors %} + {{ error }} + {% endfor %} + {{ form.fhir_compatibility_new.label(class="form-label mt-2") }} + {{ form.fhir_compatibility_new(class="form-control") }} + {% for error in form.fhir_compatibility_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.categories.label(class="form-label") }} + {{ 을 form.categories(class="form-control") }} + {% for error in form.categories.errors %} + {{ error }} + {% endfor %} + {{ form.categories_new.label(class="form-label mt-2") }} + {{ form.categories_new(class="form-control") }} + {% for error in form.categories_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.specialties.label(class="form-label") }} + {{ form.specialties(class="form-control") }} + {% for error in form.specialties.errors %} + {{ error }} + {% endfor %} + {{ form.specialties_new.label(class="form-label mt-2") }} + {{ form.specialties_new(class="form-control") }} + {% for error in form.specialties_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.licensing_pricing.label(class="form-label") }} + {{ form.licensing_pricing(class="form-control") }} + {% for error in form.licensing_pricing.errors %} + {{ error }} + {% endfor %} + {{ form.licensing_pricing_new.label(class="form-label mt-2") }} + {{ form.licensing_pricing_new(class="form-control") }} + {% for error in form.licensing_pricing_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.os_support.label(class="form-label") }} + {{ form.os_support(class="form-control") }} + {% for error in form.os_support.errors %} + {{ error }} + {% endfor %} + {{ form.os_support_new.label(class="form-label mt-2") }} + {{ form.os_support_new(class="form-control") }} + {% for error in form.os_support_new.errors %} + {{ error }} + {% endfor %} +
+
+ {{ 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 %} + {{ error }} + {% endfor %} +
+
+ {{ form.app_image_uploads.label(class="form-label") }} + {{ form.app_image_uploads(class="form-control") }} + {% for error in form.app_image_uploads.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.ehr_support.label(class="form-label") }} + {{ form.ehr_support(class="form-control") }} + {% for error in form.ehr_support.errors %} + {{ error }} + {% endfor %} + {{ form.ehr_support_new.label(class="form-label mt-2") }} + {{ form.ehr_support_new(class="form-control") }} + {% for error in form.ehr_support_new.errors %} + {{ error }} + {% endfor %} +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/register_user.html b/app/templates/register_user.html new file mode 100644 index 0000000..66d61cc --- /dev/null +++ b/app/templates/register_user.html @@ -0,0 +1,40 @@ + + +{% extends "base.html" %} + +{% block content %} +

Register

+
+ {{ form.hidden_tag() }} +
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control") }} + {% for error in form.username.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control") }} + {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.confirm_password.label(class="form-label") }} + {{ form.confirm_password(class="form-control") }} + {% for error in form.confirm_password.errors %} + {{ error }} + {% endfor %} +
+ {{ form.submit(class="btn btn-primary") }} +
+

Already have an account? Login

+{% endblock %} \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..9efb0e2 --- /dev/null +++ b/config.py @@ -0,0 +1,8 @@ +#config.py +import os + +class Config: + BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(BASE_DIR, "instance", "smart_app_gallery.db")}' + SQLALCHEMY_TRACK_MODIFICATIONS = False + SECRET_KEY = 'your-secret-key' # Replace with a secure key in production \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..21737c4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' +services: + app: + build: . + ports: + - "5000:5000" + volumes: + - ./instance:/app/instance + - ./uploads:/app/uploads + environment: + - FLASK_APP=app + - FLASK_ENV=development + command: ["flask", "run", "--host=0.0.0.0"] \ No newline at end of file diff --git a/instance/smart_app_gallery.db b/instance/smart_app_gallery.db new file mode 100644 index 0000000..fcddc26 Binary files /dev/null and b/instance/smart_app_gallery.db differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aaf3e73 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/seed.py b/seed.py new file mode 100644 index 0000000..80128d6 --- /dev/null +++ b/seed.py @@ -0,0 +1,60 @@ +from app import db, create_app +from app.models import ApplicationType, Category, OSSupport, FHIRSupport, Speciality, PricingLicense, DesignedFor, EHRSupport + +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() + +with app.app_context(): + # Ensure tables are created + db.create_all() + + try: + # Application Type + app_types = ['Bulk Data', 'SMART', 'SMART Health Cards'] + seed_table(ApplicationType, app_types) + + # Categories + categories = [ + 'Care Coordination', 'Clinical Research', 'Data Visualization', 'Disease Management', + 'Genomics', 'Medication', 'Patient Engagement', 'Population Health', 'Risk Calculation', + 'FHIR Tools', 'COVID-19', 'Telehealth' + ] + seed_table(Category, categories) + + # OS Support + os_supports = ['iOS', 'Android', 'Web', 'Mac', 'Windows', 'Linux'] + seed_table(OSSupport, os_supports) + + # FHIR Support + fhir_supports = ['DSTU 1', 'DSTU 2', 'STU 3', 'R4'] + seed_table(FHIRSupport, fhir_supports) + + # Speciality + specialties = [ + 'Anesthesiology', 'Cardiology', 'Gastrointestinal', 'Infectious Disease', 'Neurology', + 'Obstetrics', 'Oncology', 'Pediatrics', 'Pulmonary', 'Renal', 'Rheumatology', 'Trauma', + 'Primary care' + ] + seed_table(Speciality, specialties) + + # Pricing/License + pricings = ['Open Source', 'Free', 'Per User', 'Site-Based', 'Other'] + seed_table(PricingLicense, pricings) + + # Designed For + designed_fors = ['Clinicians', 'Patients', 'Patients & Clinicians', 'IT'] + seed_table(DesignedFor, designed_fors) + + # EHR Support + ehr_supports = ['Allscripts', 'Athena Health', 'Epic', 'Cerner'] + seed_table(EHRSupport, ehr_supports) + + print("Database seeded successfully!") + except Exception as e: + print(f"Seeding failed: {str(e)}") + db.session.rollback() \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..e2cbfe9 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/smartflare.png b/static/smartflare.png new file mode 100644 index 0000000..9edba89 Binary files /dev/null and b/static/smartflare.png differ