5 Commits

Author SHA1 Message Date
6afa4619ef Add reCAPTCHA env variables to docker-compose.yml 2025-11-22 12:10:24 +01:00
57bb0f5b9e Add reCAPTCHA feature 2025-11-22 11:53:28 +01:00
8de4378ad9 Add missing cron scripts. 2025-11-22 11:12:40 +01:00
c375b9ee3d Add cron file 2025-11-22 10:52:58 +01:00
983d10ea97 Prune unverified accounts 2025-11-22 10:44:50 +01:00
14 changed files with 809 additions and 92 deletions

View File

@@ -24,4 +24,10 @@ MAIL_PORT=587
MAIL_USE_TLS=true MAIL_USE_TLS=true
MAIL_USERNAME=your-email@example.com MAIL_USERNAME=your-email@example.com
MAIL_PASSWORD=your-password MAIL_PASSWORD=your-password
MAIL_DEFAULT_SENDER=TimeTrack <noreply@timetrack.com> MAIL_DEFAULT_SENDER=TimeTrack <noreply@timetrack.com>
# reCAPTCHA Configuration (Google reCAPTCHA v2)
# Get your keys at: https://www.google.com/recaptcha/admin
RECAPTCHA_SITE_KEY=your-site-key-here
RECAPTCHA_SECRET_KEY=your-secret-key-here
RECAPTCHA_ENABLED=true

View File

@@ -39,8 +39,16 @@ docker-compose up
# Debug mode with hot-reload # Debug mode with hot-reload
docker-compose -f docker-compose.debug.yml up docker-compose -f docker-compose.debug.yml up
# Manual cleanup of unverified accounts
docker exec timetrack-timetrack-1 python cleanup_unverified_accounts.py
# Dry run to see what would be deleted
docker exec timetrack-timetrack-1 python cleanup_unverified_accounts.py --dry-run
``` ```
**Note:** Unverified accounts are automatically cleaned up every hour via cron job in the Docker container.
## Database Operations ## Database Operations
### Flask-Migrate Commands ### Flask-Migrate Commands
@@ -187,6 +195,7 @@ When modifying billing/invoice features:
- Two-factor authentication (2FA) using TOTP - Two-factor authentication (2FA) using TOTP
- Session-based authentication with "Remember Me" option - Session-based authentication with "Remember Me" option
- Email verification for new accounts (configurable) - Email verification for new accounts (configurable)
- Automatic cleanup of unverified accounts after 24 hours
### Mobile UI Features ### Mobile UI Features
- Progressive Web App (PWA) manifest for installability - Progressive Web App (PWA) manifest for installability

View File

@@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
python3-dev \ python3-dev \
postgresql-client \ postgresql-client \
cron \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -35,6 +36,12 @@ RUN pip install gunicorn==21.2.0
# Copy the rest of the application # Copy the rest of the application
COPY . . COPY . .
# Setup cron job for cleanup
COPY docker-cron /etc/cron.d/cleanup-cron
RUN chmod 0644 /etc/cron.d/cleanup-cron && \
crontab /etc/cron.d/cleanup-cron && \
touch /var/log/cron.log
# Create the SQLite database directory with proper permissions # Create the SQLite database directory with proper permissions
RUN mkdir -p /app/instance && chmod 777 /app/instance RUN mkdir -p /app/instance && chmod 777 /app/instance

153
app.py
View File

