import base64 import datetime import pytz from datetime import datetime from odoo import http, _,fields from odoo.http import request from odoo.addons.portal.controllers.portal import CustomerPortal from odoo.exceptions import AccessError, ValidationError import logging _logger = logging.getLogger(__name__) class CppPortal(CustomerPortal): @http.route(['/my/cpp_entries'], type='http', auth="user", website=True) def portal_cpp_entries(self, **kw): """List all CPP entries for current portal user""" domain = [('create_uid', '=', request.env.user.id)] entries = request.env['cpp.entry'].search(domain, order='create_date desc') return request.render('cpp_entry.portal_my_cpp_entries', { 'entries': entries, 'page_name': 'cpp_entries', 'error': kw.get('error'), }) @http.route(['/my/cpp_entries/new', '/my/cpp_entries/'], type='http', auth="user", website=True) def portal_cpp_form(self, entry_id=None, **kw): if entry_id: entry = request.env['cpp.entry'].browse(int(entry_id)) if not entry.exists(): return request.redirect('/my/cpp_entries?error=Entry+not+found') if entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'): raise AccessError(_("Access denied")) else: entry = request.env['cpp.entry'] # Load provinces provinces = request.env['address.address'].sudo().search([ ('parent_location', '=', False) ], order='location_name asc') # Load districts for selected province districts = request.env['address.address'] if entry.province_id: districts = request.env['address.address'].sudo().search([ ('parent_location', '=', entry.province_id.id) ], order='location_name asc') # Load communes for selected district communes = request.env['address.address'] if entry.district_id: communes = request.env['address.address'].sudo().search([ ('parent_location', '=', entry.district_id.id) ], order='location_name asc') # SEPARATE VOTERS BY STATUS voters_not_voted = request.env['info.voter'] voters_voted = request.env['info.voter'] if entry and entry.exists(): # Get all voters for this entry all_voters = request.env['info.voter'].search([ ('cpp_entry', '=', entry.id) ], order='name asc') # Separate into two lists voters_not_voted = all_voters.filtered(lambda v: not v.status_vote) voters_voted = all_voters.filtered(lambda v: v.status_vote) return request.render('cpp_entry.portal_cpp_form', { 'entry': entry, 'provinces': provinces, 'districts': districts, 'communes': communes, 'voters_not_voted': voters_not_voted, # Pass to template 'voters_voted': voters_voted, # Pass to template 'page_name': 'cpp_entries', }) @http.route(['/my/cpp_entries/submit'], type='http', auth="user", website=True, csrf=True, methods=['POST']) def submit_cpp_entry(self, **post): """Handle main entry form submission""" entry_id = post.get('entry_id') try: # ✅ Validation: Ensure Province is selected if not post.get('province_id') or not str(post['province_id']).isdigit(): return request.redirect('/my/cpp_entries/new?error=Please+select+a+Province+(ខេត្ដ/ក្រុង)') vals = {} if post.get('province_id') and str(post['province_id']).isdigit(): vals['province_id'] = int(post['province_id']) if post.get('district_id') and str(post['district_id']).isdigit(): vals['district_id'] = int(post['district_id']) if post.get('commune_id') and str(post['commune_id']).isdigit(): vals['commune_id'] = int(post['commune_id']) # ✅ Handle al_office field from model if post.get('al_office'): vals['al_office'] = post.get('al_office').strip() if entry_id and str(entry_id) != '0' and str(entry_id).isdigit(): entry = request.env['cpp.entry'].browse(int(entry_id)) if not entry.exists(): return request.redirect('/my/cpp_entries?error=Entry+not+found') entry.write(vals) else: entry = request.env['cpp.entry'].create(vals) return request.redirect(f'/my/cpp_entries/{entry.id}') except Exception as e: error_msg = str(e).replace(' ', '+') return request.redirect(f'/my/cpp_entries?error={error_msg}') @http.route(['/my/cpp_entries/voter/new'], type='http', auth="user", website=True) def portal_voter_new(self, entry_id=0, status_vote=0, **kw): """Show form to add new voter""" try: entry_id = int(entry_id) if entry_id else 0 except (ValueError, TypeError): entry_id = 0 entry = request.env['cpp.entry'].browse(entry_id) if entry_id else None if not entry or not entry.exists(): return request.redirect('/my/cpp_entries?error=Please+create+CPP+entry+first') if entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'): raise AccessError(_("Access denied")) genders = [] try: if 'gender.gender' in request.env: genders = request.env['gender.gender'].search([], order='name') except Exception: pass return request.render('cpp_entry.portal_voter_form', { 'entry': entry, 'voter': request.env['info.voter'], 'genders': genders, 'status_vote': int(status_vote), 'page_name': 'cpp_entries', }) @http.route(['/my/cpp_entries/voter//edit'], type='http', auth="user", website=True) def portal_voter_edit(self, voter_id, **kw): """Show form to edit existing voter""" voter = request.env['info.voter'].browse(voter_id) if not voter.exists(): return request.redirect('/my/cpp_entries?error=Voter+not+found') if voter.cpp_entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'): raise AccessError(_("Access denied")) genders = [] try: if 'gender.gender' in request.env: genders = request.env['gender.gender'].search([], order='name') except Exception: pass return request.render('cpp_entry.portal_voter_form', { 'entry': voter.cpp_entry, 'voter': voter, 'genders': genders, 'status_vote': 1 if voter.status_vote else 0, 'page_name': 'cpp_entries', }) @http.route(['/my/cpp_entries/voter/submit'], type='http', auth="user", website=True, csrf=True, methods=['POST']) def submit_voter(self, **post): """Handle voter form submission with strict validation.""" entry_id = post.get('entry_id', '') voter_id = post.get('voter_id', '0') try: raw_status = post.get('status_vote') if raw_status: # Convert to string, make lowercase, and check if it matches "checked" values is_voted = str(raw_status).lower() in ('on', '1', 'true', 'yes') else: # If unchecked, the form sends nothing (None or empty string) is_voted = False # 1. VALIDATE ENTRY ID if not entry_id or not str(entry_id).isdigit(): raise ValueError("Invalid or missing Entry ID. Cannot save voter.") entry = request.env['cpp.entry'].browse(int(entry_id)) if not entry.exists(): raise ValueError("CPP Entry not found.") if entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'): raise AccessError(_("Permission denied")) # 2. VALIDATE NAME (Matches @api.constrains in model) name = post.get('name', '').strip() if len(name) < 2: raise ValidationError(_("ឈ្មោះត្រូវមានយ៉ាងតិច ២ តួអក្សរ (Name must be at least 2 characters)")) vals = { 'cpp_entry': entry.id, 'name': name, 'address': post.get('address', '').strip(), 'phone': post.get('phone', '').strip(), 'status_vote': is_voted, } # 3. SAFE GENDER CONVERSION gender_id = post.get('gender', '') if gender_id and str(gender_id).isdigit(): vals['gender'] = int(gender_id) # 4. DATE OF BIRTH (Optional) dob = post.get('dob') if dob: vals['dob'] = dob # 5. PHOTO UPLOAD / DELETION photo = request.httprequest.files.get('photo') if photo and photo.filename: # Check file extension ext = photo.filename.rsplit('.', 1)[-1].lower() if ext not in ('jpg', 'jpeg', 'png', 'gif', 'bmp'): raise ValidationError(_("Invalid file type. Only images allowed.")) # Check file size (2MB max) photo.seek(0, 2) # Seek to end size = photo.tell() # Get size photo.seek(0) # Reset to beginning if size > 2 * 1024 * 1024: # 2MB raise ValidationError(_("File size exceeds 2MB limit.")) # Read and encode photo_data = photo.read() vals['photo'] = base64.b64encode(photo_data) # Debug: Log if photo is being saved _logger.info(f"Photo uploaded: {photo.filename}, Size: {size} bytes") # 6. CREATE OR UPDATE if voter_id and str(voter_id) != '0' and str(voter_id).isdigit(): voter = request.env['info.voter'].browse(int(voter_id)) if not voter.exists(): raise ValueError("Voter not found.") voter.write(vals) else: request.env['info.voter'].create(vals) return request.redirect(f'/my/cpp_entries/{entry.id}') except Exception as e: error_msg = str(e).replace(' ', '+') # Redirect back to the entry page with the error message if entry_id and str(entry_id).isdigit(): return request.redirect(f'/my/cpp_entries/{entry_id}?error={error_msg}') return request.redirect(f'/my/cpp_entries?error={error_msg}') @http.route(['/my/cpp_entries/voter//delete'], type='http', auth="user", website=True, csrf=True) def delete_voter(self, voter_id, **kw): """Delete a voter""" voter = request.env['info.voter'].browse(voter_id) if not voter.exists(): return request.redirect('/my/cpp_entries?error=Voter+not+found') entry = voter.cpp_entry if entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'): raise AccessError(_("Permission denied")) voter.unlink() return request.redirect(f'/my/cpp_entries/{entry.id}') @http.route(['/cpp_entry/get_districts'], type='http', auth='user', website=True) def get_districts(self, province_id, **kw): if not province_id or not str(province_id).isdigit(): return request.make_json_response([]) districts = request.env['address.address'].sudo().search([ ('parent_location', '=', int(province_id)) ], order='location_name asc') result = [{'id': d.id, 'name': d.location_name or d.name} for d in districts] return request.make_json_response(result) @http.route(['/cpp_entry/get_communes'], type='http', auth='user', website=True) def get_communes(self, district_id, **kw): if not district_id or not str(district_id).isdigit(): return request.make_json_response([]) communes = request.env['address.address'].sudo().search([ ('parent_location', '=', int(district_id)) ], order='location_name asc') result = [{'id': c.id, 'name': c.location_name or c.name} for c in communes] return request.make_json_response(result) @http.route(['/my/cpp_entries/voter//toggle_vote'], type='http', auth="user", website=True, csrf=True) def toggle_voter_status(self, voter_id, **kw): """Quick toggle for status_vote""" voter = request.env['info.voter'].browse(voter_id) if not voter.exists(): return request.redirect('/my/cpp_entries?error=Voter+not+found') # Security check if voter.cpp_entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'): raise AccessError(_("Permission denied")) # Toggle the boolean value voter.write({'status_vote': not voter.status_vote}) # Redirect back to the entry page return request.redirect(f'/my/cpp_entries/{voter.cpp_entry.id}') @http.route(['/my/cpp_entries/dashboard'], type='http', auth="user", website=True) def cpp_dashboard(self, **kw): user = request.env.user # ✅ FIX: Get correct local datetime utc_now = datetime.utcnow() user_tz = request.env.user.tz or 'UTC' tz = pytz.timezone(user_tz) current_dt = pytz.utc.localize(utc_now).astimezone(tz) # 1. BASE DOMAIN domain = [] if not user.has_group('base.group_system'): domain.append(('create_uid', '=', user.id)) # 2. GET FILTER VALUES province_id = kw.get('province_id') district_id = kw.get('district_id') commune_id = kw.get('commune_id') al_office = kw.get('al_office', '').strip() company_id = kw.get('company_id') # 3. APPLY FILTERS TO DOMAIN if province_id and province_id.isdigit(): domain.append(('province_id', '=', int(province_id))) if district_id and district_id.isdigit(): domain.append(('district_id', '=', int(district_id))) if commune_id and commune_id.isdigit(): domain.append(('commune_id', '=', int(commune_id))) if al_office: domain.append(('al_office', 'ilike', al_office)) if company_id and company_id.isdigit(): domain.append(('company_id', '=', int(company_id))) all_entries = request.env['cpp.entry'].sudo().search(domain) # 4. DATA AGGREGATION (Same logic as before) hours = list(range(8, 16)) commune_data = {} totals = { 'voters': 0, 'voted': 0, 'members': 0, 'members_voted': 0, 'non_members': 0, 'non_members_voted': 0 } def safe_pct(num, den): return (num / den * 100) if den > 0 else 0.0 for entry in all_entries: voters = entry.info_ids commune_name = entry.commune_id.location_name if entry.commune_id else "មិនកំណត់" if commune_name not in commune_data: commune_data[commune_name] = { 'total': 0, 'voted': 0, 'members': 0, 'members_voted': 0, 'non_members': 0, 'non_members_voted': 0, 'hourly': {h: 0 for h in hours} } voted_voters = voters.filtered(lambda v: v.status_vote) members = voters.filtered(lambda v: v.status == '1') non_members = voters.filtered(lambda v: v.status == '2') members_voted = members.filtered(lambda v: v.status_vote) non_members_voted = non_members.filtered(lambda v: v.status_vote) c = commune_data[commune_name] c['total'] += len(voters) c['voted'] += len(voted_voters) c['members'] += len(members) c['members_voted'] += len(members_voted) c['non_members'] += len(non_members) c['non_members_voted'] += len(non_members_voted) totals['voters'] += len(voters) totals['voted'] += len(voted_voters) totals['members'] += len(members) totals['members_voted'] += len(members_voted) totals['non_members'] += len(non_members) totals['non_members_voted'] += len(non_members_voted) for v in voted_voters: if v.create_date: h = v.create_date.hour if h in hours: c['hourly'][h] += 1 # Calculate percentages totals['members_pct'] = safe_pct(totals['members_voted'], totals['members']) totals['non_members_pct'] = safe_pct(totals['non_members_voted'], totals['non_members']) for data in commune_data.values(): data['pct'] = safe_pct(data['voted'], data['total']) data['members_pct'] = safe_pct(data['members_voted'], data['members']) data['non_members_pct'] = safe_pct(data['non_members_voted'], data['non_members']) cum = 0 for h in hours: cum += data['hourly'][h] data['hourly'][h] = { 'count': data['hourly'][h], 'cum': cum, 'pct': safe_pct(cum, data['total']) } overall_pct = safe_pct(totals['voted'], totals['voters']) # 5. PREPARE DROPDOWN DATA # Provinces (Top level addresses) provinces = request.env['address.address'].sudo().search( [('parent_location', '=', False)], order='location_name asc' ) # Districts (Children of selected province) districts = request.env['address.address'] if province_id and province_id.isdigit(): districts = request.env['address.address'].sudo().search( [('parent_location', '=', int(province_id))], order='location_name asc' ) else: # If no province selected, show all districts (optional, or keep empty) # districts = request.env['address.address'].sudo().search([('parent_location', '!=', False)]) pass # Communes (Children of selected district) communes = request.env['address.address'] if district_id and district_id.isdigit(): communes = request.env['address.address'].sudo().search( [('parent_location', '=', int(district_id))], order='location_name asc' ) # Companies companies = request.env['res.company'].sudo().search([], order='name asc') return request.render('cpp_entry.cpp_dashboard', { 'current_dt': current_dt, 'hours': hours, 'commune_data': commune_data, 'totals': totals, 'overall_pct': overall_pct, 'provinces': provinces, 'districts': districts, 'communes': communes, 'companies': companies, 'sel_province': province_id, 'sel_district': district_id, 'sel_commune': commune_id, 'sel_al_office': al_office, 'sel_company': company_id, 'page_name': 'cpp_dashboard', }) @http.route(['/my/cpp_entries/party_list'], type='http', auth="user", website=True) def party_list(self, **kw): """List all parties with voter counts""" user = request.env.user current_dt = datetime.now() # Get user's timezone user_tz = request.env.user.tz or 'UTC' tz = pytz.timezone(user_tz) current_dt = pytz.utc.localize(current_dt).astimezone(tz) # Base domain domain = [] if not user.has_group('base.group_system'): domain.append(('create_uid', '=', user.id)) # Apply location filters district_id = kw.get('district_id') commune_id = kw.get('commune_id') if district_id and district_id.isdigit(): domain.append(('district_id', '=', int(district_id))) if commune_id and commune_id.isdigit(): domain.append(('commune_id', '=', int(commune_id))) all_entries = request.env['cpp.entry'].sudo().search(domain) # Aggregate voters by party/status party_stats = {} total_voters = 0 for entry in all_entries: voters = entry.info_ids for voter in voters: # Determine party if voter.status == '1': party_name = "គណបក្សប្រជាជនកម្ពុជា (CPP)" party_key = 'cpp' elif voter.status == '2': party_name = "មិនមែនសមាជិកគណបក្ស" party_key = 'non_member' else: party_name = "មិនកំណត់" party_key = 'unknown' if party_key not in party_stats: party_stats[party_key] = { 'name': party_name, 'total': 0, 'voted': 0, 'not_voted': 0, 'members': 0, 'male': 0, 'female': 0 } stats = party_stats[party_key] stats['total'] += 1 if voter.status_vote: stats['voted'] += 1 else: stats['not_voted'] += 1 if voter.gender: if voter.gender.name == 'ប្រុស': stats['male'] += 1 elif voter.gender.name == 'ស្រី': stats['female'] += 1 total_voters += 1 # Get location filters for template districts = request.env['address.address'].sudo().search( [('parent_location', '=', False)], order='location_name asc' ) communes = request.env['address.address'] if district_id and district_id.isdigit(): communes = request.env['address.address'].sudo().search( [('parent_location', '=', int(district_id))], order='location_name asc' ) return request.render('cpp_entry.party_list', { 'current_dt': current_dt, 'party_stats': party_stats, 'total_voters': total_voters, 'districts': districts, 'communes': communes, 'sel_district': district_id, 'sel_commune': commune_id, 'page_name': 'party_list', }) @http.route(['/my/cpp_entries/party_voters/'], type='http', auth="user", website=True) def party_voters_detail(self, party_key, **kw): """Show detailed list of voters for a specific party""" user = request.env.user current_dt = datetime.now() # Get user's timezone user_tz = request.env.user.tz or 'UTC' tz = pytz.timezone(user_tz) current_dt = pytz.utc.localize(current_dt).astimezone(tz) # Base domain domain = [] if not user.has_group('base.group_system'): domain.append(('create_uid', '=', user.id)) # Apply location filters district_id = kw.get('district_id') commune_id = kw.get('commune_id') if district_id and district_id.isdigit(): domain.append(('district_id', '=', int(district_id))) if commune_id and commune_id.isdigit(): domain.append(('commune_id', '=', int(commune_id))) all_entries = request.env['cpp.entry'].sudo().search(domain) # Collect voters by party voters_list = [] party_name = "" for entry in all_entries: for voter in entry.info_ids: # Determine party if voter.status == '1': voter_party = 'cpp' voter_party_name = "គណបក្សប្រជាជនកម្ពុជា (CPP)" elif voter.status == '2': voter_party = 'non_member' voter_party_name = "មិនមែនសមាជិកគណបក្ស" else: voter_party = 'unknown' voter_party_name = "មិនកំណត់" # Filter by requested party if voter_party == party_key: party_name = voter_party_name voters_list.append({ 'id': voter.id, 'name': voter.name, 'gender': voter.gender.name if voter.gender else '-', 'dob': voter.dob, 'phone': voter.phone, 'address': voter.address, 'status_vote': voter.status_vote, 'commune': entry.commune_id.location_name if entry.commune_id else '-', 'district': entry.district_id.location_name if entry.district_id else '-', }) # Sort by name voters_list.sort(key=lambda x: x['name']) # Statistics total = len(voters_list) voted = len([v for v in voters_list if v['status_vote']]) not_voted = total - voted male = len([v for v in voters_list if v['gender'] == 'ប្រុស']) female = len([v for v in voters_list if v['gender'] == 'ស្រី']) # Get location filters districts = request.env['address.address'].sudo().search( [('parent_location', '=', False)], order='location_name asc' ) communes = request.env['address.address'] if district_id and district_id.isdigit(): communes = request.env['address.address'].sudo().search( [('parent_location', '=', int(district_id))], order='location_name asc' ) return request.render('cpp_entry.party_voters_detail', { 'current_dt': current_dt, 'party_key': party_key, 'party_name': party_name, 'voters_list': voters_list, 'total': total, 'voted': voted, 'not_voted': not_voted, 'male': male, 'female': female, 'districts': districts, 'communes': communes, 'sel_district': district_id, 'sel_commune': commune_id, 'page_name': 'party_voters', })