# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import ipaddress import json import logging import requests from odoo import models, fields, api, _ from odoo.exceptions import UserError from odoo.http import request as http_request _logger = logging.getLogger(__name__) class FusionClockLocation(models.Model): _name = 'fusion.clock.location' _description = 'Clock-In Location' _order = 'sequence, name' _rec_name = 'name' name = fields.Char(string='Location Name', required=True) address = fields.Char(string='Address') latitude = fields.Float(string='Latitude', digits=(10, 7)) longitude = fields.Float(string='Longitude', digits=(10, 7)) radius = fields.Integer( string='Radius (meters)', default=100, help="Geofence radius in meters. Employees must be within this distance to clock in/out.", ) sequence = fields.Integer(default=10) company_id = fields.Many2one( 'res.company', string='Company', default=lambda self: self.env.company, required=True, ) active = fields.Boolean(default=True) employee_ids = fields.Many2many( 'hr.employee', 'fusion_clock_location_employee_rel', 'location_id', 'employee_id', string='Allowed Employees', ) all_employees = fields.Boolean( string='All Employees', default=True, help="If checked, all employees can clock in/out at this location.", ) color = fields.Char(string='Map Color', default='#10B981') note = fields.Text(string='Notes') timezone = fields.Selection( '_tz_get', string='Timezone', default=lambda self: self.env.user.tz or 'UTC', ) # IP whitelist fallback ip_whitelist = fields.Text( string='IP Whitelist', help="One IP address or CIDR per line. Used as fallback when GPS is unavailable.", ) # Photo verification require_photo = fields.Boolean( string='Require Photo on Clock-In', default=False, help="If enabled, employees must take a selfie when clocking in at this location.", ) # Computed attendance_count = fields.Integer( string='Total Attendances', compute='_compute_attendance_count', ) map_url = fields.Char( string='Map Preview URL', compute='_compute_map_url', ) @api.model def _tz_get(self): import pytz return [(tz, tz) for tz in sorted(pytz.all_timezones_set)] @api.depends('latitude', 'longitude', 'radius') def _compute_map_url(self): api_key = self.env['ir.config_parameter'].sudo().get_param('fusion_clock.google_maps_api_key', '') for rec in self: if rec.latitude and rec.longitude and api_key: rec.map_url = ( f"https://maps.googleapis.com/maps/api/staticmap?" f"center={rec.latitude},{rec.longitude}&zoom=16&size=600x300&maptype=roadmap" f"&markers=color:green%7C{rec.latitude},{rec.longitude}" f"&key={api_key}" ) else: rec.map_url = False def _compute_attendance_count(self): for rec in self: rec.attendance_count = self.env['hr.attendance'].search_count([ ('x_fclk_location_id', '=', rec.id), ]) def check_ip_whitelist(self, client_ip): """Check if a client IP matches this location's whitelist. Returns True if matched, False otherwise. """ if not self.ip_whitelist or not client_ip: return False try: client = ipaddress.ip_address(client_ip) for line in self.ip_whitelist.strip().split('\n'): line = line.strip() if not line or line.startswith('#'): continue try: network = ipaddress.ip_network(line, strict=False) if client in network: return True except ValueError: continue except ValueError: return False return False def action_detect_ip(self): """Detect the IP the Odoo server sees from your browser and add it.""" self.ensure_one() ip = '' if http_request: ip = http_request.httprequest.environ.get( 'HTTP_X_FORWARDED_FOR', '' ).split(',')[0].strip() if not ip: ip = http_request.httprequest.remote_addr or '' if not ip: raise UserError(_("Could not detect your IP from the request.")) existing = (self.ip_whitelist or '').strip() existing_lines = [l.strip() for l in existing.split('\n') if l.strip()] if existing else [] if ip in existing_lines: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Already Whitelisted'), 'message': _('IP %s is already in the whitelist.') % ip, 'type': 'warning', 'sticky': False, }, } existing_lines.append(ip) self.ip_whitelist = '\n'.join(existing_lines) extra = '' try: resp = requests.get(f'https://ipapi.co/{ip}/json/', timeout=5) if resp.ok: data = resp.json() city = data.get('city', '') org = data.get('org', '') if city or org: extra = f" ({', '.join(filter(None, [city, org]))})" except Exception: pass return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('IP Detected & Added'), 'message': _('Added %s%s to the whitelist. This is the IP the server sees from your browser.') % (ip, extra), 'type': 'success', 'sticky': False, }, } def action_geocode_address(self): """Geocode the address to get lat/lng using Google Geocoding API. Falls back to Nominatim (OpenStreetMap) if Google fails. """ self.ensure_one() if not self.address: raise UserError(_("Please enter an address first.")) api_key = self.env['ir.config_parameter'].sudo().get_param('fusion_clock.google_maps_api_key', '') if api_key: try: url = 'https://maps.googleapis.com/maps/api/geocode/json' params = {'address': self.address, 'key': api_key} response = requests.get(url, params=params, timeout=10) data = response.json() if data.get('status') == 'OK' and data.get('results'): location = data['results'][0]['geometry']['location'] self.write({ 'latitude': location['lat'], 'longitude': location['lng'], }) formatted = data['results'][0].get('formatted_address', '') if formatted: self.address = formatted return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Geocoding Successful'), 'message': _('Location set to: %s, %s') % (location['lat'], location['lng']), 'type': 'success', 'sticky': False, }, } elif data.get('status') == 'REQUEST_DENIED': _logger.warning("Google Geocoding API denied. Falling back to Nominatim.") else: _logger.warning("Google geocoding returned: %s. Trying Nominatim.", data.get('status')) except requests.exceptions.RequestException as e: _logger.warning("Google geocoding network error: %s. Trying Nominatim.", e) try: url = 'https://nominatim.openstreetmap.org/search' params = { 'q': self.address, 'format': 'json', 'limit': 1, } headers = {'User-Agent': 'FusionClock/1.0 (Odoo Module)'} response = requests.get(url, params=params, headers=headers, timeout=10) data = response.json() if data and len(data) > 0: lat = float(data[0]['lat']) lng = float(data[0]['lon']) display_name = data[0].get('display_name', '') self.write({ 'latitude': lat, 'longitude': lng, }) if display_name: self.address = display_name return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Geocoding Successful (via OpenStreetMap)'), 'message': _('Location set to: %s, %s') % (lat, lng), 'type': 'success', 'sticky': False, }, } else: raise UserError(_("Could not geocode address. No results found.")) except requests.exceptions.RequestException as e: raise UserError(_("Network error during geocoding: %s") % str(e)) def action_view_attendances(self): """Open attendance records for this location.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Attendances at %s') % self.name, 'res_model': 'hr.attendance', 'view_mode': 'list,form', 'domain': [('x_fclk_location_id', '=', self.id)], 'context': {'default_x_fclk_location_id': self.id}, }