diff --git a/app.py b/app.py index 1351c0a..8a32fe1 100644 --- a/app.py +++ b/app.py @@ -1,57 +1,157 @@ -from flask import Flask, render_template, redirect, url_for, request, abort -from flask_wtf.csrf import CSRFProtect -from flask_sqlalchemy import SQLAlchemy -from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user -from werkzeug.security import generate_password_hash, check_password_hash +from flask import Flask, render_template, redirect, url_for, request, abort, flash, jsonify, send_from_directory import os +from flask_wtf.csrf import CSRFProtect +from functools import wraps +from datetime import datetime, timedelta +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.sql.sqltypes import Text +from flask_login import ( + LoginManager, UserMixin, login_user, login_required, logout_user, current_user, +) +from werkzeug.security import generate_password_hash, check_password_hash +import json + +# Booking configuration constants +MAX_BOOKING_DAYS_AHEAD = 182 # Maximum booking date is 6 months (182 days) from now app = Flask(__name__) csrf = CSRFProtect(app) -app.config["SECRET_KEY"] = os.environ["FLASK_SECRET_KEY"] -from urllib.parse import quote_plus +@app.template_filter("datetimeformat") +def datetimeformat(value, format="%Y-%m-%d %H:%M"): + if value is None: + return "" + return value.strftime(format) -# Get database credentials from environment -db_password = os.environ['DB_PASSWORD'] -# URL-encode all special characters including @ and $ -encoded_password = quote_plus(db_password, safe='') +@app.template_filter("json_parse") +def json_parse(value): + """Parse a JSON string""" + if not value or not isinstance(value, str): + return value + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + return value + +app.config["SECRET_KEY"] = os.environ["FLASK_SECRET_KEY"] +db_password = os.environ["DB_PASSWORD"] app.logger.info(f"Using database password: {'*' * len(db_password)}") app.config["SQLALCHEMY_DATABASE_URI"] = f"postgresql://flaskuser:{db_password}@db:5432/flaskdb" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False - +MAX_BOOKING_DAYS_AHEAD = 182 # Maximum booking date is 6 months (182 days) from now db = SQLAlchemy(app) - -# Initialize database CLI command @app.cli.command("init-db") def init_db(): - import os - with app.app_context(): db.create_all() - print("Database initialized") - + print("Database initialized") login_manager = LoginManager(app) login_manager.login_view = "login" +class Company(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), unique=True) + floor_map_filename = db.Column(db.String(255), nullable=True) + + def __repr__(self): + return f"" class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(100), unique=True) - password = db.Column(db.String(200)) # Increased for password hash - admin = db.Column(db.Boolean, default=False) + password = db.Column(db.String(200)) + first_name = db.Column(db.String(50)) + last_name = db.Column(db.String(50)) + avatar_url = db.Column(db.String(255), nullable=True) + reset_token = db.Column(db.String(100), unique=True, nullable=True) + reset_token_expires = db.Column(db.DateTime, nullable=True) + is_system_admin = db.Column(db.Boolean, default=False) + is_company_admin = db.Column(db.Boolean, default=False) + company_id = db.Column(db.Integer, db.ForeignKey("company.id")) + company = db.relationship("Company", backref="users", lazy=True) + invited_by = db.Column(db.Integer, db.ForeignKey("user.id")) + inviter = db.relationship("User", remote_side=[id], backref="invited_users") + bookings = db.relationship("Booking", backref="user", lazy=True) + + def __repr__(self): + return f"" + + def has_profile(self): + """Check if user has set up their profile""" + return self.first_name and self.last_name + + def complete_profile(self): + """Check if user profile is complete""" + return self.has_profile() and self.avatar_url +class Group(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), unique=True) + description = db.Column(Text, nullable=True) + company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False) + user_ids = db.Column(db.Text, nullable=True) + + def __repr__(self): + return f"" + +class ResourcePermission(db.Model): + id = db.Column(db.Integer, primary_key=True) + resource_id = db.Column(db.Integer, db.ForeignKey("resource.id"), unique=True) + permission_type = db.Column(db.String(20)) + group_id = db.Column(db.Integer, db.ForeignKey("group.id"), nullable=True) + user_ids = db.Column(db.Text, nullable=True) + + def __repr__(self): + return f"" + +class Resource(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100)) + description = db.Column(Text) + image_url = db.Column(db.String(255), nullable=True) + company_id = db.Column(db.Integer, db.ForeignKey("company.id")) + position_x = db.Column(db.Integer, nullable=True) + position_y = db.Column(db.Integer, nullable=True) + resource_type = db.Column(db.String(50), default="workoffice") # e.g., "workoffice", "meeting_room" + permissions = db.relationship("ResourcePermission", backref="resource", lazy=True) + + def __repr__(self): + return f"" + +class Booking(db.Model): + id = db.Column(db.Integer, primary_key=True) + resource_id = db.Column(db.Integer, db.ForeignKey("resource.id")) + resource_type = db.Column(db.String(50)) + user_id = db.Column(db.Integer, db.ForeignKey("user.id")) + start_time = db.Column(db.DateTime) + end_time = db.Column(db.DateTime) + purpose = db.Column(db.Text) + resource = db.relationship("Resource", backref="bookings", lazy=True) + + def __repr__(self): + return f"" + +class Invitation(db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(100)) + token = db.Column(db.String(100), unique=True) + company_id = db.Column(db.Integer, db.ForeignKey("company.id")) + invited_by = db.Column(db.Integer, db.ForeignKey("user.id")) + expires_at = db.Column(db.DateTime) + + def __repr__(self): + return f"" + @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) - @app.route("/") def home(): - return redirect(url_for("login")) - + return render_template("index.html") @app.route("/login", methods=["GET", "POST"]) def login(): @@ -59,48 +159,226 @@ def login(): email = request.form.get("email") password = request.form.get("password") user = User.query.filter_by(email=email).first() - if user and check_password_hash(user.password, password): login_user(user) return redirect(url_for("dashboard")) - return "Invalid credentials" return render_template("login.html") - @app.route("/register", methods=["GET", "POST"]) def register(): + token = request.args.get("token") if request.method == "POST": email = request.form.get("email") password = request.form.get("password") confirm_password = request.form.get("confirm_password") - + first_name = request.form.get("first_name", "") + last_name = request.form.get("last_name", "") + company_name = request.form.get("company_name") if not token else None + + # Handle avatar upload + avatar_url = None + avatar_image = request.files.get("avatar") + if avatar_image and avatar_image.filename: + from werkzeug.utils import secure_filename + if secure_filename(avatar_image.filename).lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): + import uuid + filename = secure_filename(f"user_avatar_{uuid.uuid4().hex}_{avatar_image.filename}") + upload_folder = os.path.join(app.root_path, "static", "user_avatars") + os.makedirs(upload_folder, exist_ok=True) + avatar_image.save(os.path.join(upload_folder, filename)) + avatar_url = f"user_avatars/{filename}" + if password != confirm_password: return "Passwords do not match" - if User.query.filter_by(email=email).first(): return "Email already registered" - - password = generate_password_hash(password) - - new_user = User(email=email, password=password) - - # Automatically make first user admin + if token: + invitation = Invitation.query.filter_by(token=token).first() + if not invitation or invitation.expires_at < datetime.now(): + return "Invalid or expired invitation" + company = Company.query.get(invitation.company_id) + if not company: + return "Invalid company" + new_user = User( + email=email, + password=generate_password_hash(password), + company_id=company.id, + invited_by=invitation.invited_by, + first_name=first_name, + last_name=last_name, + avatar_url=avatar_url + ) + db.session.add(new_user) + db.session.delete(invitation) + db.session.commit() + return redirect(url_for("login")) + company_name = request.form.get("company_name") + existing_company = Company.query.filter_by(name=company_name).first() + if existing_company: + return "Company name already exists" + new_company = Company(name=company_name) + db.session.add(new_company) + db.session.flush() + new_user = User( + email=email, + password=generate_password_hash(password), + company_id=new_company.id, + is_company_admin=True, + first_name=first_name or "User", # Default to "User" if not provided + last_name=last_name or "Account", # Default to "Account" if not provided + avatar_url=avatar_url + ) if User.query.count() == 0: - new_user.admin = True - + new_user.is_system_admin = True db.session.add(new_user) db.session.commit() - return redirect(url_for("login")) - return render_template("register.html") - + return render_template("register.html", token=token) @app.route("/dashboard") @login_required def dashboard(): - return render_template("dashboard.html") + if current_user.company_id: + resources = Resource.query.filter_by(company_id=current_user.company_id).all() + return render_template("resources.html", resources=resources) + return redirect(url_for("login")) +def has_permission_check(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_system_admin and not current_user.company_id: + abort(403) + return f(*args, **kwargs) + return decorated_function + +def system_admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_system_admin: + abort(403) + return f(*args, **kwargs) + return decorated_function + +def company_admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_company_admin: + abort(403) + return f(*args, **kwargs) + return decorated_function + +@app.route("/resource/add", methods=["GET", "POST"]) +@login_required +@company_admin_required +def add_resource(): + if request.method == "POST": + name = request.form.get("name") + description = request.form.get("description") + image = request.files.get("image") + resource_type = request.form.get("resource_type", "workoffice") + permission_type = request.form.get("permission_type", "everyone") + group_id = request.form.get("group_id") + user_ids_json = request.form.get("user_ids") + + # Handle image upload + image_url = None + if image and image.filename: + from werkzeug.utils import secure_filename + if secure_filename(image.filename).lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): + import uuid + filename = secure_filename(f"resource_{uuid.uuid4().hex}_{image.filename}") + upload_folder = os.path.join(app.root_path, "static", "resource_images") + os.makedirs(upload_folder, exist_ok=True) + image.save(os.path.join(upload_folder, filename)) + image_url = f"resource_images/{filename}" + + try: + new_resource = Resource(name=name, description=description, image_url=image_url, company_id=current_user.company_id, resource_type=resource_type) + db.session.add(new_resource) + db.session.flush() + if permission_type == "group" and group_id: + permission = ResourcePermission(resource_id=new_resource.id, permission_type="group", group_id=group_id) + elif permission_type == "users" and user_ids_json: + try: + user_ids = json.loads(user_ids_json) + permission = ResourcePermission(resource_id=new_resource.id, permission_type="users", user_ids=json.dumps(user_ids)) + except json.JSONDecodeError: + flash("Invalid user IDs format", "danger") + return redirect(url_for("add_resource")) + else: + permission = ResourcePermission(resource_id=new_resource.id, permission_type="everyone") + db.session.add(permission) + db.session.commit() + flash("Resource created successfully", "success") + return redirect(url_for("company_admin")) + except Exception as e: + db.session.rollback() + flash(f"Error creating resource: {str(e)}", "danger") + return redirect(url_for("add_resource")) + groups = Group.query.filter_by(company_id=current_user.company_id).all() + return render_template("edit_resource.html", groups=groups) + +@app.route("/resource/edit/", methods=["GET", "POST"]) +@login_required +@company_admin_required +def edit_resource(resource_id): + resource = Resource.query.get_or_404(resource_id) + if request.method == "POST": + resource.name = request.form.get("name") + resource.description = request.form.get("description") + resource_type = request.form.get("resource_type", resource.resource_type) + resource.resource_type = resource_type + position_x = request.form.get("position_x") + position_y = request.form.get("position_y") + if position_x: + resource.position_x = int(position_x) if position_x.strip() else None + if position_y: + resource.position_y = int(position_y) if position_y.strip() else None + + # Handle image upload + image = request.files.get("image") + if image and image.filename: + from werkzeug.utils import secure_filename + if secure_filename(image.filename).lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): + import uuid + # Delete old image if exists + if resource.image_url: + old_filename = resource.image_url.replace('resource_images/', '') + old_path = os.path.join(app.root_path, "static", old_filename) + if os.path.exists(old_path): + os.remove(old_path) + filename = secure_filename(f"resource_{uuid.uuid4().hex}_{image.filename}") + upload_folder = os.path.join(app.root_path, "static", "resource_images") + os.makedirs(upload_folder, exist_ok=True) + image.save(os.path.join(upload_folder, filename)) + resource.image_url = f"resource_images/{filename}" + + permission_type = request.form.get("permission_type", "everyone") + group_id = request.form.get("group_id") + user_ids_json = request.form.get("user_ids") + existing_permission = ResourcePermission.query.filter_by(resource_id=resource_id).first() + if existing_permission: + db.session.delete(existing_permission) + db.session.flush() + if permission_type == "group" and group_id: + permission = ResourcePermission(resource_id=resource_id, permission_type="group", group_id=group_id) + elif permission_type == "users" and user_ids_json: + try: + user_ids = json.loads(user_ids_json) + permission = ResourcePermission(resource_id=resource_id, permission_type="users", user_ids=json.dumps(user_ids)) + except json.JSONDecodeError: + flash("Invalid user IDs format", "danger") + return redirect(url_for("edit_resource", resource_id=resource_id)) + else: + permission = ResourcePermission(resource_id=resource_id, permission_type="everyone") + db.session.add(permission) + db.session.commit() + flash("Resource updated successfully", "success") + return redirect(url_for("company_admin")) + groups = Group.query.filter_by(company_id=current_user.company_id).all() + permission = ResourcePermission.query.filter_by(resource_id=resource_id).first() + return render_template("edit_resource.html", resource=resource, groups=groups, permission=permission, resource_type=resource.resource_type) @app.route("/logout") @login_required @@ -108,26 +386,500 @@ def logout(): logout_user() return redirect(url_for("login")) -@app.route("/admin", methods=["GET", "POST"]) +@app.route("/book/", methods=["GET", "POST"]) @login_required -def admin(): - if not current_user.admin: +def book_resource(resource_id): + resource = Resource.query.get_or_404(resource_id) + permission = ResourcePermission.query.filter_by(resource_id=resource_id).first() + can_book = check_permission(resource_id, resource.company_id, current_user.id) + if not can_book: + flash("You don't have permission to book this resource", "danger") + return redirect(url_for("dashboard")) + + if request.method == "POST": + start_time = datetime.fromisoformat(request.form.get("start_time")) + end_time = datetime.fromisoformat(request.form.get("end_time")) + + # Check if start time is beyond max advance booking period + now = datetime.now() + max_bookable_time = now + timedelta(days=MAX_BOOKING_DAYS_AHEAD) + if start_time > max_bookable_time: + flash(f"Bookings can only be made up to {MAX_BOOKING_DAYS_AHEAD} months in advance", "danger") + return redirect(url_for("book_resource", resource_id=resource_id)) + + # Check if user already has an overlapping booking on the SAME resource type (max 1 active booking per type at a time) + conflicting_booking = Booking.query.filter( + Booking.user_id == current_user.id, + Booking.resource_type == resource.resource_type, # Only check same resource type + Booking.start_time < end_time, + Booking.end_time > start_time + ).first() + + if conflicting_booking: + flash(f"You already have a {resource.resource_type.replace('_', ' ')} booking during this time slot", "danger") + return redirect(url_for("book_resource", resource_id=resource_id)) + + new_booking = Booking(resource_id=resource_id, resource_type=resource.resource_type, user_id=current_user.id, start_time=start_time, end_time=end_time, purpose=request.form.get("purpose")) + db.session.add(new_booking) + db.session.commit() + return redirect(url_for("dashboard")) + + return render_template("book_resource.html", resource=resource) + +@app.route("/my-bookings") +@login_required +def my_bookings(): + bookings = Booking.query.filter_by(user_id=current_user.id).all() + return render_template("my_bookings.html", bookings=bookings) + +@app.route("/api/my-bookings") +@login_required +def api_my_bookings(): + bookings = Booking.query.filter_by(user_id=current_user.id).all() + events = [{'title': f"{booking.resource.name} - {booking.purpose}", 'start': booking.start_time.isoformat(), 'end': booking.end_time.isoformat()} for booking in bookings] + return jsonify(events) + +@app.route("/api/resource-bookings/") +@login_required +def api_resource_bookings(resource_id): + resource = Resource.query.get_or_404(resource_id) + if resource.company_id != current_user.company_id: + abort(403) + bookings = Booking.query.filter_by(resource_id=resource_id).all() + events = [{'title': f"Booked by {booking.user.email.split('@')[0]}" if booking.user_id != current_user.id else "Your booking", 'start': booking.start_time.isoformat(), 'end': booking.end_time.isoformat(), 'color': '#28a745' if booking.user_id == current_user.id else '#dc3545'} for booking in bookings] + return jsonify(events) + +@app.route("/api/resource-availability") +@login_required +def api_resource_availability(): + if not current_user.company_id: + abort(403) + import pytz + resources = Resource.query.filter_by(company_id=current_user.company_id).all() + stockholm_tz = pytz.timezone('Europe/Stockholm') + now = datetime.now(stockholm_tz).replace(tzinfo=None) + original_width = 5712 + original_height = 4284 + resource_data = [] + for resource in resources: + if resource.position_x is not None and resource.position_y is not None: + current_booking = Booking.query.filter(Booking.resource_id == resource.id, Booking.start_time <= now, Booking.end_time > now).first() + is_available = current_booking is None + x_percent = (resource.position_x / original_width) * 100 + y_percent = (resource.position_y / original_height) * 100 + resource_data.append({'id': resource.id, 'name': resource.name, 'x': x_percent, 'y': y_percent, 'available': is_available, 'image_url': resource.image_url}) + return jsonify(resource_data) + +@app.route("/api/update-resource-position/", methods=["POST"]) +@login_required +def api_update_resource_position(resource_id): + if not current_user.company_id: + abort(403) + resource = Resource.query.get_or_404(resource_id) + if resource.company_id != current_user.company_id: + abort(403) + if not current_user.is_company_admin: + abort(403) + try: + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'No data provided'}), 400 + original_width = 5712 + original_height = 4284 + x_original = int((data.get('x_percent', 0) / 100) * original_width) + y_original = int((data.get('y_percent', 0) / 100) * original_height) + resource.position_x = x_original + resource.position_y = y_original + db.session.commit() + return jsonify({'success': True, 'message': 'Resource position updated successfully'}) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/delete-booking/", methods=["POST"]) +@login_required +def delete_booking(booking_id): + booking = Booking.query.get_or_404(booking_id) + if booking.user_id != current_user.id and not current_user.is_company_admin: + abort(403) + db.session.delete(booking) + db.session.commit() + flash("Booking deleted successfully", "success") + return redirect(url_for("my_bookings")) + +@app.route("/admin/system", methods=["GET", "POST"]) +@login_required +@system_admin_required +def system_admin(): + if request.method == "POST": + action = request.form.get("action") + user_id = request.form.get("user_id") + user = User.query.get(user_id) + if user and user != current_user: + if action == "promote_system": + user.is_system_admin = True + elif action == "demote_system": + if User.query.filter_by(is_system_admin=True).count() > 1: + user.is_system_admin = False + elif action == "promote_company": + user.is_company_admin = True + elif action == "demote_company": + company_admins = User.query.filter_by(company_id=user.company_id, is_company_admin=True).count() + if company_admins > 1: + user.is_company_admin = False + elif action == "delete": + db.session.delete(user) + db.session.commit() + users = User.query.all() + companies = Company.query.all() + groups = Group.query.all() + return render_template("admin_system.html", users=users, companies=companies, groups=groups) + +@app.route("/admin/company", methods=["GET", "POST"]) +@login_required +@company_admin_required +def company_admin(): + company = Company.query.get(current_user.company_id) + if request.method == "POST": + action = request.form.get("action") + if action == "upload_floor_map" and "floor_map" in request.files: + from werkzeug.utils import secure_filename + file = request.files["floor_map"] + if file.filename: + filename = secure_filename(f"company_{current_user.company_id}_{file.filename}") + upload_folder = os.path.join(app.root_path, "static", "floor_maps") + os.makedirs(upload_folder, exist_ok=True) + file.save(os.path.join(upload_folder, filename)) + company.floor_map_filename = filename + db.session.commit() + flash("Floor map uploaded successfully", "success") + return redirect(url_for("company_admin")) + email = request.form.get("email") + if email: + existing_user = User.query.filter_by(email=email).first() + if existing_user: + return "User already exists" + token = os.urandom(24).hex() + new_invite = Invitation(email=email, token=token, company_id=current_user.company_id, invited_by=current_user.id, expires_at=datetime.now() + timedelta(days=3)) + db.session.add(new_invite) + db.session.commit() + invite_link = url_for("register", token=token, _external=True) + return f"Invitation sent! Share this link: {invite_link}" + invitations = Invitation.query.filter_by(company_id=current_user.company_id) + company_users = User.query.filter_by(company_id=current_user.company_id).options(db.joinedload(User.company), db.joinedload(User.inviter)) + resources = Resource.query.filter_by(company_id=current_user.company_id).all() + groups = Group.query.filter_by(company_id=current_user.company_id).all() + return render_template("admin_company.html", users=company_users, invitations=invitations, company=company, resources=resources, groups=groups) + +@app.route("/api/groups", methods=["GET"]) +@login_required +@has_permission_check +def api_groups(): + if not current_user.company_id: + abort(403) + groups = Group.query.filter_by(company_id=current_user.company_id).all() + group_data = [{'id': g.id, 'name': g.name} for g in groups] + return jsonify(group_data) + +@app.route("/api/get-users", methods=["GET"]) +@login_required +@has_permission_check +def api_get_users(): + if not current_user.company_id: + abort(403) + users = User.query.filter_by(company_id=current_user.company_id).all() + user_ids = [u.id for u in users] + return jsonify(user_ids) + +@app.route("/create-group/", methods=["GET", "POST"]) +@login_required +@company_admin_required +def create_group_page(company_id): + if company_id != current_user.company_id: + abort(403) + + if request.method == "POST": + name = request.form.get("name") + description = request.form.get("description") + user_ids = request.form.getlist("user_ids[]") + + if not name: + flash("Group name is required", "danger") + return redirect(url_for("create_group_page", company_id=company_id)) + + existing_group = Group.query.filter_by(name=name, company_id=company_id).first() + if existing_group: + flash(f"Group '{name}' already exists", "danger") + return redirect(url_for("create_group_page", company_id=company_id)) + + new_group = Group(name=name, description=description, company_id=company_id) + db.session.add(new_group) + db.session.flush() + + if user_ids: + new_group.user_ids = json.dumps(user_ids) + + db.session.commit() + + flash(f"Group '{name}' created successfully", "success") + return redirect(url_for("company_admin", company_id=company_id)) + + users = User.query.filter_by(company_id=company_id).all() + return render_template("create_group.html", company=company_id, users=users) + +@app.route("/api/create-group/", methods=["GET", "POST"]) +@login_required +@company_admin_required +def create_group_api(company_id): + if company_id != current_user.company_id: + abort(403) + + if request.method == "GET": + groups = Group.query.filter_by(company_id=company_id).all() + return jsonify([{'id': g.id, 'name': g.name} for g in groups]) + + name = request.form.get("name") + if not name: + abort(400) + existing_group = Group.query.filter_by(name=name, company_id=company_id).first() + if existing_group: + abort(409) + new_group = Group(name=name, company_id=company_id) + db.session.add(new_group) + db.session.commit() + flash(f"Group '{name}' created successfully", "success") + return jsonify({'success': True, 'id': new_group.id, 'name': name}) + +@app.route("/group-members/", methods=["GET", "POST"]) +@login_required +@company_admin_required +def group_members_page(group_id): + """Page for managing group members""" + group = Group.query.get_or_404(group_id) + if group.company_id != current_user.company_id: abort(403) if request.method == "POST": action = request.form.get("action") user_id = request.form.get("user_id") - user = User.query.get(user_id) - - if user and user != current_user: # Prevent self-modification - if action == "promote": - user.admin = True - elif action == "demote": - user.admin = False - elif action == "delete": - db.session.delete(user) - - db.session.commit() + if action == "add" and user_id: + user = User.query.get(user_id) + if user and user.company_id == group.company_id: + try: + user_ids = json.loads(group.user_ids) + except (json.JSONDecodeError, TypeError): + user_ids = [] + if str(user_id) not in user_ids and int(user_id) not in user_ids: + user_ids.append(int(user_id)) + group.user_ids = json.dumps(user_ids) + db.session.commit() + flash(f"User {user.email} added to group '{group.name}'", "success") + else: + flash(f"User {user.email} already a member", "info") + else: + flash(f"User {user.email} not in this company", "warning") + elif action == "remove" and user_id: + try: + user_ids = json.loads(group.user_ids) + except (json.JSONDecodeError, TypeError): + user_ids = [] + # Normalize to int for consistent comparison and removal + user_id_int = int(user_id) + user_id_str = str(user_id) + # Check both string and int versions to handle mixed types + is_in_list = user_id_str in user_ids or user_id_int in user_ids + if is_in_list: + # Remove the matching element (handle both types) + if user_id_str in user_ids: + user_ids.remove(user_id_str) + elif user_id_int in user_ids: + user_ids.remove(user_id_int) + group.user_ids = json.dumps(user_ids) + db.session.commit() + flash(f"User removed from group '{group.name}'", "success") + else: + flash(f"User not a member of this group", "warning") + return redirect(url_for("group_members_page", group_id=group_id)) - users = User.query.all() - return render_template("admin.html", users=users) + company_users = User.query.filter_by(company_id=group.company_id).options(db.joinedload(User.inviter)).all() + if group.user_ids: + try: + member_ids_list = json.loads(group.user_ids) + except (json.JSONDecodeError, TypeError): + member_ids_list = [] + else: + member_ids_list = [] + return render_template("group_members.html", group=group, users=company_users, member_ids=member_ids_list) + +@app.route("/api/group-members/", methods=["GET", "POST"]) +@login_required +@company_admin_required +def group_members_api(group_id): + """API endpoint for group members - kept for backwards compatibility""" + group = Group.query.get_or_404(group_id) + if group.company_id != current_user.company_id: + abort(403) + + if request.method == "GET": + members = [{'id': u.id, 'email': u.email} for u in User.query.filter_by(company_id=group.company_id).all()] + return jsonify({'members': members}) + + return jsonify({'success': True, 'members': []}) + +@app.route("/api/delete-group/", methods=["POST"]) +@login_required +@company_admin_required +def delete_group(group_id): + group = Group.query.get_or_404(group_id) + if group.company_id != current_user.company_id: + abort(403) + ResourcePermission.query.filter_by(group_id=group_id).delete() + db.session.delete(group) + db.session.commit() + flash(f"Group deleted successfully", "success") + return jsonify({'success': True}) + +@app.route("/profile", methods=["GET", "POST"]) +@login_required +def profile(): + """Profile management page""" + if request.method == "POST": + action = request.form.get("action") + first_name = request.form.get("first_name", "") + last_name = request.form.get("last_name", "") + avatar_image = request.files.get("avatar") + new_password = request.form.get("new_password", "") + confirm_password = request.form.get("confirm_password", "") + current_password = request.form.get("current_password", "") + + # Handle avatar upload + avatar_url = current_user.avatar_url + if avatar_image and avatar_image.filename: + from werkzeug.utils import secure_filename + if secure_filename(avatar_image.filename).lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): + import uuid + filename = secure_filename(f"user_avatar_{uuid.uuid4().hex}_{avatar_image.filename}") + upload_folder = os.path.join(app.root_path, "static", "user_avatars") + os.makedirs(upload_folder, exist_ok=True) + avatar_image.save(os.path.join(upload_folder, filename)) + avatar_url = f"user_avatars/{filename}" + # Delete old avatar if exists (only if it uses the user_avatars prefix) + if current_user.avatar_url and current_user.avatar_url.startswith('user_avatars/'): + old_filename = current_user.avatar_url.replace('user_avatars/', '') + old_path = os.path.join(app.root_path, "static", old_filename) + if os.path.exists(old_path): + os.remove(old_path) + + # Handle password change + if action == "change_password": + if not current_password or not new_password: + flash("Please fill in all password fields", "danger") + return redirect(url_for("profile")) + if new_password != confirm_password: + flash("New passwords do not match", "danger") + return redirect(url_for("profile")) + # Verify current password before changing + if not check_password_hash(current_user.password, current_password): + flash("Current password is incorrect", "danger") + return redirect(url_for("profile")) + current_user.password = generate_password_hash(new_password) + db.session.commit() + flash("Password changed successfully", "success") + return redirect(url_for("profile")) + + # Handle profile update + current_user.first_name = first_name or current_user.first_name + current_user.last_name = last_name or current_user.last_name + current_user.avatar_url = avatar_url + db.session.commit() + flash("Profile updated successfully", "success") + return redirect(url_for("profile")) + + return render_template("profile.html", user=current_user) + +@app.route("/forgot-password", methods=["GET", "POST"]) +def forgot_password(): + """Forgot password page""" + if request.method == "POST": + email = request.form.get("email") + existing_user = User.query.filter_by(email=email).first() + if existing_user: + # Generate reset token + import secrets + token = secrets.token_urlsafe(16) + existing_user.reset_token = token + existing_user.reset_token_expires = datetime.now() + timedelta(hours=24) + db.session.commit() + flash("Password reset link generated. Please enter your new password.", "success") + return redirect(url_for("set_password", token=token)) + flash("Email not found. Please check your email address and try again.", "danger") + return render_template("forgot_password.html") + +@app.route("/set-password/", methods=["GET", "POST"]) +def set_password(token): + """Set new password page""" + if request.method == "POST": + new_password = request.form.get("new_password") + confirm_password = request.form.get("confirm_password") + if new_password != confirm_password: + flash("Passwords do not match", "danger") + return redirect(url_for("set_password", token=token)) + + # Find user with this token + import secrets + for user in User.query.all(): + if user.reset_token == token: + user.password = generate_password_hash(new_password) + user.reset_token = None + user.reset_token_expires = None + db.session.commit() + flash("Password updated successfully. Please log in again.", "success") + return redirect(url_for("login")) + flash("Invalid or expired reset token", "danger") + + return render_template("set_password.html", token=token) + +@app.route("/at-the-office") +@login_required +def at_the_office(): + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + today_end = today + timedelta(days=1) + resources = Resource.query.filter_by(company_id=current_user.company_id).all() + resource_ids = [r.id for r in resources] + office_bookings = Booking.query.filter(Booking.start_time >= today, Booking.start_time < today_end, Booking.resource_id.in_(resource_ids)).all() + bookings_by_user = {} + for booking in office_bookings: + user_email = booking.user.email + if user_email not in bookings_by_user: + bookings_by_user[user_email] = [] + bookings_by_user[user_email].append(booking) + office_booking_list = [{'email': user_email, 'bookings': user_bookings} for user_email, user_bookings in bookings_by_user.items()] + return render_template("at_the_office.html", bookings=office_booking_list) + +def check_permission(resource_id, company_id, user_id): + resource = Resource.query.get(resource_id) + user = User.query.get(user_id) + if not user or user.company_id != company_id: + return False + permission = ResourcePermission.query.filter_by(resource_id=resource_id).first() + if not permission: + return True + if permission.permission_type == "everyone": + return True + if permission.permission_type == "group" and permission.group_id: + group = Group.query.get(permission.group_id) + if group: + if not group.user_ids or group.user_ids.strip() == "": + return False + group_user_ids = json.loads(group.user_ids) + if user_id in group_user_ids: + return True + else: + return False + if permission.permission_type == "users" and permission.user_ids: + allowed_users = json.loads(permission.user_ids) + if user_id in allowed_users: + return True + else: + return False + return True \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1d0cadd..e09c32d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,31 @@ -version: "3" +version: "3.8" services: db: - image: "postgres:latest" + image: "postgres:15" + env_file: + - .env volumes: - postgres_data:/var/lib/postgresql/data - environment: POSTGRES_USER: flaskuser POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: flaskdb - healthcheck: test: ["CMD-SHELL", "pg_isready -U flaskuser -d flaskdb"] interval: 5s timeout: 5s retries: 5 start_period: 10s - ports: - "5432:5432" - + networks: + - app-network + web: build: . + env_file: + - .env depends_on: db: condition: service_healthy @@ -34,10 +37,51 @@ services: - DB_PASSWORD=${DB_PASSWORD} volumes: - ./instance:/app/instance + - ./static/floor_maps:/app/static/floor_maps command: sh -c "sleep 2 && flask init-db && gunicorn --bind 0.0.0.0:5001 --workers 4 app:app" - - + networks: + - app-network + nginx-proxy-manager: + image: "jc21/nginx-proxy-manager:latest" + restart: unless-stopped + ports: + - "81:81" # Admin Web UI + - "80:80" # HTTP proxy + - "443:443" # HTTPS proxy + environment: + - DB_MYSQL_HOST=nginx-proxy-manager-db + - DB_MYSQL_PORT=3306 + - DB_MYSQL_USER=nginxproxyadmin + - DB_MYSQL_PASSWORD=nginxproxyadminpassword + - DB_MYSQL_NAME=nginxproxyadmin + volumes: + - npm_data:/data + - /etc/ssl/certs:/etc/ssl/certs:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + labels: + - "com.github.jcmall.nginx-proxy-manager=true" + networks: + - app-network + + nginx-proxy-manager-db: + image: "mysql:8.0" + restart: unless-stopped + environment: + - MYSQL_ROOT_PASSWORD=rootpassword + - MYSQL_DATABASE=nginxproxyadmin + - MYSQL_USER=nginxproxyadmin + - MYSQL_PASSWORD=nginxproxyadminpassword + volumes: + - npm_db_data:/var/lib/mysql + networks: + - app-network + +networks: + app-network: + driver: bridge volumes: postgres_data: + npm_data: + npm_db_data: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 71ab25c..583f639 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ werkzeug==3.0.0 gunicorn==21.2.0 psycopg2-binary==2.9.9 python-dotenv==1.0.0 +Flask-WTF==1.2.1 +pytz==2024.1 diff --git a/scripts/check_group_member.py b/scripts/check_group_member.py new file mode 100644 index 0000000..88f20d8 --- /dev/null +++ b/scripts/check_group_member.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Script to verify if a user is in a specific group. +Usage: python scripts/check_group_member.py +""" + +import os +import sys +import json + +# Check for environment variable or use default file path +env_file = os.path.expanduser("~/.env") +if os.path.exists(env_file): + with open(env_file, 'r') as f: + for line in f: + if line.startswith('DB_PASSWORD=') or line.startswith('DB_PASSWORD '): + db_password = line.split('=')[1].strip().strip('"\'') + break + else: + db_password = None +else: + db_password = os.environ.get("DB_PASSWORD") + +if not db_password: + print("Error: DB_PASSWORD environment variable not set") + sys.exit(1) + +# Use flask-sqlalchemy +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + +app = Flask(__name__) +app.config["SQLALCHEMY_DATABASE_URI"] = f"postgresql://flaskuser:{db_password}@localhost:5432/flaskdb" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +db = SQLAlchemy(app) + +# Import models from app.py +sys.path.insert(0, '/Users/le0an/Developer/Personal/circle-flexoffice') +import app as main_app + +with main_app.app.app_context(): + user = None # Default for demo purposes - in production would query by email + + if not user: + print(f"❌ User 'andreas@mgmtatlas.com' not found in the database") + sys.exit(1) + + print(f"Found user: {user.email} (ID: {user.id})") + + # Find the group by name + group = main_app.Group.query.filter_by(name='O213').first() + + if not group: + print(f"❌ Group 'O213' not found in the database") + sys.exit(1) + + print(f"Found group: {group.name} (ID: {group.id}, Company: {group.company.name})") + + # Check if user is in the group + if not group.user_ids or group.user_ids.strip() == "": + print(f"❌ User '{user.email}' is NOT in group '{group.name}' (group has no members)") + else: + try: + member_ids = json.loads(group.user_ids) + if user.id in member_ids: + print(f"✅ User '{user.email}' IS in group '{group.name}'") + print(f" Group members count: {len(member_ids)}") + else: + print(f"❌ User '{user.email}' is NOT in group '{group.name}'") + print(f" Group members count: {len(member_ids)}") + print(f" Current member IDs: {member_ids}") + except (json.JSONDecodeError, TypeError) as e: + print(f"⚠️ Error parsing group.user_ids: {e}") + print(f" Raw value: {group.user_ids}") \ No newline at end of file diff --git a/scripts/cleanup_resources_bookings.py b/scripts/cleanup_resources_bookings.py new file mode 100644 index 0000000..ee73587 --- /dev/null +++ b/scripts/cleanup_resources_bookings.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +Database Cleanup Script for Resources/Bookings Tables +This script removes resources, bookings tables from PostgreSQL database. +Usage: cd /Users/le0an/Developer/Personal/ManagementPortal && FLASK_APP=app.py flask cleanup-resources-bookings +Or run directly with app context. +""" + +import sys +sys.path.insert(0, '/Users/le0an/Developer/Personal/ManagementPortal') + +from sqlalchemy import text + + +def main(): + """Clean up resources and bookings tables.""" + + print("=" * 60) + print("Database Cleanup: Removing Resources & Bookings Tables") + print("=" * 60) + print() + + # Import after adding path to sys.path + from app import db, app + + with app.app_context(): + print("1. Dropping bookings table...") + try: + db.session.execute(text("""DROP TABLE IF EXISTS booking CASCADE""")) + print(" ✓ Bookings removed (with cascading deletes)") + except Exception as e: + print(f" ✗ Error removing bookings: {e}") + + print() + print("2. Dropping resource_permissions table...") + try: + db.session.execute(text("""DROP TABLE IF EXISTS resource_permission CASCADE""")) + print(" ✓ ResourcePermissions removed (with cascading deletes)") + except Exception as e: + print(f" ✗ Error removing permissions: {e}") + + print() + print("3. Dropping resources table...") + try: + db.session.execute(text("""DROP TABLE IF EXISTS resource CASCADE""")) + print(" ✓ Resources removed (with cascading deletes)") + except Exception as e: + print(f" ✗ Error removing resources: {e}") + + print() + print("-" * 60) + print("Cleanup completed!") + print("-" * 60) + + # Re-import models to clear references from deleted tables + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/migrate_group_ids.py b/scripts/migrate_group_ids.py new file mode 100644 index 0000000..496986a --- /dev/null +++ b/scripts/migrate_group_ids.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Migrate existing group user_ids from string to integer format. +Run this to fix existing groups that have string-stored member IDs. +""" +import os +import sys + +# Add the project root to the path +sys.path.insert(0, '/Users/le0an/Developer/Personal/circle-flexoffice') + +# Read credentials from environment variables +FLASK_SECRET_KEY = os.environ.get('FLASK_SECRET_KEY', 'dev') +DB_PASSWORD = os.environ.get('DB_PASSWORD', 'password') + +os.environ['FLASK_SECRET_KEY'] = FLASK_SECRET_KEY +os.environ['DB_PASSWORD'] = DB_PASSWORD + +from app import app, db, Group, json + +app.config['TESTING'] = True + +def migrate_group_ids(): + with app.app_context(): + groups = Group.query.filter(Group.user_ids != None).all() + fixed = 0 + for group in groups: + if group.user_ids: + try: + user_ids = json.loads(group.user_ids) + # Try converting to integers + int_user_ids = [] + for uid in user_ids: + try: + int_uid = int(uid) + int_user_ids.append(int_uid) + except (ValueError, TypeError): + # If it's already an int or can't be converted, keep as is + int_user_ids.append(uid) + + # Only update if there was a change + if user_ids != int_user_ids: + group.user_ids = json.dumps(int_user_ids) + db.session.commit() + fixed += 1 + print(f'Fixed group {group.id} ({group.name}): {user_ids} -> {int_user_ids}') + except Exception as e: + print(f'Error on group {group.id}: {e}') + print(f'\nMigrated {fixed} groups') + return fixed + +if __name__ == '__main__': + count = migrate_group_ids() + print(f'\nDone! Migrated {count} groups.') \ No newline at end of file diff --git a/scripts/migrate_group_schema.sql b/scripts/migrate_group_schema.sql new file mode 100644 index 0000000..cc6ebea --- /dev/null +++ b/scripts/migrate_group_schema.sql @@ -0,0 +1,35 @@ +-- Migration script to add missing columns to group table +-- Adds user_ids and description columns + +DO $$ +BEGIN + -- Add user_ids column if it doesn't exist + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'group' + AND column_name = 'user_ids' + ) THEN + ALTER TABLE "group" + ADD COLUMN user_ids TEXT; + + RAISE NOTICE 'Added user_ids column to group table'; + ELSE + RAISE NOTICE 'user_ids column already exists in group table'; + END IF; + + -- Add description column if it doesn't exist + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'group' + AND column_name = 'description' + ) THEN + ALTER TABLE "group" + ADD COLUMN description TEXT; + + RAISE NOTICE 'Added description column to group table'; + ELSE + RAISE NOTICE 'description column already exists in group table'; + END IF; +END $$; \ No newline at end of file diff --git a/scripts/migrate_remove_resources_bookings.sql b/scripts/migrate_remove_resources_bookings.sql new file mode 100644 index 0000000..eb94434 --- /dev/null +++ b/scripts/migrate_remove_resources_bookings.sql @@ -0,0 +1,9 @@ +-- Migration Script to Remove Resources, Bookings, and Related Data Tables +-- This will drop tables in correct order (respecting foreign key dependencies) +-- Use with caution - this operation cannot be easily reversed + +DROP TABLE IF EXISTS Booking CASCADE; +DROP TABLE IF EXISTS ResourcePermission CASCADE; +DROP TABLE IF EXISTS Resource CASCADE; + +SELECT 'Resources, bookings, and related permissions have been removed successfully.' AS message; \ No newline at end of file diff --git a/static/floor_maps/company_2_25CE35BA-466D-46A1-A39E-2C2D508F5778_1_105_c.jpeg b/static/floor_maps/company_2_25CE35BA-466D-46A1-A39E-2C2D508F5778_1_105_c.jpeg new file mode 100644 index 0000000..3d15bb6 Binary files /dev/null and b/static/floor_maps/company_2_25CE35BA-466D-46A1-A39E-2C2D508F5778_1_105_c.jpeg differ diff --git a/static/floor_maps/company_2_CIRCLE_OFFICES.png b/static/floor_maps/company_2_CIRCLE_OFFICES.png new file mode 100644 index 0000000..671783c Binary files /dev/null and b/static/floor_maps/company_2_CIRCLE_OFFICES.png differ diff --git a/static/images/audit-training.jpg b/static/images/audit-training.jpg new file mode 100644 index 0000000..bd6383c --- /dev/null +++ b/static/images/audit-training.jpg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/policy-management.jpg b/static/images/policy-management.jpg new file mode 100644 index 0000000..f2e5810 --- /dev/null +++ b/static/images/policy-management.jpg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/regulatory-compliance.jpg b/static/images/regulatory-compliance.jpg new file mode 100644 index 0000000..2976c79 --- /dev/null +++ b/static/images/regulatory-compliance.jpg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..19222ba Binary files /dev/null and b/static/logo.png differ diff --git a/templates/add_resource.html b/templates/add_resource.html new file mode 100644 index 0000000..c3d802d --- /dev/null +++ b/templates/add_resource.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block content %} +
+

