diff --git a/requirements.txt b/requirements.txt index d0bf4eae..5f7a0aa4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ pyproj python-dateutil python-magic python-multipart +pytz qrcode requests schwifty diff --git a/spp_attendance/README.rst b/spp_attendance/README.rst new file mode 100644 index 00000000..bf5d3456 --- /dev/null +++ b/spp_attendance/README.rst @@ -0,0 +1,245 @@ +================== +OpenSPP Attendance +================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f14cdc55cd485da151349989e21bdafb68dcad327eef90ac39756a284a0d3779 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_attendance + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Tracks participant attendance for social protection program activities. +Records presence/absence with date, time, location, and activity type. +Provides OAuth-secured API endpoints for external systems to submit +attendance data and import participants from external registries. + +**Positioning vs ``spp_session_tracking``:** this module is a +standalone, API-first attendance service — it keeps its own participant +registry (synced from an external registry) and receives attendance +events from external systems over REST, so it can run on a bare instance +without the program stack. ``spp_session_tracking`` covers the +complementary case: attendance at sessions and trainings managed +*inside* a program instance. The two do not share models and can +coexist. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Record attendance with date, time, type, location, and category + (present/absent) +- Query attendance history via ``get_attendance_list()`` with filtering + by date range, type, and location +- Import participants from external registries via configurable field + mapping +- Generate OAuth client credentials for external API access +- Enforce configurable uniqueness constraints (date/time/type/location) + to prevent duplicate records + +Key Models +~~~~~~~~~~ + ++------------------------------------------+----------------------------------+ +| Model | Description | ++==========================================+==================================+ +| ``spp.attendance.subscriber`` | Participant registry, inherits | +| | res.partner | ++------------------------------------------+----------------------------------+ +| ``spp.attendance.list`` | Attendance record with date, | +| | time, type, location | ++------------------------------------------+----------------------------------+ +| ``spp.attendance.type`` | Configurable attendance event | +| | types | ++------------------------------------------+----------------------------------+ +| ``spp.attendance.location`` | Configurable attendance | +| | locations | ++------------------------------------------+----------------------------------+ +| ``spp.attendance.api.client.credential`` | OAuth credentials for external | +| | API clients | ++------------------------------------------+----------------------------------+ +| ``spp.import.attendance.wizard`` | Wizard for importing from | +| | external registries | ++------------------------------------------+----------------------------------+ + +Configuration +~~~~~~~~~~~~~ + +After installing: + +1. Navigate to **Settings > SPP Attendance Settings** to configure: + + - Uniqueness constraints (date/time/type/location) + - Server URL and API endpoints + - Field mappings for import (personal information, identifiers, + contact details) + +2. Create attendance types: **Attendance > Configuration > Attendance + Type** +3. Create locations: **Attendance > Configuration > Attendance + Location** +4. Generate OAuth credentials: **Settings > Attendance API Client + Credentials** + +UI Location +~~~~~~~~~~~ + +- **Main Menu**: Attendance > Subscriber +- **Configuration**: Attendance > Configuration (Attendance Type, + Attendance Location) +- **API Credentials**: Settings > Attendance API Client Credentials +- **Settings**: Settings > SPP Attendance Settings +- **Subscriber Form**: Contains "Attendance" and "Person Information" + tabs + +Security +~~~~~~~~ + +=========================================== ========= +Group Access +=========================================== ========= +``spp_attendance.group_attendance_viewer`` Read +``spp_attendance.group_attendance_manager`` Full CRUD +=========================================== ========= + +Extension Points +~~~~~~~~~~~~~~~~ + +- Override ``get_attendance_list()`` on ``spp.attendance.subscriber`` to + customize attendance queries +- Inherit ``spp.attendance.list`` to add domain-specific attendance + metadata +- Extend ``_import_attendance()`` in wizard to customize import logic + +Dependencies +~~~~~~~~~~~~ + +``base``, ``spp_oauth``, ``spp_security`` + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Syncing participants from a registry +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The participant import (Settings > SPP Attendance Settings, used by the +*Import Attendance* wizard) is fully configuration-driven: an auth +endpoint, a search endpoint, and JSON-path mappings into the response. +The shipped defaults target the SPDCI-style interface of a legacy +openspp-modules registry (``/oauth2/client/token`` + +``/registry/sync/search``). + +To sync from an **OpenSPP2** registry instead, point the settings at the +DCI server modules (``spp_dci_server`` + ``spp_dci_server_social`` must +be installed on the registry instance): + ++-----------------+----------------------------------------------------+ +| Setting | Value for an OpenSPP2 registry | ++=================+====================================================+ +| Server URL | ``https://`` | ++-----------------+----------------------------------------------------+ +| Auth Endpoint | ``/api/v2/spp/oauth/token`` (spp_api_v2 | +| | client-credentials endpoint) | ++-----------------+----------------------------------------------------+ +| Import Endpoint | ``/dci_api/v1/registry/sync/search`` | ++-----------------+----------------------------------------------------+ + +The JSON-path mappings (personal information, identifier, names, contact +fields) must match the DCI search response envelope of the target +registry; adjust them from the defaults as needed. + +API access for external systems +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Create client credentials under **Attendance > Configuration > API + Clients**. The client secret is displayed **once** at creation (and + on regeneration) — store it securely; only a hash is kept. +2. Obtain a token: ``POST /auth/token`` with + ``client_id``/``client_secret``. +3. Call the attendance endpoints with the token in the ``Authorization`` + header (``Bearer`` scheme). + +Signing keys come from ``spp_oauth`` (Settings > General Settings > +OpenSPP OAuth); the RSA keypair must be configured before tokens can be +issued. + +Changelog +========= + +19.0.2.0.0 +~~~~~~~~~~ + +- Initial migration from openspp-modules +- fix(security): store API client secrets as scrypt hashes instead of + plaintext. Secrets are shown once at creation/regeneration and can no + longer be read back afterwards — including secrets that existed before + the upgrade, which a migration hashes in place. Clients keep + authenticating with their unchanged secrets. The Attendance Viewer + group's read access to the credential model is removed. +- fix: the REST API works on Odoo 19 again — every response with a body + crashed with ``AttributeError`` because ``date_utils.json_default`` + was removed from Odoo +- fix: gender is never fabricated as "Male" — the res.partner field + default, the subscriber related-field default, the registry-import + fallback and the subscriber-create fallback all stamped it when gender + was unknown; the value now comes only from source data +- fix(wizard): a missing sync-configuration parameter raises the + intended error message instead of crashing with ``KeyError``; access + tokens already carrying a ``Basic`` scheme are no longer + double-prefixed +- fix(wizard): registry sync requests carry a 30s timeout so a hung + remote registry cannot freeze the worker + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-reichie020212| image:: https://github.com/reichie020212.png?size=40px + :target: https://github.com/reichie020212 + :alt: reichie020212 + +Current maintainer: + +|maintainer-reichie020212| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_attendance/__init__.py b/spp_attendance/__init__.py new file mode 100644 index 00000000..e4f4917a --- /dev/null +++ b/spp_attendance/__init__.py @@ -0,0 +1,3 @@ +from . import controllers +from . import models +from . import wizard diff --git a/spp_attendance/__manifest__.py b/spp_attendance/__manifest__.py new file mode 100644 index 00000000..f9444b59 --- /dev/null +++ b/spp_attendance/__manifest__.py @@ -0,0 +1,34 @@ +# pylint: disable=pointless-statement +{ + "name": "OpenSPP Attendance", + "category": "OpenSPP", + "version": "19.0.2.0.0", + "summary": "This module accurately tracks participant attendance for social protection program activities, capturing essential details like date, time, location, and activity type. It integrates attendance records to inform conditional benefit eligibility and leverages spp_oauth to secure API endpoints for external data submission.", + "sequence": 1, + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["reichie020212"], + "depends": ["base", "spp_oauth", "spp_security"], + "external_dependencies": {"python": ["pytz"]}, + "data": [ + "data/ir_config_parameter_data.xml", + "security/privileges.xml", + "security/security.xml", + "security/ir.model.access.csv", + "views/main_views.xml", + "views/attendance_views.xml", + "views/attendance_api_client_views.xml", + "views/res_config_view.xml", + "views/attendance_type_views.xml", + "views/attendance_location_views.xml", + "wizard/import_attendance_view.xml", + ], + "assets": {}, + "demo": [], + "images": [], + "application": True, + "installable": True, + "auto_install": False, +} diff --git a/spp_attendance/controllers/__init__.py b/spp_attendance/controllers/__init__.py new file mode 100644 index 00000000..e046e49f --- /dev/null +++ b/spp_attendance/controllers/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/spp_attendance/controllers/controllers.py b/spp_attendance/controllers/controllers.py new file mode 100644 index 00000000..bfc0706b --- /dev/null +++ b/spp_attendance/controllers/controllers.py @@ -0,0 +1,715 @@ +import json +import logging +from datetime import datetime + +import werkzeug.wrappers + +from odoo.http import Controller, request, route +from odoo.osv import expression +from odoo.tools import json_default + +from odoo.addons.spp_oauth.tools import OpenSPPOAuthJWTException, verify_and_decode_signature + +_logger = logging.getLogger(__name__) + + +class SppAttendanceController(Controller): + def response_wrapper(self, status, data): + return werkzeug.wrappers.Response( + status=status, + content_type="application/json; charset=utf-8", + response=json.dumps(data, default=json_default) if data else None, + ) + + def error_wrapper(self, code, message): + error = {"error": {"code": code, "message": message}} + return self.response_wrapper(code, error) + + def get_auth_header(self, headers, raise_exception=False): + auth_header = headers.get("Authorization") or headers.get("authorization") + if not auth_header or not auth_header.startswith("Bearer "): + if raise_exception: + error = { + "error": "Unauthorized", + "error_description": "Your token could not be authenticated.", + } + return self.response_wrapper(401, error) + return auth_header + + def verify_auth_header(self): + auth_header = self.get_auth_header(request.httprequest.headers, raise_exception=True) + + access_token = auth_header.replace("Bearer ", "").replace("\\n", "").encode("utf-8") + try: + verify_and_decode_signature(request.env, access_token) + except OpenSPPOAuthJWTException: + return False + + return True + + def check_required_fields(self, data, required_fields=None): + if not required_fields: + return [] + + missing_required_fields = [] + for field in required_fields: + if field not in data: + missing_required_fields.append(field) + return missing_required_fields + + def validate_page_and_limit(self, page, limit): + try: + page = int(page) + limit = int(limit) + except ValueError: + return "Page and limit must be integers." + + if page < 1 or limit < 1: + return "Page and limit must be positive integers." + + return None + + def validate_date(self, from_date, to_date): + if not all([from_date, to_date]): + return None + + try: + from_date = datetime.strptime(from_date, "%Y-%m-%d") + to_date = datetime.strptime(to_date, "%Y-%m-%d") + except ValueError: + return "FromDate and ToDate must be in the format YYYY-MM-DD." + + if from_date > to_date: + return "FromDate must be less than or equal to ToDate." + + return None + + def validate_request_header_and_body(self): + if not self.verify_auth_header(): + return self.error_wrapper(401, "Unauthorized") + + req = request + data = req.httprequest.data or "{}" + try: + data = json.loads(data) + except json.decoder.JSONDecodeError: + return self.error_wrapper(400, "data must be in JSON format.") + + return None + + def validate_entity(self, model_name, model_id, model_label): + if model_id: + try: + model_id = int(model_id) + except ValueError: + return self.error_wrapper(400, f"{model_label} must be an integer.") + + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + entity = request.env[model_name].sudo().search([("id", "=", model_id)], limit=1) + if not entity: + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + model_ids = request.env[model_name].sudo().search([]) + error_message = f"{model_label} does not exist." + if model_ids: + choices = ", ".join([f"{model.id} for {model.name}" for model in model_ids]) + error_message = f"{model_label} does not exist. Available {model_label}s: {choices}." + return self.error_wrapper(400, error_message) + return None + + def validate_attendance_type(self, attendance_type): + return self.validate_entity("spp.attendance.type", attendance_type, "Attendance Type") + + def validate_attendance_location(self, attendance_location): + return self.validate_entity("spp.attendance.location", attendance_location, "Attendance Location") + + @route( + "/auth/token", + type="http", + auth="none", + methods=["POST"], + csrf=False, + ) + def auth_get_access_token(self, **kwargs): + req = request + data = req.httprequest.data or "{}" + try: + data = json.loads(data) + except json.decoder.JSONDecodeError: + return self.response_wrapper( + 400, + { + "error": "Bad Request", + "error_description": "data must be in JSON format.", + }, + ) + + client_id = data.get("client_id", "") + client_secret = data.get("client_secret", "") + + if not all([client_id, client_secret]): + error = { + "error": "Bad Request", + "error_description": ("client_id and client_secret are required."), + } + return self.response_wrapper(400, error) + + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + client = req.env["spp.attendance.api.client.credential"].sudo().authenticate(client_id, client_secret) + + if not client: + error = { + "error": "Unauthorized", + "error_description": "Invalid client id or secret.", + } + return self.response_wrapper(401, error) + + access_token = client.generate_access_token() + + data = { + "access_token": access_token, + "token_type": "Bearer", + } + return self.response_wrapper(200, data) + + @route( + "/attendances", + type="http", + auth="none", + methods=["POST"], + csrf=False, + ) + def create_attendance_list(self, ignore_unique=None, **kwargs): + if error := self.validate_request_header_and_body(): + return error + + req = request + data = req.httprequest.data or "{}" + data = json.loads(data) + + if ignore_unique: + ignore_unique = ignore_unique.lower() in ["true", "1", "t", "y", "yes"] + + if missing_required_fields := self.check_required_fields( + data, ["records", "submitted_by", "submitted_datetime"] + ): + return self.error_wrapper(400, f"Missing required fields: {', '.join(missing_required_fields)}") + + current_date = datetime.now().strftime("%Y-%m-%d") + submitted_datetime = data.get("submitted_datetime", current_date) + submitted_by = data.get("submitted_by") + submission_source = data.get("submission_source", "") + + attendance_list_data = [] + person_id_list = [] + + ALLOWED_CATEGORIES = req.env["spp.attendance.list"].ALLOWED_CATEGORIES + + for person_data in data["records"]: + if missing_required_fields := self.check_required_fields(person_data, ["time_card", "person_id"]): + return self.error_wrapper(400, f"Missing required fields: {', '.join(missing_required_fields)}") + + person_id = person_data.get("person_id") + subscriber_id = ( + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + req.env["spp.attendance.subscriber"].sudo().search([("person_identifier", "=", person_id)], limit=1) + ) + if not subscriber_id: + return self.error_wrapper(400, "person_id does not exist.") + + time_cards = person_data.get("time_card") + for time_card in time_cards: + missing_required_fields = self.check_required_fields(time_card, ["date_time"]) + if missing_required_fields: + return self.error_wrapper( + 400, f"Missing required fields for time_card: {', '.join(missing_required_fields)}" + ) + + attendance_datetime = time_card.get("date_time") + attendance_datetime = datetime.strptime(attendance_datetime, "%Y-%m-%d %H:%M:%S") + + attendance_date = str(attendance_datetime.date()) + attendance_time = str(attendance_datetime.time()) + + category = time_card.get("attendance_category", "present") + category = category.lower() + if category not in ALLOWED_CATEGORIES: + return self.error_wrapper( + 400, f"Invalid category. Allowed categories are {', '.join(ALLOWED_CATEGORIES)}." + ) + + attendance_type = time_card.get("attendance_type", False) or False + if result := self.validate_attendance_type(attendance_type): + return result + attendance_type = int(attendance_type) + + attendance_location = time_card.get("attendance_location", False) or False + if result := self.validate_attendance_location(attendance_location): + return result + attendance_location = int(attendance_location) + + attendance_description = time_card.get("attendance_description", "") + attendance_external_url = time_card.get("attendance_external_url", "") + attendane_list_vals = { + "subscriber_id": subscriber_id.id, + "attendance_date": attendance_date, + "attendance_time": attendance_time, + "attendance_type_id": attendance_type, + "attendance_category": category, + "attendance_location_id": attendance_location, + "attendance_description": attendance_description, + "attendance_external_url": attendance_external_url, + "submitted_by": submitted_by, + "submitted_datetime": submitted_datetime, + "submission_source": submission_source, + } + + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + new_attendance_list_ids = req.env["spp.attendance.list"].sudo().new(attendane_list_vals) + if person_id not in person_id_list: + person_id_list.append(person_id) + if not new_attendance_list_ids.check_uniqueness(): + if ignore_unique: + continue + return self.error_wrapper( + 400, "An attendance record is already exists. Please check the date, time, type, location." + ) + + attendance_list_data.append(attendane_list_vals) + + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + req.env["spp.attendance.list"].sudo().create(attendance_list_data) + + return self.response_wrapper( + 200, {"message": "Attendance list created successfully.", "person_ids": person_id_list} + ) + + def get_time_card_vals(self, time_card): + ALLOWED_CATEGORIES = request.env["spp.attendance.list"].ALLOWED_CATEGORIES + vals = {} + if "date_time" in time_card: + attendance_datetime = time_card.get("date_time") + attendance_datetime = datetime.strptime(attendance_datetime, "%Y-%m-%d %H:%M:%S") + attendance_date = str(attendance_datetime.date()) + attendance_time = str(attendance_datetime.time()) + + vals["attendance_date"] = attendance_date + vals["attendance_time"] = attendance_time + + if "attendance_type" in time_card: + attendance_type = time_card.get("attendance_type") + if result := self.validate_attendance_type(attendance_type): + return result + attendance_type = int(attendance_type) + vals["attendance_type_id"] = attendance_type + + if "attendance_location" in time_card: + attendance_location = time_card.get("attendance_location") + if result := self.validate_attendance_location(attendance_location): + return result + attendance_location = int(attendance_location) + vals["attendance_location_id"] = attendance_location + + if "attendance_description" in time_card: + attendance_description = time_card.get("attendance_description") + vals["attendance_description"] = attendance_description + + if "attendance_external_url" in time_card: + attendance_external_url = time_card.get("attendance_external_url") + vals["attendance_external_url"] = attendance_external_url + + if "attendance_category" in time_card: + category = time_card["attendance_category"] + category = category.lower() + if category not in ALLOWED_CATEGORIES: + return self.error_wrapper( + 400, f"Invalid category. Allowed categories are {', '.join(ALLOWED_CATEGORIES)}." + ) + vals["attendance_category"] = category + return vals + + @route( + "/attendances", + type="http", + auth="none", + methods=["PUT"], + csrf=False, + ) + def update_attendance_list(self, **kwargs): + if error := self.validate_request_header_and_body(): + return error + + req = request + data = req.httprequest.data or "{}" + data = json.loads(data) + + if missing_required_fields := self.check_required_fields( + data, ["records", "submitted_by", "submitted_datetime"] + ): + return self.error_wrapper(400, f"Missing required fields: {', '.join(missing_required_fields)}") + + current_date = datetime.now().strftime("%Y-%m-%d") + submitted_datetime = data.get("submitted_datetime", current_date) + submitted_by = data.get("submitted_by") + submission_source = data.get("submission_source", "") + + for attendance_data in data["records"]: + if missing_required_fields := self.check_required_fields(attendance_data, ["time_card", "id"]): + return self.error_wrapper( + 400, f"Missing required fields for records: {', '.join(missing_required_fields)}" + ) + + attendance_id = attendance_data.get("id") + if not isinstance(attendance_id, int): + return self.error_wrapper(400, "id must be an integer.") + + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + attendance_list_id = req.env["spp.attendance.list"].sudo().search([("id", "=", attendance_id)], limit=1) + + if not attendance_list_id: + return self.error_wrapper(400, f"Attendance ID {attendance_id} does not exist.") + + time_card = attendance_data.get("time_card") + if not isinstance(time_card, dict): + return self.error_wrapper(400, "time_card must be an object.") + + vals = self.get_time_card_vals(time_card) + if not isinstance(vals, dict): + # it returns an error if it is not a dict: return the vals + return vals + + if vals: + vals["submitted_by"] = submitted_by + vals["submitted_datetime"] = submitted_datetime + vals["submission_source"] = submission_source + attendance_list_id.write(vals) + + return self.response_wrapper(200, {"message": "Attendance list updated successfully."}) + + @route( + "/attendances", + type="http", + auth="none", + methods=["DELETE"], + csrf=False, + ) + def delete_attendance_list(self, ids=None, **kwargs): + if error := self.validate_request_header_and_body(): + return error + + if not ids: + ids = [] + else: + try: + ids = [int(item) for item in ids.split(",")] + except ValueError: + _logger.warning("Invalid ids parameter format: expected comma-separated integers") + return self.error_wrapper(400, "ids must be a list of integers.") + + req = request + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + attendance_list_ids = req.env["spp.attendance.list"].sudo().search([("id", "in", ids)]) + + if not attendance_list_ids: + return self.error_wrapper(400, "Attendance list does not exist.") + + attendance_list_ids.unlink() + return self.response_wrapper(200, {"message": "Attendance list deleted successfully."}) + + @route( + "/attendance/", + type="http", + auth="none", + methods=["GET"], + csrf=False, + ) + def attendance_list_person(self, person_identifier, page=1, limit=30, **kwargs): + if error := self.validate_request_header_and_body(): + return error + + req = request + data = req.httprequest.data or "{}" + data = json.loads(data) + + subscriber_id = ( + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + req.env["spp.attendance.subscriber"].sudo().search([("person_identifier", "=", person_identifier)], limit=1) + ) + + if not subscriber_id: + return self.error_wrapper(400, f"PersonID {person_identifier} does not exist.") + + if page_limit_error_message := self.validate_page_and_limit(page, limit): + return self.error_wrapper(400, page_limit_error_message) + + page = int(page) + limit = int(limit) + offset = (page - 1) * limit + + from_date = kwargs.get("from_date", None) + to_date = kwargs.get("to_date", None) + + if from_date and to_date: + if date_error_message := self.validate_date(from_date, to_date): + return self.error_wrapper(400, date_error_message) + else: + from_date = datetime.strptime(from_date, "%Y-%m-%d") + to_date = datetime.strptime(to_date, "%Y-%m-%d") + + attendance_type = kwargs.get("attendance_type", False) or False + if result := self.validate_attendance_type(attendance_type): + return result + attendance_type = int(attendance_type) + + attendance_location = kwargs.get("attendance_location", False) or False + if result := self.validate_attendance_location(attendance_location): + return result + attendance_location = int(attendance_location) + + total_attendance, attendance_record = subscriber_id.get_attendance_list( + from_date=from_date, + to_date=to_date, + attendance_type_id=attendance_type, + attendance_location_id=attendance_location, + offset=offset, + limit=limit, + ) + attendance_record["pagination"] = { + "page": page, + "limit": limit, + "total_records": total_attendance, + "total_pages": (total_attendance + limit - 1) // limit, + } + + return self.response_wrapper( + 200, + attendance_record, + ) + + @route( + "/attendances", + type="http", + auth="none", + methods=["GET"], + csrf=False, + ) + def attendance_list(self, page=1, limit=30, last_id=None, **kwargs): + if error := self.validate_request_header_and_body(): + return error + + req = request + data = req.httprequest.data or "{}" + data = json.loads(data) + + if page_limit_error_message := self.validate_page_and_limit(page, limit): + return self.error_wrapper(400, page_limit_error_message) + + page = int(page) + limit = int(limit) + + # Use cursor-based pagination if last_id provided, otherwise fall back to offset + if last_id: + try: + last_id = int(last_id) + except ValueError: + return self.error_wrapper(400, "last_id must be an integer.") + + from_date = kwargs.get("from_date", None) + to_date = kwargs.get("to_date", None) + + if from_date and to_date: + if date_error_message := self.validate_date(from_date, to_date): + return self.error_wrapper(400, date_error_message) + else: + from_date = datetime.strptime(from_date, "%Y-%m-%d") + to_date = datetime.strptime(to_date, "%Y-%m-%d") + + attendance_type = kwargs.get("attendance_type", False) or False + if result := self.validate_attendance_type(attendance_type): + return result + attendance_type = int(attendance_type) + + attendance_location = kwargs.get("attendance_location", False) or False + if result := self.validate_attendance_location(attendance_location): + return result + attendance_location = int(attendance_location) + + domain = [] + + person_identifier_list = data.get("person_ids", []) + if person_identifier_list: + domain.append(("person_identifier", "in", person_identifier_list)) + + # Apply cursor-based pagination + if last_id is not None: + domain = expression.AND([domain, [("id", ">", last_id)]]) + + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + subscriber_model = request.env["spp.attendance.subscriber"].sudo() + subscriber_ids = subscriber_model.search(domain, limit=limit, order="id") + total_records = subscriber_model.search_count(domain) + + records = [] + for subscriber_id in subscriber_ids: + total_attendance, attendance_record = subscriber_id.get_attendance_list( + from_date=from_date, + to_date=to_date, + attendance_type_id=attendance_type, + attendance_location_id=attendance_location, + ) + records.append(attendance_record) + + response_data = { + "records": records, + "pagination": { + "page": page, + "limit": limit, + "total_records": total_records, + "total_pages": (total_records + limit - 1) // limit, + }, + } + + return self.response_wrapper( + 200, + response_data, + ) + + @route( + "/attendance/types", + type="http", + auth="none", + methods=["GET"], + csrf=False, + ) + def get_attendance_types(self): + if error := self.validate_request_header_and_body(): + return error + + req = request + data = req.httprequest.data or "{}" + data = json.loads(data) + + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + attendance_type_ids = req.env["spp.attendance.type"].sudo().search([]) + + return self.response_wrapper( + 200, + { + "records": [ + { + "id": attendance_type.id, + "name": attendance_type.name, + "description": attendance_type.description, + } + for attendance_type in attendance_type_ids + ], + }, + ) + + @route( + "/attendance/locations", + type="http", + auth="none", + methods=["GET"], + csrf=False, + ) + def get_attendance_locations(self): + if error := self.validate_request_header_and_body(): + return error + + req = request + data = req.httprequest.data or "{}" + data = json.loads(data) + + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + attendance_location_ids = req.env["spp.attendance.location"].sudo().search([]) + + return self.response_wrapper( + 200, + { + "records": [ + { + "id": attendance_location.id, + "name": attendance_location.name, + "description": attendance_location.description, + } + for attendance_location in attendance_location_ids + ], + }, + ) + + @route( + "/subscribers", + type="http", + auth="none", + methods=["GET"], + csrf=False, + ) + def get_subscriber_list_information(self, page=1, limit=30, last_id=None, **kwargs): + if error := self.validate_request_header_and_body(): + return error + + if page_limit_error_message := self.validate_page_and_limit(page, limit): + return self.error_wrapper(400, page_limit_error_message) + + page = int(page) + limit = int(limit) + + # Use cursor-based pagination if last_id provided + if last_id: + try: + last_id = int(last_id) + except ValueError: + return self.error_wrapper(400, "last_id must be an integer.") + + domain = [] + + # Apply cursor-based pagination + if last_id is not None: + domain = expression.AND([domain, [("id", ">", last_id)]]) + + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + subscriber_model = request.env["spp.attendance.subscriber"].sudo() + subscriber_ids = subscriber_model.search(domain, limit=limit, order="id") + total_records = subscriber_model.search_count(domain) + + records = subscriber_ids.get_attendance_info_list() + + response_data = { + "records": records, + "pagination": { + "page": page, + "limit": limit, + "total_records": total_records, + "total_pages": (total_records + limit - 1) // limit, + }, + } + + return self.response_wrapper( + 200, + response_data, + ) + + @route( + "/subscriber/", + type="http", + auth="none", + methods=["GET"], + csrf=False, + ) + def get_subscriber_information(self, person_identifier, **kwargs): + if error := self.validate_request_header_and_body(): + return error + subscriber_id = ( + # nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream + request.env["spp.attendance.subscriber"] + .sudo() + .search([("person_identifier", "=", person_identifier)], limit=1) + ) + + if not subscriber_id: + return self.error_wrapper(400, f"PersonID {person_identifier} does not exist.") + + return self.response_wrapper( + 200, + subscriber_id.get_attendance_subscriber_info(), + ) diff --git a/spp_attendance/data/ir_config_parameter_data.xml b/spp_attendance/data/ir_config_parameter_data.xml new file mode 100644 index 00000000..649ad4f3 --- /dev/null +++ b/spp_attendance/data/ir_config_parameter_data.xml @@ -0,0 +1,58 @@ + + + + + + + spp_attendance.attendance_auth_endpoint + /oauth2/client/token + + + + spp_attendance.access_token_mapping + access_token + + + + spp_attendance.attendance_import_endpoint + /registry/sync/search + + + + spp_attendance.personal_information_mapping + message.search_response.0.data.reg_records + + + + spp_attendance.person_identifier_mapping + identifier.0.identifier + + + + spp_attendance.family_name_mapping + familyName + + + + spp_attendance.given_name_mapping + givenName + + + + spp_attendance.email_mapping + email + + + + spp_attendance.phone_mapping + phoneNumbers.0.phone + + + + spp_attendance.gender_mapping + sex + + diff --git a/spp_attendance/i18n/lo.po b/spp_attendance/i18n/lo.po new file mode 100644 index 00000000..f5c135a7 --- /dev/null +++ b/spp_attendance/i18n/lo.po @@ -0,0 +1,1831 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * spp_attendance +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" +"Language: lo\n" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__event_count +msgid "# Events" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__meeting_count +msgid "# Meetings" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "%(name)s is not set." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__type +msgid "" +"- Contact: Use this to organize the contact details of employees of a given " +"company (e.g. CEO, CFO, ...).\n" +"- Invoice Address: Preferred address for all invoices. Selected by default " +"when you invoice an order that belongs to this company.\n" +"- Delivery Address: Preferred address for all deliveries. Selected by " +"default when you deliver an order that belongs to this company.\n" +"- Other: Other address for the company (e.g. subsidiary, ...)" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_subscriber_partner_id_uniq +msgid "A subscriber with the same partner already exists." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_subscriber_person_identifier_uniq +msgid "A subscriber with the same person identifier already exists." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/models/attendance_list.py:0 +#: model:ir.model.fields.selection,name:spp_attendance.selection__spp_attendance_list__attendance_category__absent +#, python-format +msgid "Absent" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__access_token_mapping +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Access Token Mapping" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_account_payable_id +msgid "Account Payable" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_account_receivable_id +msgid "Account Receivable" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__active +msgid "Active" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__active_lang_count +msgid "Active Lang Count" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_ids +msgid "Activities" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_state +msgid "Activity State" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_type_icon +msgid "Activity Type Icon" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_partner__addl_name +#: model:ir.model.fields,field_description:spp_attendance.field_res_users__addl_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__addl_name +msgid "Additional Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__additional_info +msgid "Additional info" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +msgid "Addl. Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__type +msgid "Address Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__lang +msgid "" +"All the emails and documents sent to this contact will be translated in this " +"language." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: spp_attendance +#: model:ir.module.category,name:spp_attendance.attendance_module_management +#: model:ir.ui.menu,name:spp_attendance.menu_root +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +msgid "Attendance" +msgstr "" + +#. module: spp_attendance +#: model:ir.ui.menu,name:spp_attendance.menu_attendance_api_client_credentiale +msgid "Attendance API Client Credentials" +msgstr "" + +#. module: spp_attendance +#: model:ir.actions.act_window,name:spp_attendance.spp_attendance_client_credential_window +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_form +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_view_credentials +msgid "Attendance Client Credentials" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Attendance Data Uniqueness" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +msgid "Attendance Information" +msgstr "" + +#. module: spp_attendance +#: model:ir.model,name:spp_attendance.model_spp_attendance_list +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__attendance_list_ids +msgid "Attendance List" +msgstr "" + +#. module: spp_attendance +#: model:ir.actions.act_window,name:spp_attendance.attendance_location_action_window +#: model:ir.model,name:spp_attendance.model_spp_attendance_location +#: model:ir.ui.menu,name:spp_attendance.attendance_location_menu +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Attendance Location" +msgstr "" + +#. module: spp_attendance +#: model:res.groups,name:spp_attendance.attendance_manager +msgid "Attendance Manager" +msgstr "" + +#. module: spp_attendance +#: model:ir.actions.act_window,name:spp_attendance.attendance_subscriber_action_window +#: model:ir.model,name:spp_attendance.model_spp_attendance_subscriber +msgid "Attendance Subscriber" +msgstr "" + +#. module: spp_attendance +#: model:ir.actions.act_window,name:spp_attendance.attendance_type_action_windown +#: model:ir.model,name:spp_attendance.model_spp_attendance_type +#: model:ir.ui.menu,name:spp_attendance.attendance_type_menu +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Attendance Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_type_name_uniq +msgid "Attendance Type must be unique!" +msgstr "" + +#. module: spp_attendance +#: model:res.groups,name:spp_attendance.attendance_viewer +msgid "Attendance Viewer" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__attendance_auth_endpoint +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Auth Endpoint" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__auth_header +msgid "Auth Headers" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Auth Headers must be in JSON format" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__auth_type +msgid "Auth Type" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.import_attendance_wizard +msgid "Authentication" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Authentication failed. Reason: %s." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__avatar_1920 +msgid "Avatar" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__avatar_1024 +msgid "Avatar 1024" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__avatar_128 +msgid "Avatar 128" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__avatar_256 +msgid "Avatar 256" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__avatar_512 +msgid "Avatar 512" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__bank_account_count +msgid "Bank" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__bank_ids +msgid "Banks" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__barcode +msgid "Barcode" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields.selection,name:spp_attendance.selection__spp_import_attendance_wizard__auth_type__basic +msgid "Basic" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields.selection,name:spp_attendance.selection__spp_import_attendance_wizard__auth_type__bearer +msgid "Bearer" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__is_blacklisted +msgid "Blacklist" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__mobile_blacklisted +msgid "Blacklisted Phone Is Mobile" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__phone_blacklisted +msgid "Blacklisted Phone is Phone" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_bounce +msgid "Bounce" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__can_publish +msgid "Can Publish" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.import_attendance_wizard +msgid "Cancel" +msgstr "" + +#. module: spp_attendance +#: model:ir.actions.act_window,name:spp_attendance.action_change_request_cancel_wizard +msgid "Cancel Change Request" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_category +msgid "Category" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__channel_ids +msgid "Channels" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__is_company +msgid "Check if the contact is a company, otherwise it is a person" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__employee +msgid "Check this box if this contact is an Employee." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__city +msgid "City" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__client_id +msgid "Client" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_form +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_view_credentials +msgid "Client ID" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/models/attendance_api_client_credentials.py:0 +#, python-format +msgid "Client ID and Client Secret is already showed once." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_api_client_credential_client_id_uniq +msgid "Client ID must be unique!" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__name +msgid "Client Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_api_client_credential_name_uniq +msgid "Client Name must be unique!" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__client_secret +msgid "Client Secret" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_api_client_credential_client_secret_uniq +msgid "Client Secret must be unique !" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__peppol_eas +msgid "" +"Code used to identify the Endpoint for BIS Billing 3.0 and its derivatives.\n" +" List available at https://docs.peppol.eu/poacc/billing/3.0/" +"codelist/eas/" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__color +msgid "Color Index" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__commercial_partner_id +msgid "Commercial Entity" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__ref_company_ids +msgid "Companies that refers to partner" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__company_id +msgid "Company" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__company_registry +msgid "Company ID" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__company_name +msgid "Company Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__commercial_company_name +msgid "Company Name Entity" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__company_type +msgid "Company Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_gid +msgid "Company database ID" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__contact_address +msgid "Complete Address" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__complete_name +msgid "Complete Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model,name:spp_attendance.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: spp_attendance +#: model:ir.ui.menu,name:spp_attendance.attendance_configuration_menu_root +msgid "Configuration" +msgstr "" + +#. module: spp_attendance +#: model:ir.model,name:spp_attendance.model_res_partner +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__child_ids +msgid "Contact" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_bounce +msgid "Counter of the number of bounced emails for this contact" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__country_id +msgid "Country" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__country_code +msgid "Country Code" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.actions.act_window,help:spp_attendance.spp_attendance_client_credential_window +msgid "Create and manage the Attendance API Client Credentials." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.actions.act_window,help:spp_attendance.attendance_location_action_window +msgid "Create and manage the attendance location." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.actions.act_window,help:spp_attendance.attendance_type_action_windown +msgid "Create and manage the attendance type." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.actions.act_window,help:spp_attendance.attendance_subscriber_action_window +msgid "Create and manage the attendance." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__create_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__create_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__create_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__create_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__create_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__create_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__create_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__create_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__create_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__create_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__credit_limit +msgid "Credit Limit" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__credit_to_invoice +msgid "Credit To Invoice" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__credit_limit +msgid "Credit limit specific to this partner." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__currency_id +msgid "Currency" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_stock_customer +msgid "Customer Location" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_payment_term_id +msgid "Customer Payment Terms" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__customer_rank +msgid "Customer Rank" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Data before the index is not a list" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__date +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Date" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__days_sales_outstanding +msgid "Days Sales Outstanding (DSO)" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__trust +msgid "Degree of trust you have in this debtor" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_description +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__description +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__description +msgid "Description" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__display_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__display_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__display_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__display_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__display_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__duplicated_bank_account_partners_count +msgid "Duplicated Bank Account Partners Count" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__partner_share +msgid "" +"Either customer (not a user), either shared user. Indicated the current " +"partner is a customer without access or with a limited access created for " +"sharing data." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Element %s not found in data" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__email_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__email +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Email" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__employee +msgid "Employee" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Endpoing of the API to Authenticate." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Endpoint of the API to import attendance." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_external_url +msgid "External URL" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__family_name_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_res_partner__family_name +#: model:ir.model.fields,field_description:spp_attendance.field_res_users__family_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__family_name +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Family Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__phone_sanitized +msgid "" +"Field used to store sanitized phone number. Helps speeding up searches and " +"comparisons." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__fiscal_country_codes +msgid "Fiscal Country Codes" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_account_position_id +msgid "Fiscal Position" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__ubl_cii_format +msgid "Format" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__email_formatted +msgid "Format email address \"Name \"" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__email_formatted +msgid "Formatted Email" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_name +msgid "Full Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__gender_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_res_partner__gender_char +#: model:ir.model.fields,field_description:spp_attendance.field_res_users__gender_char +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__gender_char +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Gender" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_latitude +msgid "Geo Latitude" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_longitude +msgid "Geo Longitude" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__given_name_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_res_partner__given_name +#: model:ir.model.fields,field_description:spp_attendance.field_res_users__given_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__given_name +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Given Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__has_message +msgid "Has Message" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__has_unreconciled_entries +msgid "Has Unreconciled Entries" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__id +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__id +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__id +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__id +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__id +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__id +msgid "ID" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__im_status +msgid "IM Status" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_exception_icon +msgid "Icon" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_partner__identifier +#: model:ir.model.fields,field_description:spp_attendance.field_res_users__identifier +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__identifier +msgid "Identifier" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_needaction +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_has_error +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__is_blacklisted +msgid "" +"If the email address is on the blacklist, the contact won't receive mass " +"mailing anymore, from any list" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__phone_sanitized_blacklisted +msgid "" +"If the sanitized phone number is on the blacklist, the contact won't receive " +"mass mailing sms anymore, from any list" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__image_1920 +msgid "Image" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__image_1024 +msgid "Image 1024" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__image_128 +msgid "Image 128" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__image_256 +msgid "Image 256" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__image_512 +msgid "Image 512" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#: model_terms:ir.ui.view,arch_db:spp_attendance.import_attendance_wizard +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +#, python-format +msgid "Import" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/models/registry_config.py:0 +#, python-format +msgid "Import Attendance" +msgstr "" + +#. module: spp_attendance +#: model:ir.model,name:spp_attendance.model_spp_import_attendance_wizard +msgid "Import Attendance Wizard" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__attendance_import_endpoint +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Import Endpoint" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__import_header +msgid "Import Headers" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Import Headers must be in JSON format" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Import failed. Reason: %s." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "" +"Import successfully. \n" +" Pagination information: %s" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Imported %s persons." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Index out of range" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__mobile_blacklisted +msgid "" +"Indicates if a blacklisted sanitized phone number is a mobile number. Helps " +"distinguish which number is blacklisted when there is both a " +"mobile and phone field in a model." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__phone_blacklisted +msgid "" +"Indicates if a blacklisted sanitized phone number is a phone number. Helps " +"distinguish which number is blacklisted when there is both a " +"mobile and phone field in a model." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__industry_id +msgid "Industry" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__contact_address_inline +msgid "Inlined Complete Address" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__invoice_warn +msgid "Invoice" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__invoice_ids +msgid "Invoices" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__is_public +msgid "Is Public" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__is_published +msgid "Is Published" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__is_company +msgid "Is a Company" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__function +msgid "Job Position" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__journal_item_count +msgid "Journal Items" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__lang +msgid "Language" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__write_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__write_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__write_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__write_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__write_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__write_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__write_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__write_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__write_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__write_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__calendar_last_notif_ack +msgid "Last notification marked as read from base Calendar" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__last_time_entries_checked +msgid "" +"Last time the invoices & payments matching was performed for this partner. " +"It is set either if there's not at least an unreconciled debit and an " +"unreconciled credit or if you click the \"Done\" button." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__last_time_entries_checked +msgid "Latest Invoices & Payments Matching Date" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__limit +msgid "Limit" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_location_id +msgid "Location" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_view_credentials +msgid "" +"Make sure to copy your client id and client secret now. You won't be able to " +"see it again!" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for email." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for family name." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for gender." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for given name." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for person identifier." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for personal informations. Last element must be a list." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for phone." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mappings" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__meeting_ids +msgid "Meetings" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__invoice_warn_msg +msgid "Message for Invoice" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__picking_warn_msg +msgid "Message for Stock Picking" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_ids +msgid "Messages" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__mobile +msgid "Mobile" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__name +msgid "Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_calendar_event_id +msgid "Next Activity Calendar Event" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__email_normalized +msgid "Normalized Email" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/models/attendance_api_client_credentials.py:0 +#, python-format +msgid "Not allowed to export on this model." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__comment +msgid "Notes" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_view_credentials +msgid "OK" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__page +msgid "Page" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__parent_name +msgid "Parent name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_id +msgid "Partner" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__contract_ids +msgid "Partner Contracts" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__use_partner_credit_limit +msgid "Partner Limit" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__same_company_registry_partner_id +msgid "Partner with same Company Registry" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__same_vat_partner_id +msgid "Partner with same Tax ID" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__debit_limit +msgid "Payable Limit" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__payment_token_count +msgid "Payment Token Count" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__payment_token_ids +msgid "Payment Tokens" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__peppol_endpoint +msgid "Peppol Endpoint" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__peppol_eas +msgid "Peppol e-address (EAS)" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__person_identifier_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__person_identifier +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Person Identifier" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +msgid "Person Information" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__personal_information_mapping +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Personal Information" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Personal Information Mapping must point to a list." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__phone_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__phone +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Phone" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__phone_sanitized_blacklisted +msgid "Phone Blacklisted" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__phone_mobile_search +msgid "Phone/Mobile" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Please fill in the following required fields: %s, " +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__pos_order_ids +msgid "Pos Order" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__pos_order_count +msgid "Pos Order Count" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/models/attendance_list.py:0 +#: model:ir.model.fields.selection,name:spp_attendance.selection__spp_attendance_list__attendance_category__present +#, python-format +msgid "Present" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_product_pricelist +msgid "Pricelist" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__raw_body +msgid "Raw Body for Authentication" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__raw_body_for_import +msgid "Raw Body for Import" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__ref +msgid "Reference" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__parent_id +msgid "Related Company" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__website_id +msgid "Restrict publishing to this website." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_has_sms_error +msgid "SMS Delivery error" +msgstr "" + +#. module: spp_attendance +#: model:ir.model,name:spp_attendance.model_spp_attendance_api_client_credential +msgid "SPP Attendance Client Credential" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "SPP Attendance Settings" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__user_id +msgid "Salesperson" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__phone_sanitized +msgid "Sanitized Number" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__invoice_warn +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__picking_warn +msgid "" +"Selecting the \"Warning\" option will notify user with the message, " +"Selecting \"Blocking Message\" will throw an exception with the message and " +"block the flow. The Message has to be written in the next field." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__self +msgid "Self" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__server_url +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Server URL" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Server URL of the API." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_share +msgid "Share Partner" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_form +msgid "Show" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__show_credit_limit +msgid "Show Credit Limit" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__signup_expiration +msgid "Signup Expiration" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__signup_token +msgid "Signup Token" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__signup_type +msgid "Signup Token Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__signup_valid +msgid "Signup Token is Valid" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__signup_url +msgid "Signup URL" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__starred_message_ids +msgid "Starred Message" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__state_id +msgid "State" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__static_map_url +msgid "Static Map Url" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__static_map_url_is_valid +msgid "Static Map Url Is Valid" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__picking_warn +msgid "Stock Picking" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__street +msgid "Street" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__street2 +msgid "Street2" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__submission_source +msgid "Submission Source" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__submitted_by +msgid "Submitted By" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__submitted_datetime +msgid "Submitted Datetime" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +msgid "Submitted Information" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__subscriber_id +#: model:ir.ui.menu,name:spp_attendance.attendance_subscriber_menu +msgid "Subscriber" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__supplier_rank +msgid "Supplier Rank" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__category_id +msgid "Tags" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__vat +msgid "Tax ID" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__country_code +msgid "" +"The ISO country code in two chars. \n" +"You can use this field for quick search." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__vat +msgid "" +"The Tax Identification Number. Values here will be validated based on the " +"country format. You can use '/' to indicate that the partner is not subject " +"to tax." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_account_position_id +msgid "" +"The fiscal position determines the taxes/accounts used for this contact." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__website_url +msgid "The full URL to access the document through the website." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__user_id +msgid "The internal user in charge of this contact." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__pos_order_count +msgid "The number of point of sales orders related to this customer" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__has_unreconciled_entries +msgid "" +"The partner has at least one unreconciled debit and credit since last time " +"the invoices & payments matching was performed." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "" +"The path to the access token in the response. leave blank if response is a " +"string." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#: model:ir.model.fields,help:spp_attendance.field_spp_import_attendance_wizard__raw_body +#, python-format +msgid "The raw body to be sent to the auth URL. Must be in JSON format." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#: model:ir.model.fields,help:spp_attendance.field_spp_import_attendance_wizard__raw_body_for_import +#, python-format +msgid "The raw body to be sent to the import URL. Must be in JSON format." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__company_registry +msgid "" +"The registry number of the company. Use it if it is different from the Tax " +"ID. It must be unique across all partners of a same country" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_stock_customer +msgid "" +"The stock location used as destination when sending goods to this contact." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_stock_supplier +msgid "" +"The stock location used as source when receiving goods from this contact." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_account_payable_id +msgid "" +"This account will be used instead of the default one as the payable account " +"for the current partner" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_account_receivable_id +msgid "" +"This account will be used instead of the default one as the receivable " +"account for the current partner" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__email_normalized +msgid "" +"This field is used to search on email address as the primary email field can " +"contain more than strictly an email address." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_supplier_payment_term_id +msgid "" +"This payment term will be used instead of the default one for purchase " +"orders and vendor bills" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_payment_term_id +msgid "" +"This payment term will be used instead of the default one for sales orders " +"and customer invoices" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_product_pricelist +msgid "" +"This pricelist will be used, instead of the default one, for sales to the " +"current partner" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_time +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Time" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__tz +msgid "Timezone" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__tz_offset +msgid "Timezone offset" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__title +msgid "Title" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__total_invoiced +msgid "Total Invoiced" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__debit +msgid "Total Payable" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__credit +msgid "Total Receivable" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__credit +msgid "Total amount this customer owes you." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__debit +msgid "Total amount you have to pay to this vendor." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_type_id +msgid "Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "URL and Auth Settings" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Unable to connect to the server. Please check your internet connection." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__date_unique +msgid "Unique Date" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__location_unique +msgid "Unique Location" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__time_unique +msgid "Unique Time" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__type_unique +msgid "Unique Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__peppol_endpoint +msgid "" +"Unique identifier used by the BIS Billing 3.0 and its derivatives, also " +"known as 'Endpoint ID'." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__barcode +msgid "Use a barcode to identify this contact." +msgstr "" + +#. module: spp_attendance +#: model:ir.module.category,description:spp_attendance.attendance_module_management +msgid "User access level for Attendance" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__user_ids +msgid "Users" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_stock_supplier +msgid "Vendor Location" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_supplier_payment_term_id +msgid "Vendor Payment Terms" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__show_button_clicked +msgid "Viewed?" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__website_published +msgid "Visible on current website" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__visitor_ids +msgid "Visitors" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__website_id +msgid "Website" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__website +msgid "Website Link" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__website_url +msgid "Website URL" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__website_message_ids +msgid "Website communication history" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__tz +msgid "" +"When printing documents and exporting/importing data, time values are " +"computed according to this timezone.\n" +"If the timezone is not set, UTC (Coordinated Universal Time) is used.\n" +"Anywhere else, time values are computed according to the time offset of your " +"web client." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__zip +msgid "Zip" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__days_sales_outstanding +msgid "" +"[(Total Receivable/Total Revenue) * number of days since the first invoice] " +"for this customer" +msgstr "" diff --git a/spp_attendance/i18n/spp_attendance.pot b/spp_attendance/i18n/spp_attendance.pot new file mode 100644 index 00000000..23c68593 --- /dev/null +++ b/spp_attendance/i18n/spp_attendance.pot @@ -0,0 +1,1826 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * spp_attendance +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__event_count +msgid "# Events" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__meeting_count +msgid "# Meetings" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "%(name)s is not set." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__type +msgid "" +"- Contact: Use this to organize the contact details of employees of a given company (e.g. CEO, CFO, ...).\n" +"- Invoice Address: Preferred address for all invoices. Selected by default when you invoice an order that belongs to this company.\n" +"- Delivery Address: Preferred address for all deliveries. Selected by default when you deliver an order that belongs to this company.\n" +"- Other: Other address for the company (e.g. subsidiary, ...)" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_subscriber_partner_id_uniq +msgid "A subscriber with the same partner already exists." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_subscriber_person_identifier_uniq +msgid "A subscriber with the same person identifier already exists." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/models/attendance_list.py:0 +#: model:ir.model.fields.selection,name:spp_attendance.selection__spp_attendance_list__attendance_category__absent +#, python-format +msgid "Absent" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__access_token_mapping +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Access Token Mapping" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_account_payable_id +msgid "Account Payable" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_account_receivable_id +msgid "Account Receivable" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__active +msgid "Active" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__active_lang_count +msgid "Active Lang Count" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_ids +msgid "Activities" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_state +msgid "Activity State" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_type_icon +msgid "Activity Type Icon" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_partner__addl_name +#: model:ir.model.fields,field_description:spp_attendance.field_res_users__addl_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__addl_name +msgid "Additional Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__additional_info +msgid "Additional info" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +msgid "Addl. Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__type +msgid "Address Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__lang +msgid "" +"All the emails and documents sent to this contact will be translated in this" +" language." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: spp_attendance +#: model:ir.module.category,name:spp_attendance.attendance_module_management +#: model:ir.ui.menu,name:spp_attendance.menu_root +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +msgid "Attendance" +msgstr "" + +#. module: spp_attendance +#: model:ir.ui.menu,name:spp_attendance.menu_attendance_api_client_credentiale +msgid "Attendance API Client Credentials" +msgstr "" + +#. module: spp_attendance +#: model:ir.actions.act_window,name:spp_attendance.spp_attendance_client_credential_window +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_form +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_view_credentials +msgid "Attendance Client Credentials" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Attendance Data Uniqueness" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +msgid "Attendance Information" +msgstr "" + +#. module: spp_attendance +#: model:ir.model,name:spp_attendance.model_spp_attendance_list +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__attendance_list_ids +msgid "Attendance List" +msgstr "" + +#. module: spp_attendance +#: model:ir.actions.act_window,name:spp_attendance.attendance_location_action_window +#: model:ir.model,name:spp_attendance.model_spp_attendance_location +#: model:ir.ui.menu,name:spp_attendance.attendance_location_menu +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Attendance Location" +msgstr "" + +#. module: spp_attendance +#: model:res.groups,name:spp_attendance.attendance_manager +msgid "Attendance Manager" +msgstr "" + +#. module: spp_attendance +#: model:ir.actions.act_window,name:spp_attendance.attendance_subscriber_action_window +#: model:ir.model,name:spp_attendance.model_spp_attendance_subscriber +msgid "Attendance Subscriber" +msgstr "" + +#. module: spp_attendance +#: model:ir.actions.act_window,name:spp_attendance.attendance_type_action_windown +#: model:ir.model,name:spp_attendance.model_spp_attendance_type +#: model:ir.ui.menu,name:spp_attendance.attendance_type_menu +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Attendance Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_type_name_uniq +msgid "Attendance Type must be unique!" +msgstr "" + +#. module: spp_attendance +#: model:res.groups,name:spp_attendance.attendance_viewer +msgid "Attendance Viewer" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__attendance_auth_endpoint +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Auth Endpoint" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__auth_header +msgid "Auth Headers" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Auth Headers must be in JSON format" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__auth_type +msgid "Auth Type" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.import_attendance_wizard +msgid "Authentication" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Authentication failed. Reason: %s." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__avatar_1920 +msgid "Avatar" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__avatar_1024 +msgid "Avatar 1024" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__avatar_128 +msgid "Avatar 128" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__avatar_256 +msgid "Avatar 256" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__avatar_512 +msgid "Avatar 512" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__bank_account_count +msgid "Bank" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__bank_ids +msgid "Banks" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__barcode +msgid "Barcode" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields.selection,name:spp_attendance.selection__spp_import_attendance_wizard__auth_type__basic +msgid "Basic" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields.selection,name:spp_attendance.selection__spp_import_attendance_wizard__auth_type__bearer +msgid "Bearer" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__is_blacklisted +msgid "Blacklist" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__mobile_blacklisted +msgid "Blacklisted Phone Is Mobile" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__phone_blacklisted +msgid "Blacklisted Phone is Phone" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_bounce +msgid "Bounce" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__can_publish +msgid "Can Publish" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.import_attendance_wizard +msgid "Cancel" +msgstr "" + +#. module: spp_attendance +#: model:ir.actions.act_window,name:spp_attendance.action_change_request_cancel_wizard +msgid "Cancel Change Request" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_category +msgid "Category" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__channel_ids +msgid "Channels" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__is_company +msgid "Check if the contact is a company, otherwise it is a person" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__employee +msgid "Check this box if this contact is an Employee." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__city +msgid "City" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__client_id +msgid "Client" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_form +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_view_credentials +msgid "Client ID" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/models/attendance_api_client_credentials.py:0 +#, python-format +msgid "Client ID and Client Secret is already showed once." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_api_client_credential_client_id_uniq +msgid "Client ID must be unique!" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__name +msgid "Client Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_api_client_credential_name_uniq +msgid "Client Name must be unique!" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__client_secret +msgid "Client Secret" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.constraint,message:spp_attendance.constraint_spp_attendance_api_client_credential_client_secret_uniq +msgid "Client Secret must be unique !" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__peppol_eas +msgid "" +"Code used to identify the Endpoint for BIS Billing 3.0 and its derivatives.\n" +" List available at https://docs.peppol.eu/poacc/billing/3.0/codelist/eas/" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__color +msgid "Color Index" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__commercial_partner_id +msgid "Commercial Entity" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__ref_company_ids +msgid "Companies that refers to partner" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__company_id +msgid "Company" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__company_registry +msgid "Company ID" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__company_name +msgid "Company Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__commercial_company_name +msgid "Company Name Entity" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__company_type +msgid "Company Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_gid +msgid "Company database ID" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__contact_address +msgid "Complete Address" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__complete_name +msgid "Complete Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model,name:spp_attendance.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: spp_attendance +#: model:ir.ui.menu,name:spp_attendance.attendance_configuration_menu_root +msgid "Configuration" +msgstr "" + +#. module: spp_attendance +#: model:ir.model,name:spp_attendance.model_res_partner +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__child_ids +msgid "Contact" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_bounce +msgid "Counter of the number of bounced emails for this contact" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__country_id +msgid "Country" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__country_code +msgid "Country Code" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.actions.act_window,help:spp_attendance.spp_attendance_client_credential_window +msgid "Create and manage the Attendance API Client Credentials." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.actions.act_window,help:spp_attendance.attendance_location_action_window +msgid "Create and manage the attendance location." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.actions.act_window,help:spp_attendance.attendance_type_action_windown +msgid "Create and manage the attendance type." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.actions.act_window,help:spp_attendance.attendance_subscriber_action_window +msgid "Create and manage the attendance." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__create_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__create_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__create_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__create_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__create_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__create_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__create_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__create_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__create_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__create_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__credit_limit +msgid "Credit Limit" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__credit_to_invoice +msgid "Credit To Invoice" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__credit_limit +msgid "Credit limit specific to this partner." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__currency_id +msgid "Currency" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_stock_customer +msgid "Customer Location" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_payment_term_id +msgid "Customer Payment Terms" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__customer_rank +msgid "Customer Rank" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Data before the index is not a list" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__date +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Date" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__days_sales_outstanding +msgid "Days Sales Outstanding (DSO)" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__trust +msgid "Degree of trust you have in this debtor" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_description +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__description +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__description +msgid "Description" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__display_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__display_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__display_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__display_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__display_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__duplicated_bank_account_partners_count +msgid "Duplicated Bank Account Partners Count" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__partner_share +msgid "" +"Either customer (not a user), either shared user. Indicated the current " +"partner is a customer without access or with a limited access created for " +"sharing data." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Element %s not found in data" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__email_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__email +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Email" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__employee +msgid "Employee" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Endpoing of the API to Authenticate." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Endpoint of the API to import attendance." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_external_url +msgid "External URL" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__family_name_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_res_partner__family_name +#: model:ir.model.fields,field_description:spp_attendance.field_res_users__family_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__family_name +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Family Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__phone_sanitized +msgid "" +"Field used to store sanitized phone number. Helps speeding up searches and " +"comparisons." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__fiscal_country_codes +msgid "Fiscal Country Codes" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_account_position_id +msgid "Fiscal Position" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__ubl_cii_format +msgid "Format" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__email_formatted +msgid "Format email address \"Name \"" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__email_formatted +msgid "Formatted Email" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_name +msgid "Full Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__gender_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_res_partner__gender_char +#: model:ir.model.fields,field_description:spp_attendance.field_res_users__gender_char +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__gender_char +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Gender" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_latitude +msgid "Geo Latitude" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_longitude +msgid "Geo Longitude" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__given_name_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_res_partner__given_name +#: model:ir.model.fields,field_description:spp_attendance.field_res_users__given_name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__given_name +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Given Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__has_message +msgid "Has Message" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__has_unreconciled_entries +msgid "Has Unreconciled Entries" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__id +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__id +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__id +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__id +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__id +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__id +msgid "ID" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__im_status +msgid "IM Status" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_exception_icon +msgid "Icon" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_partner__identifier +#: model:ir.model.fields,field_description:spp_attendance.field_res_users__identifier +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__identifier +msgid "Identifier" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_needaction +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_has_error +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__is_blacklisted +msgid "" +"If the email address is on the blacklist, the contact won't receive mass " +"mailing anymore, from any list" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__phone_sanitized_blacklisted +msgid "" +"If the sanitized phone number is on the blacklist, the contact won't receive" +" mass mailing sms anymore, from any list" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__image_1920 +msgid "Image" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__image_1024 +msgid "Image 1024" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__image_128 +msgid "Image 128" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__image_256 +msgid "Image 256" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__image_512 +msgid "Image 512" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#: model_terms:ir.ui.view,arch_db:spp_attendance.import_attendance_wizard +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +#, python-format +msgid "Import" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/models/registry_config.py:0 +#, python-format +msgid "Import Attendance" +msgstr "" + +#. module: spp_attendance +#: model:ir.model,name:spp_attendance.model_spp_import_attendance_wizard +msgid "Import Attendance Wizard" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__attendance_import_endpoint +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Import Endpoint" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__import_header +msgid "Import Headers" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Import Headers must be in JSON format" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Import failed. Reason: %s." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "" +"Import successfully. \n" +" Pagination information: %s" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Imported %s persons." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Index out of range" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__mobile_blacklisted +msgid "" +"Indicates if a blacklisted sanitized phone number is a mobile number. Helps " +"distinguish which number is blacklisted when there is both a " +"mobile and phone field in a model." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__phone_blacklisted +msgid "" +"Indicates if a blacklisted sanitized phone number is a phone number. Helps " +"distinguish which number is blacklisted when there is both a " +"mobile and phone field in a model." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__industry_id +msgid "Industry" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__contact_address_inline +msgid "Inlined Complete Address" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__invoice_warn +msgid "Invoice" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__invoice_ids +msgid "Invoices" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__is_public +msgid "Is Public" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__is_published +msgid "Is Published" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__is_company +msgid "Is a Company" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__function +msgid "Job Position" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__journal_item_count +msgid "Journal Items" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__lang +msgid "Language" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__write_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__write_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__write_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__write_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__write_uid +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__write_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__write_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__write_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__write_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__write_date +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__calendar_last_notif_ack +msgid "Last notification marked as read from base Calendar" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__last_time_entries_checked +msgid "" +"Last time the invoices & payments matching was performed for this partner. " +"It is set either if there's not at least an unreconciled debit and an " +"unreconciled credit or if you click the \"Done\" button." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__last_time_entries_checked +msgid "Latest Invoices & Payments Matching Date" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__limit +msgid "Limit" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_location_id +msgid "Location" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_view_credentials +msgid "" +"Make sure to copy your client id and client secret now. You won't be able to" +" see it again!" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for email." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for family name." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for gender." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for given name." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for person identifier." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for personal informations. Last element must be a list." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mapping for phone." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Mappings" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__meeting_ids +msgid "Meetings" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__invoice_warn_msg +msgid "Message for Invoice" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__picking_warn_msg +msgid "Message for Stock Picking" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_ids +msgid "Messages" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__mobile +msgid "Mobile" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_location__name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__name +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_type__name +msgid "Name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_calendar_event_id +msgid "Next Activity Calendar Event" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__email_normalized +msgid "Normalized Email" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/models/attendance_api_client_credentials.py:0 +#, python-format +msgid "Not allowed to export on this model." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__comment +msgid "Notes" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_view_credentials +msgid "OK" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__page +msgid "Page" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__parent_name +msgid "Parent name" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_id +msgid "Partner" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__contract_ids +msgid "Partner Contracts" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__use_partner_credit_limit +msgid "Partner Limit" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__same_company_registry_partner_id +msgid "Partner with same Company Registry" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__same_vat_partner_id +msgid "Partner with same Tax ID" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__debit_limit +msgid "Payable Limit" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__payment_token_count +msgid "Payment Token Count" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__payment_token_ids +msgid "Payment Tokens" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__peppol_endpoint +msgid "Peppol Endpoint" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__peppol_eas +msgid "Peppol e-address (EAS)" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__person_identifier_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__person_identifier +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Person Identifier" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +msgid "Person Information" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__personal_information_mapping +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Personal Information" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Personal Information Mapping must point to a list." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__phone_mapping +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__phone +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Phone" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__phone_sanitized_blacklisted +msgid "Phone Blacklisted" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__phone_mobile_search +msgid "Phone/Mobile" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "Please fill in the following required fields: %s, " +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__pos_order_ids +msgid "Pos Order" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__pos_order_count +msgid "Pos Order Count" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/models/attendance_list.py:0 +#: model:ir.model.fields.selection,name:spp_attendance.selection__spp_attendance_list__attendance_category__present +#, python-format +msgid "Present" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_product_pricelist +msgid "Pricelist" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__raw_body +msgid "Raw Body for Authentication" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_import_attendance_wizard__raw_body_for_import +msgid "Raw Body for Import" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__ref +msgid "Reference" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__parent_id +msgid "Related Company" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__website_id +msgid "Restrict publishing to this website." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__message_has_sms_error +msgid "SMS Delivery error" +msgstr "" + +#. module: spp_attendance +#: model:ir.model,name:spp_attendance.model_spp_attendance_api_client_credential +msgid "SPP Attendance Client Credential" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "SPP Attendance Settings" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__user_id +msgid "Salesperson" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__phone_sanitized +msgid "Sanitized Number" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__invoice_warn +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__picking_warn +msgid "" +"Selecting the \"Warning\" option will notify user with the message, " +"Selecting \"Blocking Message\" will throw an exception with the message and " +"block the flow. The Message has to be written in the next field." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__self +msgid "Self" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__server_url +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Server URL" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Server URL of the API." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__partner_share +msgid "Share Partner" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_client_credential_form +msgid "Show" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__show_credit_limit +msgid "Show Credit Limit" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__signup_expiration +msgid "Signup Expiration" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__signup_token +msgid "Signup Token" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__signup_type +msgid "Signup Token Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__signup_valid +msgid "Signup Token is Valid" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__signup_url +msgid "Signup URL" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__starred_message_ids +msgid "Starred Message" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__state_id +msgid "State" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__static_map_url +msgid "Static Map Url" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__static_map_url_is_valid +msgid "Static Map Url Is Valid" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__picking_warn +msgid "Stock Picking" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__street +msgid "Street" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__street2 +msgid "Street2" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__submission_source +msgid "Submission Source" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__submitted_by +msgid "Submitted By" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__submitted_datetime +msgid "Submitted Datetime" +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.attendance_subscriber_form +msgid "Submitted Information" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__subscriber_id +#: model:ir.ui.menu,name:spp_attendance.attendance_subscriber_menu +msgid "Subscriber" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__supplier_rank +msgid "Supplier Rank" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__category_id +msgid "Tags" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__vat +msgid "Tax ID" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__country_code +msgid "" +"The ISO country code in two chars. \n" +"You can use this field for quick search." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__vat +msgid "" +"The Tax Identification Number. Values here will be validated based on the " +"country format. You can use '/' to indicate that the partner is not subject " +"to tax." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_account_position_id +msgid "" +"The fiscal position determines the taxes/accounts used for this contact." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__website_url +msgid "The full URL to access the document through the website." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__user_id +msgid "The internal user in charge of this contact." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__pos_order_count +msgid "The number of point of sales orders related to this customer" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__has_unreconciled_entries +msgid "" +"The partner has at least one unreconciled debit and credit since last time " +"the invoices & payments matching was performed." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "" +"The path to the access token in the response. leave blank if response is a " +"string." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#: model:ir.model.fields,help:spp_attendance.field_spp_import_attendance_wizard__raw_body +#, python-format +msgid "The raw body to be sent to the auth URL. Must be in JSON format." +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#: model:ir.model.fields,help:spp_attendance.field_spp_import_attendance_wizard__raw_body_for_import +#, python-format +msgid "The raw body to be sent to the import URL. Must be in JSON format." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__company_registry +msgid "" +"The registry number of the company. Use it if it is different from the Tax " +"ID. It must be unique across all partners of a same country" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_stock_customer +msgid "" +"The stock location used as destination when sending goods to this contact." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_stock_supplier +msgid "" +"The stock location used as source when receiving goods from this contact." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_account_payable_id +msgid "" +"This account will be used instead of the default one as the payable account " +"for the current partner" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_account_receivable_id +msgid "" +"This account will be used instead of the default one as the receivable " +"account for the current partner" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__email_normalized +msgid "" +"This field is used to search on email address as the primary email field can" +" contain more than strictly an email address." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_supplier_payment_term_id +msgid "" +"This payment term will be used instead of the default one for purchase " +"orders and vendor bills" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_payment_term_id +msgid "" +"This payment term will be used instead of the default one for sales orders " +"and customer invoices" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__property_product_pricelist +msgid "" +"This pricelist will be used, instead of the default one, for sales to the " +"current partner" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_time +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "Time" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__tz +msgid "Timezone" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__tz_offset +msgid "Timezone offset" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__title +msgid "Title" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__total_invoiced +msgid "Total Invoiced" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__debit +msgid "Total Payable" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__credit +msgid "Total Receivable" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__credit +msgid "Total amount this customer owes you." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__debit +msgid "Total amount you have to pay to this vendor." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_list__attendance_type_id +msgid "Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "" + +#. module: spp_attendance +#: model_terms:ir.ui.view,arch_db:spp_attendance.spp_attendance_res_config_settings_view_form +msgid "URL and Auth Settings" +msgstr "" + +#. module: spp_attendance +#. odoo-python +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#: code:addons/spp_attendance/wizard/import_attendance.py:0 +#, python-format +msgid "" +"Unable to connect to the server. Please check your internet connection." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__date_unique +msgid "Unique Date" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__location_unique +msgid "Unique Location" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__time_unique +msgid "Unique Time" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_res_config_settings__type_unique +msgid "Unique Type" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__peppol_endpoint +msgid "" +"Unique identifier used by the BIS Billing 3.0 and its derivatives, also " +"known as 'Endpoint ID'." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__barcode +msgid "Use a barcode to identify this contact." +msgstr "" + +#. module: spp_attendance +#: model:ir.module.category,description:spp_attendance.attendance_module_management +msgid "User access level for Attendance" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__user_ids +msgid "Users" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_stock_supplier +msgid "Vendor Location" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__property_supplier_payment_term_id +msgid "Vendor Payment Terms" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_api_client_credential__show_button_clicked +msgid "Viewed?" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__website_published +msgid "Visible on current website" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__visitor_ids +msgid "Visitors" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__website_id +msgid "Website" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__website +msgid "Website Link" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__website_url +msgid "Website URL" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__website_message_ids +msgid "Website communication history" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__tz +msgid "" +"When printing documents and exporting/importing data, time values are computed according to this timezone.\n" +"If the timezone is not set, UTC (Coordinated Universal Time) is used.\n" +"Anywhere else, time values are computed according to the time offset of your web client." +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,field_description:spp_attendance.field_spp_attendance_subscriber__zip +msgid "Zip" +msgstr "" + +#. module: spp_attendance +#: model:ir.model.fields,help:spp_attendance.field_spp_attendance_subscriber__days_sales_outstanding +msgid "" +"[(Total Receivable/Total Revenue) * number of days since the first invoice] " +"for this customer" +msgstr "" diff --git a/spp_attendance/migrations/19.0.2.0.0/post-migration.py b/spp_attendance/migrations/19.0.2.0.0/post-migration.py new file mode 100644 index 00000000..09572aee --- /dev/null +++ b/spp_attendance/migrations/19.0.2.0.0/post-migration.py @@ -0,0 +1,36 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Hash existing plaintext API client secrets in place. + +Before 19.0.2.0.0 the client secret was stored in plaintext. This migration +computes the scrypt hash for every credential that still carries a plaintext +secret and then blanks the plaintext column. Clients keep authenticating with +their unchanged secrets; only recoverability of the plaintext disappears. + +Idempotent: rows that already have a hash and no plaintext are untouched. +""" + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + env = api.Environment(cr, SUPERUSER_ID, {}) + Credential = env["spp.attendance.api.client.credential"] + + credentials = Credential.search([("client_secret", "!=", False)]) + hashed = 0 + for credential in credentials: + vals = {"client_secret": False} + if not credential.client_secret_hash: + vals["client_secret_hash"] = Credential._hash_secret(credential.client_secret) + hashed += 1 + credential.write(vals) + + _logger.info( + "spp_attendance: hashed %d plaintext client secret(s), scrubbed %d record(s)", + hashed, + len(credentials), + ) diff --git a/spp_attendance/models/__init__.py b/spp_attendance/models/__init__.py new file mode 100644 index 00000000..81f811d7 --- /dev/null +++ b/spp_attendance/models/__init__.py @@ -0,0 +1,7 @@ +from . import attendance_subscriber +from . import attendance_list +from . import attendance_api_client_credentials +from . import attendance_type +from . import registry_config +from . import attendance_location +from . import res_partner diff --git a/spp_attendance/models/attendance_api_client_credentials.py b/spp_attendance/models/attendance_api_client_credentials.py new file mode 100644 index 00000000..470c559f --- /dev/null +++ b/spp_attendance/models/attendance_api_client_credentials.py @@ -0,0 +1,181 @@ +import base64 +import calendar +import hashlib +import hmac +import os +import secrets +import uuid +from datetime import datetime, timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from odoo.addons.spp_oauth.tools import calculate_signature + +TOKEN_EXPIRATION_MIN = os.getenv("ATTENDANCE_TOKEN_EXPIRATION_MIN", 10) + +# scrypt parameters (memory-hard, GPU-resistant) — same construction as +# spp_api_v2's spp.api.client so secret handling is uniform across the stack +SCRYPT_N = 16384 # CPU/memory cost factor (must be power of 2) +SCRYPT_R = 8 # Block size +SCRYPT_P = 1 # Parallelization factor +SCRYPT_DKLEN = 64 # Derived key length + + +class AttendanceApiClientCredential(models.Model): + _name = "spp.attendance.api.client.credential" + _description = "SPP Attendance Client Credential" + + @api.model + def _generate_client_id(self): + client_id = str(uuid.uuid4()) + while self.search_count([("client_id", "=", client_id)]): + client_id = str(uuid.uuid4()) + + return f"c-id-{client_id}" + + @api.model + def _generate_client_secret(self): + return f"c-secret-{secrets.token_urlsafe(32)}" + + name = fields.Char("Client Name", required=True) + + # bearer fields + client_id = fields.Char(required=True, readonly=True, default=_generate_client_id) + # Plaintext secret exists only between creation and the one-time display; + # it is scrubbed as soon as the credentials are shown. Only the scrypt + # hash below is kept for authentication. + client_secret = fields.Char(readonly=True) + client_secret_hash = fields.Char(readonly=True) + + show_button_clicked = fields.Boolean("Viewed?") + + _name_uniq = models.Constraint("unique(name)", "Client Name must be unique!") + _client_id_uniq = models.Constraint("unique(client_id)", "Client ID must be unique!") + + ALLOW_EXPORT = False + + @api.model + def _hash_secret(self, secret): + """Hash a secret using scrypt. + + Format: $scrypt$salt_base64$hash_base64 + """ + salt = os.urandom(16) + hash_bytes = hashlib.scrypt( + secret.encode("utf-8"), + salt=salt, + n=SCRYPT_N, + r=SCRYPT_R, + p=SCRYPT_P, + dklen=SCRYPT_DKLEN, + ) + salt_b64 = base64.b64encode(salt).decode("ascii") + hash_b64 = base64.b64encode(hash_bytes).decode("ascii") + return f"$scrypt${salt_b64}${hash_b64}" + + @api.model + def _verify_secret(self, secret, secret_hash): + """Verify a secret against its scrypt hash in constant time.""" + if not secret or not secret_hash: + return False + try: + _empty, scheme, salt_b64, hash_b64 = secret_hash.split("$") + if scheme != "scrypt": + return False + salt = base64.b64decode(salt_b64) + expected = base64.b64decode(hash_b64) + except (ValueError, TypeError): + return False + + candidate = hashlib.scrypt( + secret.encode("utf-8"), + salt=salt, + n=SCRYPT_N, + r=SCRYPT_R, + p=SCRYPT_P, + dklen=SCRYPT_DKLEN, + ) + return hmac.compare_digest(candidate, expected) + + @api.model + def authenticate(self, client_id, client_secret): + """Return the matching credential record, or an empty recordset. + + Lookup is by client_id only; the secret is verified against the + stored scrypt hash — plaintext secrets are never compared or stored. + """ + credential = self.search([("client_id", "=", client_id)], limit=1) + if not credential: + return self.browse() + if not self._verify_secret(client_secret, credential.client_secret_hash): + return self.browse() + return credential + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get("client_secret"): + vals["client_secret"] = self._generate_client_secret() + vals["client_secret_hash"] = self._hash_secret(vals["client_secret"]) + # Plaintext stays only until show_credentials() scrubs it + return super().create(vals_list) + + @api.model + def generate_access_token(self): + today = datetime.today() + expiry_datetime = today + timedelta(minutes=TOKEN_EXPIRATION_MIN) + + header = {"alg": "RS256", "typ": "JWT"} + payload = { + "iat": calendar.timegm(today.timetuple()), + "exp": calendar.timegm(expiry_datetime.timetuple()), + "iss": "openspp:auth-service", + } + + return calculate_signature(self.env, header, payload) + + def _open_show_credentials_wizard(self, client_secret): + """Open the one-time display wizard and scrub the stored plaintext.""" + self.ensure_one() + wizard = self.env["spp.attendance.show.credential.wizard"].create( + { + "credential_id": self.id, + "client_name": self.name, + "display_client_id": self.client_id, + "display_client_secret": client_secret, + } + ) + # Scrub before the dialog even renders: the wizard record carries the + # value; the credential record keeps only the hash from here on. + self.write({"client_secret": False, "show_button_clicked": True}) + return { + "type": "ir.actions.act_window", + "name": _("Attendance Client Credentials"), + "res_model": "spp.attendance.show.credential.wizard", + "res_id": wizard.id, + "view_mode": "form", + "view_id": self.env.ref("spp_attendance.view_spp_attendance_show_credential_wizard_form").id, + "target": "new", + } + + def show_credentials(self): + self.ensure_one() + + if self.show_button_clicked or not self.client_secret: + raise UserError(_("Client ID and Client Secret is already showed once.")) + + return self._open_show_credentials_wizard(self.client_secret) + + def action_regenerate_secret(self): + """Rotate the secret: store the new hash, display the plaintext once.""" + self.ensure_one() + new_secret = self._generate_client_secret() + self.write({"client_secret_hash": self._hash_secret(new_secret)}) + return self._open_show_credentials_wizard(new_secret) + + def export_data(self, fields_to_export): + if not self.ALLOW_EXPORT: + raise UserError(_("Not allowed to export on this model.")) + + return super().export_data(fields_to_export) diff --git a/spp_attendance/models/attendance_list.py b/spp_attendance/models/attendance_list.py new file mode 100644 index 00000000..9613d7b9 --- /dev/null +++ b/spp_attendance/models/attendance_list.py @@ -0,0 +1,63 @@ +from odoo import _, fields, models + + +class Attendance(models.Model): + _name = "spp.attendance.list" + _description = "Attendance List" + + PRESENT = "present" + ABSENT = "absent" + ALLOWED_CATEGORIES = [PRESENT, ABSENT] + + subscriber_id = fields.Many2one("spp.attendance.subscriber", required=True, readonly=True) + attendance_date = fields.Date(required=True, default=lambda self: fields.Date.today(), string="Date") + attendance_time = fields.Char(required=True, string="Time", default="00:00:00") + + attendance_type_id = fields.Many2one("spp.attendance.type", string="Type") + attendance_location_id = fields.Many2one("spp.attendance.location", string="Location") + attendance_description = fields.Text(string="Description") + attendance_external_url = fields.Char(string="External URL") + + submitted_by = fields.Char(required=True) + submitted_datetime = fields.Char( + required=True, default=lambda self: fields.Datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + submission_source = fields.Char() + attendance_category = fields.Selection( + selection=[(PRESENT, _("Present")), (ABSENT, _("Absent"))], + string="Category", + default=PRESENT, + required=True, + ) + + def get_unique_domain(self): + self.ensure_one() + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + date_unique = self.env["ir.config_parameter"].sudo().get_param("spp_attendance.date_unique") + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + time_unique = self.env["ir.config_parameter"].sudo().get_param("spp_attendance.time_unique") + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + type_unique = self.env["ir.config_parameter"].sudo().get_param("spp_attendance.type_unique") + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + location_unique = self.env["ir.config_parameter"].sudo().get_param("spp_attendance.location_unique") + + domain = [] + if date_unique: + domain.append(("attendance_date", "=", self.attendance_date)) + if time_unique: + domain.append(("attendance_time", "=", self.attendance_time)) + if type_unique: + domain.append(("attendance_type_id", "=", self.attendance_type_id.id)) + if location_unique: + domain.append(("attendance_location_id", "=", self.attendance_location_id.id)) + return domain + + def check_uniqueness(self, domain=None): + for rec in self: + domain = rec.get_unique_domain() + if domain: + new_domain = domain + [("subscriber_id", "=", rec.subscriber_id.id)] + count = self.search_count(new_domain) + if count > 0: + return False + return True diff --git a/spp_attendance/models/attendance_location.py b/spp_attendance/models/attendance_location.py new file mode 100644 index 00000000..c04c46cf --- /dev/null +++ b/spp_attendance/models/attendance_location.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class SPPAttendanceLocation(models.Model): + _name = "spp.attendance.location" + _description = "Attendance Location" + _order = "name ASC" + + name = fields.Char(required=True) + description = fields.Char() diff --git a/spp_attendance/models/attendance_subscriber.py b/spp_attendance/models/attendance_subscriber.py new file mode 100644 index 00000000..86e1e61b --- /dev/null +++ b/spp_attendance/models/attendance_subscriber.py @@ -0,0 +1,189 @@ +from odoo import api, fields, models + + +class AttendanceSubscriber(models.Model): + _name = "spp.attendance.subscriber" + _inherits = { + "res.partner": "partner_id", + } + _description = "Attendance Subscriber" + + name = fields.Char(related="partner_id.name", inherited=True) + active = fields.Boolean(default=True) + partner_id = fields.Many2one("res.partner", required=True, ondelete="restrict", readonly=True) + attendance_list_ids = fields.One2many("spp.attendance.list", "subscriber_id") + person_identifier = fields.Char(required=True, inverse="_inverse_person_identifier", store=True) + + partner_name = fields.Char(compute="_compute_partner_name", string="Full Name") + family_name = fields.Char(related="partner_id.family_name", inherited=True, required=True, readonly=False) + given_name = fields.Char(related="partner_id.given_name", inherited=True, required=True, readonly=False) + addl_name = fields.Char(related="partner_id.addl_name", inherited=True, readonly=False) + email = fields.Char( + related="partner_id.email", inherited=True, compute="_compute_partner", inverse="_inverse_partner", store=True + ) + phone = fields.Char( + related="partner_id.phone", inherited=True, compute="_compute_partner", inverse="_inverse_partner", store=True + ) + # Odoo 19 removed partner.mobile; mirror the main phone instead to keep API compatibility + mobile = fields.Char( + related="partner_id.phone", inherited=True, compute="_compute_partner", inverse="_inverse_partner", store=True + ) + gender_char = fields.Char(string="Gender", related="partner_id.gender_char", inherited=True, readonly=False) + + _partner_id_uniq = models.Constraint("unique(partner_id)", "A subscriber with the same partner already exists.") + _person_identifier_uniq = models.Constraint( + "unique(person_identifier)", + "A subscriber with the same person identifier already exists.", + ) + + def _inverse_person_identifier(self): + for record in self: + record.partner_id.write({"identifier": record.person_identifier}) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if "partner_id" not in vals: + partner_name = f"{vals.get('family_name')}, {vals.get('given_name')}" + if vals.get("addl_name"): + partner_name += f" {vals.get('addl_name')}" + if partner_id := self.env["res.partner"].search( + [ + ("family_name", "=", vals.get("family_name")), + ("given_name", "=", vals.get("given_name")), + ("identifier", "=", vals.get("identifier")), + ], + limit=1, + ): + vals["partner_id"] = partner_id.id + else: + partner_id = self.env["res.partner"].create( + { + "name": partner_name, + "family_name": vals.get("family_name"), + "given_name": vals.get("given_name"), + "addl_name": vals.get("addl_name"), + "email": vals.get("email"), + "phone": vals.get("phone") or vals.get("mobile"), + "identifier": vals.get("person_identifier"), + "gender_char": (vals.get("gender_char") or "").title() or False, + } + ) + vals["partner_id"] = partner_id.id + return super().create(vals_list) + + @api.depends("partner_id.name", "partner_id.email", "partner_id.phone") + def _compute_partner(self): + for record in self: + if record.partner_id: + record.write( + { + "email": record.partner_id.email, + "phone": record.partner_id.phone, + "mobile": record.partner_id.phone, + } + ) + + def _inverse_partner(self): + for record in self: + if record.partner_id: + record.partner_id.write( + { + "name": f"{record.family_name}, {record.given_name}", + "email": record.email, + "phone": record.phone or record.mobile, + } + ) + + @api.depends("family_name", "given_name", "addl_name") + def _compute_partner_name(self): + for record in self: + if record.family_name and record.given_name: + record.partner_name = f"{record.family_name}, {record.given_name}" + if record.addl_name: + record.partner_name += f" {record.addl_name}" + else: + record.partner_name = "" + + def get_attendance_list( + self, + from_date=None, + to_date=None, + attendance_type_id=None, + attendance_location_id=None, + offset=0, + limit=None, + ): + domain = [("subscriber_id", "=", self.id)] + if from_date and to_date: + domain += [("attendance_date", ">=", from_date), ("attendance_date", "<=", to_date)] + if attendance_type_id: + domain += [("attendance_type_id", "=", attendance_type_id)] + if attendance_location_id: + domain += [("attendance_location_id", "=", attendance_location_id)] + + attendance_list_ids = self.env["spp.attendance.list"].search( + domain, offset=offset, limit=limit, order="attendance_date desc, attendance_time desc" + ) + # nosemgrep: odoo-sudo-without-context — count/read of the subscriber's own attendance for API serialization + total_attendances = self.env["spp.attendance.list"].sudo().search_count(domain) + + present_domain = domain + [("attendance_category", "=", "present")] + number_of_days_present = list( + # nosemgrep: odoo-sudo-without-context — count/read of the subscriber's own attendance for API serialization + set(self.env["spp.attendance.list"].sudo().search(present_domain).mapped("attendance_date")) + ) + + return total_attendances, { + "person_id": self.person_identifier, + "dates_present": number_of_days_present, + "number_of_days_present": len(number_of_days_present), + "attendance_list": [ + { + "id": attendance.id, + "date": attendance.attendance_date, + "time": attendance.attendance_time, + "attendance_type": { + "id": attendance.attendance_type_id.id, + "name": attendance.attendance_type_id.name, + "description": attendance.attendance_type_id.description, + } + if attendance.attendance_type_id + else {}, + "attendance_location": { + "id": attendance.attendance_location_id.id, + "name": attendance.attendance_location_id.name, + "description": attendance.attendance_location_id.description, + } + if attendance.attendance_location_id + else {}, + "attendance_description": attendance.attendance_description or "", + "attendance_external_url": attendance.attendance_external_url or "", + "submitted_by": attendance.submitted_by, + "submitted_datetime": attendance.submitted_datetime, + "submission_source": attendance.submission_source or "", + "attendance_category": attendance.attendance_category, + } + for attendance in attendance_list_ids + ], + } + + def get_attendance_info_list(self): + attendance_infos = [] + for subscriber in self: + attendance_infos.append(subscriber.get_attendance_subscriber_info()) + return attendance_infos + + def get_attendance_subscriber_info(self): + self.ensure_one() + return { + # Use the external person identifier as the public ID instead of the database ID. + "id": self.person_identifier, + "person_id": self.person_identifier, + "name": self.partner_name, + "family_name": self.family_name, + "given_name": self.given_name, + "email": self.email, + "phone": self.phone, + "mobile": self.mobile, + } diff --git a/spp_attendance/models/attendance_type.py b/spp_attendance/models/attendance_type.py new file mode 100644 index 00000000..a0fc4eff --- /dev/null +++ b/spp_attendance/models/attendance_type.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class SPPAttendanceType(models.Model): + _name = "spp.attendance.type" + _description = "Attendance Type" + _order = "name ASC" + + name = fields.Char(required=True) + description = fields.Char() + + _name_uniq = models.Constraint("unique(name)", "Attendance Type must be unique!") diff --git a/spp_attendance/models/registry_config.py b/spp_attendance/models/registry_config.py new file mode 100644 index 00000000..db57eb35 --- /dev/null +++ b/spp_attendance/models/registry_config.py @@ -0,0 +1,123 @@ +from odoo import _, api, fields, models + + +class RegistryConfig(models.TransientModel): + _inherit = "res.config.settings" + + server_url = fields.Char( + string="Server URL", + config_parameter="spp_attendance.server_url", + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + default=lambda self: self.env["ir.config_parameter"].sudo().get_param("web.base.url"), + ) + + attendance_auth_endpoint = fields.Char( + string="Auth Endpoint", + config_parameter="spp_attendance.attendance_auth_endpoint", + default="/oauth2/client/token", + ) + access_token_mapping = fields.Char( + config_parameter="spp_attendance.access_token_mapping", + default="access_token", + ) + attendance_import_endpoint = fields.Char( + string="Import Endpoint", + config_parameter="spp_attendance.attendance_import_endpoint", + default="/registry/sync/search", + ) + + personal_information_mapping = fields.Char( + string="Personal Information", + config_parameter="spp_attendance.personal_information_mapping", + default="message.search_response.0.data.reg_records", + ) + person_identifier_mapping = fields.Char( + string="Person Identifier", + config_parameter="spp_attendance.person_identifier_mapping", + default="identifier.0.identifier", + ) + family_name_mapping = fields.Char( + string="Family Name", + config_parameter="spp_attendance.family_name_mapping", + default="familyName", + ) + given_name_mapping = fields.Char( + string="Given Name", + config_parameter="spp_attendance.given_name_mapping", + default="givenName", + ) + email_mapping = fields.Char( + string="Email", + config_parameter="spp_attendance.email_mapping", + default="email", + ) + phone_mapping = fields.Char( + string="Phone", + config_parameter="spp_attendance.phone_mapping", + default="phoneNumbers.0.phone", + ) + gender_mapping = fields.Char( + string="Gender", + config_parameter="spp_attendance.gender_mapping", + default="sex", + ) + + is_date_unique = fields.Boolean( + string="Unique Date", + config_parameter="spp_attendance.date_unique", + default=False, + inverse="_inverse_unique_fields", + ) + is_time_unique = fields.Boolean( + string="Unique Time", + config_parameter="spp_attendance.time_unique", + default=False, + inverse="_inverse_unique_fields", + ) + is_type_unique = fields.Boolean( + string="Unique Type", + config_parameter="spp_attendance.type_unique", + default=False, + inverse="_inverse_unique_fields", + ) + is_location_unique = fields.Boolean( + string="Unique Location", + config_parameter="spp_attendance.location_unique", + default=False, + ) + + def action_import(self): + self.ensure_one() + + form_id = self.env.ref("spp_attendance.import_attendance_wizard").id + action = { + "name": _("Import Attendance"), + "type": "ir.actions.act_window", + "view_mode": "form", + "view_id": form_id, + "view_type": "form", + "res_model": "spp.import.attendance.wizard", + "target": "new", + } + return action + + @api.onchange("is_date_unique", "is_time_unique", "is_type_unique") + def _inverse_unique_fields(self): + if not self.is_date_unique: + self.is_time_unique = False + self.is_type_unique = False + self.is_location_unique = False + if not self.is_time_unique: + self.is_type_unique = False + self.is_location_unique = False + if not self.is_type_unique: + self.is_location_unique = False + + @api.model + def set_server_url(self): + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + if not self.env["ir.config_parameter"].sudo().get_param("spp_attendance.server_url"): + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + web_base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access + self.env["ir.config_parameter"].sudo().set_param("spp_attendance.server_url", web_base_url) diff --git a/spp_attendance/models/res_partner.py b/spp_attendance/models/res_partner.py new file mode 100644 index 00000000..d9cd6d7a --- /dev/null +++ b/spp_attendance/models/res_partner.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class ResPartnerInherit(models.Model): + _inherit = "res.partner" + + family_name = fields.Char(translate=False) + given_name = fields.Char(translate=False) + addl_name = fields.Char(translate=False, string="Additional Name") + identifier = fields.Char() + gender_char = fields.Char(translate=False, string="Gender") diff --git a/spp_attendance/pyproject.toml b/spp_attendance/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_attendance/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_attendance/readme/DESCRIPTION.md b/spp_attendance/readme/DESCRIPTION.md new file mode 100644 index 00000000..41225ec0 --- /dev/null +++ b/spp_attendance/readme/DESCRIPTION.md @@ -0,0 +1,63 @@ +Tracks participant attendance for social protection program activities. Records presence/absence with date, time, location, and activity type. Provides OAuth-secured API endpoints for external systems to submit attendance data and import participants from external registries. + +**Positioning vs `spp_session_tracking`:** this module is a standalone, API-first attendance +service — it keeps its own participant registry (synced from an external registry) and receives +attendance events from external systems over REST, so it can run on a bare instance without the +program stack. `spp_session_tracking` covers the complementary case: attendance at sessions and +trainings managed *inside* a program instance. The two do not share models and can coexist. + +### Key Capabilities + +- Record attendance with date, time, type, location, and category (present/absent) +- Query attendance history via `get_attendance_list()` with filtering by date range, type, and location +- Import participants from external registries via configurable field mapping +- Generate OAuth client credentials for external API access +- Enforce configurable uniqueness constraints (date/time/type/location) to prevent duplicate records + +### Key Models + +| Model | Description | +| -------------------------------------- | -------------------------------------------------- | +| `spp.attendance.subscriber` | Participant registry, inherits res.partner | +| `spp.attendance.list` | Attendance record with date, time, type, location | +| `spp.attendance.type` | Configurable attendance event types | +| `spp.attendance.location` | Configurable attendance locations | +| `spp.attendance.api.client.credential` | OAuth credentials for external API clients | +| `spp.import.attendance.wizard` | Wizard for importing from external registries | + +### Configuration + +After installing: + +1. Navigate to **Settings > SPP Attendance Settings** to configure: + - Uniqueness constraints (date/time/type/location) + - Server URL and API endpoints + - Field mappings for import (personal information, identifiers, contact details) +2. Create attendance types: **Attendance > Configuration > Attendance Type** +3. Create locations: **Attendance > Configuration > Attendance Location** +4. Generate OAuth credentials: **Settings > Attendance API Client Credentials** + +### UI Location + +- **Main Menu**: Attendance > Subscriber +- **Configuration**: Attendance > Configuration (Attendance Type, Attendance Location) +- **API Credentials**: Settings > Attendance API Client Credentials +- **Settings**: Settings > SPP Attendance Settings +- **Subscriber Form**: Contains "Attendance" and "Person Information" tabs + +### Security + +| Group | Access | +| ---------------------------------------- | --------- | +| `spp_attendance.group_attendance_viewer` | Read | +| `spp_attendance.group_attendance_manager` | Full CRUD | + +### Extension Points + +- Override `get_attendance_list()` on `spp.attendance.subscriber` to customize attendance queries +- Inherit `spp.attendance.list` to add domain-specific attendance metadata +- Extend `_import_attendance()` in wizard to customize import logic + +### Dependencies + +`base`, `spp_oauth`, `spp_security` diff --git a/spp_attendance/readme/HISTORY.md b/spp_attendance/readme/HISTORY.md new file mode 100644 index 00000000..2725c920 --- /dev/null +++ b/spp_attendance/readme/HISTORY.md @@ -0,0 +1,18 @@ +### 19.0.2.0.0 + +- Initial migration from openspp-modules +- fix(security): store API client secrets as scrypt hashes instead of plaintext. Secrets are shown + once at creation/regeneration and can no longer be read back afterwards — including secrets that + existed before the upgrade, which a migration hashes in place. Clients keep authenticating with + their unchanged secrets. The Attendance Viewer group's read access to the credential model is + removed. +- fix: the REST API works on Odoo 19 again — every response with a body crashed with + `AttributeError` because `date_utils.json_default` was removed from Odoo +- fix: gender is never fabricated as "Male" — the res.partner field default, the subscriber + related-field default, the registry-import fallback and the subscriber-create fallback all + stamped it when gender was unknown; the value now comes only from source data +- fix(wizard): a missing sync-configuration parameter raises the intended error message instead of + crashing with `KeyError`; access tokens already carrying a `Basic` scheme are no longer + double-prefixed +- fix(wizard): registry sync requests carry a 30s timeout so a hung remote registry cannot freeze + the worker diff --git a/spp_attendance/readme/USAGE.md b/spp_attendance/readme/USAGE.md new file mode 100644 index 00000000..21c03ddb --- /dev/null +++ b/spp_attendance/readme/USAGE.md @@ -0,0 +1,28 @@ +### Syncing participants from a registry + +The participant import (Settings > SPP Attendance Settings, used by the *Import Attendance* +wizard) is fully configuration-driven: an auth endpoint, a search endpoint, and JSON-path mappings +into the response. The shipped defaults target the SPDCI-style interface of a legacy +openspp-modules registry (`/oauth2/client/token` + `/registry/sync/search`). + +To sync from an **OpenSPP2** registry instead, point the settings at the DCI server modules +(`spp_dci_server` + `spp_dci_server_social` must be installed on the registry instance): + +| Setting | Value for an OpenSPP2 registry | +| --- | --- | +| Server URL | `https://` | +| Auth Endpoint | `/api/v2/spp/oauth/token` (spp_api_v2 client-credentials endpoint) | +| Import Endpoint | `/dci_api/v1/registry/sync/search` | + +The JSON-path mappings (personal information, identifier, names, contact fields) must match the +DCI search response envelope of the target registry; adjust them from the defaults as needed. + +### API access for external systems + +1. Create client credentials under **Attendance > Configuration > API Clients**. The client secret + is displayed **once** at creation (and on regeneration) — store it securely; only a hash is kept. +2. Obtain a token: `POST /auth/token` with `client_id`/`client_secret`. +3. Call the attendance endpoints with the token in the `Authorization` header (`Bearer` scheme). + +Signing keys come from `spp_oauth` (Settings > General Settings > OpenSPP OAuth); the RSA keypair +must be configured before tokens can be issued. diff --git a/spp_attendance/security/ir.model.access.csv b/spp_attendance/security/ir.model.access.csv new file mode 100644 index 00000000..03fbaaa1 --- /dev/null +++ b/spp_attendance/security/ir.model.access.csv @@ -0,0 +1,14 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +access_spp_attendance_subscriber_manager,Attendance Subscriber Manager,model_spp_attendance_subscriber,spp_attendance.group_attendance_manager,1,1,1,1 +access_spp_attendance_list_manager,Attendance List Manager,model_spp_attendance_list,spp_attendance.group_attendance_manager,1,1,1,1 +access_spp_attendance_api_client_credential_manager,Attendance API Client Manager,model_spp_attendance_api_client_credential,spp_attendance.group_attendance_manager,1,1,1,1 +access_spp_attendance_show_credential_wizard_manager,Show Credential Wizard Manager,model_spp_attendance_show_credential_wizard,spp_attendance.group_attendance_manager,1,1,1,1 +access_spp_import_attendance_wizard_manager,Import Attendance Wizard Manager,model_spp_import_attendance_wizard,spp_attendance.group_attendance_manager,1,1,1,1 +access_spp_attendance_type_manager,Attendance Type Manager,model_spp_attendance_type,spp_attendance.group_attendance_manager,1,1,1,1 +access_spp_attendance_location_manager,Attendance Location Manager,model_spp_attendance_location,spp_attendance.group_attendance_manager,1,1,1,1 + +access_spp_attendance_subscriber_viewer,Attendance Subscriber Viewer,model_spp_attendance_subscriber,spp_attendance.group_attendance_viewer,1,0,0,0 +access_spp_attendance_list_viewer,Attendance List Viewer,model_spp_attendance_list,spp_attendance.group_attendance_viewer,1,0,0,0 +access_spp_attendance_type_viewer,Attendance Type Viewer,model_spp_attendance_type,spp_attendance.group_attendance_viewer,1,0,0,0 +access_spp_attendance_location_viewer,Attendance Location Viewer,model_spp_attendance_location,spp_attendance.group_attendance_viewer,1,0,0,0 diff --git a/spp_attendance/security/privileges.xml b/spp_attendance/security/privileges.xml new file mode 100644 index 00000000..b2f980ba --- /dev/null +++ b/spp_attendance/security/privileges.xml @@ -0,0 +1,15 @@ + + + + + Viewer + + 10 + + + + Manager + + 20 + + diff --git a/spp_attendance/security/security.xml b/spp_attendance/security/security.xml new file mode 100644 index 00000000..43064188 --- /dev/null +++ b/spp_attendance/security/security.xml @@ -0,0 +1,48 @@ + + + + + Viewer + + Can view attendance records. + + + + Manager + + Full attendance management including all CRUD operations. + + + + + + + + + + + Attendance Viewer (Deprecated) + Deprecated: Use group_attendance_viewer instead. + + + + + Attendance Manager (Deprecated) + Deprecated: Use group_attendance_manager instead. + + + diff --git a/spp_attendance/static/description/icon-transparent.png b/spp_attendance/static/description/icon-transparent.png new file mode 100644 index 00000000..35f8fec2 Binary files /dev/null and b/spp_attendance/static/description/icon-transparent.png differ diff --git a/spp_attendance/static/description/icon.png b/spp_attendance/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_attendance/static/description/icon.png differ diff --git a/spp_attendance/static/description/index.html b/spp_attendance/static/description/index.html new file mode 100644 index 00000000..9f285c44 --- /dev/null +++ b/spp_attendance/static/description/index.html @@ -0,0 +1,627 @@ + + + + + +OpenSPP Attendance + + + +
+

