updated to latest working

This commit is contained in:
Andreas Jönsson 2026-06-09 16:13:47 +02:00
parent bd611b68c0
commit 8e07598d91
30 changed files with 2296 additions and 88 deletions

856
app.py
View File

@ -1,57 +1,157 @@
from flask import Flask, render_template, redirect, url_for, request, abort from flask import Flask, render_template, redirect, url_for, request, abort, flash, jsonify, send_from_directory
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
import os 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__) app = Flask(__name__)
csrf = CSRFProtect(app) csrf = CSRFProtect(app)
app.config["SECRET_KEY"] = os.environ["FLASK_SECRET_KEY"] @app.template_filter("datetimeformat")
from urllib.parse import quote_plus def datetimeformat(value, format="%Y-%m-%d %H:%M"):
if value is None:
return ""
return value.strftime(format)
# Get database credentials from environment @app.template_filter("json_parse")
db_password = os.environ['DB_PASSWORD'] def json_parse(value):
# URL-encode all special characters including @ and $ """Parse a JSON string"""
encoded_password = quote_plus(db_password, safe='') 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.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_DATABASE_URI"] = f"postgresql://flaskuser:{db_password}@db:5432/flaskdb"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
MAX_BOOKING_DAYS_AHEAD = 182 # Maximum booking date is 6 months (182 days) from now
db = SQLAlchemy(app) db = SQLAlchemy(app)
# Initialize database CLI command
@app.cli.command("init-db") @app.cli.command("init-db")
def init_db(): def init_db():
import os
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
print("Database initialized") print("Database initialized")
login_manager = LoginManager(app) login_manager = LoginManager(app)
login_manager.login_view = "login" 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"<Company {self.name}>"
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(100), unique=True) email = db.Column(db.String(100), unique=True)
password = db.Column(db.String(200)) # Increased for password hash password = db.Column(db.String(200))
admin = db.Column(db.Boolean, default=False) 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"<User {self.email}>"
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"<Group {self.name} (Company {self.company.name})>"
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"<ResourcePermission resource_id={self.resource_id}, type={self.permission_type}>"
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"<Resource {self.name} (type: {self.resource_type})>"
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"<Booking {self.id}>"
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"<Invitation {self.email}>"
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
return User.query.get(int(user_id)) return User.query.get(int(user_id))
@app.route("/") @app.route("/")
def home(): def home():
return redirect(url_for("login")) return render_template("index.html")
@app.route("/login", methods=["GET", "POST"]) @app.route("/login", methods=["GET", "POST"])
def login(): def login():
@ -59,48 +159,226 @@ def login():
email = request.form.get("email") email = request.form.get("email")
password = request.form.get("password") password = request.form.get("password")
user = User.query.filter_by(email=email).first() user = User.query.filter_by(email=email).first()
if user and check_password_hash(user.password, password): if user and check_password_hash(user.password, password):
login_user(user) login_user(user)
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
return "Invalid credentials" return "Invalid credentials"
return render_template("login.html") return render_template("login.html")
@app.route("/register", methods=["GET", "POST"]) @app.route("/register", methods=["GET", "POST"])
def register(): def register():
token = request.args.get("token")
if request.method == "POST": if request.method == "POST":
email = request.form.get("email") email = request.form.get("email")
password = request.form.get("password") password = request.form.get("password")
confirm_password = request.form.get("confirm_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: if password != confirm_password:
return "Passwords do not match" return "Passwords do not match"
if User.query.filter_by(email=email).first(): if User.query.filter_by(email=email).first():
return "Email already registered" return "Email already registered"
if token:
password = generate_password_hash(password) invitation = Invitation.query.filter_by(token=token).first()
if not invitation or invitation.expires_at < datetime.now():
new_user = User(email=email, password=password) return "Invalid or expired invitation"
company = Company.query.get(invitation.company_id)
# Automatically make first user admin 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: if User.query.count() == 0:
new_user.admin = True new_user.is_system_admin = True
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
return redirect(url_for("login")) return redirect(url_for("login"))
return render_template("register.html") return render_template("register.html", token=token)
@app.route("/dashboard") @app.route("/dashboard")
@login_required @login_required
def dashboard(): 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/<int:resource_id>", 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") @app.route("/logout")
@login_required @login_required
@ -108,26 +386,500 @@ def logout():
logout_user() logout_user()
return redirect(url_for("login")) return redirect(url_for("login"))
@app.route("/admin", methods=["GET", "POST"]) @app.route("/book/<int:resource_id>", methods=["GET", "POST"])
@login_required @login_required
def admin(): def book_resource(resource_id):
if not current_user.admin: 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/<int:resource_id>")
@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/<int:resource_id>", 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/<int:booking_id>", 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/<int:company_id>", 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/<int:company_id>", 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/<int:group_id>", 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) abort(403)
if request.method == "POST": if request.method == "POST":
action = request.form.get("action") action = request.form.get("action")
user_id = request.form.get("user_id") user_id = request.form.get("user_id")
user = User.query.get(user_id) 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))
if user and user != current_user: # Prevent self-modification company_users = User.query.filter_by(company_id=group.company_id).options(db.joinedload(User.inviter)).all()
if action == "promote": if group.user_ids:
user.admin = True try:
elif action == "demote": member_ids_list = json.loads(group.user_ids)
user.admin = False except (json.JSONDecodeError, TypeError):
elif action == "delete": member_ids_list = []
db.session.delete(user) 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/<int:group_id>", 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/<int:group_id>", 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() db.session.commit()
flash("Password changed successfully", "success")
return redirect(url_for("profile"))
users = User.query.all() # Handle profile update
return render_template("admin.html", users=users) 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/<token>", 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

View File

