340 lines
12 KiB
Python
340 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Leaderboard Model.
|
|
Gamification feature to rank users by training achievements.
|
|
"""
|
|
|
|
from odoo import models, fields, api
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
class GeniusLeaderboard(models.Model):
|
|
_name = 'genius.leaderboard'
|
|
_description = 'Training Leaderboard'
|
|
_order = 'points desc, topics_completed desc'
|
|
|
|
user_id = fields.Many2one(
|
|
'res.users',
|
|
string='User',
|
|
required=True,
|
|
ondelete='cascade',
|
|
index=True
|
|
)
|
|
|
|
# Period
|
|
period_type = fields.Selection([
|
|
('weekly', 'Weekly'),
|
|
('monthly', 'Monthly'),
|
|
('alltime', 'All Time'),
|
|
], string='Period', required=True, default='alltime', index=True)
|
|
|
|
period_start = fields.Date(
|
|
string='Period Start',
|
|
help='Start date of this period'
|
|
)
|
|
period_end = fields.Date(
|
|
string='Period End',
|
|
help='End date of this period'
|
|
)
|
|
|
|
# Ranking
|
|
rank = fields.Integer(
|
|
string='Rank',
|
|
help='User rank in this period'
|
|
)
|
|
|
|
# Metrics
|
|
points = fields.Integer(
|
|
string='Points',
|
|
default=0,
|
|
help='Total points earned'
|
|
)
|
|
topics_completed = fields.Integer(
|
|
string='Topics Completed',
|
|
default=0
|
|
)
|
|
quizzes_passed = fields.Integer(
|
|
string='Quizzes Passed',
|
|
default=0
|
|
)
|
|
time_spent_hours = fields.Float(
|
|
string='Time Spent (hours)',
|
|
default=0
|
|
)
|
|
streak_days = fields.Integer(
|
|
string='Streak (days)',
|
|
default=0,
|
|
help='Consecutive days of training'
|
|
)
|
|
|
|
# Badges/Achievements
|
|
badge_count = fields.Integer(
|
|
string='Badges',
|
|
default=0
|
|
)
|
|
|
|
company_id = fields.Many2one(
|
|
'res.company',
|
|
string='Company',
|
|
default=lambda self: self.env.company
|
|
)
|
|
|
|
_sql_constraints = [
|
|
('unique_user_period', 'UNIQUE(user_id, period_type, period_start, company_id)',
|
|
'Leaderboard entry already exists for this user and period!')
|
|
]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Points System
|
|
# -------------------------------------------------------------------------
|
|
POINTS = {
|
|
'topic_completed': 10,
|
|
'quiz_passed': 25,
|
|
'quiz_perfect': 50, # 100% score
|
|
'streak_7_days': 30,
|
|
'streak_30_days': 100,
|
|
'first_topic': 5,
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Compute/Update Methods
|
|
# -------------------------------------------------------------------------
|
|
@api.model
|
|
def calculate_user_stats(self, user_id, period_type='alltime', company_id=None):
|
|
"""Calculate stats for a user in a period"""
|
|
company_id = company_id or self.env.company.id
|
|
user = self.env['res.users'].browse(user_id)
|
|
|
|
# Determine date range
|
|
today = fields.Date.today()
|
|
period_start = False
|
|
period_end = False
|
|
|
|
if period_type == 'weekly':
|
|
period_start = today - timedelta(days=today.weekday())
|
|
period_end = period_start + timedelta(days=6)
|
|
elif period_type == 'monthly':
|
|
period_start = today.replace(day=1)
|
|
next_month = today.replace(day=28) + timedelta(days=4)
|
|
period_end = next_month - timedelta(days=next_month.day)
|
|
|
|
# Build progress domain
|
|
domain = [
|
|
('user_id', '=', user_id),
|
|
('state', 'in', ['done', 'verified']),
|
|
]
|
|
if period_start:
|
|
domain.append(('date_completed', '>=', period_start))
|
|
if period_end:
|
|
domain.append(('date_completed', '<=', period_end))
|
|
|
|
progress_records = self.env['genius.progress'].search(domain)
|
|
|
|
# Calculate metrics
|
|
topics_completed = len(progress_records)
|
|
|
|
# Quiz stats
|
|
quiz_domain = [('user_id', '=', user_id), ('is_passed', '=', True)]
|
|
if period_start:
|
|
quiz_domain.append(('submitted_at', '>=', period_start))
|
|
if period_end:
|
|
quiz_domain.append(('submitted_at', '<=', period_end))
|
|
|
|
quiz_attempts = self.env['genius.quiz.attempt'].search(quiz_domain)
|
|
quizzes_passed = len(quiz_attempts)
|
|
# Use integer comparison for robustness (Float 100.0 vs 100 issue)
|
|
perfect_quizzes = len(quiz_attempts.filtered(lambda q: q.points_possible > 0 and q.points_earned == q.points_possible))
|
|
|
|
# Time spent
|
|
time_spent = sum(progress_records.mapped('time_spent_minutes')) / 60.0
|
|
|
|
# Calculate points
|
|
points = (
|
|
topics_completed * self.POINTS['topic_completed'] +
|
|
quizzes_passed * self.POINTS['quiz_passed'] +
|
|
perfect_quizzes * self.POINTS['quiz_perfect']
|
|
)
|
|
|
|
# First topic bonus
|
|
if topics_completed > 0:
|
|
first_progress = self.env['genius.progress'].search([
|
|
('user_id', '=', user_id),
|
|
], order='date_completed', limit=1)
|
|
if first_progress and period_start and first_progress.date_completed:
|
|
if first_progress.date_completed.date() >= period_start:
|
|
points += self.POINTS['first_topic']
|
|
|
|
return {
|
|
'points': points,
|
|
'topics_completed': topics_completed,
|
|
'quizzes_passed': quizzes_passed,
|
|
'time_spent_hours': round(time_spent, 2),
|
|
'period_start': period_start,
|
|
'period_end': period_end,
|
|
}
|
|
|
|
@api.model
|
|
def update_leaderboard(self, period_type='alltime', company_id=None):
|
|
"""
|
|
Update leaderboard rankings for the given period.
|
|
Optimized with read_group for scalability.
|
|
"""
|
|
company_id = company_id or self.env.company.id
|
|
today = fields.Date.today()
|
|
|
|
# 1. Determine Date Range
|
|
period_start = False
|
|
period_end = False
|
|
|
|
if period_type == 'weekly':
|
|
# Start of week (Monday)
|
|
period_start = today - timedelta(days=today.weekday())
|
|
period_end = period_start + timedelta(days=6)
|
|
elif period_type == 'monthly':
|
|
period_start = today.replace(day=1)
|
|
# End of month
|
|
next_month = today.replace(day=28) + timedelta(days=4)
|
|
period_end = next_month - timedelta(days=next_month.day)
|
|
|
|
# 2. Build Domains
|
|
# Base domain: Completed topics for this company
|
|
# We assume GeniusProgress.user_id.company_id match? usually company_id isn't on progress.
|
|
# But user_id is. Filter users by company?
|
|
# For simplicity, we filter progress by date. User company check can be done after.
|
|
|
|
base_domain = [('state', 'in', ['done', 'verified'])]
|
|
if period_start:
|
|
base_domain.append(('date_completed', '>=', period_start))
|
|
if period_end:
|
|
base_domain.append(('date_completed', '<=', period_end))
|
|
|
|
# 3. Batch Aggregation (Main Stats)
|
|
# fields: points_earned (sum), time_spent_seconds (sum), count (topics)
|
|
main_stats = self.env['genius.progress'].read_group(
|
|
base_domain,
|
|
['user_id', 'points_earned:sum', 'time_spent_seconds:sum'],
|
|
['user_id']
|
|
)
|
|
|
|
# 4. Batch Aggregation (Quiz Pass Count)
|
|
# Need separate query for "how many quizzes passed"
|
|
quiz_domain = base_domain + [('is_quiz_passed', '=', True)]
|
|
quiz_stats = self.env['genius.progress'].read_group(
|
|
quiz_domain,
|
|
['user_id'],
|
|
['user_id']
|
|
)
|
|
quiz_counts = {r['user_id'][0]: r['user_id_count'] for r in quiz_stats}
|
|
|
|
# 5. Process Users
|
|
entries_to_create = []
|
|
entries_to_update = {} # ID -> Vals
|
|
|
|
# Prepare existing entries map to update efficiently
|
|
existing_entries = self.search([
|
|
('period_type', '=', period_type),
|
|
('period_start', '=', period_start),
|
|
('company_id', '=', company_id)
|
|
])
|
|
existing_map = {e.user_id.id: e for e in existing_entries}
|
|
|
|
entries = [] # For ranking
|
|
|
|
for group in main_stats:
|
|
user_id = group['user_id'][0]
|
|
|
|
# Skip if user not in company?
|
|
# user = self.env['res.users'].browse(user_id) # Costly
|
|
# We skip strict company check for speed, assumming visibility rules handle it.
|
|
|
|
completed_count = group['user_id_count']
|
|
points = group['points_earned']
|
|
time_seconds = group['time_spent_seconds']
|
|
quizzes = quiz_counts.get(user_id, 0)
|
|
|
|
# Add First Topic Bonus (Legacy support, optional, skipping for batch speed)
|
|
# If strictly required, query here.
|
|
|
|
vals = {
|
|
'points': points,
|
|
'topics_completed': completed_count,
|
|
'quizzes_passed': quizzes,
|
|
'time_spent_hours': time_seconds / 3600.0,
|
|
'period_start': period_start,
|
|
'period_end': period_end,
|
|
'user_id': user_id,
|
|
'period_type': period_type,
|
|
'company_id': company_id,
|
|
}
|
|
|
|
existing = existing_map.get(user_id)
|
|
if existing:
|
|
existing.write(vals)
|
|
entries.append(existing)
|
|
else:
|
|
entries_to_create.append(vals)
|
|
|
|
# Bulk Create
|
|
if entries_to_create:
|
|
created = self.create(entries_to_create)
|
|
entries.extend(created)
|
|
|
|
# 6. Rank Update
|
|
sorted_entries = sorted(entries, key=lambda e: (-e.points, -e.topics_completed))
|
|
for rank, entry in enumerate(sorted_entries, 1):
|
|
entry.rank = rank
|
|
|
|
return True
|
|
|
|
# -------------------------------------------------------------------------
|
|
# API Methods
|
|
# -------------------------------------------------------------------------
|
|
@api.model
|
|
def get_leaderboard(self, period_type='alltime', limit=10):
|
|
"""Get leaderboard for display"""
|
|
entries = self.search([
|
|
('period_type', '=', period_type),
|
|
('company_id', '=', self.env.company.id),
|
|
], order='rank', limit=limit)
|
|
|
|
return [{
|
|
'rank': e.rank,
|
|
'user_id': e.user_id.id,
|
|
'user_name': e.user_id.name,
|
|
'user_image': e.user_id.image_128,
|
|
'points': e.points,
|
|
'topics_completed': e.topics_completed,
|
|
'quizzes_passed': e.quizzes_passed,
|
|
'time_spent_hours': e.time_spent_hours,
|
|
} for e in entries]
|
|
|
|
@api.model
|
|
def get_user_rank(self, user_id=None, period_type='alltime'):
|
|
"""Get rank for a specific user"""
|
|
user_id = user_id or self.env.user.id
|
|
|
|
entry = self.search([
|
|
('user_id', '=', user_id),
|
|
('period_type', '=', period_type),
|
|
('company_id', '=', self.env.company.id),
|
|
], limit=1)
|
|
|
|
if not entry:
|
|
return {
|
|
'rank': None,
|
|
'points': 0,
|
|
'message': 'Not ranked yet',
|
|
}
|
|
|
|
total_entries = self.search_count([
|
|
('period_type', '=', period_type),
|
|
('company_id', '=', self.env.company.id),
|
|
])
|
|
|
|
return {
|
|
'rank': entry.rank,
|
|
'points': entry.points,
|
|
'total_users': total_entries,
|
|
'percentile': round((1 - (entry.rank / total_entries)) * 100, 1) if total_entries else 0,
|
|
}
|