OpenSPP Attendance

+ + +

Alpha License: LGPL-3 OpenSPP/OpenSPP2

+

Tracks participant attendance for social protection program activities. +Records presence/absence with date, time, location, and activity type. +Provides OAuth-secured API endpoints for external systems to submit +attendance data and import participants from external registries.

+

Positioning vs ``spp_session_tracking``: this module is a +standalone, API-first attendance service — it keeps its own participant +registry (synced from an external registry) and receives attendance +events from external systems over REST, so it can run on a bare instance +without the program stack. spp_session_tracking covers the +complementary case: attendance at sessions and trainings managed +inside a program instance. The two do not share models and can +coexist.

+
+

Key Capabilities

+
    +
  • Record attendance with date, time, type, location, and category +(present/absent)
  • +
  • Query attendance history via get_attendance_list() with filtering +by date range, type, and location
  • +
  • Import participants from external registries via configurable field +mapping
  • +
  • Generate OAuth client credentials for external API access
  • +
  • Enforce configurable uniqueness constraints (date/time/type/location) +to prevent duplicate records
  • +
+
+
+

Key Models

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + +
ModelDescription
spp.attendance.subscriberParticipant registry, inherits +res.partner
spp.attendance.listAttendance record with date, +time, type, location
spp.attendance.typeConfigurable attendance event +types
spp.attendance.locationConfigurable attendance +locations
spp.attendance.api.client.credentialOAuth credentials for external +API clients
spp.import.attendance.wizardWizard for importing from +external registries
+
+
+