@ -1,28 +1,31 @@
version: "3" version: "3.8"
services: services:
db: db:
image: "postgres:latest" image: "postgres:15"
env_file:
- .env
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
environment: environment:
POSTGRES_USER: flaskuser POSTGRES_USER: flaskuser
POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: flaskdb POSTGRES_DB: flaskdb
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U flaskuser -d flaskdb"] test: ["CMD-SHELL", "pg_isready -U flaskuser -d flaskdb"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s start_period: 10s
ports: ports:
- "5432:5432" - "5432:5432"
networks:
- app-network
web: web:
build: . build: .
env_file:
- .env
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@ -34,10 +37,51 @@ services:
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD}
volumes: volumes:
- ./instance:/app/instance - ./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" 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: volumes:
postgres_data: postgres_data:
npm_data:
npm_db_data:

View File

@ -6,3 +6,5 @@ werkzeug==3.0.0
gunicorn==21.2.0 gunicorn==21.2.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-dotenv==1.0.0 python-dotenv==1.0.0
Flask-WTF==1.2.1
pytz==2024.1

View File

@ -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 <email> <group_name>
"""
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}")

View File

@ -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()

View File

@ -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.')

View File

@ -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 $$;

View File

@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="200" viewBox="0 0 600 200"><rect width="100%" height="100%" fill="#2d4a6e"/><path fill="#ddeef7" d="M133.22 100.47h13.11l-5.02-13.71q-.37-.91-.77-2.17-.4-1.25-.8-2.7-.37 1.45-.77 2.72t-.77 2.21zm10.46-25.65 16.18 41.21h-5.92q-1 0-1.63-.5t-.94-1.24l-3.08-8.41h-17.07l-3.08 8.41q-.22.66-.88 1.2t-1.62.54h-5.99l16.22-41.21zm37.5 11.97h7.04v29.24h-4.3q-1.4 0-1.77-1.28l-.48-2.34q-1.8 1.82-3.96 2.95-2.17 1.13-5.11 1.13-2.39 0-4.23-.82-1.84-.81-3.09-2.29t-1.9-3.52q-.64-2.04-.64-4.49V86.79h7.04v18.58q0 2.68 1.24 4.15t3.72 1.47q1.83 0 3.42-.82 1.6-.81 3.02-2.23zm32.35 20.58V94.26q-1.2-1.46-2.61-2.06-1.41-.59-3.03-.59-1.6 0-2.88.59-1.28.6-2.2 1.81-.91 1.22-1.39 3.08-.49 1.87-.49 4.4 0 2.57.42 4.35.41 1.78 1.18 2.91.77 1.12 1.88 1.62t2.48.5q2.19 0 3.73-.91t2.91-2.59m0-33.69h7.04v42.35h-4.3q-1.4 0-1.77-1.28l-.6-2.82q-1.77 2.02-4.06 3.27-2.3 1.26-5.34 1.26-2.4 0-4.39-1-2-1-3.44-2.89-1.44-1.9-2.22-4.69t-.78-6.39q0-3.24.88-6.04.88-2.79 2.54-4.84 1.65-2.05 3.96-3.21 2.31-1.15 5.18-1.15 2.45 0 4.19.77t3.11 2.08zm14.22 13.11h7.07v29.24h-7.07zm8.04-8.52q0 .91-.37 1.71t-.99 1.39q-.61.6-1.43.96-.83.36-1.77.36-.91 0-1.73-.36-.81-.36-1.41-.96-.6-.59-.95-1.39-.36-.8-.36-1.71 0-.94.36-1.77.35-.83.95-1.42.6-.6 1.41-.96.82-.36 1.73-.36.94 0 1.77.36.82.36 1.43.96.62.59.99 1.42t.37 1.77m15.96 38.22q-3.82 0-5.86-2.16-2.04-2.15-2.04-5.94V92.06h-2.99q-.57 0-.97-.37t-.4-1.11v-2.79l4.71-.77 1.48-7.98q.14-.57.54-.89.4-.31 1.03-.31h3.64v9.21h7.81v5.01h-7.81v15.85q0 1.37.67 2.13.67.77 1.84.77.66 0 1.1-.15.44-.16.77-.33t.58-.33q.26-.15.52-.15.31 0 .51.15.2.16.43.47l2.1 3.42q-1.53 1.29-3.53 1.94-1.99.66-4.13.66m55.2-41.67v6.3h-12.42v34.91h-7.67V81.12h-12.43v-6.3zm10.52 13.74.43 3.3q1.36-2.62 3.25-4.12 1.88-1.49 4.44-1.49 2.03 0 3.25.88l-.46 5.27q-.14.52-.41.73t-.73.21q-.42 0-1.26-.14t-1.64-.14q-1.17 0-2.08.34t-1.64.98q-.73.65-1.28 1.56-.56.91-1.04 2.08v18.01h-7.04V86.79h4.13q1.08 0 1.51.38.43.39.57 1.39m30.49 19.92v-4.93q-3.05.14-5.13.52-2.08.39-3.33.99t-1.8 1.39q-.54.8-.54 1.74 0 1.85 1.1 2.65t2.86.8q2.17 0 3.75-.78 1.58-.79 3.09-2.38m-14.87-15.36-1.26-2.26q5.05-4.61 12.14-4.61 2.57 0 4.59.84 2.03.84 3.42 2.34 1.4 1.49 2.13 3.57.72 2.08.72 4.56v18.47h-3.19q-1 0-1.54-.3t-.85-1.21l-.63-2.11q-1.11 1-2.17 1.75-1.05.76-2.19 1.27t-2.44.79q-1.29.27-2.86.27-1.85 0-3.42-.5t-2.71-1.5-1.77-2.48q-.62-1.48-.62-3.45 0-1.11.37-2.21.37-1.09 1.21-2.09t2.18-1.88q1.34-.89 3.29-1.54 1.95-.66 4.55-1.07 2.59-.41 5.92-.5v-1.71q0-2.93-1.25-4.34-1.25-1.42-3.62-1.42-1.71 0-2.84.4-1.12.4-1.98.9-.85.5-1.55.9t-1.55.4q-.74 0-1.26-.39-.51-.38-.82-.89m28.58-6.33h7.07v29.24h-7.07zm8.04-8.52q0 .91-.37 1.71t-.98 1.39q-.62.6-1.44.96-.83.36-1.77.36-.91 0-1.72-.36-.82-.36-1.42-.96-.59-.59-.95-1.39t-.36-1.71q0-.94.36-1.77t.95-1.42q.6-.6 1.42-.96.81-.36 1.72-.36.94 0 1.77.36.82.36 1.44.96.61.59.98 1.42t.37 1.77m12.31 9.8.49 2.31q.88-.88 1.86-1.64.99-.75 2.08-1.28 1.1-.53 2.35-.83 1.26-.3 2.74-.3 2.39 0 4.25.82 1.85.81 3.09 2.28 1.24 1.46 1.88 3.5t.64 4.49v18.61h-7.04V97.42q0-2.68-1.22-4.15-1.23-1.47-3.74-1.47-1.82 0-3.42.83-1.59.83-3.02 2.25v21.15h-7.04V86.79h4.31q1.36 0 1.79 1.28m26.28-1.28h7.07v29.24h-7.07zm8.04-8.52q0 .91-.37 1.71-.38.8-.99 1.39-.61.6-1.44.96-.82.36-1.76.36-.92 0-1.73-.36t-1.41-.96q-.6-.59-.95-1.39-.36-.8-.36-1.71 0-.94.36-1.77.35-.83.95-1.42.6-.6 1.41-.96t1.73-.36q.94 0 1.76.36.83.36 1.44.96.61.59.99 1.42.37.83.37 1.77m12.31 9.8.48 2.31q.89-.88 1.87-1.64.98-.75 2.08-1.28t2.35-.83 2.74-.3q2.39 0 4.24.82 1.86.81 3.1 2.28 1.24 1.46 1.88 3.5t.64 4.49v18.61h-7.04V97.42q0-2.68-1.23-4.15-1.22-1.47-3.73-1.47-1.82 0-3.42.83t-3.02 2.25v21.15h-7.04V86.79h4.3q1.37 0 1.8 1.28m36.73 12.63q1.32 0 2.28-.36.97-.36 1.61-.98.65-.63.97-1.51.33-.89.33-1.94 0-2.17-1.3-3.44-1.29-1.26-3.89-1.26-2.59 0-3.89 1.26-1.29 1.27-1.29 3.44 0 1.02.32 1.91.33.88.97 1.52.65.64 1.63 1t2.26.36m7.96 16.64q0-.85-.52-1.4-.51-.54-1.39-.84-.89-.3-2.07-.44t-2.51-.21q-1.32-.07-2.73-.13t-2.75-.23q-1.17.66-1.9 1.54t-.73 2.05q0 .77.39 1.44.38.67 1.22 1.16.84.48 2.18.75t3.28.27q1.97 0 3.39-.3 1.43-.3 2.35-.82.93-.53 1.36-1.26.43-.72.43-1.58m-1.4-29.55h8.41v2.62q0 1.25-1.51 1.54l-2.63.48q.6 1.51.6 3.31 0 2.16-.87 3.92-.87 1.75-2.4 2.97-1.54 1.23-3.64 1.9-2.09.67-4.52.67-.85 0-1.65-.09-.8-.08-1.57-.22-1.36.82-1.36 1.85 0 .88.81 1.3.81.41 2.15.58t3.05.21q1.71.05 3.5.19 1.8.14 3.51.5 1.71.35 3.05 1.12t2.15 2.1q.81 1.32.81 3.4 0 1.94-.95 3.77-.96 1.82-2.77 3.25-1.81 1.42-4.44 2.29-2.64.87-6 .87-3.31 0-5.76-.64t-4.07-1.71q-1.63-1.07-2.43-2.47-.79-1.39-.79-2.91 0-2.05 1.24-3.43t3.4-2.21q-1.17-.6-1.85-1.59-.68-1-.68-2.63 0-.65.24-1.35t.71-1.38q.47-.69 1.18-1.3.72-.61 1.68-1.1-2.22-1.19-3.49-3.19-1.27-1.99-1.27-4.67 0-2.17.87-3.92t2.43-2.99q1.55-1.24 3.67-1.9 2.13-.65 4.63-.65 1.89 0 3.54.38 1.65.39 3.02 1.13"/></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>Add New Resource</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label for="name" class="form-label">Resource Name</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="3"></textarea>
</div>
<div class="mb-3">
<label for="resource_type" class="form-label">Resource Type</label>
<select class="form-select" id="resource_type" name="resource_type">
<option value="workoffice" selected>Work Office</option>
<option value="meeting_room">Meeting Room</option>
</select>
<small class="text-muted">Only 1 booking per type allowed at a time (can book multiple different types)</small>
</div>
<button type="submit" class="btn btn-primary">Save Resource</button>
<a href="{{ url_for('company_admin') }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,116 @@
{% extends "base.html" %}
{% block title %}Company Admin{% endblock %}
{% block content %}
<div class="container mt-4">
<h2>{{ company.name }} Administration</h2>
<!-- Groups Management Section -->
<div class="card mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Groups</span>
<a href="{{ url_for('create_group_page', company_id=company.id) }}" class="btn btn-sm btn-primary">Create Group</a>
</div>
<div class="card-body">
<div class="list-group" id="groupList">
{% for group in groups %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-1">{{ group.name }}</h5>
<small class="text-muted">
{% if group.user_ids %}
Members: {{ group.user_ids|json_parse|length }}
{% else %}
No members
{% endif %}
</small>
</div>
<div>
<a href="{{ url_for('group_members_page', group_id=group.id) }}" class="btn btn-sm btn-secondary">Manage Members</a>
<button type="button" class="btn btn-sm btn-danger" onclick="deleteGroup({{ group.id }}, '{{ group.name }}')">Delete</button>
</div>
</div>
{% else %}
<div class="alert alert-info">No groups found. Create one to organize your users.</div>
{% endfor %}
</div>
</div>
</div>
<!-- Invite Users Section -->
<div class="card mt-3">
<div class="card-header">Invite Users</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="input-group">
<input type="email" name="email" class="form-control" placeholder="Enter email to invite" required>
<button type="submit" class="btn btn-primary">Send Invitation</button>
</div>
</form>
</div>
</div>
<!-- Active Invitations -->
<div class="card mt-3">
<div class="card-header">Active Invitations</div>
<ul class="list-group list-group-flush">
{% for invite in invitations %}
<li class="list-group-item">
{{ invite.email }} - Expires {{ invite.expires_at|datetimeformat }}
</li>
{% else %}
<li class="list-group-item">No active invitations</li>
{% endfor %}
</ul>
</div>
<!-- Company Users -->
<div class="card mt-3">
<div class="card-header">Company Users</div>
<ul class="list-group list-group-flush">
{% for user in users %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
{{ user.email }}
{% if user.invited_by %}
<small class="text-muted">(Invited by {{ user.invited_by.email }})</small>
{% else %}
<small class="text-muted">(Self-registered)</small>
{% endif %}
</div>
<div>
{% if user.is_company_admin %}
<span class="badge bg-primary">Admin</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
<script>
function deleteGroup(groupId, groupName) {
if (confirm(`Are you sure you want to delete the group "${groupName}"? This will also remove its resource permissions.`)) {
fetch(`/api/delete-group/${groupId}`, {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('input[name="csrf_token"]').value
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Group deleted successfully');
location.reload();
} else {
alert('Error: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error deleting group');
});
}
}
</script>
{% endblock %}

View File

@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}System Admin{% endblock %}
{% block content %}
<div class="container mt-4">
<h2>System Administration</h2>
<div class="card mt-3">
<div class="card-header">Manage Users</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>Email</th>
<th>Company</th>
<th>System Admin</th>
<th>Company Admin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.email }}</td>
<td>{{ user.company.name if user.company else '-' }}</td>
<td>{% if user.is_system_admin %}✓{% endif %}</td>
<td>{% if user.is_company_admin %}✓{% endif %}</td>
<td>
<form method="POST" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="user_id" value="{{ user.id }}">
<div class="btn-group" role="group">
{% if not user.is_system_admin %}
<button name="action" value="promote_system" class="btn btn-sm btn-success">System+</button>
{% else %}
<button name="action" value="demote_system" class="btn btn-sm btn-warning">System-</button>
{% endif %}
{% if user.company_id and not user.is_company_admin %}
<button name="action" value="promote_company" class="btn btn-sm btn-primary">Company+</button>
{% elif user.is_company_admin %}
<button name="action" value="demote_company" class="btn btn-sm btn-warning">Company-</button>
{% endif %}
</div>
<button name="action" value="delete" class="btn btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card mt-3">
<div class="card-header">Registered Companies</div>
<ul class="list-group list-group-flush">
{% for company in companies %}
<li class="list-group-item">
{{ company.name }} ({{ company.users|length }} users)
</li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

View File

@ -6,38 +6,92 @@
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style> <style>
.login-logo-container {
text-align: center;
margin: 0;
}
.login-logo {
max-width: 180px;
width: 50%;
height: auto;
display: block;
margin: 0 auto;
}
@media (max-width: 768px) {
.auth-wrapper {
padding: 0 1rem;
min-height: calc(100vh - 160px);
justify-content: center;
gap: 20px;
}
.form-container {
margin-top: -10px;
top: 0;
}
.login-logo-container {
margin: 0;
}
.login-logo {
width: 35%;
max-width: 120px;
margin-bottom: 0;
}
.form-container {
margin: 0 auto;
}
}
.auth-wrapper { .auth-wrapper {
min-height: 100vh;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
padding: 2rem; padding: 0 2rem;
gap: 10px;
} }
.form-container { .form-container {
max-width: 400px; max-width: 400px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
position: static;
} }
</style> </style>
</head> </head>
<body class="bg-light"> <body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4"> {% block navbar %}
<div class="container"> <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-3">
<a class="navbar-brand" href="{{ url_for('dashboard') }}">flask-base</a> <div class="container">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <a class="navbar-brand" href="{{ url_for('dashboard') }}">mgmtatlas.com</a>
<span class="navbar-toggler-icon"></span> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
</button> <span class="navbar-toggler-icon"></span>
<div class="collapse navbar-collapse" id="navbarNav"> </button>
<div class="navbar-nav ms-auto"> <div class="collapse navbar-collapse" id="navbarNav">
{% if current_user.admin %} <div class="navbar-nav ms-auto">
<a class="nav-link" href="{{ url_for('admin') }}">Admin</a> {% if current_user.is_authenticated %}
{% if current_user.is_system_admin %}
<a class="nav-link" href="{{ url_for('system_admin') }}">System Admin</a>
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_company_admin %}
<a class="nav-link" href="{{ url_for('company_admin') }}">Company Admin</a>
{% endif %}
<a class="nav-link" href="{{ url_for('profile') }}">My Profile</a>
<a class="nav-link" href="{{ url_for('logout') }}">Logout</a> <a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
{% endif %} {% endif %}
</div>
</div> </div>
</div> </div>
</nav> </div>
</nav>
{% endblock %}
<div class="container mt-3">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body> </body>

View File

@ -0,0 +1,235 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h2>Book {{ resource.name }}</h2>
{% if resource.image_url %}
<img src="{{ url_for('static', filename=resource.image_url) }}" alt="{{ resource.name }}" class="img-fluid mb-4 rounded" style="max-width: 100%; max-height: 400px; object-fit: cover; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
{% endif %}
<p class="text-muted">{{ resource.description }}</p>
<div class="row">
<div class="col-md-8">
<div id="calendar"></div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Create Booking</h5>
<form method="POST" id="bookingForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label for="start_time" class="form-label">Start Time</label>
<input type="datetime-local" class="form-control" id="start_time" name="start_time" required readonly>
</div>
<div class="mb-3">
<label for="end_time" class="form-label">End Time</label>
<input type="datetime-local" class="form-control" id="end_time" name="end_time" required readonly>
</div>
<div class="mb-3">
<label for="purpose" class="form-label">Purpose</label>
<textarea class="form-control" id="purpose" name="purpose" rows="3" placeholder="e.g., Client meeting, Team call, etc."></textarea>
</div>
<button type="submit" class="btn btn-primary w-100">Book Now</button>
<button type="button" class="btn btn-secondary w-100 mt-2" id="clearSelection">Clear Selection</button>
</form>
<div class="mt-3">
<div class="alert alert-info" role="alert">
<strong>Booking Rules:</strong><br>
- Bookings can be made up to 6 months in advance<br>
- Only one booking per resource at a time (no overlapping slots)<br>
- Click on the calendar to select start time<br>
- Click again to select end time<br>
- Green events are your bookings<br>
- Red events are others' bookings
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<link href='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.css' rel='stylesheet' />
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js'></script>
<style>
#calendar {
max-width: 100%;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.fc-event {
cursor: pointer;
}
.fc-timegrid-slot {
cursor: pointer;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var startTimeInput = document.getElementById('start_time');
var endTimeInput = document.getElementById('end_time');
var purposeInput = document.getElementById('purpose');
var clearButton = document.getElementById('clearSelection');
var bookingForm = document.getElementById('bookingForm');
var selectedStart = null;
var selectedEnd = null;
var tempEvent = null;
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
weekends: false,
slotMinTime: '06:00:00',
slotMaxTime: '22:00:00',
allDaySlot: false,
selectable: true,
selectMirror: true,
nowIndicator: true,
slotDuration: '00:30:00',
height: 'auto',
minDate: new Date(), // Cannot book before today
maxDate: new Date(Date.now() + 182 * 24 * 60 * 60 * 1000), // Cannot book more than 6 months ahead
events: {
url: '/api/resource-bookings/{{ resource.id }}',
failure: function() {
alert('Failed to load bookings');
}
},
dateClick: function(info) {
// If we haven't selected start yet, or we're starting over
if (selectedStart === null) {
selectedStart = info.date;
selectedEnd = new Date(info.date.getTime() + 60 * 60 * 1000); // Default 1 hour
// Update inputs
startTimeInput.value = formatDateTimeLocal(selectedStart);
endTimeInput.value = formatDateTimeLocal(selectedEnd);
// Show temporary event
if (tempEvent) {
tempEvent.remove();
}
tempEvent = calendar.addEvent({
title: 'New Booking (select end time)',
start: selectedStart,
end: selectedEnd,
color: '#6c757d',
editable: false
});
} else {
// Selecting end time
if (info.date > selectedStart) {
selectedEnd = info.date;
endTimeInput.value = formatDateTimeLocal(selectedEnd);
// Update temporary event
if (tempEvent) {
tempEvent.remove();
}
tempEvent = calendar.addEvent({
title: 'New Booking (ready to submit)',
start: selectedStart,
end: selectedEnd,
color: '#0d6efd',
editable: false
});
// Focus on purpose field
purposeInput.focus();
} else {
alert('End time must be after start time. Please select a later time.');
}
}
},
select: function(info) {
selectedStart = info.start;
selectedEnd = info.end;
startTimeInput.value = formatDateTimeLocal(selectedStart);
endTimeInput.value = formatDateTimeLocal(selectedEnd);
if (tempEvent) {
tempEvent.remove();
}
tempEvent = calendar.addEvent({
title: 'New Booking (ready to submit)',
start: selectedStart,
end: selectedEnd,
color: '#0d6efd',
editable: false
});
// Set default purpose based on resource type if not already set
if (!purposeInput.value) {
purposeInput.value = getDefaultValue(resource.resource_type);
}
purposeInput.focus();
}
});
calendar.render();
// Clear selection button
clearButton.addEventListener('click', function() {
selectedStart = null;
selectedEnd = null;
startTimeInput.value = '';
endTimeInput.value = '';
purposeInput.value = '';
if (tempEvent) {
tempEvent.remove();
tempEvent = null;
}
});
// Set default purpose on form submit
bookingForm.addEventListener('submit', function(e) {
if (!selectedStart || !selectedEnd) {
e.preventDefault();
alert('Please select a time slot on the calendar');
return false;
}
// Set default purpose if empty
if (!purposeInput.value.trim()) {
purposeInput.value = getDefaultValue(resource.resource_type);
}
});
// Get default purpose based on resource type
function getDefaultValue(resourceType) {
const purposeMap = {
'Work Office': 'Office work',
'Meeting Room': 'Meeting',
'Phone Booth': 'Phone call',
'Focus Room': 'Focus work',
'Collaborative Space': 'Collaboration',
'Conference Room': 'Conference'
};
return purposeMap[resourceType] || '';
}
function formatDateTimeLocal(date) {
var year = date.getFullYear();
var month = String(date.getMonth() + 1).padStart(2, '0');
var day = String(date.getDate()).padStart(2, '0');
var hours = String(date.getHours()).padStart(2, '0');
var minutes = String(date.getMinutes()).padStart(2, '0');
return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes;
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Create Group - {{ company.name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<a href="{{ url_for('company_admin', company_id=company.id) }}" class="btn btn-secondary mb-3">&larr; Back to Admin</a>
<div class="card">
<div class="card-header">
<h4>Create New Group</h4>
</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label for="group_name" class="form-label">Group Name</label>
<input type="text" class="form-control" id="group_name" name="name" required autofocus placeholder="e.g., Developers, Marketing Team">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" id="group_description" name="description" rows="3" placeholder="Optional description for the group"></textarea>
</div>
<hr>
<h5>Group Members</h5>
<p class="text-muted">Select users to add to this group:</p>
<div class="mb-3">
<label for="users_select" class="form-label">Select Users</label>
<select class="form-select" id="users_select" name="user_ids[]" multiple size="10">
{% for user in users %}
<option value="{{ user.id }}">{{ user.email }}</option>
{% endfor %}
</select>
</div>
<div class="alert alert-info">
<strong>Tip:</strong> Hold Ctrl/Cmd to select multiple users at once.
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">Create Group</button>
<a href="{{ url_for('company_admin', company_id=company.id) }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
<script>
// Auto-resize textarea
const textarea = document.getElementById('group_description');
if (textarea) {
textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = this.scrollHeight + 'px';
});
}
</script>
{% endblock %}

View File

@ -21,7 +21,7 @@
</li> </li>
<li class="list-group-item d-flex justify-content-between"> <li class="list-group-item d-flex justify-content-between">
<span>Account Created:</span> <span>Account Created:</span>
<span class="text-muted">Just now</span> <span class="text-muted">Before the dinosaurs, there was your account</span>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -0,0 +1,167 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h2>{% if resource %}Edit {{ resource.name }}{% else %}Add New Resource{% endif %}</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label for="name" class="form-label">Resource Name</label>
<input type="text" class="form-control" id="name" name="name"
value="{% if resource %}{{ resource.name }}{% endif %}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description"
rows="3" required>{% if resource %}{{ resource.description }}{% endif %}</textarea>
</div>
<div class="mb-3">
<label for="resource_type" class="form-label">Resource Type</label>
<select class="form-select" id="resource_type" name="resource_type">
<option value="workoffice" {% if resource and resource.resource_type == 'workoffice' %}selected{% endif %}>Work Office</option>
<option value="meeting_room" {% if resource and resource.resource_type == 'meeting_room' %}selected{% endif %}>Meeting Room</option>
</select>
<small class="text-muted">Only 1 booking per type allowed at a time (can book multiple different types)</small>
</div>
<!-- Image upload -->
<div class="mb-3">
<label class="form-label">Resource Image (optional)</label>
<div class="row">
<div class="col-md-8">
<input type="file" class="form-control" id="image" name="image" accept=".png,.jpg,.jpeg,.gif,.webp">
<small class="text-muted">Upload an image of the resource (optional)</small>
{% if resource and resource.image_url %}
<div class="mt-2">
<img src="{{ url_for('static', filename=resource.image_url) }}" alt="Current image" class="img-thumbnail">
</div>
{% endif %}
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body text-center text-muted">
<i class="fas fa-cloud-upload-alt fa-3x mb-2"></i>
<p>Upload image of the resource</p>
<small>Supported formats: PNG, JPG, GIF, WEBP</small>
</div>
</div>
</div>
</div>
</div>
<hr>
<h5>Permissions</h5>
<div class="mb-3">
<label class="form-label">Floor Map Position (optional)</label>
<div class="row">
<div class="col-md-6">
<label for="position_x" class="form-label">X Coordinate</label>
<input type="number" class="form-control" id="position_x" name="position_x"
value="{% if resource and resource.position_x %}{{ resource.position_x }}{% endif %}"
placeholder="Enter X position">
</div>
<div class="col-md-6">
<label for="position_y" class="form-label">Y Coordinate</label>
<input type="number" class="form-control" id="position_y" name="position_y"
value="{% if resource and resource.position_y %}{{ resource.position_y }}{% endif %}"
placeholder="Enter Y position">
</div>
</div>
<small class="text-muted">Click on the floor map in Company Admin to get coordinates</small>
</div>
<hr>
<h5>Permissions</h5>
<div class="mb-3">
<label for="permission_type" class="form-label">Who can use this resource?</label>
<select class="form-select" id="permission_type" name="permission_type" required>
<!-- New resource - no permission yet -->
{% if not resource %}
<option value="everyone" selected>Anyone in the company</option>
<option value="group">Specific group only</option>
<option value="users">Specific users only</option>
{% else %}
<!-- Existing resource - load permission from database -->
{% if permission %}
<option value="everyone" {% if permission.permission_type == 'everyone' %}selected{% endif %}>Anyone in the company</option>
<option value="group" {% if permission.permission_type == 'group' %}selected{% endif %}>Specific group only</option>
<option value="users" {% if permission.permission_type == 'users' %}selected{% endif %}>Specific users only</option>
{% else %}
<option value="everyone" selected>Anyone in the company</option>
<option value="group">Specific group only</option>
<option value="users">Specific users only</option>
{% endif %}
{% endif %}
</select>
<small class="text-muted">Select who should be able to book this resource</small>
</div>
<!-- Group selection -->
<div class="mb-3" id="group_section" style="display: {% if permission and permission.permission_type == 'group' %}block{% else %}none{% endif %};">
<label for="group_id" class="form-label">Select Group</label>
<select class="form-select" id="group_id" name="group_id">
<option value="">-- Select a group --</option>
{% for group in groups %}
<option value="{{ group.id }}" {% if permission and permission.group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
</select>
</div>
<!-- User IDs selection -->
<div class="mb-3" id="users_section" style="display: {% if permission and permission.permission_type == 'users' %}block{% else %}none{% endif %};">
<label for="user_ids" class="form-label">Select Users (comma-separated IDs)</label>
<input type="text" class="form-control" id="user_ids" name="user_ids"
value="{% if permission and permission.user_ids %}{{ permission.user_ids }}{% endif %}"
placeholder="1,2,3">
<small class="text-muted">Enter user IDs to grant access (comma-separated)</small>
<br><br>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="loadUserIds()">
Load User IDs
</button>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="{{ url_for('company_admin') }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
<script>
function loadUserIds() {
fetch('/api/get-users')
.then(response => response.json())
.then(userIds => {
// Convert to string with commas
const idsStr = userIds.join(',');
document.getElementById('user_ids').value = idsStr;
})
.catch(error => console.error('Error loading user IDs:', error));
}
// Toggle sections based on permission type
document.addEventListener('DOMContentLoaded', function() {
const permissionType = document.getElementById('permission_type');
const groupSection = document.getElementById('group_section');
const usersSection = document.getElementById('users_section');
function updateVisibility() {
const type = permissionType.value;
if (type === 'group') {
groupSection.style.display = 'block';
usersSection.style.display = 'none';
} else if (type === 'users') {
groupSection.style.display = 'none';
usersSection.style.display = 'block';
} else {
groupSection.style.display = 'none';
usersSection.style.display = 'none';
}
}
permissionType.addEventListener('change', updateVisibility);
updateVisibility(); // Initial call
});
</script>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Forgot Password{% endblock %}
{% block content %}
<div class="auth-wrapper">
<div class="form-container">
<div class="card shadow">
<div class="card-body">
<h2 class="card-title text-center mb-4">Forgot Password</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-4">
<label class="form-label">Email address</label>
<input type="email" class="form-control" name="email" placeholder="Enter your email" required>
</div>
<button type="submit" class="btn btn-primary w-100">Reset Password</button>
</form>
<div class="text-center mt-3">
Remember your password? <a href="{{ url_for('login') }}" class="text-decoration-none">Login here</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}Group Members - {{ group.name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<a href="{{ url_for('company_admin', company_id=group.company_id) }}" class="btn btn-secondary mb-3">&larr; Back to Admin</a>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4>Group: {{ group.name }}</h4>
<a href="{{ url_for('create_group_page', company_id=group.company_id) }}" class="btn btn-sm btn-primary">Create New Group</a>
</div>
<div class="card-body">
{% if group.user_ids %}
{% set group_member_ids = group.user_ids|json_parse %}
{% if group_member_ids and group_member_ids|length > 0 %}
<div class="alert alert-info">
<strong>Group Members ({{ group_member_ids|length }}):</strong>
<ul class="mb-0">
{% for uid in group_member_ids %}
{% set user = users|selectattr('id', 'equalto', uid)|first %}
{% if user %}
<li>
<span class="fw-bold">{{ user.email }}</span>
{% if user.id == current_user.id %}
<span class="badge bg-secondary">You</span>
{% endif %}
</li>
{% else %}
<li>
<span class="fw-bold text-muted">User ID: {{ uid }}</span>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% endif %}
{% else %}
<div class="alert alert-warning">
<strong>No members yet.</strong> Use the form below to add users to this group.
</div>
{% endif %}
<h5>Add/Remove Members</h5>
<form method="POST" class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="row">
<div class="col-md-8">
<select class="form-select" name="user_id">
<option value="">Select a user...</option>
{% for user in users %}
{% if user.company_id == group.company_id %}
<option value="{{ user.id }}">{{ user.email }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="col-md-4">
<div class="btn-group w-100">
<button type="submit" name="action" value="add" class="btn btn-success">Add to Group</button>
{% if group.user_ids %}
<button type="submit" name="action" value="remove" class="btn btn-danger">Remove from Group</button>
{% endif %}
</div>
</div>
</div>
</form>
<hr>
<h5>Current Members</h5>
{% if group_member_ids and group_member_ids|length > 0 %}
<div class="list-group">
{% for uid in group_member_ids %}
{% set user = users|selectattr('id', 'equalto', uid)|first %}
{% if user %}
<a href="{{ url_for('company_admin', company_id=group.company_id) }}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between align-items-center">
<h6 class="mb-0">{{ user.email }}</h6>
<small>Member</small>
</div>
</a>
{% endif %}
{% endfor %}
</div>
{% else %}
<p class="text-muted">No members added yet. Use the form above to add users.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

75
templates/index.html Normal file
View File

@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block navbar %}{% endblock %}
{% block title %}Governance and Compliance Portal - Home{% endblock %}
{% block content %}
<div class="login-logo-container mb-5">
<img src="{{ url_for('static', filename='logo.png') }}" class="login-logo">
</div>
<div class="container mt-5">
<div class="hero-section bg-dark text-white rounded-3 p-5 mb-5 position-relative overflow-hidden">
<div class="hero-overlay"></div>
<div class="position-relative">
<h1 class="display-4 fw-bold mb-4">Governance and Compliance Portal</h1>
<p class="lead mb-4">Your trusted partner for regulatory frameworks, policy documentation, and compliance assurance.</p>
<a href="{{ url_for('login') }}" class="btn btn-primary btn-lg px-5">Access Portal</a>
</div>
</div>
<div class="row g-4 mb-5">
<div class="col-md-4">
<div class="card h-100 shadow">
<img src="/static/images/regulatory-compliance.jpg" alt="Regulatory Compliance" style="height: 200px; object-fit: cover;">
<div class="card-body">
<h5 class="card-title">Regulatory Frameworks</h5>
<p class="card-text">Stay compliant with industry regulations and standards. We help you navigate complex compliance requirements effortlessly.</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 shadow">
<img src="/static/images/policy-management.jpg" alt="Policy Management" style="height: 200px; object-fit: cover;">
<div class="card-body">
<h5 class="card-title">Policy Documentation</h5>
<p class="card-text">Access comprehensive policy documents, guidelines, and best practices for organizational governance.</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 shadow">
<img src="/static/images/audit-training.jpg" alt="Audit Support & Training" style="height: 200px; object-fit: cover;">
<div class="card-body">
<h5 class="card-title">Compliance Audits & Training</h5>
<p class="card-text">Streamlined audit preparation, compliance training programs, and documentation management.</p>
</div>
</div>
</div>
</div>
</div>
<style>
.hero-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(rgba(27,50,68,0.8), rgba(49,100,138,0.8));
}
.hero-section {
background-image: url('https://images.unsplash.com/photo-1521737711867-e3b97375f902?auto=format&fit=crop&w=1350');
background-size: cover;
background-position: center;
}
.bg-governance {
background-color: #2d4a6e;
}
</style>
{% endblock %}

View File

@ -1,8 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Login{% endblock %} {% block title %}Login - mgmtatlas.com{% endblock %}
{% block navbar %}{% endblock %}
{% block content %} {% block content %}
<div class="login-logo-container">
<img src="{{ url_for('static', filename='logo.png') }}" alt="mgmtatlas.com logo" class="login-logo">
</div>
<div class="auth-wrapper"> <div class="auth-wrapper">
<div class="form-container"> <div class="form-container">
<div class="card shadow"> <div class="card shadow">

154
templates/profile.html Normal file
View File

@ -0,0 +1,154 @@
{% extends "base.html" %}
{% block title %}My Profile{% endblock %}
{% block content %}
<div class="profile-container">
<form method="POST" enctype="multipart/form-data" action="{{ url_for('profile') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="avatar">Avatar</label>
{% if user.avatar_url %}
<div class="avatar-preview">
<img src="{{ user.avatar_url }}" alt="Avatar">
<small>Current avatar</small>
</div>
{% endif %}
<input type="file" name="avatar" accept="image/*" id="avatar" class="form-control-file">
<small>Upload a new avatar (PNG, JPG, GIF, WEBP)</small>
</div>
<div class="form-group">
<label for="first_name">First Name</label>
<input type="text" name="first_name" id="first_name" class="form-control"
value="{{ user.first_name if user.first_name else '' }}" placeholder="Enter first name">
{% if not user.has_profile() %}
<small class="text-warning">Profile incomplete - please add your name</small>
{% endif %}
</div>
<div class="form-group">
<label for="last_name">Last Name</label>
<input type="text" name="last_name" id="last_name" class="form-control"
value="{{ user.last_name if user.last_name else '' }}" placeholder="Enter last name">
{% if not user.has_profile() %}
<small class="text-warning">Profile incomplete - please add your name</small>
{% endif %}
</div>
<h3>Profile Information</h3>
<hr>
<h3>Change Password</h3>
<hr>
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" name="current_password" id="current_password" class="form-control" placeholder="Enter your current password">
<small>Required to verify your identity before changing password</small>
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" name="new_password" id="new_password" class="form-control" placeholder="Enter new password">
<small>Must be at least 8 characters long</small>
</div>
<div class="form-group">
<label for="confirm_password">Confirm New Password</label>
<input type="password" name="confirm_password" id="confirm_password" class="form-control" placeholder="Confirm your new password">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="action" name="action" value="change_password">
Change my password
</label>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Update Profile</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<style>
.profile-container {
max-width: 600px;
margin: 2rem auto;
padding: 1rem;
}
.profile-form {
background: #fff;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
hr {
margin: 1rem 0;
border: none;
border-top: 1px solid #ddd;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-control-file {
display: block;
margin-top: 0.5rem;
width: 100%;
padding: 0.5rem;
border: 2px dashed #ddd;
border-radius: 4px;
cursor: pointer;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 4px;
text-decoration: none;
font-weight: 500;
border: none;
cursor: pointer;
margin-right: 0.5rem;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.text-warning {
color: #e67e22;
}
</style>
{% endblock %}

View File

@ -8,20 +8,39 @@
<div class="card shadow"> <div class="card shadow">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-center mb-4">Create Account</h2> <h2 class="card-title text-center mb-4">Create Account</h2>
<form method="POST"> <form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% if not token %}
<div class="mb-3">
<label class="form-label">Company Name</label>
<input type="text" class="form-control" name="company_name" required>
</div>
{% endif %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Email address</label> <label class="form-label">Email address</label>
<input type="email" class="form-control" name="email" required> <input type="email" class="form-control" name="email" required>
</div> </div>
<div class="mb-4"> <div class="mb-3">
<label class="form-label">First Name</label>
<input type="text" class="form-control" name="first_name">
</div>
<div class="mb-3">
<label class="form-label">Last Name</label>
<input type="text" class="form-control" name="last_name">
</div>
<div class="mb-3">
<label class="form-label">Password</label> <label class="form-label">Password</label>
<input type="password" class="form-control" name="password" required> <input type="password" class="form-control" name="password" required>
</div> </div>
<div class="mb-4"> <div class="mb-3">
<label class="form-label">Confirm Password</label> <label class="form-label">Confirm Password</label>
<input type="password" class="form-control" name="confirm_password" required> <input type="password" class="form-control" name="confirm_password" required>
</div> </div>
<div class="mb-4">
<label class="form-label">Avatar (Optional)</label>
<input type="file" class="form-control" name="avatar" accept="image/*">
<small class="text-muted">Upload a profile picture (PNG, JPG, GIF, WEBP)</small>
</div>
<button type="submit" class="btn btn-primary w-100">Register</button> <button type="submit" class="btn btn-primary w-100">Register</button>
</form> </form>
<div class="text-center mt-3"> <div class="text-center mt-3">

27
templates/resources.html Normal file
View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Resources{% endblock %}
{% block content %}
<div class="container mt-4">
<h2>Welcome to Management Portal</h2>
{% if current_user.company_id %}
<div class="card shadow border-0 bg-light">
<div class="card-body text-center py-5">
<p>You are logged in for company: <strong>{{ current_user.company.name }}</strong></p>
{% if current_user.is_company_admin or current_user.role == 'admin' %}
<a href="{{ url_for('company_admin') }}" class="btn btn-primary me-3">Go to Admin</a>
{% endif %}
</div>
</div>
{% else %}
<div class="alert alert-info py-4 text-center" role="alert">
You are not associated with any company. Please create a new account or log in to get started.
<a href="{{ url_for('login') }}" class="btn btn-primary mt-2 ms-3">Log In</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Set New Password{% endblock %}
{% block content %}
<div class="auth-wrapper">
<div class="form-container">
<div class="card shadow">
<div class="card-body">
<h2 class="card-title text-center mb-4">Set New Password</h2>
<div class="alert alert-info">
<p>This is a reset request from your email address.</p>
<small>For security, we'll verify the email before allowing the password reset.</small>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label class="form-label">Email Address</label>
<input type="email" class="form-control" name="email" value="{{ user.email if user else '' }}" required>
<small class="text-muted">The email address associated with this account</small>
</div>
<div class="mb-3">
<label class="form-label">New Password</label>
<input type="password" class="form-control" name="new_password" required>
<small class="text-muted">At least 8 characters</small>
</div>
<div class="mb-3">
<label class="form-label">Confirm New Password</label>
<input type="password" class="form-control" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Update Password</button>
</form>
<div class="text-center mt-3">
<a href="{{ url_for('login') }}" class="text-decoration-none">Back to Login</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}