feat(fusion_clock): auto-wipe clock-in/out photos after a retention window
Privacy/space housekeeping for the kiosk verification selfies. A new daily cron
(_cron_fusion_wipe_old_photos) deletes the photo attachments on attendances
whose clock-in is older than fusion_clock.photo_retention_days (default 60).
Only the images are removed — attendance records, worked hours and penalties
are kept. Clearing the attachment-backed binary reclaims filestore space.
- Configurable in Settings → Fusion Clock → NFC Kiosk ("Auto-Wipe Photos After
(days)"); set 0 to disable.
- Wipes all three photo fields (NFC check-in/out + legacy portal photo),
batched with per-batch savepoints.
- tests/test_photo_retention.py covers wipe-old / keep-recent / retention=0.
Verified live on entech (19.0.3.11.7) via a rollback-only dry run: a 70-day
shift's photos were wiped (record + 8h hours preserved) while a 5-day shift's
photo was kept; nothing persisted. 0 attendances currently exceed 60 days.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.3.11.6',
|
'version': '19.0.3.11.7',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -61,4 +61,16 @@
|
|||||||
<field name="priority">80</field>
|
<field name="priority">80</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Photo Wipe Cron: runs daily, deletes clock photos past the retention window -->
|
||||||
|
<record id="cron_wipe_old_photos" model="ir.cron">
|
||||||
|
<field name="name">Fusion Clock: Wipe Old Clock Photos</field>
|
||||||
|
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_fusion_wipe_old_photos()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="priority">65</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -341,6 +341,57 @@ class HrAttendance(models.Model):
|
|||||||
att.id, str(e),
|
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
|
@api.model
|
||||||
def _cron_fusion_check_absences(self):
|
def _cron_fusion_check_absences(self):
|
||||||
"""Cron job: check for absent employees (no attendance on workday)."""
|
"""Cron job: check for absent employees (no attendance on workday)."""
|
||||||
|
|||||||
@@ -268,6 +268,15 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
help="Which clock location is bound to the NFC kiosk for this company. "
|
help="Which clock location is bound to the NFC kiosk for this company. "
|
||||||
"Required when the kiosk is enabled.",
|
"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):
|
def set_values(self):
|
||||||
super().set_values()
|
super().set_values()
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
from . import test_nfc_models
|
from . import test_nfc_models
|
||||||
from . import test_clock_nfc_kiosk
|
from . import test_clock_nfc_kiosk
|
||||||
from . import test_shift_planner
|
from . import test_shift_planner
|
||||||
|
from . import test_photo_retention
|
||||||
|
|||||||
64
fusion_clock/tests/test_photo_retention.py
Normal file
64
fusion_clock/tests/test_photo_retention.py
Normal file
@@ -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")
|
||||||
@@ -258,6 +258,10 @@
|
|||||||
<label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
|
<label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
|
||||||
<field name="fclk_nfc_photo_required"/>
|
<field name="fclk_nfc_photo_required"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt8">
|
||||||
|
<label for="fclk_photo_retention_days" string="Auto-Wipe Photos After (days)" class="col-lg-5 o_light_label"/>
|
||||||
|
<field name="fclk_photo_retention_days"/>
|
||||||
|
</div>
|
||||||
<div class="row mt8">
|
<div class="row mt8">
|
||||||
<label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
|
<label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
|
||||||
<field name="fclk_nfc_enroll_password" password="True"/>
|
<field name="fclk_nfc_enroll_password" password="True"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user