Configuration

+

After installing:

+
    +
  1. Navigate to Settings > SPP Attendance Settings to configure:
      +
    • Uniqueness constraints (date/time/type/location)
    • +
    • Server URL and API endpoints
    • +
    • Field mappings for import (personal information, identifiers, +contact details)
    • +
    +
  2. +
  3. Create attendance types: Attendance > Configuration > Attendance +Type
  4. +
  5. Create locations: Attendance > Configuration > Attendance +Location
  6. +
  7. Generate OAuth credentials: Settings > Attendance API Client +Credentials
  8. +
+
+
+

UI Location

+
    +
  • Main Menu: Attendance > Subscriber
  • +
  • Configuration: Attendance > Configuration (Attendance Type, +Attendance Location)
  • +
  • API Credentials: Settings > Attendance API Client Credentials
  • +
  • Settings: Settings > SPP Attendance Settings
  • +
  • Subscriber Form: Contains “Attendance” and “Person Information” +tabs
  • +
+
+
+

Security

+ ++++ + + + + + + + + + + + + + +
GroupAccess
spp_attendance.group_attendance_viewerRead
spp_attendance.group_attendance_managerFull CRUD
+
+
+

Extension Points

+
    +
  • Override get_attendance_list() on spp.attendance.subscriber to +customize attendance queries
  • +
  • Inherit spp.attendance.list to add domain-specific attendance +metadata
  • +
  • Extend _import_attendance() in wizard to customize import logic
  • +
