diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index c6d62dfb..a04faf42 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Clock', - 'version': '19.0.3.11.6', + 'version': '19.0.3.11.7', 'category': 'Human Resources/Attendances', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'description': """ diff --git a/fusion_clock/data/ir_cron_data.xml b/fusion_clock/data/ir_cron_data.xml index 2a51456f..761455a3 100644 --- a/fusion_clock/data/ir_cron_data.xml +++ b/fusion_clock/data/ir_cron_data.xml @@ -61,4 +61,16 @@ 80 + + + Fusion Clock: Wipe Old Clock Photos + + code + model._cron_fusion_wipe_old_photos() + 1 + days + True + 65 + + diff --git a/fusion_clock/models/hr_attendance.py b/fusion_clock/models/hr_attendance.py index 2419bee0..0f953cbd 100644 --- a/fusion_clock/models/hr_attendance.py +++ b/fusion_clock/models/hr_attendance.py @@ -341,6 +341,57 @@ class HrAttendance(models.Model): att.id, str(e), ) + @api.model + def _cron_fusion_wipe_old_photos(self): + """Cron job: delete clock-in/out verification photos older than the + configured retention window (``fusion_clock.photo_retention_days``). + + Only the images are removed — the attendance records, worked hours and + penalties are kept. The photos are attachment-backed binary fields, so we + unlink the underlying ir.attachment rows directly, which reclaims the + filestore space. Set the retention to 0 to disable the wipe entirely.""" + ICP = self.env['ir.config_parameter'].sudo() + retention_days = int(ICP.get_param('fusion_clock.photo_retention_days', '60') or 0) + if retention_days <= 0: + return # 0 / unset → auto-wipe disabled + + cutoff = fields.Datetime.now() - timedelta(days=retention_days) + old_attendances = self.sudo().search([('check_in', '<', cutoff)]) + if not old_attendances: + return + + Attachment = self.env['ir.attachment'].sudo() + photo_fields = [ + 'x_fclk_check_in_photo', # NFC kiosk clock-in selfie + 'x_fclk_check_out_photo', # NFC kiosk clock-out selfie + 'x_fclk_checkin_photo', # legacy portal clock-in photo + ] + wiped = 0 + # Batch the attendances so the res_id IN (...) list stays bounded, and + # isolate each batch in a savepoint so one bad row can't abort the rest. + for offset in range(0, len(old_attendances), 500): + batch_ids = old_attendances[offset:offset + 500].ids + photos = Attachment.search([ + ('res_model', '=', 'hr.attendance'), + ('res_field', 'in', photo_fields), + ('res_id', 'in', batch_ids), + ]) + if not photos: + continue + try: + with self.env.cr.savepoint(): + count = len(photos) + photos.unlink() + wiped += count + except Exception as e: + _logger.error("Fusion Clock: Failed to wipe a photo batch: %s", e) + + if wiped: + _logger.info( + "Fusion Clock: Wiped %s clock verification photo(s) older than %s days.", + wiped, retention_days, + ) + @api.model def _cron_fusion_check_absences(self): """Cron job: check for absent employees (no attendance on workday).""" diff --git a/fusion_clock/models/res_config_settings.py b/fusion_clock/models/res_config_settings.py index 3518f0da..df6b9fb5 100644 --- a/fusion_clock/models/res_config_settings.py +++ b/fusion_clock/models/res_config_settings.py @@ -268,6 +268,15 @@ class ResConfigSettings(models.TransientModel): help="Which clock location is bound to the NFC kiosk for this company. " "Required when the kiosk is enabled.", ) + fclk_photo_retention_days = fields.Integer( + string='Auto-Wipe Photos After (days)', + config_parameter='fusion_clock.photo_retention_days', + default=60, + help="Clock-in/out verification photos older than this many days are deleted " + "automatically by a daily cron. The attendance record, worked hours and " + "penalties are kept — only the images are removed, reclaiming storage. " + "Set to 0 to disable the auto-wipe.", + ) def set_values(self): super().set_values() diff --git a/fusion_clock/tests/__init__.py b/fusion_clock/tests/__init__.py index dffa6faa..01994245 100644 --- a/fusion_clock/tests/__init__.py +++ b/fusion_clock/tests/__init__.py @@ -3,3 +3,4 @@ from . import test_nfc_models from . import test_clock_nfc_kiosk from . import test_shift_planner +from . import test_photo_retention diff --git a/fusion_clock/tests/test_photo_retention.py b/fusion_clock/tests/test_photo_retention.py new file mode 100644 index 00000000..42d1ff56 --- /dev/null +++ b/fusion_clock/tests/test_photo_retention.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import base64 +from datetime import timedelta + +from odoo import fields +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestPhotoRetention(TransactionCase): + """The daily wipe cron must delete verification photos older than the + retention window while keeping the attendance records (and recent photos).""" + + def setUp(self): + super().setUp() + self.Attendance = self.env['hr.attendance'] + self.ICP = self.env['ir.config_parameter'].sudo() + self.emp = self.env['hr.employee'].create({'name': 'Retention Test Emp'}) + # x_fclk_check_*_photo are plain Binary fields (no image validation), + # so arbitrary base64 bytes are fine for the test. + self.photo = base64.b64encode(b'pretend-jpeg-bytes') + self.ICP.set_param('fusion_clock.photo_retention_days', '60') + + def _make_attendance(self, days_ago): + start = fields.Datetime.now() - timedelta(days=days_ago) + att = self.Attendance.create({ + 'employee_id': self.emp.id, + 'check_in': start, + 'check_out': start + timedelta(hours=8), + }) + att.x_fclk_check_in_photo = self.photo + att.x_fclk_check_out_photo = self.photo + return att + + def test_old_photos_wiped_recent_kept(self): + old = self._make_attendance(days_ago=70) + recent = self._make_attendance(days_ago=5) + self.assertTrue(old.x_fclk_check_in_photo) + self.assertTrue(recent.x_fclk_check_in_photo) + + self.Attendance._cron_fusion_wipe_old_photos() + self.env.invalidate_all() # binary fields are cached; re-read from DB + + # Old shift: both photos gone, but the record + hours survive. + self.assertFalse(old.x_fclk_check_in_photo, "Old check-in photo should be wiped") + self.assertFalse(old.x_fclk_check_out_photo, "Old check-out photo should be wiped") + self.assertTrue(old.exists(), "Attendance record itself must be kept") + self.assertGreater(old.worked_hours, 0.0, "Worked hours must be preserved") + + # Recent shift: photo untouched. + self.assertTrue(recent.x_fclk_check_in_photo, "Recent photo should be kept") + + def test_zero_retention_disables_wipe(self): + self.ICP.set_param('fusion_clock.photo_retention_days', '0') + old = self._make_attendance(days_ago=200) + + self.Attendance._cron_fusion_wipe_old_photos() + self.env.invalidate_all() + + self.assertTrue(old.x_fclk_check_in_photo, "Retention=0 must disable the wipe") diff --git a/fusion_clock/views/res_config_settings_views.xml b/fusion_clock/views/res_config_settings_views.xml index 122a587c..f0bafb35 100644 --- a/fusion_clock/views/res_config_settings_views.xml +++ b/fusion_clock/views/res_config_settings_views.xml @@ -258,6 +258,10 @@