@@ -19,6 +19,7 @@ from flask_mail import Mail, Message
from dotenv import load_dotenv from dotenv import load_dotenv
from password_utils import PasswordValidator from password_utils import PasswordValidator
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from recaptcha_helper import recaptcha
# Import blueprints # Import blueprints
from routes.notes import notes_bp from routes.notes import notes_bp
@@ -102,14 +103,14 @@ def force_http_scheme():
if app.debug or os.environ.get('FLASK_ENV') == 'debug': if app.debug or os.environ.get('FLASK_ENV') == 'debug':
from flask import url_for as original_url_for from flask import url_for as original_url_for
import functools import functools
@functools.wraps(original_url_for) @functools.wraps(original_url_for)
def url_for_http(*args, **kwargs): def url_for_http(*args, **kwargs):
# Force _scheme to http if _external is True # Force _scheme to http if _external is True
if kwargs.get('_external'): if kwargs.get('_external'):
kwargs['_scheme'] = 'http' kwargs['_scheme'] = 'http'
return original_url_for(*args, **kwargs) return original_url_for(*args, **kwargs)
app.jinja_env.globals['url_for'] = url_for_http app.jinja_env.globals['url_for'] = url_for_http
# Configure Flask-Mail # Configure Flask-Mail
@@ -129,6 +130,9 @@ logger.info(f"Mail default sender: {app.config['MAIL_DEFAULT_SENDER']}")
mail = Mail(app) mail = Mail(app)
# Initialize reCAPTCHA
recaptcha.init_app(app)
# Initialize the database with the app # Initialize the database with the app
db.init_app(app) db.init_app(app)
@@ -365,7 +369,7 @@ def robots_txt():
def sitemap_xml(): def sitemap_xml():
"""Generate XML sitemap for search engines""" """Generate XML sitemap for search engines"""
pages = [] pages = []
# Static pages accessible without login # Static pages accessible without login
static_pages = [ static_pages = [
{'loc': '/', 'priority': '1.0', 'changefreq': 'daily'}, {'loc': '/', 'priority': '1.0', 'changefreq': 'daily'},
@@ -373,7 +377,7 @@ def sitemap_xml():
{'loc': '/register', 'priority': '0.9', 'changefreq': 'monthly'}, {'loc': '/register', 'priority': '0.9', 'changefreq': 'monthly'},
{'loc': '/forgot_password', 'priority': '0.5', 'changefreq': 'monthly'}, {'loc': '/forgot_password', 'priority': '0.5', 'changefreq': 'monthly'},
] ]
for page in static_pages: for page in static_pages:
pages.append({ pages.append({
'loc': request.host_url[:-1] + page['loc'], 'loc': request.host_url[:-1] + page['loc'],
@@ -381,10 +385,10 @@ def sitemap_xml():
'priority': page['priority'], 'priority': page['priority'],
'changefreq': page['changefreq'] 'changefreq': page['changefreq']
}) })
sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>\n' sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
sitemap_xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' sitemap_xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for page in pages: for page in pages:
sitemap_xml += ' <url>\n' sitemap_xml += ' <url>\n'
sitemap_xml += f' <loc>{page["loc"]}</loc>\n' sitemap_xml += f' <loc>{page["loc"]}</loc>\n'
@@ -392,9 +396,9 @@ def sitemap_xml():
sitemap_xml += f' <changefreq>{page["changefreq"]}</changefreq>\n' sitemap_xml += f' <changefreq>{page["changefreq"]}</changefreq>\n'
sitemap_xml += f' <priority>{page["priority"]}</priority>\n' sitemap_xml += f' <priority>{page["priority"]}</priority>\n'
sitemap_xml += ' </url>\n' sitemap_xml += ' </url>\n'
sitemap_xml += '</urlset>' sitemap_xml += '</urlset>'
return Response(sitemap_xml, mimetype='application/xml') return Response(sitemap_xml, mimetype='application/xml')
@app.route('/site.webmanifest') @app.route('/site.webmanifest')
@@ -652,6 +656,13 @@ def register():
if not is_valid: if not is_valid:
error = password_errors[0] # Show first error error = password_errors[0] # Show first error
# Verify reCAPTCHA
if not error:
recaptcha_response = request.form.get('g-recaptcha-response')
is_valid, recaptcha_error = recaptcha.verify(recaptcha_response, request.remote_addr)
if not is_valid:
error = recaptcha_error
# Find company by code or create new one if no code provided # Find company by code or create new one if no code provided
company = None company = None
if company_code: if company_code:
@@ -729,31 +740,47 @@ def register():
# Make first user in company an admin with full privileges # Make first user in company an admin with full privileges
if is_first_user_in_company: if is_first_user_in_company:
new_user.role = Role.ADMIN new_user.role = Role.ADMIN
new_user.is_verified = True # Auto-verify first user in company # Removed auto-verification - all users must verify email
elif not email_verification_required: elif not email_verification_required:
# If email verification is disabled, auto-verify new users # If email verification is disabled, auto-verify new users
new_user.is_verified = True new_user.is_verified = True
# Generate verification token (even if not needed, for consistency) # Generate verification token
token = new_user.generate_verification_token() token = new_user.generate_verification_token()
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
if is_first_user_in_company: if not email_verification_required:
# First user in company gets admin privileges and is auto-verified
logger.info(f"First user account created in company {company.name}: {username} with admin privileges")
flash(f'Welcome! You are the first user in {company.name} and have been granted administrator privileges. You can now log in.', 'success')
elif not email_verification_required:
# Email verification is disabled, user can log in immediately # Email verification is disabled, user can log in immediately
logger.info(f"User account created with auto-verification in company {company.name}: {username}") logger.info(f"User account created with auto-verification in company {company.name}: {username}")
flash('Registration successful! You can now log in.', 'success') flash('Registration successful! You can now log in.', 'success')
else: else:
# Send verification email for regular users when verification is required # Send verification email for all users (including first user)
verification_url = url_for('verify_email', token=token, _external=True) verification_url = url_for('verify_email', token=token, _external=True)
msg = Message(f'Verify your {g.branding.app_name} account', recipients=[email]) msg = Message(f'Verify your {g.branding.app_name} account', recipients=[email])
msg.body = f'''Hello {username},
# Special message for first user in company
if is_first_user_in_company:
msg.body = f'''Hello {username},
Thank you for registering with {g.branding.app_name}. You are the first user in {company.name} and have been granted administrator privileges.
To complete your registration and access your account, please click on the link below:
{verification_url}
This link will expire in 24 hours.
If you did not register for {g.branding.app_name}, please ignore this email.
Best regards,
The {g.branding.app_name} Team
'''
logger.info(f"First user account created in company {company.name}: {username} with admin privileges - verification email sent")
flash(f'Welcome! You are the first user in {company.name} and have been granted administrator privileges. Please check your email to verify your account.', 'success')
else:
msg.body = f'''Hello {username},
Thank you for registering with {g.branding.app_name}. To complete your registration, please click on the link below: Thank you for registering with {g.branding.app_name}. To complete your registration, please click on the link below:
@@ -766,9 +793,10 @@ If you did not register for {g.branding.app_name}, please ignore this email.
Best regards, Best regards,
The {g.branding.app_name} Team The {g.branding.app_name} Team
''' '''
logger.info(f"User account created in company {company.name}: {username} - verification email sent")
flash('Registration initiated! Please check your email to verify your account.', 'success')
mail.send(msg) mail.send(msg)
logger.info(f"Verification email sent to {email}")
flash('Registration initiated! Please check your email to verify your account.', 'success')
return redirect(url_for('login')) return redirect(url_for('login'))
except Exception as e: except Exception as e:
@@ -815,6 +843,13 @@ def register_freelancer():
if not is_valid: if not is_valid:
error = password_errors[0] # Show first error error = password_errors[0] # Show first error
# Verify reCAPTCHA
if not error:
recaptcha_response = request.form.get('g-recaptcha-response')
is_valid, recaptcha_error = recaptcha.verify(recaptcha_response, request.remote_addr)
if not is_valid:
error = recaptcha_error
# Check for existing users globally (freelancers get unique usernames/emails) # Check for existing users globally (freelancers get unique usernames/emails)
if not error: if not error:
if User.query.filter_by(username=username).first(): if User.query.filter_by(username=username).first():
@@ -851,6 +886,9 @@ def register_freelancer():
db.session.add(personal_company) db.session.add(personal_company)
db.session.flush() # Get company ID db.session.flush() # Get company ID
# Check if email verification is required
email_verification_required = get_system_setting('email_verification_required', 'true') == 'true'
# Create freelancer user # Create freelancer user
new_user = User( new_user = User(
username=username, username=username,
@@ -859,15 +897,42 @@ def register_freelancer():
account_type=AccountType.FREELANCER, account_type=AccountType.FREELANCER,
business_name=business_name if business_name else None, business_name=business_name if business_name else None,
role=Role.ADMIN, # Freelancers are admins of their personal company role=Role.ADMIN, # Freelancers are admins of their personal company
is_verified=True # Auto-verify freelancers is_verified=not email_verification_required # Only auto-verify if email verification is disabled
) )
new_user.set_password(password) new_user.set_password(password)
# Generate verification token
token = new_user.generate_verification_token()
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
logger.info(f"Freelancer account created: {username} with personal company: {company_name}") if not email_verification_required:
flash(f'Welcome {username}! Your freelancer account has been created successfully. You can now log in.', 'success') # Email verification is disabled, user can log in immediately
logger.info(f"Freelancer account created with auto-verification: {username} with personal company: {company_name}")
flash(f'Welcome {username}! Your freelancer account has been created successfully. You can now log in.', 'success')
else:
# Send verification email
verification_url = url_for('verify_email', token=token, _external=True)
msg = Message(f'Verify your {g.branding.app_name} freelancer account', recipients=[email])
msg.body = f'''Hello {username},
Thank you for registering as a freelancer with {g.branding.app_name}. Your personal workspace "{company_name}" has been created.
To complete your registration and access your account, please click on the link below:
{verification_url}
This link will expire in 24 hours.
If you did not register for {g.branding.app_name}, please ignore this email.
Best regards,
The {g.branding.app_name} Team
'''
mail.send(msg)
logger.info(f"Freelancer account created: {username} with personal company: {company_name} - verification email sent")
flash(f'Welcome {username}! Your freelancer workspace has been created. Please check your email to verify your account.', 'success')
return redirect(url_for('login')) return redirect(url_for('login'))
@@ -986,11 +1051,11 @@ def forgot_password():
"""Handle forgot password requests""" """Handle forgot password requests"""
if request.method == 'POST': if request.method == 'POST':
username_or_email = request.form.get('username_or_email', '').strip() username_or_email = request.form.get('username_or_email', '').strip()
if not username_or_email: if not username_or_email:
flash('Please enter your username or email address.', 'error') flash('Please enter your username or email address.', 'error')
return render_template('forgot_password.html', title='Forgot Password') return render_template('forgot_password.html', title='Forgot Password')
# Try to find user by username or email # Try to find user by username or email
user = User.query.filter( user = User.query.filter(
db.or_( db.or_(
@@ -998,11 +1063,11 @@ def forgot_password():
User.email == username_or_email User.email == username_or_email
) )
).first() ).first()
if user and user.email: if user and user.email:
# Generate reset token # Generate reset token
token = user.generate_password_reset_token() token = user.generate_password_reset_token()
# Send reset email # Send reset email
reset_url = url_for('reset_password', token=token, _external=True) reset_url = url_for('reset_password', token=token, _external=True)
msg = Message( msg = Message(
@@ -1023,7 +1088,7 @@ If you did not request a password reset, please ignore this email.
Best regards, Best regards,
The {g.branding.app_name if g.branding else "TimeTrack"} Team The {g.branding.app_name if g.branding else "TimeTrack"} Team
''' '''
try: try:
mail.send(msg) mail.send(msg)
logger.info(f"Password reset email sent to user {user.username}") logger.info(f"Password reset email sent to user {user.username}")
@@ -1031,11 +1096,11 @@ The {g.branding.app_name if g.branding else "TimeTrack"} Team
logger.error(f"Failed to send password reset email: {str(e)}") logger.error(f"Failed to send password reset email: {str(e)}")
flash('Failed to send reset email. Please contact support.', 'error') flash('Failed to send reset email. Please contact support.', 'error')
return render_template('forgot_password.html', title='Forgot Password') return render_template('forgot_password.html', title='Forgot Password')
# Always show success message to prevent user enumeration # Always show success message to prevent user enumeration
flash('If an account exists with that username or email address, we have sent a password reset link.', 'success') flash('If an account exists with that username or email address, we have sent a password reset link.', 'success')
return redirect(url_for('login')) return redirect(url_for('login'))
return render_template('forgot_password.html', title='Forgot Password') return render_template('forgot_password.html', title='Forgot Password')
@app.route('/reset_password/<token>', methods=['GET', 'POST']) @app.route('/reset_password/<token>', methods=['GET', 'POST'])
@@ -1043,42 +1108,42 @@ def reset_password(token):
"""Handle password reset with token""" """Handle password reset with token"""
# Find user by reset token # Find user by reset token
user = User.query.filter_by(password_reset_token=token).first() user = User.query.filter_by(password_reset_token=token).first()
if not user or not user.verify_password_reset_token(token): if not user or not user.verify_password_reset_token(token):
flash('Invalid or expired reset link.', 'error') flash('Invalid or expired reset link.', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
if request.method == 'POST': if request.method == 'POST':
password = request.form.get('password') password = request.form.get('password')
confirm_password = request.form.get('confirm_password') confirm_password = request.form.get('confirm_password')
# Validate input # Validate input
error = None error = None
if not password: if not password:
error = 'Password is required' error = 'Password is required'
elif password != confirm_password: elif password != confirm_password:
error = 'Passwords do not match' error = 'Passwords do not match'
# Validate password strength # Validate password strength
if not error: if not error:
validator = PasswordValidator() validator = PasswordValidator()
is_valid, password_errors = validator.validate(password) is_valid, password_errors = validator.validate(password)
if not is_valid: if not is_valid:
error = password_errors[0] error = password_errors[0]
if error: if error:
flash(error, 'error') flash(error, 'error')
return render_template('reset_password.html', token=token, title='Reset Password') return render_template('reset_password.html', token=token, title='Reset Password')
# Update password # Update password
user.set_password(password) user.set_password(password)
user.clear_password_reset_token() user.clear_password_reset_token()
db.session.commit() db.session.commit()
logger.info(f"Password reset successful for user {user.username}") logger.info(f"Password reset successful for user {user.username}")
flash('Your password has been reset successfully. Please log in with your new password.', 'success') flash('Your password has been reset successfully. Please log in with your new password.', 'success')
return redirect(url_for('login')) return redirect(url_for('login'))
return render_template('reset_password.html', token=token, title='Reset Password') return render_template('reset_password.html', token=token, title='Reset Password')
@app.route('/dashboard') @app.route('/dashboard')
@@ -2931,14 +2996,14 @@ def render_markdown():
try: try:
data = request.get_json() data = request.get_json()
content = data.get('content', '') content = data.get('content', '')
if not content: if not content:
return jsonify({'html': '<p class="preview-placeholder">Start typing to see the preview...</p>'}) return jsonify({'html': '<p class="preview-placeholder">Start typing to see the preview...</p>'})
# Parse frontmatter and extract body # Parse frontmatter and extract body
from frontmatter_utils import parse_frontmatter from frontmatter_utils import parse_frontmatter
metadata, body = parse_frontmatter(content) metadata, body = parse_frontmatter(content)
# Render markdown to HTML # Render markdown to HTML
try: try:
import markdown import markdown
@@ -2947,13 +3012,13 @@ def render_markdown():
except ImportError: except ImportError:
# Fallback if markdown not installed # Fallback if markdown not installed
html = f'<pre>{body}</pre>' html = f'<pre>{body}</pre>'
return jsonify({'html': html}) return jsonify({'html': html})
except Exception as e: except Exception as e:
logger.error(f"Error rendering markdown: {str(e)}") logger.error(f"Error rendering markdown: {str(e)}")
return jsonify({'html': '<p class="error">Error rendering markdown</p>'}) return jsonify({'html': '<p class="error">Error rendering markdown</p>'})
if __name__ == '__main__': if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000)) port = int(os.environ.get('PORT', 5000))
app.run(debug=True, host='0.0.0.0', port=port) app.run(debug=True, host='0.0.0.0', port=port)

327
cleanup_inactive_accounts.py Executable file
View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python
"""
Cleanup verified but inactive user accounts.
Identifies and removes bot-created or unused accounts that:
- Are verified (is_verified = True)
- Have never logged in (no SystemEvent login records)
- Have never created time entries
- Are older than a specified number of days
This script can be run manually or scheduled via cron.
"""
import os
import sys
from datetime import datetime, timedelta
from sqlalchemy import and_, or_
# Add the application path to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import app, db
from models import User, Company, SystemEvent, TimeEntry
from models.enums import Role
def find_inactive_verified_accounts(min_age_days=30, dry_run=False):
"""
Find verified user accounts that have never been used.
Args:
min_age_days (int): Minimum age in days for accounts to be considered
dry_run (bool): If True, only show what would be deleted without actually deleting.
Returns:
tuple: (accounts_to_delete, details_list)
"""
with app.app_context():
cutoff_time = datetime.utcnow() - timedelta(days=min_age_days)
# Find verified accounts older than cutoff
verified_old_users = User.query.filter(
and_(
User.is_verified == True,
User.created_at < cutoff_time
)
).all()
inactive_accounts = []
for user in verified_old_users:
# Check if user has any time entries
time_entry_count = TimeEntry.query.filter_by(user_id=user.id).count()
# Check if user has any login events
login_event_count = SystemEvent.query.filter(
and_(
SystemEvent.user_id == user.id,
SystemEvent.event_type == 'user_login'
)
).count()
# If no time entries and no logins, this is an inactive account
if time_entry_count == 0 and login_event_count == 0:
account_age_days = (datetime.utcnow() - user.created_at).days
# Get company info
company = Company.query.get(user.company_id) if user.company_id else None
company_name = company.name if company else "Unknown"
# Check if user is the only admin in their company
is_only_admin = False
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
other_admins = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id,
User.role.in_([Role.ADMIN, Role.SYSTEM_ADMIN])
)
).count()
is_only_admin = (other_admins == 0)
inactive_accounts.append({
'user': user,
'company_name': company_name,
'age_days': account_age_days,
'is_only_admin': is_only_admin,
'company': company
})
return inactive_accounts
def cleanup_inactive_accounts(min_age_days=30, dry_run=False, skip_admins=True):
"""
Delete inactive verified accounts.
Args:
min_age_days (int): Minimum age in days for accounts to be considered
dry_run (bool): If True, only show what would be deleted without actually deleting
skip_admins (bool): If True, skip accounts that are the only admin in their company
Returns:
int: Number of accounts deleted (or would be deleted in dry run mode)
"""
with app.app_context():
inactive_accounts = find_inactive_verified_accounts(min_age_days, dry_run)
deleted_count = 0
skipped_count = 0
companies_deleted = 0
print(f"\nFound {len(inactive_accounts)} inactive verified account(s) older than {min_age_days} days")
print("=" * 80)
for account_info in inactive_accounts:
user = account_info['user']
company_name = account_info['company_name']
age_days = account_info['age_days']
is_only_admin = account_info['is_only_admin']
company = account_info['company']
# Skip if this is the only admin and skip_admins is True
if is_only_admin and skip_admins:
print(f"\nSKIPPED (only admin): {user.username}")
print(f" Email: {user.email}")
print(f" Company: {company_name} (ID: {user.company_id})")
print(f" Role: {user.role.value}")
print(f" Created: {user.created_at} ({age_days} days ago)")
skipped_count += 1
continue
if dry_run:
print(f"\nWOULD DELETE: {user.username}")
print(f" Email: {user.email}")
print(f" Company: {company_name} (ID: {user.company_id})")
print(f" Role: {user.role.value}")
print(f" Created: {user.created_at} ({age_days} days ago)")
# Check if company would be deleted too
if company:
other_users = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id
)
).count()
if other_users == 0:
print(f" -> Would also delete empty company: {company_name}")
deleted_count += 1
else:
print(f"\nDELETING: {user.username}")
print(f" Email: {user.email}")
print(f" Company: {company_name} (ID: {user.company_id})")
print(f" Role: {user.role.value}")
print(f" Created: {user.created_at} ({age_days} days ago)")
# Log the deletion as a system event
try:
SystemEvent.log_event(
event_type='user_deleted',
event_category='system',
description=f'Inactive verified user {user.username} deleted (no activity for {age_days} days)',
user_id=None, # No user context for system cleanup
company_id=user.company_id
)
except Exception as e:
print(f" Warning: Could not log deletion event: {e}")
# Check if company should be deleted
if company:
other_users = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id
)
).count()
# Delete the user
db.session.delete(user)
deleted_count += 1
# If no other users, delete the company too
if other_users == 0:
print(f" -> Also deleting empty company: {company_name}")
db.session.delete(company)
companies_deleted += 1
else:
# Delete the user even if company doesn't exist
db.session.delete(user)
deleted_count += 1
if not dry_run and deleted_count > 0:
db.session.commit()
print("\n" + "=" * 80)
print(f"Successfully deleted {deleted_count} inactive account(s)")
if companies_deleted > 0:
print(f"Successfully deleted {companies_deleted} empty company/companies")
if skipped_count > 0:
print(f"Skipped {skipped_count} account(s) (only admin in company)")
elif dry_run:
print("\n" + "=" * 80)
print(f"DRY RUN: Would delete {deleted_count} inactive account(s)")
if skipped_count > 0:
print(f"Would skip {skipped_count} account(s) (only admin in company)")
else:
print("\n" + "=" * 80)
print("No inactive accounts found to delete")
if skipped_count > 0:
print(f"Skipped {skipped_count} account(s) (only admin in company)")
return deleted_count
def generate_report(min_age_days=30):
"""
Generate a detailed report of inactive accounts without deleting anything.
Args:
min_age_days (int): Minimum age in days for accounts to be considered
"""
with app.app_context():
inactive_accounts = find_inactive_verified_accounts(min_age_days, dry_run=True)
print(f"\n{'='*80}")
print(f"INACTIVE VERIFIED ACCOUNTS REPORT")
print(f"Accounts older than {min_age_days} days with no activity")
print(f"Generated: {datetime.utcnow()}")
print(f"{'='*80}\n")
if not inactive_accounts:
print("No inactive verified accounts found.")
return
# Group by company
by_company = {}
for account_info in inactive_accounts:
company_name = account_info['company_name']
if company_name not in by_company:
by_company[company_name] = []
by_company[company_name].append(account_info)
# Print summary
print(f"Total inactive accounts: {len(inactive_accounts)}")
print(f"Companies affected: {len(by_company)}\n")
# Print details by company
for company_name, accounts in sorted(by_company.items()):
print(f"\nCompany: {company_name}")
print("-" * 80)
for account_info in accounts:
user = account_info['user']
age_days = account_info['age_days']
is_only_admin = account_info['is_only_admin']
admin_warning = " [ONLY ADMIN]" if is_only_admin else ""
print(f"{user.username}{admin_warning}")
print(f" Email: {user.email or 'N/A'}")
print(f" Role: {user.role.value}")
print(f" Created: {user.created_at} ({age_days} days ago)")
print()
def main():
"""Main function to handle command line arguments"""
import argparse
parser = argparse.ArgumentParser(
description='Cleanup verified but inactive user accounts (no logins, no time entries)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be deleted without actually deleting'
)
parser.add_argument(
'--min-age',
type=int,
default=30,
help='Minimum account age in days (default: 30)'
)
parser.add_argument(
'--include-admins',
action='store_true',
help='Include accounts that are the only admin in their company (default: skip them)'
)
parser.add_argument(
'--report-only',
action='store_true',
help='Generate a detailed report only, do not delete anything'
)
parser.add_argument(
'--quiet',
action='store_true',
help='Suppress output except for errors'
)
args = parser.parse_args()
if not args.quiet:
print(f"Starting cleanup of inactive verified accounts at {datetime.utcnow()}")
print("-" * 80)
try:
if args.report_only:
generate_report(min_age_days=args.min_age)
else:
deleted_count = cleanup_inactive_accounts(
min_age_days=args.min_age,
dry_run=args.dry_run,
skip_admins=not args.include_admins
)
if not args.quiet:
print("-" * 80)
print(f"Cleanup completed at {datetime.utcnow()}")
# Exit with 0 for success
sys.exit(0)
except Exception as e:
print(f"Error during cleanup: {str(e)}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()

152
cleanup_unverified_accounts.py Executable file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python
"""
Cleanup unverified user accounts older than 24 hours.
This script can be run manually or scheduled via cron.
"""
import os
import sys
from datetime import datetime, timedelta
from sqlalchemy import and_
# Add the application path to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import app, db
from models import User, Company, SystemEvent
from models.enums import Role
def cleanup_unverified_accounts(dry_run=False):
"""
Delete unverified user accounts that are older than 24 hours.
Args:
dry_run (bool): If True, only show what would be deleted without actually deleting.
Returns:
int: Number of accounts deleted (or would be deleted in dry run mode)
"""
with app.app_context():
# Find unverified accounts older than 24 hours
cutoff_time = datetime.utcnow() - timedelta(hours=24)
unverified_users = User.query.filter(
and_(
User.is_verified == False,
User.created_at < cutoff_time
)
).all()
deleted_count = 0
for user in unverified_users:
# Check if this user is the only admin in their company
# We shouldn't delete them if they are, as it would orphan the company
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
other_admins = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id,
User.role.in_([Role.ADMIN, Role.SYSTEM_ADMIN])
)
).count()
if other_admins == 0:
print(f"Skipping {user.username} (ID: {user.id}) - only admin in company {user.company_id}")
continue
if dry_run:
print(f"Would delete unverified user: {user.username} (ID: {user.id}, Email: {user.email}, Created: {user.created_at})")
deleted_count += 1
# Check if company would be deleted too (for dry run)
company = Company.query.get(user.company_id)
if company:
other_users = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id
)
).count()
if other_users == 0:
print(f" Would also delete empty company: {company.name} (ID: {company.id})")
else:
print(f"Deleting unverified user: {user.username} (ID: {user.id}, Email: {user.email}, Created: {user.created_at})")
# Log the deletion as a system event if SystemEvent exists
try:
SystemEvent.log_event(
event_type='user_deleted',
event_category='system',
description=f'Unverified user {user.username} deleted after 24 hours',
user_id=None, # No user context for system cleanup
company_id=user.company_id
)
except:
# SystemEvent might not exist, continue without logging
pass
# Check if the company should be deleted (if it was created with this user and has no other users)
company = Company.query.get(user.company_id)
if company:
other_users = User.query.filter(
and_(
User.company_id == user.company_id,
User.id != user.id
)
).count()
# Delete the user
db.session.delete(user)
deleted_count += 1
# If no other users, delete the company too
if other_users == 0:
print(f" Also deleting empty company: {company.name} (ID: {company.id})")
db.session.delete(company)
else:
# Delete the user even if company doesn't exist
db.session.delete(user)
deleted_count += 1
if not dry_run and deleted_count > 0:
db.session.commit()
print(f"\nSuccessfully deleted {deleted_count} unverified account(s)")
elif dry_run:
print(f"\nDry run: Would delete {deleted_count} unverified account(s)")
else:
print("No unverified accounts older than 24 hours found")
return deleted_count
def main():
"""Main function to handle command line arguments"""
import argparse
parser = argparse.ArgumentParser(description='Cleanup unverified user accounts older than 24 hours')
parser.add_argument('--dry-run', action='store_true',
help='Show what would be deleted without actually deleting')
parser.add_argument('--quiet', action='store_true',
help='Suppress output except for errors')
args = parser.parse_args()
if not args.quiet:
print(f"Starting cleanup of unverified accounts at {datetime.utcnow()}")
print("-" * 60)
try:
deleted_count = cleanup_unverified_accounts(dry_run=args.dry_run)
if not args.quiet:
print("-" * 60)
print(f"Cleanup completed at {datetime.utcnow()}")
# Exit with 0 for success
sys.exit(0)
except Exception as e:
print(f"Error during cleanup: {str(e)}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -43,6 +43,9 @@ services:
- MAIL_USERNAME=${MAIL_USERNAME} - MAIL_USERNAME=${MAIL_USERNAME}
- MAIL_PASSWORD=${MAIL_PASSWORD} - MAIL_PASSWORD=${MAIL_PASSWORD}
- MAIL_DEFAULT_SENDER=${MAIL_DEFAULT_SENDER} - MAIL_DEFAULT_SENDER=${MAIL_DEFAULT_SENDER}
- RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY}
- RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY}
- RECAPTCHA_ENABLED=${RECAPTCHA_ENABLED}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