+
+
+

Dependencies

+

base, spp_oauth, spp_security

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production.

+
+

Table of contents

+
+ +
+
+

Usage

+
+
+
+

Syncing participants from a registry

+

The participant import (Settings > SPP Attendance Settings, used by the +Import Attendance wizard) is fully configuration-driven: an auth +endpoint, a search endpoint, and JSON-path mappings into the response. +The shipped defaults target the SPDCI-style interface of a legacy +openspp-modules registry (/oauth2/client/token + +/registry/sync/search).

+

To sync from an OpenSPP2 registry instead, point the settings at the +DCI server modules (spp_dci_server + spp_dci_server_social must +be installed on the registry instance):

+ ++++ + + + + + + + + + + + + + + + + +
SettingValue for an OpenSPP2 registry
Server URLhttps://<registry-host>
Auth Endpoint/api/v2/spp/oauth/token (spp_api_v2 +client-credentials endpoint)
Import Endpoint/dci_api/v1/registry/sync/search
+

The JSON-path mappings (personal information, identifier, names, contact +fields) must match the DCI search response envelope of the target +registry; adjust them from the defaults as needed.

+
+
+

API access for external systems

+
    +
  1. Create client credentials under Attendance > Configuration > API +Clients. The client secret is displayed once at creation (and +on regeneration) — store it securely; only a hash is kept.
  2. +
  3. Obtain a token: POST /auth/token with +client_id/client_secret.
  4. +
  5. Call the attendance endpoints with the token in the Authorization +header (Bearer scheme).
  6. +
