odex25_standard/odex25_base/tour_genius/models/leaderboard.py

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,
}