9
docker-cron Normal file
View File

@@ -0,0 +1,9 @@
# Cron job for cleaning up unverified accounts
# Run every hour at minute 0
0 * * * * cd /app && /usr/local/bin/python /app/cleanup_unverified_accounts.py --quiet >> /var/log/cron.log 2>&1
# Cron job for cleaning up inactive verified accounts (no logins, no time entries)
# Run monthly on the 1st at 2:00 AM, cleaning accounts older than 90 days
0 2 1 * * cd /app && /usr/local/bin/python /app/cleanup_inactive_accounts.py --min-age 90 --quiet >> /var/log/cron.log 2>&1
# Empty line required at the end

110
recaptcha_helper.py Normal file
View File

@@ -0,0 +1,110 @@
"""
reCAPTCHA Helper Module
Provides verification functionality for Google reCAPTCHA v2
"""
import os
import requests
import logging
logger = logging.getLogger(__name__)
class ReCaptcha:
"""Helper class for reCAPTCHA verification"""
def __init__(self, app=None):
self.site_key = None
self.secret_key = None
self.enabled = False
if app is not None:
self.init_app(app)
def init_app(self, app):
"""Initialize reCAPTCHA with Flask app configuration"""
self.site_key = os.getenv('RECAPTCHA_SITE_KEY', '')
self.secret_key = os.getenv('RECAPTCHA_SECRET_KEY', '')
self.enabled = os.getenv('RECAPTCHA_ENABLED', 'true').lower() == 'true'
# Store in app config for template access
app.config['RECAPTCHA_SITE_KEY'] = self.site_key
app.config['RECAPTCHA_ENABLED'] = self.enabled
if self.enabled and (not self.site_key or not self.secret_key):
logger.warning("reCAPTCHA is enabled but keys are not configured properly")
def verify(self, response_token, remote_ip=None):
"""
Verify a reCAPTCHA response token
Args:
response_token (str): The g-recaptcha-response from the form
remote_ip (str, optional): The user's IP address
Returns:
tuple: (success: bool, error_message: str or None)
"""
# If reCAPTCHA is disabled, always return success
if not self.enabled:
return True, None
# Check if we have the required configuration
if not self.secret_key:
logger.error("reCAPTCHA secret key is not configured")
return False, "reCAPTCHA is not properly configured"
# Check if response token was provided
if not response_token:
return False, "Please complete the reCAPTCHA challenge"
# Prepare the verification request
verify_url = 'https://www.google.com/recaptcha/api/siteverify'
payload = {
'secret': self.secret_key,
'response': response_token
}
if remote_ip:
payload['remoteip'] = remote_ip
try:
# Make the verification request
response = requests.post(verify_url, data=payload, timeout=10)
response.raise_for_status()
result = response.json()
if result.get('success'):
logger.info("reCAPTCHA verification successful")
return True, None
else:
error_codes = result.get('error-codes', [])
logger.warning(f"reCAPTCHA verification failed: {error_codes}")
# Map error codes to user-friendly messages
error_messages = {
'missing-input-secret': 'reCAPTCHA configuration error',
'invalid-input-secret': 'reCAPTCHA configuration error',
'missing-input-response': 'Please complete the reCAPTCHA challenge',
'invalid-input-response': 'reCAPTCHA verification failed. Please try again.',
'bad-request': 'reCAPTCHA verification failed. Please try again.',
'timeout-or-duplicate': 'reCAPTCHA expired. Please try again.'
}
# Get the first error message or use a default
error_code = error_codes[0] if error_codes else 'unknown'
error_message = error_messages.get(error_code, 'reCAPTCHA verification failed. Please try again.')
return False, error_message
except requests.RequestException as e:
logger.error(f"reCAPTCHA verification request failed: {str(e)}")
return False, "Unable to verify reCAPTCHA. Please try again later."
except Exception as e:
logger.error(f"Unexpected error during reCAPTCHA verification: {str(e)}")
return False, "An error occurred during verification. Please try again."
# Create a singleton instance
recaptcha = ReCaptcha()