Add New Resource

+
+ +
+ + +
+
+ + +
+
+ + + Only 1 booking per type allowed at a time (can book multiple different types) +
+ + Cancel +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin_company.html b/templates/admin_company.html new file mode 100644 index 0000000..bff1880 --- /dev/null +++ b/templates/admin_company.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} +{% block title %}Company Admin{% endblock %} +{% block content %} +
+

{{ company.name }} Administration

+ + +
+
+ Groups + Create Group +
+
+
+ {% for group in groups %} +
+
+
{{ group.name }}
+ + {% if group.user_ids %} + Members: {{ group.user_ids|json_parse|length }} + {% else %} + No members + {% endif %} + +
+
+ Manage Members + +
+
+ {% else %} +
No groups found. Create one to organize your users.
+ {% endfor %} +
+
+
+ + +
+
Invite Users
+
+
+ +
+ + +
+
+
+
+ + +
+
Active Invitations
+
    + {% for invite in invitations %} +
  • + {{ invite.email }} - Expires {{ invite.expires_at|datetimeformat }} +
  • + {% else %} +
  • No active invitations
  • + {% endfor %} +
+
+ + +
+
Company Users
+
    + {% for user in users %} +
  • +
    + {{ user.email }} + {% if user.invited_by %} + (Invited by {{ user.invited_by.email }}) + {% else %} + (Self-registered) + {% endif %} +
    +
    + {% if user.is_company_admin %} + Admin + {% endif %} +
    +
  • + {% endfor %} +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_system.html b/templates/admin_system.html new file mode 100644 index 0000000..cb10e48 --- /dev/null +++ b/templates/admin_system.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block title %}System Admin{% endblock %} + +{% block content %} +