+

Signing keys come from spp_oauth (Settings > General Settings > +OpenSPP OAuth); the RSA keypair must be configured before tokens can be +issued.

+
+

Changelog

+
+
+
+

19.0.2.0.0

+
    +
  • Initial migration from openspp-modules
  • +
  • fix(security): store API client secrets as scrypt hashes instead of +plaintext. Secrets are shown once at creation/regeneration and can no +longer be read back afterwards — including secrets that existed before +the upgrade, which a migration hashes in place. Clients keep +authenticating with their unchanged secrets. The Attendance Viewer +group’s read access to the credential model is removed.
  • +
  • fix: the REST API works on Odoo 19 again — every response with a body +crashed with AttributeError because date_utils.json_default +was removed from Odoo
  • +
  • fix: gender is never fabricated as “Male” — the res.partner field +default, the subscriber related-field default, the registry-import +fallback and the subscriber-create fallback all stamped it when gender +was unknown; the value now comes only from source data
  • +
  • fix(wizard): a missing sync-configuration parameter raises the +intended error message instead of crashing with KeyError; access +tokens already carrying a Basic scheme are no longer +double-prefixed
  • +
  • fix(wizard): registry sync requests carry a 30s timeout so a hung +remote registry cannot freeze the worker
  • +
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainer:

