Files
Odoo-Modules/fusion_clock/models/clock_location.py
gsinghpal acd3fc455e changes
2026-03-09 15:21:22 -04:00

272 lines
10 KiB
Python

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