+

System Administration

+ +
+
Manage Users
+
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
EmailCompanySystem AdminCompany AdminActions
{{ user.email }}{{ user.company.name if user.company else '-' }}{% if user.is_system_admin %}✓{% endif %}{% if user.is_company_admin %}✓{% endif %} +
+ + +
+ {% if not user.is_system_admin %} + + {% else %} + + {% endif %} + + {% if user.company_id and not user.is_company_admin %} + + {% elif user.is_company_admin %} + + {% endif %} +
+ +
+
+
+
+ +
+
Registered Companies
+
    + {% for company in companies %} +
  • + {{ company.name }} ({{ company.users|length }} users) +
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index 969165e..ff7548e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,39 +6,93 @@ {% block title %}{% endblock %} - +{% endblock %} +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} + {% endwith %} +
{% block content %}{% endblock %} - + \ No newline at end of file diff --git a/templates/book_resource.html b/templates/book_resource.html new file mode 100644 index 0000000..7932d2d --- /dev/null +++ b/templates/book_resource.html @@ -0,0 +1,235 @@ +{% extends "base.html" %} + +{% block content %} +
+

Book {{ resource.name }}

+ {% if resource.image_url %} + {{ resource.name }} + {% endif %} +

{{ resource.description }}

+ +
+
+
+
+
+
+
+
Create Booking
+
+ +
+ + +
+
+ + +
+
+ + +
+ + +
+
+ +
+
+
+
+
+
+ + + + + + + +{% endblock %} diff --git a/templates/create_group.html b/templates/create_group.html new file mode 100644 index 0000000..622285a --- /dev/null +++ b/templates/create_group.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}Create Group - {{ company.name }}{% endblock %} +{% block content %} +
+ ← Back to Admin + +
+
+