+

reichie020212

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_attendance/static/src/img/icons/attendance.png b/spp_attendance/static/src/img/icons/attendance.png new file mode 100644 index 00000000..d39f4bc9 Binary files /dev/null and b/spp_attendance/static/src/img/icons/attendance.png differ diff --git a/spp_attendance/tests/__init__.py b/spp_attendance/tests/__init__.py new file mode 100644 index 00000000..7311743f --- /dev/null +++ b/spp_attendance/tests/__init__.py @@ -0,0 +1,8 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_controller_logic +from . import test_credentials +from . import test_http_smoke +from . import test_import_wizard +from . import test_subscriber diff --git a/spp_attendance/tests/common.py b/spp_attendance/tests/common.py new file mode 100644 index 00000000..5b4b78f6 --- /dev/null +++ b/spp_attendance/tests/common.py @@ -0,0 +1,111 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Shared fixtures for spp_attendance tests. + +Provides an RSA keypair for the spp_oauth signing helpers, standard +attendance fixtures, and a mock for ``odoo.http.request`` so controller +logic can be exercised in a plain (writable) TransactionCase — the +HttpCase layer covers read-only end-to-end smoke separately. +""" + +import json +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import patch + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from odoo.addons.spp_attendance.controllers.controllers import SppAttendanceController + + +def generate_rsa_keypair(): + """Return (private_pem, public_pem) strings for test signing.""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + public_pem = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode() + ) + return private_pem, public_pem + + +class AttendanceFixtureMixin: + """setUpClass fixtures shared by the test cases.""" + + @classmethod + def _setup_oauth_keys(cls): + private_pem, public_pem = generate_rsa_keypair() + icp = cls.env["ir.config_parameter"].sudo() + icp.set_param("spp_oauth.oauth_priv_key", private_pem) + icp.set_param("spp_oauth.oauth_pub_key", public_pem) + + @classmethod + def _setup_attendance_fixtures(cls): + cls.credential = cls.env["spp.attendance.api.client.credential"].create({"name": "Test Client"}) + cls.attendance_type = cls.env["spp.attendance.type"].create({"name": "Training"}) + cls.attendance_location = cls.env["spp.attendance.location"].create({"name": "Community Hall"}) + cls.subscriber = cls.env["spp.attendance.subscriber"].create( + { + "person_identifier": "PID-0001", + "family_name": "Dela Cruz", + "given_name": "Juan", + } + ) + + @classmethod + def _make_bearer_token(cls): + return cls.env["spp.attendance.api.client.credential"].generate_access_token() + + +class ControllerTestMixin(AttendanceFixtureMixin): + """Call controller methods directly with a mocked odoo.http.request.""" + + @classmethod + def _setup_controller(cls): + cls.controller = SppAttendanceController() + + @contextmanager + def mock_request(self, body=None, headers=None, token=None): + """Patch the controller module's ``request`` global. + + Args: + body: dict serialized to the raw request body (or a raw string) + headers: extra HTTP headers + token: bearer token; use "__valid__" for a freshly signed one + """ + all_headers = dict(headers or {}) + if token == "__valid__": + token = self._make_bearer_token() + if token: + all_headers["Authorization"] = f"Bearer {token}" + + if body is None: + raw = b"{}" + elif isinstance(body, (bytes, str)): + raw = body if isinstance(body, bytes) else body.encode() + else: + raw = json.dumps(body).encode() + + fake_request = SimpleNamespace( + env=self.env, + httprequest=SimpleNamespace(data=raw, headers=all_headers), + ) + with patch( + "odoo.addons.spp_attendance.controllers.controllers.request", + fake_request, + ): + yield fake_request + + @staticmethod + def response_json(response): + """Decode a werkzeug Response produced by the controller.""" + return json.loads(response.get_data(as_text=True) or "null") diff --git a/spp_attendance/tests/test_controller_logic.py b/spp_attendance/tests/test_controller_logic.py new file mode 100644 index 00000000..502d7b2d --- /dev/null +++ b/spp_attendance/tests/test_controller_logic.py @@ -0,0 +1,332 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Controller logic tests (Layer A of the hybrid strategy). + +Controller methods are called directly with a mocked ``request`` so the +env stays writable and every validation/error branch — including the +write endpoints — is exercised. End-to-end HTTP fidelity is covered by +the HttpCase smoke layer in test_http_smoke.py. +""" + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from .common import ControllerTestMixin + + +@tagged("post_install", "-at_install") +class TestControllerLogic(ControllerTestMixin, TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_oauth_keys() + cls._setup_attendance_fixtures() + cls._setup_controller() + + # ------------------------------------------------------------------ + # /auth/token + # ------------------------------------------------------------------ + def test_token_mint_success(self): + body = { + "client_id": self.credential.client_id, + "client_secret": self.credential.client_secret, + } + with self.mock_request(body=body): + response = self.controller.auth_get_access_token() + self.assertEqual(response.status_code, 200) + data = self.response_json(response) + self.assertEqual(data["token_type"], "Bearer") + self.assertTrue(data["access_token"]) + + def test_token_mint_wrong_secret(self): + body = {"client_id": self.credential.client_id, "client_secret": "nope"} + with self.mock_request(body=body): + response = self.controller.auth_get_access_token() + self.assertEqual(response.status_code, 401) + + def test_token_mint_missing_fields(self): + with self.mock_request(body={"client_id": "only-id"}): + response = self.controller.auth_get_access_token() + self.assertEqual(response.status_code, 400) + + def test_token_mint_malformed_body(self): + with self.mock_request(body="{not json"): + response = self.controller.auth_get_access_token() + self.assertEqual(response.status_code, 400) + + # ------------------------------------------------------------------ + # auth gate + # ------------------------------------------------------------------ + def test_protected_routes_reject_bad_bearer(self): + cases = [ + lambda: self.controller.create_attendance_list(), + lambda: self.controller.update_attendance_list(), + lambda: self.controller.delete_attendance_list(ids="1"), + lambda: self.controller.attendance_list_person("PID-0001"), + lambda: self.controller.attendance_list(), + lambda: self.controller.get_attendance_types(), + lambda: self.controller.get_attendance_locations(), + lambda: self.controller.get_subscriber_list_information(), + lambda: self.controller.get_subscriber_information("PID-0001"), + ] + for call in cases: + with self.mock_request(body={}, token="garbage-token"): + response = call() + self.assertEqual(response.status_code, 401) + + # ------------------------------------------------------------------ + # POST /attendances + # ------------------------------------------------------------------ + def _attendance_body(self, **overrides): + body = { + "submitted_by": "tester", + "submitted_datetime": "2026-08-18 08:00:00", + "records": [ + { + "person_id": "PID-0001", + "time_card": [ + { + "date_time": "2026-08-01 08:00:00", + "attendance_type": str(self.attendance_type.id), + "attendance_location": str(self.attendance_location.id), + } + ], + } + ], + } + body.update(overrides) + return body + + def test_create_attendance_success(self): + with self.mock_request(body=self._attendance_body(), token="__valid__"): + response = self.controller.create_attendance_list() + self.assertEqual(response.status_code, 200) + data = self.response_json(response) + self.assertEqual(data["person_ids"], ["PID-0001"]) + record = self.env["spp.attendance.list"].search([("subscriber_id", "=", self.subscriber.id)]) + self.assertEqual(len(record), 1) + self.assertEqual(str(record.attendance_date), "2026-08-01") + self.assertEqual(record.attendance_category, "present") + + def test_create_attendance_missing_top_level_fields(self): + with self.mock_request(body={"records": []}, token="__valid__"): + response = self.controller.create_attendance_list() + self.assertEqual(response.status_code, 400) + self.assertIn("Missing required fields", self.response_json(response)["error"]["message"]) + + def test_create_attendance_unknown_person(self): + body = self._attendance_body() + body["records"][0]["person_id"] = "PID-UNKNOWN" + with self.mock_request(body=body, token="__valid__"): + response = self.controller.create_attendance_list() + self.assertEqual(response.status_code, 400) + self.assertIn("person_id does not exist", self.response_json(response)["error"]["message"]) + + def test_create_attendance_invalid_category(self): + body = self._attendance_body() + body["records"][0]["time_card"][0]["attendance_category"] = "late" + with self.mock_request(body=body, token="__valid__"): + response = self.controller.create_attendance_list() + self.assertEqual(response.status_code, 400) + self.assertIn("Invalid category", self.response_json(response)["error"]["message"]) + + def test_create_attendance_invalid_type(self): + body = self._attendance_body() + body["records"][0]["time_card"][0]["attendance_type"] = "999999" + with self.mock_request(body=body, token="__valid__"): + response = self.controller.create_attendance_list() + self.assertEqual(response.status_code, 400) + + def test_create_attendance_duplicate_and_ignore_unique(self): + icp = self.env["ir.config_parameter"].sudo() + icp.set_param("spp_attendance.date_unique", "True") + icp.set_param("spp_attendance.time_unique", "True") + + with self.mock_request(body=self._attendance_body(), token="__valid__"): + self.controller.create_attendance_list() + + # same payload again → uniqueness violation + with self.mock_request(body=self._attendance_body(), token="__valid__"): + response = self.controller.create_attendance_list() + self.assertEqual(response.status_code, 400) + self.assertIn("already exists", self.response_json(response)["error"]["message"]) + + # ignore_unique skips duplicates instead of failing + with self.mock_request(body=self._attendance_body(), token="__valid__"): + response = self.controller.create_attendance_list(ignore_unique="true") + self.assertEqual(response.status_code, 200) + count = self.env["spp.attendance.list"].search_count([("subscriber_id", "=", self.subscriber.id)]) + self.assertEqual(count, 1, "duplicate must be skipped, not created") + + # ------------------------------------------------------------------ + # PUT /attendances + # ------------------------------------------------------------------ + def _existing_attendance(self): + return self.env["spp.attendance.list"].create( + { + "subscriber_id": self.subscriber.id, + "attendance_date": "2026-08-02", + "attendance_time": "09:00:00", + "submitted_by": "seed", + } + ) + + def test_update_attendance_success(self): + record = self._existing_attendance() + body = { + "submitted_by": "editor", + "submitted_datetime": "2026-08-18 09:00:00", + "records": [ + { + "id": record.id, + "time_card": { + "date_time": "2026-08-03 10:30:00", + "attendance_category": "absent", + "attendance_description": "updated", + }, + } + ], + } + with self.mock_request(body=body, token="__valid__"): + response = self.controller.update_attendance_list() + self.assertEqual(response.status_code, 200) + self.assertEqual(str(record.attendance_date), "2026-08-03") + self.assertEqual(record.attendance_time, "10:30:00") + self.assertEqual(record.attendance_category, "absent") + self.assertEqual(record.submitted_by, "editor") + + def test_update_attendance_bad_id(self): + body = { + "submitted_by": "editor", + "submitted_datetime": "2026-08-18 09:00:00", + "records": [{"id": "abc", "time_card": {}}], + } + with self.mock_request(body=body, token="__valid__"): + response = self.controller.update_attendance_list() + self.assertEqual(response.status_code, 400) + + body["records"] = [{"id": 999999, "time_card": {}}] + with self.mock_request(body=body, token="__valid__"): + response = self.controller.update_attendance_list() + self.assertEqual(response.status_code, 400) + + def test_update_attendance_time_card_must_be_object(self): + record = self._existing_attendance() + body = { + "submitted_by": "editor", + "submitted_datetime": "2026-08-18 09:00:00", + "records": [{"id": record.id, "time_card": ["not-a-dict"]}], + } + with self.mock_request(body=body, token="__valid__"): + response = self.controller.update_attendance_list() + self.assertEqual(response.status_code, 400) + + # ------------------------------------------------------------------ + # DELETE /attendances + # ------------------------------------------------------------------ + def test_delete_attendance(self): + record = self._existing_attendance() + with self.mock_request(body={}, token="__valid__"): + response = self.controller.delete_attendance_list(ids=str(record.id)) + self.assertEqual(response.status_code, 200) + self.assertFalse(record.exists()) + + def test_delete_attendance_bad_ids(self): + with self.mock_request(body={}, token="__valid__"): + response = self.controller.delete_attendance_list(ids="1,abc") + self.assertEqual(response.status_code, 400) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.delete_attendance_list(ids="999999") + self.assertEqual(response.status_code, 400) + + # ------------------------------------------------------------------ + # GET /attendance/ and /attendances + # ------------------------------------------------------------------ + def test_person_attendance_list_and_pagination(self): + self._existing_attendance() + with self.mock_request(body={}, token="__valid__"): + response = self.controller.attendance_list_person("PID-0001", page=1, limit=10) + self.assertEqual(response.status_code, 200) + data = self.response_json(response) + self.assertEqual(data["pagination"]["total_records"], 1) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.attendance_list_person("PID-NOPE") + self.assertEqual(response.status_code, 400) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.attendance_list_person("PID-0001", page="x", limit=10) + self.assertEqual(response.status_code, 400) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.attendance_list_person("PID-0001", page=0, limit=10) + self.assertEqual(response.status_code, 400) + + def test_person_attendance_date_filters(self): + self._existing_attendance() + with self.mock_request(body={}, token="__valid__"): + response = self.controller.attendance_list_person("PID-0001", from_date="2026-08-01", to_date="2026-08-31") + self.assertEqual(response.status_code, 200) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.attendance_list_person("PID-0001", from_date="31-08-2026", to_date="x") + self.assertEqual(response.status_code, 400) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.attendance_list_person("PID-0001", from_date="2026-08-31", to_date="2026-08-01") + self.assertEqual(response.status_code, 400) + + def test_attendance_list_cursor_pagination(self): + other = self.env["spp.attendance.subscriber"].create( + {"person_identifier": "PID-0002", "family_name": "Reyes", "given_name": "Maria"} + ) + with self.mock_request(body={}, token="__valid__"): + response = self.controller.attendance_list(limit=1, last_id=0) + self.assertEqual(response.status_code, 200) + data = self.response_json(response) + self.assertEqual(len(data["records"]), 1) + self.assertEqual(data["pagination"]["total_records"], 2) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.attendance_list(last_id="abc") + self.assertEqual(response.status_code, 400) + + # filter by person_ids in body + with self.mock_request(body={"person_ids": ["PID-0002"]}, token="__valid__"): + response = self.controller.attendance_list() + data = self.response_json(response) + self.assertEqual(data["pagination"]["total_records"], 1) + self.assertEqual(data["records"][0]["person_id"], other.person_identifier) + + # ------------------------------------------------------------------ + # GET types / locations / subscribers + # ------------------------------------------------------------------ + def test_types_and_locations(self): + with self.mock_request(body={}, token="__valid__"): + response = self.controller.get_attendance_types() + data = self.response_json(response) + self.assertIn(self.attendance_type.name, [r["name"] for r in data["records"]]) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.get_attendance_locations() + data = self.response_json(response) + self.assertIn(self.attendance_location.name, [r["name"] for r in data["records"]]) + + def test_subscriber_endpoints(self): + with self.mock_request(body={}, token="__valid__"): + response = self.controller.get_subscriber_list_information() + data = self.response_json(response) + self.assertGreaterEqual(data["pagination"]["total_records"], 1) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.get_subscriber_information("PID-0001") + self.assertEqual(response.status_code, 200) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.get_subscriber_information("PID-NOPE") + self.assertEqual(response.status_code, 400) + + with self.mock_request(body={}, token="__valid__"): + response = self.controller.get_subscriber_list_information(last_id="abc") + self.assertEqual(response.status_code, 400) diff --git a/spp_attendance/tests/test_credentials.py b/spp_attendance/tests/test_credentials.py new file mode 100644 index 00000000..5b2fa9d0 --- /dev/null +++ b/spp_attendance/tests/test_credentials.py @@ -0,0 +1,111 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from .common import AttendanceFixtureMixin + + +@tagged("post_install", "-at_install") +class TestClientCredentials(AttendanceFixtureMixin, TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_oauth_keys() + + def _create(self, name="Cred"): + return self.env["spp.attendance.api.client.credential"].create({"name": name}) + + def test_create_stores_hash_and_transient_plaintext(self): + credential = self._create() + self.assertTrue(credential.client_id.startswith("c-id-")) + self.assertTrue(credential.client_secret.startswith("c-secret-")) + self.assertTrue(credential.client_secret_hash.startswith("$scrypt$")) + self.assertNotIn(credential.client_secret, credential.client_secret_hash) + + def test_authenticate_success_and_failure(self): + credential = self._create() + plaintext = credential.client_secret + Model = self.env["spp.attendance.api.client.credential"] + + self.assertEqual(Model.authenticate(credential.client_id, plaintext), credential) + self.assertFalse(Model.authenticate(credential.client_id, "wrong-secret")) + self.assertFalse(Model.authenticate("no-such-client", plaintext)) + self.assertFalse(Model.authenticate(credential.client_id, "")) + + def test_verify_secret_rejects_malformed_hashes(self): + Model = self.env["spp.attendance.api.client.credential"] + self.assertFalse(Model._verify_secret("secret", "")) + self.assertFalse(Model._verify_secret("secret", "not-a-hash")) + self.assertFalse(Model._verify_secret("secret", "$md5$abc$def")) + self.assertFalse(Model._verify_secret("secret", "$scrypt$!!!$!!!")) + + def test_show_credentials_scrubs_plaintext_and_is_once(self): + credential = self._create() + plaintext = credential.client_secret + + action = credential.show_credentials() + wizard = self.env["spp.attendance.show.credential.wizard"].browse(action["res_id"]) + self.assertEqual(wizard.display_client_secret, plaintext) + self.assertEqual(wizard.display_client_id, credential.client_id) + + # plaintext scrubbed before the dialog even renders + self.assertFalse(credential.client_secret) + self.assertTrue(credential.show_button_clicked) + + # authentication still works against the hash + Model = self.env["spp.attendance.api.client.credential"] + self.assertEqual(Model.authenticate(credential.client_id, plaintext), credential) + + with self.assertRaises(UserError): + credential.show_credentials() + + def test_regenerate_rotates_secret_without_storing_plaintext(self): + credential = self._create() + old_plaintext = credential.client_secret + credential.show_credentials() + + action = credential.action_regenerate_secret() + wizard = self.env["spp.attendance.show.credential.wizard"].browse(action["res_id"]) + new_plaintext = wizard.display_client_secret + + self.assertNotEqual(new_plaintext, old_plaintext) + self.assertFalse(credential.client_secret, "regeneration must not store plaintext") + + Model = self.env["spp.attendance.api.client.credential"] + self.assertEqual(Model.authenticate(credential.client_id, new_plaintext), credential) + self.assertFalse(Model.authenticate(credential.client_id, old_plaintext)) + + def test_export_blocked(self): + credential = self._create() + with self.assertRaises(UserError): + credential.export_data(["client_id"]) + + def test_generate_access_token_is_verifiable(self): + from odoo.addons.spp_oauth.tools import verify_and_decode_signature + + token = self.env["spp.attendance.api.client.credential"].generate_access_token() + payload = verify_and_decode_signature(self.env, token.encode()) + self.assertEqual(payload.get("iss"), "openspp:auth-service") + + def test_migration_hashes_existing_plaintext(self): + """The 19.0.2.0.0 migration hashes and scrubs legacy plaintext rows.""" + credential = self._create() + legacy_secret = "c-secret-legacy-plaintext" + # Simulate a pre-migration row: plaintext present, no hash + credential.write({"client_secret": legacy_secret, "client_secret_hash": False}) + + import importlib.util + import os + + script = os.path.join(os.path.dirname(__file__), "..", "migrations", "19.0.2.0.0", "post-migration.py") + spec = importlib.util.spec_from_file_location("spp_attendance_migration_19_0_2_0_0", script) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.migrate(self.env.cr, "19.0.1.3.1") + + self.assertFalse(credential.client_secret) + self.assertTrue(credential.client_secret_hash) + Model = self.env["spp.attendance.api.client.credential"] + self.assertEqual(Model.authenticate(credential.client_id, legacy_secret), credential) diff --git a/spp_attendance/tests/test_http_smoke.py b/spp_attendance/tests/test_http_smoke.py new file mode 100644 index 00000000..53ca43d3 --- /dev/null +++ b/spp_attendance/tests/test_http_smoke.py @@ -0,0 +1,79 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""End-to-end HTTP smoke tests (Layer B of the hybrid strategy). + +HTTP-served requests run read-only against the test transaction (repo +precedent: spp_dci_client_compliance), so this layer covers only the +read paths, token minting, and auth rejection — the write endpoints are +fully covered by test_controller_logic.py. +""" + +import json + +from odoo.tests import HttpCase, tagged + +from .common import AttendanceFixtureMixin + + +@tagged("post_install", "-at_install") +class TestAttendanceHttpSmoke(AttendanceFixtureMixin, HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_oauth_keys() + cls._setup_attendance_fixtures() + cls.client_secret_plain = cls.credential.client_secret + cls.bearer = cls._make_bearer_token() + + def _post_json(self, path, payload, headers=None): + return self.url_open( + path, + data=json.dumps(payload), + headers={"Content-Type": "application/json", **(headers or {})}, + ) + + def test_token_mint_roundtrip(self): + response = self._post_json( + "/auth/token", + {"client_id": self.credential.client_id, "client_secret": self.client_secret_plain}, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["token_type"], "Bearer") + self.assertTrue(data["access_token"]) + + def test_token_mint_rejects_wrong_secret(self): + response = self._post_json( + "/auth/token", + {"client_id": self.credential.client_id, "client_secret": "wrong"}, + ) + self.assertEqual(response.status_code, 401) + + def test_protected_route_rejects_garbage_bearer(self): + response = self.url_open( + "/attendance/types", + headers={"Authorization": "Bearer garbage"}, + ) + self.assertEqual(response.status_code, 401) + + def test_read_endpoints_roundtrip(self): + headers = {"Authorization": f"Bearer {self.bearer}"} + + response = self.url_open("/attendance/types", headers=headers) + self.assertEqual(response.status_code, 200) + self.assertIn(self.attendance_type.name, [r["name"] for r in response.json()["records"]]) + + response = self.url_open("/attendance/locations", headers=headers) + self.assertEqual(response.status_code, 200) + + response = self.url_open("/subscribers", headers=headers) + self.assertEqual(response.status_code, 200) + self.assertGreaterEqual(response.json()["pagination"]["total_records"], 1) + + response = self.url_open("/subscriber/PID-0001", headers=headers) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["person_id"], "PID-0001") + + response = self.url_open("/attendance/PID-0001", headers=headers) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["person_id"], "PID-0001") diff --git a/spp_attendance/tests/test_import_wizard.py b/spp_attendance/tests/test_import_wizard.py new file mode 100644 index 00000000..2619a19e --- /dev/null +++ b/spp_attendance/tests/test_import_wizard.py @@ -0,0 +1,176 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +import json +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +DCI_STYLE_RESPONSE = { + "message": { + "search_response": [ + { + "data": { + "reg_records": [ + { + "identifier": [{"identifier": "PID-W1"}], + "family_name": "Garcia", + "given_name": "Luis", + "email": "luis@example.org", + "phone": "0917000001", + "gender": "male", + }, + { + "identifier": [{"identifier": "PID-W2"}], + "family_name": "Lim", + "given_name": "Rosa", + "email": "", + "phone": "", + "gender": "", + }, + ] + } + } + ] + }, + "pagination": {"page": 1, "limit": 30}, +} + + +@tagged("post_install", "-at_install") +class TestImportWizard(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + icp = cls.env["ir.config_parameter"].sudo() + icp.set_param("spp_attendance.server_url", "http://registry.test") + icp.set_param("spp_attendance.attendance_auth_endpoint", "/oauth2/client/token") + icp.set_param("spp_attendance.attendance_import_endpoint", "/registry/sync/search") + icp.set_param("spp_attendance.access_token_mapping", "access_token") + icp.set_param("spp_attendance.personal_information_mapping", "message.search_response.0.data.reg_records") + icp.set_param("spp_attendance.person_identifier_mapping", "identifier.0.identifier") + icp.set_param("spp_attendance.family_name_mapping", "family_name") + icp.set_param("spp_attendance.given_name_mapping", "given_name") + icp.set_param("spp_attendance.email_mapping", "email") + icp.set_param("spp_attendance.phone_mapping", "phone") + icp.set_param("spp_attendance.gender_mapping", "gender") + + def _wizard(self): + return self.env["spp.import.attendance.wizard"].create({}) + + def _mock_response(self, ok=True, payload=None, text=""): + response = MagicMock() + response.ok = ok + response.reason = "Testing" + response.text = text + response.json.return_value = payload + return response + + def test_import_creates_and_updates_subscribers(self): + wizard = self._wizard() + auth = self._mock_response(payload={"access_token": "tok"}) + search = self._mock_response(payload=DCI_STYLE_RESPONSE) + with patch( + "odoo.addons.spp_attendance.wizard.import_attendance.requests.post", + side_effect=[auth, search], + ): + wizard.action_import_attendance() + + Subscriber = self.env["spp.attendance.subscriber"] + first = Subscriber.search([("person_identifier", "=", "PID-W1")]) + self.assertEqual(first.family_name, "Garcia") + self.assertEqual(first.email, "luis@example.org") + self.assertEqual(first.gender_char, "Male", "gender should be title-cased from source data") + + second = Subscriber.search([("person_identifier", "=", "PID-W2")]) + self.assertTrue(second) + self.assertFalse(second.gender_char, "absent gender must stay empty, never fabricated") + + # Re-import updates in place instead of duplicating + modified = json.loads(json.dumps(DCI_STYLE_RESPONSE)) + modified["message"]["search_response"][0]["data"]["reg_records"][0]["family_name"] = "Garcia-Cruz" + auth2 = self._mock_response(payload={"access_token": "tok"}) + search2 = self._mock_response(payload=modified) + with patch( + "odoo.addons.spp_attendance.wizard.import_attendance.requests.post", + side_effect=[auth2, search2], + ): + wizard.action_import_attendance() + self.assertEqual(Subscriber.search_count([("person_identifier", "=", "PID-W1")]), 1) + self.assertEqual(first.family_name, "Garcia-Cruz") + + def test_auth_failure_raises(self): + wizard = self._wizard() + with patch( + "odoo.addons.spp_attendance.wizard.import_attendance.requests.post", + return_value=self._mock_response(ok=False), + ): + with self.assertRaises(UserError): + wizard.action_import_attendance() + + def test_import_failure_raises(self): + wizard = self._wizard() + auth = self._mock_response(payload={"access_token": "tok"}) + bad_search = self._mock_response(ok=False) + with patch( + "odoo.addons.spp_attendance.wizard.import_attendance.requests.post", + side_effect=[auth, bad_search], + ): + with self.assertRaises(UserError): + wizard.action_import_attendance() + + def test_bad_header_json_raises(self): + wizard = self._wizard() + wizard.auth_header = "{not json" + with self.assertRaises(UserError): + wizard.action_import_attendance() + + def test_mapping_must_point_to_list(self): + wizard = self._wizard() + self.env["ir.config_parameter"].sudo().set_param("spp_attendance.personal_information_mapping", "message") + with self.assertRaises(UserError): + wizard._import_attendance(DCI_STYLE_RESPONSE) + + def test_element_mapper_errors(self): + wizard = self._wizard() + self.assertIsNone(wizard.element_mapper({"a": 1}, [])) + with self.assertRaises(UserError): + wizard.element_mapper({"a": 1}, ["missing"]) + with self.assertRaises(UserError): + wizard.element_mapper({"a": {"b": 1}}, ["a", "0"]) + with self.assertRaises(UserError): + wizard.element_mapper({"a": [1]}, ["a", "5"]) + # empty containers short-circuit to None rather than raising + self.assertIsNone(wizard.element_mapper({"a": []}, ["a", "5"])) + + def test_missing_config_raises_friendly_error(self): + """A missing mapping parameter must produce the intended UserError, + not crash with a KeyError while composing the message. + + check_required_fields looks the parameter name up in + ir.config_parameter._fields, where it never exists. + """ + wizard = self._wizard() + self.env["ir.config_parameter"].sudo().set_param("spp_attendance.family_name_mapping", False) + with self.assertRaises(UserError): + wizard._import_attendance(DCI_STYLE_RESPONSE) + + def test_basic_auth_token_not_double_prefixed(self): + """A token already carrying a scheme must be passed through as-is. + + The prefix guard's operator precedence re-prefixes 'Basic ...' + tokens, producing 'Basic Basic xyz'. + """ + wizard = self._wizard() + wizard.auth_type = "Basic" + auth = self._mock_response(payload={"access_token": "Basic xyz"}) + search = self._mock_response(payload=DCI_STYLE_RESPONSE) + with patch( + "odoo.addons.spp_attendance.wizard.import_attendance.requests.post", + side_effect=[auth, search], + ) as mocked_post: + wizard.action_import_attendance() + + import_headers = mocked_post.call_args_list[1].kwargs["headers"] + self.assertEqual(import_headers["Authorization"], "Basic xyz") diff --git a/spp_attendance/tests/test_subscriber.py b/spp_attendance/tests/test_subscriber.py new file mode 100644 index 00000000..7c7af331 --- /dev/null +++ b/spp_attendance/tests/test_subscriber.py @@ -0,0 +1,97 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + + +from psycopg2.errors import UniqueViolation + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +@tagged("post_install", "-at_install") +class TestSubscriber(TransactionCase): + def _create(self, identifier="PID-S1", **extra): + vals = { + "person_identifier": identifier, + "family_name": "Santos", + "given_name": "Ana", + } + vals.update(extra) + return self.env["spp.attendance.subscriber"].create(vals) + + def test_create_builds_partner(self): + subscriber = self._create() + self.assertTrue(subscriber.partner_id) + # spp_registry (when co-installed) normalizes partner names to + # uppercase; compare case-insensitively so both configs pass + self.assertEqual(subscriber.partner_id.name.upper(), "SANTOS, ANA") + self.assertEqual(subscriber.partner_name, "Santos, Ana") + # inverse pushes the identifier onto the partner + self.assertEqual(subscriber.partner_id.identifier, "PID-S1") + # gender must remain unset unless provided (no fabricated default) + self.assertFalse(subscriber.partner_id.gender_char) + + def test_partner_name_includes_addl_name(self): + subscriber = self._create(identifier="PID-S2", addl_name="Reyes") + self.assertEqual(subscriber.partner_name, "Santos, Ana Reyes") + + def test_create_links_existing_partner(self): + partner = self.env["res.partner"].create( + { + "name": "Santos, Ana", + "family_name": "Santos", + "given_name": "Ana", + } + ) + subscriber = self._create(identifier="PID-S3") + self.assertEqual(subscriber.partner_id, partner) + + def test_person_identifier_unique(self): + self._create(identifier="PID-DUP") + # The INSERT is flushed inside create() (the person_identifier + # inverse touches the partner), so the raw UniqueViolation + # propagates instead of being converted to a ValidationError. + with self.assertRaises(UniqueViolation), mute_logger("odoo.sql_db"): + with self.env.cr.savepoint(): + self._create(identifier="PID-DUP", family_name="Other", given_name="Person") + self.env.flush_all() + + def test_get_attendance_list_filters(self): + subscriber = self._create(identifier="PID-S4") + att_type = self.env["spp.attendance.type"].create({"name": "Session"}) + self.env["spp.attendance.list"].create( + [ + { + "subscriber_id": subscriber.id, + "attendance_date": "2026-08-01", + "attendance_time": "08:00:00", + "attendance_type_id": att_type.id, + "submitted_by": "seed", + }, + { + "subscriber_id": subscriber.id, + "attendance_date": "2026-08-05", + "attendance_time": "08:00:00", + "attendance_category": "absent", + "submitted_by": "seed", + }, + ] + ) + + total, record = subscriber.get_attendance_list() + self.assertEqual(total, 2) + self.assertEqual(record["person_id"], "PID-S4") + self.assertEqual(record["number_of_days_present"], 1) + + total, _record = subscriber.get_attendance_list(attendance_type_id=att_type.id) + self.assertEqual(total, 1) + + total, _record = subscriber.get_attendance_list(from_date="2026-08-02", to_date="2026-08-31") + self.assertEqual(total, 1) + + def test_subscriber_info_uses_external_identifier(self): + subscriber = self._create(identifier="PID-S5") + info = subscriber.get_attendance_subscriber_info() + self.assertEqual(info["id"], "PID-S5", "public id must be the person identifier, not the DB id") + self.assertEqual(info["person_id"], "PID-S5") + self.assertEqual(info["name"], "Santos, Ana") diff --git a/spp_attendance/views/attendance_api_client_views.xml b/spp_attendance/views/attendance_api_client_views.xml new file mode 100644 index 00000000..1ca46002 --- /dev/null +++ b/spp_attendance/views/attendance_api_client_views.xml @@ -0,0 +1,90 @@ + + + + spp_attendance_client_credential_tree + spp.attendance.api.client.credential + + + + + + + + + + spp_attendance_client_credential_form + spp.attendance.api.client.credential + +
+ + + + + + + + + +