View File

@@ -17,3 +17,4 @@ Flask-Migrate==3.1.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
markdown==3.4.4 markdown==3.4.4
PyYAML==6.0.1 PyYAML==6.0.1
requests==2.31.0

View File

@@ -73,6 +73,16 @@ if [ -f "migrations_old/run_postgres_migrations.py" ]; then
echo "Found old migration system. Consider removing after confirming Flask-Migrate is working." echo "Found old migration system. Consider removing after confirming Flask-Migrate is working."
fi fi
# Start cron service for scheduled tasks
echo ""
echo "=== Starting Cron Service ==="
service cron start
if [ $? -eq 0 ]; then
echo "✅ Cron service started for scheduled cleanup tasks"
else
echo "⚠️ Failed to start cron service, cleanup tasks won't run automatically"
fi
# Start the Flask application with gunicorn # Start the Flask application with gunicorn
echo "" echo ""
echo "=== Starting Application ===" echo "=== Starting Application ==="

View File

@@ -7,6 +7,9 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
{% if config.RECAPTCHA_ENABLED %}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{% endif %}
<style> <style>
.registration-type { .registration-type {
display: flex; display: flex;
@@ -201,7 +204,13 @@
I agree to the Terms of Service and Privacy Policy I agree to the Terms of Service and Privacy Policy
</label> </label>
</div> </div>
{% if config.RECAPTCHA_ENABLED %}
<div class="form-group" style="margin-top: 1.5rem;">
<div class="g-recaptcha" data-sitekey="{{ config.RECAPTCHA_SITE_KEY }}"></div>
</div>
{% endif %}
<div class="form-group"> <div class="form-group">
<button type="submit" class="btn btn-primary">Create Account</button> <button type="submit" class="btn btn-primary">Create Account</button>
</div> </div>

View File

@@ -7,6 +7,9 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
{% if config.RECAPTCHA_ENABLED %}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{% endif %}
</head> </head>
<body class="auth-page"> <body class="auth-page">
<div class="auth-container"> <div class="auth-container">
@@ -78,7 +81,13 @@
I agree to the Terms of Service and Privacy Policy I agree to the Terms of Service and Privacy Policy
</label> </label>
</div> </div>
{% if config.RECAPTCHA_ENABLED %}
<div class="form-group" style="margin-top: 1.5rem;">
<div class="g-recaptcha" data-sitekey="{{ config.RECAPTCHA_SITE_KEY }}"></div>
</div>
{% endif %}
<div class="form-group"> <div class="form-group">
<button type="submit" class="btn btn-primary">Create My Workspace</button> <button type="submit" class="btn btn-primary">Create My Workspace</button>
</div> </div>

View File

@@ -8,72 +8,72 @@ from models import db
class BaseRepository: class BaseRepository:
"""Base repository with common database operations""" """Base repository with common database operations"""
def __init__(self, model): def __init__(self, model):
self.model = model self.model = model
def get_by_id(self, id): def get_by_id(self, id):
"""Get entity by ID""" """Get entity by ID"""
return self.model.query.get(id) return self.model.query.get(id)
def get_by_company(self, company_id=None): def get_by_company(self, company_id=None):
"""Get all entities for a company""" """Get all entities for a company"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return [] return []
return self.model.query.filter_by(company_id=company_id).all() return self.model.query.filter_by(company_id=company_id).all()
def get_by_company_ordered(self, company_id=None, order_by=None): def get_by_company_ordered(self, company_id=None, order_by=None):
"""Get all entities for a company with ordering""" """Get all entities for a company with ordering"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return [] return []
query = self.model.query.filter_by(company_id=company_id) query = self.model.query.filter_by(company_id=company_id)
if order_by is not None: if order_by is not None:
query = query.order_by(order_by) query = query.order_by(order_by)
return query.all() return query.all()
def exists_by_name_in_company(self, name, company_id=None, exclude_id=None): def exists_by_name_in_company(self, name, company_id=None, exclude_id=None):
"""Check if entity with name exists in company""" """Check if entity with name exists in company"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
query = self.model.query.filter_by(name=name, company_id=company_id) query = self.model.query.filter_by(name=name, company_id=company_id)
if exclude_id is not None: if exclude_id is not None:
query = query.filter(self.model.id != exclude_id) query = query.filter(self.model.id != exclude_id)
return query.first() is not None return query.first() is not None
def create(self, **kwargs): def create(self, **kwargs):
"""Create new entity""" """Create new entity"""
entity = self.model(**kwargs) entity = self.model(**kwargs)
db.session.add(entity) db.session.add(entity)
return entity return entity
def update(self, entity, **kwargs): def update(self, entity, **kwargs):
"""Update entity with given attributes""" """Update entity with given attributes"""
for key, value in kwargs.items(): for key, value in kwargs.items():
if hasattr(entity, key): if hasattr(entity, key):
setattr(entity, key, value) setattr(entity, key, value)
return entity return entity
def delete(self, entity): def delete(self, entity):
"""Delete entity""" """Delete entity"""
db.session.delete(entity) db.session.delete(entity)
def save(self): def save(self):
"""Commit changes to database""" """Commit changes to database"""
db.session.commit() db.session.commit()
def rollback(self): def rollback(self):
"""Rollback database changes""" """Rollback database changes"""
db.session.rollback() db.session.rollback()
@@ -81,42 +81,42 @@ class BaseRepository:
class CompanyScopedRepository(BaseRepository): class CompanyScopedRepository(BaseRepository):
"""Repository for entities scoped to a company""" """Repository for entities scoped to a company"""
def get_by_id_and_company(self, id, company_id=None): def get_by_id_and_company(self, id, company_id=None):
"""Get entity by ID, ensuring it belongs to the company""" """Get entity by ID, ensuring it belongs to the company"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return None return None
return self.model.query.filter_by(id=id, company_id=company_id).first() return self.model.query.filter_by(id=id, company_id=company_id).first()
def get_active_by_company(self, company_id=None): def get_active_by_company(self, company_id=None):
"""Get active entities for a company""" """Get active entities for a company"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return [] return []
# Assumes model has is_active field # Assumes model has is_active field
if hasattr(self.model, 'is_active'): if hasattr(self.model, 'is_active'):
return self.model.query.filter_by( return self.model.query.filter_by(
company_id=company_id, company_id=company_id,
is_active=True is_active=True
).all() ).all()
return self.get_by_company(company_id) return self.get_by_company(company_id)
def count_by_company(self, company_id=None): def count_by_company(self, company_id=None):
"""Count entities for a company""" """Count entities for a company"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return 0 return 0
return self.model.query.filter_by(company_id=company_id).count() return self.model.query.filter_by(company_id=company_id).count()
@@ -124,18 +124,18 @@ class CompanyScopedRepository(BaseRepository):
class UserRepository(CompanyScopedRepository): class UserRepository(CompanyScopedRepository):
"""Repository for User operations""" """Repository for User operations"""
def __init__(self): def __init__(self):
from models import User from models import User
super().__init__(User) super().__init__(User)
def get_by_username_and_company(self, username, company_id): def get_by_username_and_company(self, username, company_id):
"""Get user by username within a company""" """Get user by username within a company"""
return self.model.query.filter_by( return self.model.query.filter_by(
username=username, username=username,
company_id=company_id company_id=company_id
).first() ).first()
def get_by_email(self, email): def get_by_email(self, email):
"""Get user by email (globally unique)""" """Get user by email (globally unique)"""
return self.model.query.filter_by(email=email).first() return self.model.query.filter_by(email=email).first()
@@ -143,19 +143,19 @@ class UserRepository(CompanyScopedRepository):
class TeamRepository(CompanyScopedRepository): class TeamRepository(CompanyScopedRepository):
"""Repository for Team operations""" """Repository for Team operations"""
def __init__(self): def __init__(self):
from models import Team from models import Team
super().__init__(Team) super().__init__(Team)
def get_with_member_count(self, company_id=None): def get_with_member_count(self, company_id=None):
"""Get teams with member count""" """Get teams with member count"""
if company_id is None and hasattr(g, 'user') and g.user: if company_id is None and hasattr(g, 'user') and g.user:
company_id = g.user.company_id company_id = g.user.company_id
if company_id is None: if company_id is None:
return [] return []
# This would need a more complex query with joins # This would need a more complex query with joins
teams = self.get_by_company(company_id) teams = self.get_by_company(company_id)
for team in teams: for team in teams:
@@ -165,27 +165,27 @@ class TeamRepository(CompanyScopedRepository):
class ProjectRepository(CompanyScopedRepository): class ProjectRepository(CompanyScopedRepository):
"""Repository for Project operations""" """Repository for Project operations"""
def __init__(self): def __init__(self):
from models import Project from models import Project
super().__init__(Project) super().__init__(Project)
def get_by_code_and_company(self, code, company_id): def get_by_code_and_company(self, code, company_id):
"""Get project by code within a company""" """Get project by code within a company"""
return self.model.query.filter_by( return self.model.query.filter_by(
code=code, code=code,
company_id=company_id company_id=company_id
).first() ).first()
def get_accessible_by_user(self, user): def get_accessible_by_user(self, user):
"""Get projects accessible by a user""" """Get projects accessible by a user"""
if not user: if not user:
return [] return []
# Admin/Supervisor can see all company projects # Admin/Supervisor can see all company projects
if user.role.value in ['Administrator', 'Supervisor', 'System Administrator']: if user.role.value in ['Administrator', 'Supervisor', 'System Administrator']:
return self.get_by_company(user.company_id) return self.get_by_company(user.company_id)
# Team members see team projects + unassigned projects # Team members see team projects + unassigned projects
from models import Project from models import Project
return Project.query.filter( return Project.query.filter(
@@ -194,4 +194,4 @@ class ProjectRepository(CompanyScopedRepository):
Project.team_id == user.team_id, Project.team_id == user.team_id,
Project.team_id.is_(None) Project.team_id.is_(None)
) )
).all() ).all()