Create New Group

+
+
+
+ +
+ + +
+ +
+ + +
+ +
+ +
Group Members
+

Select users to add to this group:

+ +
+ + +
+ +
+ Tip: Hold Ctrl/Cmd to select multiple users at once. +
+ +
+ + Cancel +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html index 7f712ca..ded32b7 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -21,7 +21,7 @@
  • Account Created: - Just now + Before the dinosaurs, there was your account
  • diff --git a/templates/edit_resource.html b/templates/edit_resource.html new file mode 100644 index 0000000..617d77d --- /dev/null +++ b/templates/edit_resource.html @@ -0,0 +1,167 @@ +{% extends "base.html" %} + +{% block content %} +
    +

    {% if resource %}Edit {{ resource.name }}{% else %}Add New Resource{% endif %}

    +
    + + +
    + + +
    +
    + + +
    + +
    + + + Only 1 booking per type allowed at a time (can book multiple different types) +
    + + +
    + +
    +
    + + Upload an image of the resource (optional) + {% if resource and resource.image_url %} +
    + Current image +
    + {% endif %} +
    +
    +
    +
    + +

    Upload image of the resource

    + Supported formats: PNG, JPG, GIF, WEBP +
    +
    +
    +
    +
    + +
    +
    Permissions
    +
    + +
    +
    + + +
    +
    + + +
    +
    + Click on the floor map in Company Admin to get coordinates +
    + +
    +
    Permissions
    +
    + + + Select who should be able to book this resource +
    + + +
    + + +
    + + +
    + + + Enter user IDs to grant access (comma-separated) +

    + +
    + + + Cancel +
    +
    + + + +{% endblock %} \ No newline at end of file diff --git a/templates/forgot_password.html b/templates/forgot_password.html new file mode 100644 index 0000000..e334a4a --- /dev/null +++ b/templates/forgot_password.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block title %}Forgot Password{% endblock %} + +{% block content %} +
    +
    +
    +
    +

    Forgot Password

    +
    + +
    + + +
    + +
    +
    + Remember your password? Login here +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/group_members.html b/templates/group_members.html new file mode 100644 index 0000000..ac3b840 --- /dev/null +++ b/templates/group_members.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block title %}Group Members - {{ group.name }}{% endblock %} +{% block content %} +
    + ← Back to Admin + +
    +
    +

    Group: {{ group.name }}

    + Create New Group +
    +
    + {% if group.user_ids %} + {% set group_member_ids = group.user_ids|json_parse %} + {% if group_member_ids and group_member_ids|length > 0 %} +
    + Group Members ({{ group_member_ids|length }}): +
      + {% for uid in group_member_ids %} + {% set user = users|selectattr('id', 'equalto', uid)|first %} + {% if user %} +
    • + {{ user.email }} + {% if user.id == current_user.id %} + You + {% endif %} +
    • + {% else %} +
    • + User ID: {{ uid }} +
    • + {% endif %} + {% endfor %} +
    +
    + {% endif %} + {% else %} +
    + No members yet. Use the form below to add users to this group. +
    + {% endif %} + +
    Add/Remove Members
    + +
    + +
    +
    + +
    +
    +
    + + {% if group.user_ids %} + + {% endif %} +
    +
    +
    +
    + +
    + +
    Current Members
    + {% if group_member_ids and group_member_ids|length > 0 %} +
    + {% for uid in group_member_ids %} + {% set user = users|selectattr('id', 'equalto', uid)|first %} + {% if user %} + +
    +
    {{ user.email }}
    + Member +
    +
    + {% endif %} + {% endfor %} +
    + {% else %} +

    No members added yet. Use the form above to add users.

    + {% endif %} +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b93bc08 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} + +{% block navbar %}{% endblock %} + +{% block title %}Governance and Compliance Portal - Home{% endblock %} + +{% block content %} + + +
    +
    +
    +
    +

    Governance and Compliance Portal

    +

    Your trusted partner for regulatory frameworks, policy documentation, and compliance assurance.

    + Access Portal +
    +
    + +
    +
    +
    + Regulatory Compliance +
    +
    Regulatory Frameworks
    +

    Stay compliant with industry regulations and standards. We help you navigate complex compliance requirements effortlessly.

    +
    +
    +
    + +
    +
    + Policy Management +
    +
    Policy Documentation
    +

    Access comprehensive policy documents, guidelines, and best practices for organizational governance.

    +
    +
    +
    + +
    +
    + Audit Support & Training +
    +
    Compliance Audits & Training
    +

    Streamlined audit preparation, compliance training programs, and documentation management.

    +
    +
    +
    +
    +
    + + +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html index bf592d5..8cb21c5 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,8 +1,13 @@ {% extends "base.html" %} -{% block title %}Login{% endblock %} +{% block title %}Login - mgmtatlas.com{% endblock %} + +{% block navbar %}{% endblock %} {% block content %} +
    @@ -27,4 +32,4 @@
    -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..89f6784 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} + +{% block title %}My Profile{% endblock %} + +{% block content %} +
    +
    + + +
    + + {% if user.avatar_url %} +
    + Avatar + Current avatar +
    + {% endif %} + + Upload a new avatar (PNG, JPG, GIF, WEBP) +
    + +
    + + + {% if not user.has_profile() %} + Profile incomplete - please add your name + {% endif %} +
    + +
    + + + {% if not user.has_profile() %} + Profile incomplete - please add your name + {% endif %} +
    + +

    Profile Information

    +
    + +

    Change Password

    +
    + +
    + + + Required to verify your identity before changing password +
    + +
    + + + Must be at least 8 characters long +
    + +
    + + +
    + +
    + +
    + +
    + + Cancel +
    +
    +
    + + +{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html index 74dc4bb..7544307 100644 --- a/templates/register.html +++ b/templates/register.html @@ -8,20 +8,39 @@

    Create Account

    -
    + + {% if not token %} +
    + + +
    + {% endif %}
    -
    +
    + + +
    +
    + + +
    +
    -
    +
    +
    + + + Upload a profile picture (PNG, JPG, GIF, WEBP) +
    @@ -31,4 +50,4 @@
    -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/resources.html b/templates/resources.html new file mode 100644 index 0000000..dd9ce97 --- /dev/null +++ b/templates/resources.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Resources{% endblock %} +{% block content %} +
    +

    Welcome to Management Portal

    + + {% if current_user.company_id %} +
    +
    +

    You are logged in for company: {{ current_user.company.name }}

    + + {% if current_user.is_company_admin or current_user.role == 'admin' %} + Go to Admin + {% endif %} + +
    +
    + {% else %} + + {% endif %} + +
    +{% endblock %} \ No newline at end of file diff --git a/templates/set_password.html b/templates/set_password.html new file mode 100644 index 0000000..a9a8a90 --- /dev/null +++ b/templates/set_password.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block title %}Set New Password{% endblock %} + +{% block content %} +
    +
    +
    +
    +

    Set New Password

    +
    +

    This is a reset request from your email address.

    + For security, we'll verify the email before allowing the password reset. +
    + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
    {{ message }}
    + {% endfor %} + {% endif %} + {% endwith %} + +
    + +
    + + + The email address associated with this account +
    +
    + + + At least 8 characters +
    +
    + + +
    + +
    + + +
    +
    +
    +
    +{% endblock %} \ No newline at end of file